santifer/career-ops · error · Error

plugin egress must use HTTPS

Error message

plugin egress must use HTTPS: ${u.href}

What it means

The plugin sandbox's guarded fetch wrapper (hostOk) enforces HTTPS for all plugin network egress. Plain HTTP is permitted only when the plugin's allowsLocalhost opt-in is set AND the URL host is loopback (localhost, 127.x.x.x, ::1) — the carve-out for local AI providers like Ollama. Any other http:// URL throws this error before the request is made.

Solutions

  1. Change the plugin's URL to https://
  2. Run a local TLS proxy or expose the service over HTTPS
  3. If the service is genuinely local (Ollama/LM Studio), use http://localhost:<port> and ensure the plugin's allowsLocalhost opt-in is enabled in its manifest/config

Example fix

// before
const res = await ctx.fetch('http://api.example.com/data');
// Error: plugin egress must use HTTPS: http://api.example.com/data
// after
const res = await ctx.fetch('https://api.example.com/data');
Defensive patterns

Strategy: validation

Validate before calling

const u = new URL(target);
const isLoopback = (h) => /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?)$/i.test(h);
if (u.protocol !== 'https:' && !(allowsLocalhost && u.protocol === 'http:' && isLoopback(u.hostname))) {
  throw new Error(`upgrade to HTTPS before calling ctx.fetch: ${u.href}`);
}

Type guard

const isHttpsOrAllowedLoopback = (u, allowsLocalhost) => u.protocol === 'https:' || (allowsLocalhost && u.protocol === 'http:' && /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?)$/i.test(u.hostname));

Try / catch

try { return await ctx.fetch(url); } catch (e) { if (String(e.message).startsWith('plugin egress must use HTTPS')) { throw new Error(`${e.message} — switch the endpoint to https:// or run a local TLS proxy`); } throw e; }

Prevention

When it happens

Trigger: A plugin calling ctx.fetch('http://example.com/...') (non-loopback host); or an http:// non-loopback URL even with allowsLocalhost enabled.

Common situations: A plugin pointing at an internal dev API over HTTP; hardcoded http:// URLs in plugin code after migrating to the sandbox; testing against a staging server without TLS.

Understand the failure class

Background: "Invalid URL" / "URL cannot be empty": fix the malformed or missing URL behind request-construction failures — this error's family across 50 libraries.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/0b0b64534a6941a4. Report an issue: GitHub.

Appendix: source

Thrown at plugins/_engine.mjs:391

 * Posture note: core providers use redirect:'error' (reject ANY redirect). This
 * is the deliberately looser plugin posture — allowlist-pinned FOLLOW — because
 * keyed APIs (Notion/Google/Apify) legitimately 30x within their own host set;
 * the allowlist + per-hop re-validation + cross-host credential strip bound it.
 *
 * ADVISORY only: this binds a plugin that routes through ctx.fetch*, not one
 * that calls global fetch directly (see the trust note in README.md).
 *
 * @param {string[]} allowedHosts
 */
function makeGuardedFetch(allowedHosts, { allowsLocalhost = false } = {}) {
  const allow = new Set(allowedHosts);
  const isLoopbackHost = (h) => /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?)$/i.test(h);
  const hostOk = (u) => {
    if (u.protocol !== 'https:') {
      // Plain HTTP is allowed ONLY for an opted-in loopback host (local-AI
      // providers like Ollama/LM Studio serve http://localhost:11434).
      if (!(allowsLocalhost && u.protocol === 'http:' && isLoopbackHost(u.hostname))) {
        throw new Error(`plugin egress must use HTTPS: ${u.href}`);
      }
    }
    if (allow.size > 0 && !allow.has(u.hostname)) throw new Error(`plugin egress to "${u.hostname}" is not in allowedHosts [${[...allow].join(', ')}]`);
  };
  return async function guardedFetch(url, opts = {}) {
    const { timeoutMs = 10_000, headers = {}, method = 'GET', body = null } = opts;
    let current = new URL(url);
    hostOk(current);
    // SSRF: reject a host that resolves to a private/loopback/metadata address
    // (re-checked on every redirect hop). Loopback allowed only when opted in.
    await resolveAndValidate(current.hostname, { allowsLocalhost });
    let reqHeaders = { ...headers };
    for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), timeoutMs);
      let res;
      try {
        res = await fetch(current.href, {

View on GitHub (pinned to aac998c7ed)