zengate 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,7 +63,7 @@ zengate
63
63
 
64
64
  ```bash
65
65
  docker run -d --name zengate -p 127.0.0.1:8083:8083 -v zengate:/data developingchet/zengate
66
- docker logs zengate # shows the generated key once
66
+ docker exec zengate node index.js setup --show # prints the generated key
67
67
  ```
68
68
 
69
69
  Images are published for `linux/amd64` and `linux/arm64`, tagged `latest`, `1`, `1.2` and `1.2.3`.
@@ -101,17 +101,19 @@ npm start
101
101
 
102
102
  ### First start
103
103
 
104
- The gateway creates a config file with a random API key and prints the key once:
104
+ The gateway creates a config file with a random API key and says where it is. The key itself is not printed, so it does not end up in service logs:
105
105
 
106
106
  ```
107
- Created a gateway API key (saved to /home/you/.config/zengate/config.json):
108
-
109
- sk-zg-...
107
+ Created a gateway API key and saved it to /home/you/.config/zengate/config.json.
108
+ Use it as the OpenAI API key in your client. To see it, run `zengate setup --show`
109
+ or read API_KEY from that file; `zengate setup --rotate` replaces it.
110
110
 
111
111
  OpenAI-compatible API on http://127.0.0.1:8083/v1 (auth: API key)
112
112
  7 models: big-pickle, ...
113
113
  ```
114
114
 
115
+ In Docker, run `docker exec zengate node index.js setup --show`; under systemd, read `/var/lib/zengate/config.json` with `sudo`.
116
+
115
117
  Where the config file lives:
116
118
 
117
119
  | How you run it | Config file |
@@ -129,6 +131,7 @@ Key commands (`npm run setup -- <flag>` from a source checkout):
129
131
  ```bash
130
132
  zengate setup # create a key if none exists
131
133
  zengate setup --rotate # replace the key
134
+ zengate setup --show # print the saved key
132
135
  zengate setup --print # print a fresh key without saving it (for env vars and secret stores)
133
136
  zengate --help
134
137
  ```
@@ -240,7 +243,7 @@ Set values as environment variables or in the config file (same names; see [`con
240
243
  | `OPENCODE_AGENT` | `plan` | The OpenCode agent each session uses. |
241
244
  | `OPENCODE_PATH` | bundled | Use a different `opencode` binary. |
242
245
  | `OPENCODE_SERVER_URL` | none | Attach to an existing `opencode serve` instead of starting one (see below). |
243
- | `OPENCODE_SERVER_USERNAME` / `OPENCODE_SERVER_PASSWORD` | `opencode` / none | Basic auth for that server. |
246
+ | `OPENCODE_SERVER_USERNAME` / `OPENCODE_SERVER_PASSWORD` | `opencode` / none | Basic auth for that server. Credentials inside `OPENCODE_SERVER_URL` are refused. |
244
247
  | `ALLOW_INSECURE_BACKEND_HTTP` | `false` | Allow a non-loopback `http://` server URL. |
245
248
  | `CONFIG_FILE` | see [First start](#first-start) | Where the config file lives (environment variable only). |
246
249
 
@@ -275,12 +278,12 @@ sha256sum -c checksums.txt
275
278
 
276
279
  ## Security
277
280
 
278
- - The API key is required unless you set `ALLOW_NO_AUTH`. Keys are compared in constant time, the config file is written with mode `0600`, and keys are never logged.
281
+ - The API key is required unless you set `ALLOW_NO_AUTH`. Keys are compared in constant time, the config file is written with mode `0600`, and keys are never logged: the first start reports where the generated key was saved, not the key.
279
282
  - The gateway binds to loopback by default and warns when it listens elsewhere, because traffic is plain HTTP, so put TLS in front.
280
283
  - OpenCode tools are never executed, and OpenCode runs isolated from your home directory and configuration.
281
284
  - Attachment URLs must be `https` and resolve to public addresses. Loopback, private, link-local and similar ranges (including IPv6 forms that embed them) are refused, which blocks SSRF into your network. The gateway fetches them itself and connects only to the address it checked, including on every redirect, so DNS rebinding cannot reach an internal host. OpenCode never sees the URL.
282
285
  - Conversation history is sent to the model as a tagged transcript. Message text, file names and tool names are escaped so they cannot fake another turn, a tool result or a function call.
283
- - Rate limiting, a bounded queue with a wait limit, body and attachment size limits, and per-request timeouts all apply. Requests that are uploading, queued or running are capped at `MAX_CONCURRENT + MAX_QUEUE`, which bounds the memory held by request bodies, and one client address may be uploading at most a quarter of that at once. Attachment URLs appear in errors and logs without their query string or credentials. A request with `n` choices uses `n` concurrency slots, and slots are always released on disconnect or timeout.
286
+ - Rate limiting, a bounded queue with a wait limit, body and attachment size limits, and per-request timeouts all apply. Requests that are uploading, queued or running are capped at `MAX_CONCURRENT + MAX_QUEUE`, which bounds the memory held by request bodies, and one client address may be uploading at most a quarter of that at once. Attachment hosts are resolved inside the request's slot and timeout, with at most two DNS lookups in flight across the gateway. Attachment URLs appear in errors and logs as their origin only, without path, query string or credentials. A request with `n` choices uses `n` concurrency slots, and slots are always released on disconnect or timeout.
284
287
  - Stored responses (`previous_response_id`, `GET /v1/responses/{id}`) are visible only to the API key that created them.
285
288
  - The gateway sends no telemetry and never logs request bodies. OpenCode's auto-update and session sharing are disabled.
286
289
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zengate",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Keyless OpenAI-compatible API (Chat Completions + Responses) for OpenCode's free Zen models, served through the real OpenCode CLI.",
5
5
  "author": {
6
6
  "name": "developingchet",
@@ -52,7 +52,7 @@
52
52
  "license": "MIT",
53
53
  "dependencies": {
54
54
  "express": "^5.1.0",
55
- "opencode-ai": "1.18.32"
55
+ "opencode-ai": "1.18.34"
56
56
  },
57
57
  "overrides": {
58
58
  "es-define-property": "npm:@socketregistry/es-define-property@^1",
package/scripts/setup.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // Gateway key helper (same as `zengate setup`).
3
3
  // npm run setup create config.json with a key (keeps an existing key)
4
4
  // npm run setup -- --rotate replace the key in config.json
5
+ // npm run setup -- --show print the key saved in config.json
5
6
  // npm run setup -- --print only print a fresh key (for env vars / secret stores)
6
7
  import path from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
@@ -12,7 +13,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
13
  const { command, flags } = parseCommand(['setup', ...process.argv.slice(2)]);
13
14
 
14
15
  if (command !== 'setup') {
15
- process.stderr.write(`Unknown option: ${process.argv.slice(2).join(' ')}. Use --rotate or --print.\n`);
16
+ process.stderr.write(`Unknown option: ${process.argv.slice(2).join(' ')}. Use --rotate, --show or --print.\n`);
16
17
  process.exitCode = 2;
17
18
  } else {
18
19
  try {
package/src/bootstrap.js CHANGED
@@ -41,13 +41,11 @@ export function loadOrProvisionConfig({ configPath, env = process.env, logger, p
41
41
  'or set ALLOW_NO_AUTH=true to deliberately serve without a key.',
42
42
  ]);
43
43
  }
44
+ // Only where the key is, never the key: service managers keep stdout as logs.
44
45
  print('');
45
- print(` Created a gateway API key (saved to ${configPath}):`);
46
- print('');
47
- print(` ${key}`);
48
- print('');
49
- print(' Use it as the OpenAI API key in your client. It is not shown again;');
50
- print(' read it from that file, or run `zengate setup --rotate` to replace it.');
46
+ print(` Created a gateway API key and saved it to ${configPath}.`);
47
+ print(' Use it as the OpenAI API key in your client. To see it, run `zengate setup --show`');
48
+ print(' or read API_KEY from that file; `zengate setup --rotate` replaces it.');
51
49
  print('');
52
50
  logger.debug('Provisioned a new API key', { configPath });
53
51
  return loadConfig({ ...fileConfig, API_KEY: key }, env);
package/src/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { generateApiKey, writeConfigFile } from './bootstrap.js';
2
2
  import { readConfigFile } from './config.js';
3
3
 
4
- const SETUP_FLAGS = new Set(['--rotate', '--print']);
4
+ const SETUP_FLAGS = new Set(['--rotate', '--print', '--show']);
5
5
 
6
6
  export const HELP_TEXT = `zengate: an OpenAI-compatible API for OpenCode's free Zen models
7
7
 
@@ -9,6 +9,7 @@ Usage:
9
9
  zengate start the gateway (creates an API key on first start)
10
10
  zengate setup create config.json with an API key (keeps an existing key)
11
11
  zengate setup --rotate replace the API key
12
+ zengate setup --show print the saved API key
12
13
  zengate setup --print print a fresh key without saving it
13
14
  zengate --version print the version
14
15
  zengate --help show this help
@@ -29,19 +30,29 @@ export function parseCommand(args) {
29
30
  return { command: 'unknown', flags: args };
30
31
  }
31
32
 
33
+ /** Print the API key saved in config.json. */
34
+ function showKey({ configPath, flags, out }) {
35
+ if (flags.length > 1) throw new Error('--show cannot be combined with other setup options.');
36
+ const { API_KEY: key } = readConfigFile(configPath);
37
+ if (!key) throw new Error(`${configPath} has no API_KEY. Run \`zengate setup\` to create one.`);
38
+ out(key);
39
+ return 0;
40
+ }
41
+
32
42
  /**
33
- * Create, keep or rotate the gateway API key in config.json.
43
+ * Create, keep, show or rotate the gateway API key in config.json.
34
44
  * @param {{ configPath: string, flags: string[], out: (line?: string) => void }} options
35
45
  * @returns {number} process exit code
36
46
  */
37
47
  export function runSetup({ configPath, flags, out }) {
48
+ if (flags.includes('--show')) return showKey({ configPath, flags, out });
38
49
  if (flags.includes('--print')) {
39
50
  out(generateApiKey());
40
51
  return 0;
41
52
  }
42
53
  const current = readConfigFile(configPath);
43
54
  if (current.API_KEY && !flags.includes('--rotate')) {
44
- out(`${configPath} already has an API_KEY. Use --rotate to replace it.`);
55
+ out(`${configPath} already has an API_KEY. Use --show to print it or --rotate to replace it.`);
45
56
  return 0;
46
57
  }
47
58
  const key = generateApiKey();
package/src/config.js CHANGED
@@ -127,6 +127,9 @@ function validateServerUrl(config, problems) {
127
127
  problems.push('OPENCODE_SERVER_URL must be an absolute http(s) URL');
128
128
  return;
129
129
  }
130
+ if (url.username || url.password) {
131
+ problems.push('OPENCODE_SERVER_URL must not contain a username or password; set OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD instead');
132
+ }
130
133
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
131
134
  problems.push('OPENCODE_SERVER_URL must use http or https');
132
135
  } else if (url.protocol === 'http:' && !isLoopbackHost(url.hostname) && !config.ALLOW_INSECURE_BACKEND_HTTP) {
@@ -7,7 +7,6 @@ import { mapLimited } from './map-limited.js';
7
7
  import { buildPrompt } from './prompt.js';
8
8
  import { inlineRemoteAttachments } from './remote-media.js';
9
9
  import { openSse } from './sse-writer.js';
10
- import { assertPublicUrls } from './url-guard.js';
11
10
 
12
11
  const completionId = () => `chatcmpl-${crypto.randomBytes(12).toString('hex')}`;
13
12
  const now = () => Math.floor(Date.now() / 1000);
@@ -40,7 +39,6 @@ export function chatCompletionsHandler({ runner, catalog, limits }) {
40
39
  const request = parseChatRequest(req.body, limits);
41
40
  const model = await catalog.resolve(request.model);
42
41
  const prompt = buildPrompt(request, model);
43
- await assertPublicUrls(prompt.parts);
44
42
  setIgnoredParams(res, request.ignored);
45
43
  await req.withSlot(async (signal, parallel) => {
46
44
  const ready = await inlineRemoteAttachments(prompt, { ...limits, signal });
@@ -4,7 +4,7 @@ import { ApiError, invalidRequest, unsupported } from '../server/errors.js';
4
4
  import { fileBlock } from './markup.js';
5
5
  import { mapLimited } from './map-limited.js';
6
6
  import { kindForMime } from './media.js';
7
- import { isBlockedAddress, MAX_REMOTE_PARTS, publicLookup, resolvePublic } from './url-guard.js';
7
+ import { assertPublicUrls, isBlockedAddress, publicLookup, resolvePublic } from './url-guard.js';
8
8
 
9
9
  const FETCH_TIMEOUT_MS = 30000;
10
10
  const MAX_REDIRECTS = 3;
@@ -18,14 +18,15 @@ const USER_AGENT = 'zengate (+https://github.com/developingchet/zengate)';
18
18
  const attachmentError = (message) => invalidRequest(message, null, 'invalid_attachment_url');
19
19
 
20
20
  /**
21
- * An attachment URL as it may appear in errors and logs: signed URLs carry
22
- * their token in the query string, and some URLs carry credentials, so both
23
- * are left out.
21
+ * An attachment URL as it may appear in errors and logs: only its origin.
22
+ * Signed URLs carry their token in the query string or in a path segment,
23
+ * and some URLs carry credentials, so all of those are left out.
24
24
  */
25
25
  export function shown(url) {
26
26
  try {
27
27
  const parsed = new URL(url);
28
- return `${parsed.origin}${parsed.pathname}${parsed.search ? '?…' : ''}`;
28
+ const bare = parsed.pathname === '/' && !parsed.search && !parsed.hash;
29
+ return `${parsed.origin}/${bare ? '' : '…'}`;
29
30
  } catch {
30
31
  return '(invalid URL)';
31
32
  }
@@ -157,7 +158,7 @@ function requestOnce(url, { transport, lookup, signal }) {
157
158
  async function download(url, { transport, lookup, isBlocked, signal, limit }) {
158
159
  let current = new URL(url);
159
160
  for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
160
- if (net.isIP(current.hostname.replace(/^\[|\]$/g, ''))) await resolvePublic(current.hostname, isBlocked);
161
+ if (net.isIP(current.hostname.replace(/^\[|\]$/g, ''))) await resolvePublic(current.hostname, { isBlocked });
161
162
  const response = await requestOnce(current, { transport, lookup, signal });
162
163
  const status = response.statusCode || 0;
163
164
  if (status >= 300 && status < 400 && response.headers.location) {
@@ -198,6 +199,10 @@ async function inlinePart(part, options) {
198
199
  * inlined text) fetched by the gateway, so OpenCode never fetches a
199
200
  * client-chosen URL itself: no redirects to internal hosts, no DNS
200
201
  * rebinding, and remote files obey the same size limits as inline ones.
202
+ * Every host is checked before the first download starts. Callers run this
203
+ * inside the request's slot, so the DNS lookups and downloads count against
204
+ * MAX_CONCURRENT and stop when `signal` aborts (client disconnect or
205
+ * REQUEST_TIMEOUT_MS).
201
206
  * @param {{ parts: object[] }} prompt from buildPrompt
202
207
  * @param {{ maxBytes: number, maxTotalBytes: number, signal: AbortSignal,
203
208
  * transport?: { request: typeof https.request }, isBlocked?: (address: string) => boolean }} options
@@ -206,14 +211,13 @@ async function inlinePart(part, options) {
206
211
  export async function inlineRemoteAttachments(prompt, { maxBytes, maxTotalBytes, signal, transport = https, isBlocked = isBlockedAddress }) {
207
212
  const remote = prompt.parts.filter((part) => typeof part.url === 'string' && part.url.startsWith('https:'));
208
213
  if (remote.length === 0) return prompt;
209
- if (remote.length > MAX_REMOTE_PARTS) {
210
- throw invalidRequest(`At most ${MAX_REMOTE_PARTS} attachments may be URLs; send the rest as data URIs.`, null, 'too_many_attachment_urls');
211
- }
214
+ await assertPublicUrls(remote, { isBlocked, signal });
212
215
  // One failed attachment fails the request, so the other downloads are cancelled too.
213
216
  const siblings = new AbortController();
217
+ const downloads = AbortSignal.any([signal, siblings.signal]);
214
218
  const options = {
215
- transport, lookup: publicLookup(isBlocked), isBlocked,
216
- signal: AbortSignal.any([signal, siblings.signal]),
219
+ transport, lookup: publicLookup({ isBlocked, signal: downloads }), isBlocked,
220
+ signal: downloads,
217
221
  budget: createBudget({ maxBytes, maxTotalBytes }),
218
222
  };
219
223
  const fetched = await mapLimited(remote, PARALLEL_FETCHES, (part) => inlinePart(part, options), () => siblings.abort());
@@ -6,7 +6,6 @@ import { inlineRemoteAttachments } from './remote-media.js';
6
6
  import { createResponseBuilder } from './response-builder.js';
7
7
  import { parseResponsesRequest } from './responses-request.js';
8
8
  import { openSse } from './sse-writer.js';
9
- import { assertPublicUrls } from './url-guard.js';
10
9
 
11
10
  function assistantHistory(result) {
12
11
  return { role: 'assistant', content: result.content, media: [], toolCalls: result.toolCalls.map(({ id, name, arguments: args }) => ({ id, name, arguments: args })) };
@@ -22,7 +21,6 @@ export function responsesHandlers({ runner, catalog, store, limits }) {
22
21
  const request = parseResponsesRequest(req.body, limits, scoped);
23
22
  const model = await catalog.resolve(request.model);
24
23
  const prompt = buildPrompt(request, model);
25
- await assertPublicUrls(prompt.parts);
26
24
  setIgnoredParams(res, request.ignored);
27
25
 
28
26
  await req.withSlot(async (signal) => {
@@ -1,6 +1,7 @@
1
1
  import dns from 'node:dns/promises';
2
2
  import net from 'node:net';
3
3
  import { invalidRequest } from '../server/errors.js';
4
+ import { mapLimited } from './map-limited.js';
4
5
 
5
6
  /**
6
7
  * Attachment URLs are fetched by the gateway, so a client could otherwise
@@ -23,9 +24,64 @@ const BLOCKED_V6 = new net.BlockList();
23
24
  ].forEach(([address, prefix]) => BLOCKED_V6.addSubnet(address, prefix, 'ipv6'));
24
25
 
25
26
  const LOOKUP_TIMEOUT_MS = 5000;
27
+ /**
28
+ * dns.lookup runs getaddrinfo on libuv's thread pool (four threads by
29
+ * default, shared with file system and crypto work) and cannot be cancelled:
30
+ * a lookup the caller stopped waiting for keeps its thread until the resolver
31
+ * answers or gives up. Each lookup holds a permit until it really settles, so
32
+ * names that never resolve occupy at most this many threads process-wide.
33
+ */
34
+ const MAX_LOOKUPS_IN_FLIGHT = 2;
26
35
  /** Most attachments one request may send as URLs. */
27
36
  export const MAX_REMOTE_PARTS = 16;
28
37
 
38
+ let lookupsInFlight = 0;
39
+ const lookupWaiters = new Set();
40
+
41
+ /** Waits for a lookup permit; rejects with the signal's reason if it aborts first. */
42
+ function acquireLookup(signal) {
43
+ if (signal.aborted) return Promise.reject(signal.reason);
44
+ if (lookupsInFlight < MAX_LOOKUPS_IN_FLIGHT) {
45
+ lookupsInFlight += 1;
46
+ return Promise.resolve();
47
+ }
48
+ return new Promise((resolve, reject) => {
49
+ const onAbort = () => {
50
+ lookupWaiters.delete(grant);
51
+ reject(signal.reason);
52
+ };
53
+ const grant = () => {
54
+ signal.removeEventListener('abort', onAbort);
55
+ lookupsInFlight += 1;
56
+ resolve();
57
+ };
58
+ lookupWaiters.add(grant);
59
+ signal.addEventListener('abort', onAbort, { once: true });
60
+ });
61
+ }
62
+
63
+ function releaseLookup() {
64
+ lookupsInFlight -= 1;
65
+ const [next] = lookupWaiters;
66
+ if (next) {
67
+ lookupWaiters.delete(next);
68
+ next();
69
+ }
70
+ }
71
+
72
+ /** Settles like `promise`, or rejects with the signal's reason once it aborts. */
73
+ function untilAborted(promise, signal) {
74
+ return new Promise((resolve, reject) => {
75
+ const onAbort = () => reject(signal.reason);
76
+ if (signal.aborted) {
77
+ onAbort();
78
+ return;
79
+ }
80
+ signal.addEventListener('abort', onAbort, { once: true });
81
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
82
+ });
83
+ }
84
+
29
85
  /** The eight 16-bit groups of a valid IPv6 address (which may end in dotted IPv4). */
30
86
  function ipv6Groups(address) {
31
87
  let text = address.toLowerCase();
@@ -62,30 +118,30 @@ export function isBlockedAddress(address) {
62
118
  return v4 ? BLOCKED_V4.check(v4, 'ipv4') : BLOCKED_V6.check(plain, 'ipv6');
63
119
  }
64
120
 
65
- async function resolve(hostname) {
121
+ /** The addresses of `hostname`, within LOOKUP_TIMEOUT_MS (waiting for a permit included). */
122
+ async function resolve(hostname, signal) {
66
123
  const literal = hostname.replace(/^\[|\]$/g, '');
67
124
  if (net.isIP(literal)) return [{ address: literal, family: net.isIP(literal) }];
68
- let timer;
69
- const timeout = new Promise((_, reject) => {
70
- timer = setTimeout(() => reject(new Error('DNS lookup timed out')), LOOKUP_TIMEOUT_MS);
71
- timer.unref();
72
- });
73
- try {
74
- return await Promise.race([dns.lookup(literal, { all: true, verbatim: true }), timeout]);
75
- } finally {
76
- clearTimeout(timer);
77
- }
125
+ const deadline = AbortSignal.any([AbortSignal.timeout(LOOKUP_TIMEOUT_MS), ...(signal ? [signal] : [])]);
126
+ await acquireLookup(deadline);
127
+ const lookup = (async () => dns.lookup(literal, { all: true, verbatim: true }))();
128
+ lookup.then(releaseLookup, releaseLookup);
129
+ return untilAborted(lookup, deadline);
78
130
  }
79
131
 
80
132
  /**
81
133
  * Every address `hostname` resolves to, provided all of them are public.
82
134
  * @param {string} hostname
83
- * @param {(address: string) => boolean} [isBlocked]
135
+ * @param {{ isBlocked?: (address: string) => boolean, signal?: AbortSignal }} [options]
136
+ * `signal` stops the wait (rejecting with its reason) when the request ends
84
137
  * @returns {Promise<{ address: string, family: number }[]>}
85
138
  */
86
- export async function resolvePublic(hostname, isBlocked = isBlockedAddress) {
139
+ export async function resolvePublic(hostname, { isBlocked = isBlockedAddress, signal } = {}) {
87
140
  // One message for both failures, so clients cannot probe which internal names exist.
88
- const records = await resolve(hostname).catch(() => []);
141
+ const records = await resolve(hostname, signal).catch(() => {
142
+ if (signal?.aborted) throw signal.reason;
143
+ return [];
144
+ });
89
145
  if (!records.length || records.some((record) => isBlocked(record.address))) {
90
146
  throw invalidRequest(`Attachment host '${hostname}' is not a public address.`, null, 'invalid_attachment_url');
91
147
  }
@@ -96,11 +152,11 @@ export async function resolvePublic(hostname, isBlocked = isBlockedAddress) {
96
152
  * A `lookup` for http(s).request that only ever connects to the addresses it
97
153
  * has just checked, so DNS rebinding between check and connect cannot reach
98
154
  * an internal address.
99
- * @param {(address: string) => boolean} [isBlocked]
155
+ * @param {{ isBlocked?: (address: string) => boolean, signal?: AbortSignal }} [options]
100
156
  */
101
- export function publicLookup(isBlocked = isBlockedAddress) {
157
+ export function publicLookup({ isBlocked = isBlockedAddress, signal } = {}) {
102
158
  return (hostname, options, callback) => {
103
- resolvePublic(hostname, isBlocked).then((records) => {
159
+ resolvePublic(hostname, { isBlocked, signal }).then((records) => {
104
160
  const wanted = options?.family ? records.filter((record) => record.family === options.family) : records;
105
161
  if (!wanted.length) {
106
162
  callback(Object.assign(new Error(`No IPv${options.family} address for ${hostname}`), { code: 'ENOTFOUND' }));
@@ -114,16 +170,21 @@ export function publicLookup(isBlocked = isBlockedAddress) {
114
170
  }
115
171
 
116
172
  /**
117
- * Reject prompt parts whose https URL resolves to a non-public address. This
118
- * is a fast early check; the fetch itself re-checks through publicLookup.
173
+ * Reject prompt parts whose https URL resolves to a non-public address, so a
174
+ * request fails before any download starts. The fetch itself re-checks
175
+ * through publicLookup.
119
176
  * @param {{ url?: string }[]} parts OpenCode prompt parts
177
+ * @param {{ isBlocked?: (address: string) => boolean, signal?: AbortSignal }} [options]
120
178
  */
121
- export async function assertPublicUrls(parts) {
179
+ export async function assertPublicUrls(parts, { isBlocked = isBlockedAddress, signal } = {}) {
122
180
  const remote = parts.filter((part) => typeof part.url === 'string' && part.url.startsWith('https:'));
123
181
  // Checked before resolving anything, so one request cannot queue thousands of DNS lookups.
124
182
  if (remote.length > MAX_REMOTE_PARTS) {
125
183
  throw invalidRequest(`At most ${MAX_REMOTE_PARTS} attachments may be URLs; send the rest as data URIs.`, null, 'too_many_attachment_urls');
126
184
  }
127
- const hosts = new Set(remote.map((part) => new URL(part.url).hostname));
128
- await Promise.all([...hosts].map((hostname) => resolvePublic(hostname)));
185
+ const hosts = [...new Set(remote.map((part) => new URL(part.url).hostname))];
186
+ // Once one host is refused, the others stop waiting for a lookup permit.
187
+ const siblings = new AbortController();
188
+ const checks = AbortSignal.any([siblings.signal, ...(signal ? [signal] : [])]);
189
+ await mapLimited(hosts, hosts.length, (hostname) => resolvePublic(hostname, { isBlocked, signal: checks }), () => siblings.abort());
129
190
  }
@@ -1,5 +1,5 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
- import { createOpencodeClient } from './client.js';
2
+ import { backendOrigin, createOpencodeClient } from './client.js';
3
3
  import { resolveOpencodeBinary, spawnCommand } from './binary.js';
4
4
  import {
5
5
  backendEnv, createIsolatedRoot, HEARTBEAT_MS, randomPassword, recordBackendPid, removeIsolatedRoot, sweepStaleRoots, touchIsolatedRoot,
@@ -227,6 +227,7 @@ export function createManagedBackend({ opencodePath, logger }) {
227
227
  /** Uses an OpenCode server someone else runs (OPENCODE_SERVER_URL). */
228
228
  export function createAttachedBackend({ url, username, password, logger }) {
229
229
  const client = createOpencodeClient({ baseUrl: url, username, password });
230
+ const shownUrl = backendOrigin(url);
230
231
  let ready = false;
231
232
  let version = null;
232
233
  let timer = null;
@@ -245,8 +246,8 @@ export function createAttachedBackend({ url, username, password, logger }) {
245
246
  return Object.freeze({
246
247
  mode: 'attached',
247
248
  async start() {
248
- if (!(await probe())) throw new Error(`OpenCode server at ${url} is not healthy (GET /global/health). Check the URL and credentials.`);
249
- logger.info(`Using OpenCode ${version || ''} at ${url}`.replace(' ', ' '));
249
+ if (!(await probe())) throw new Error(`OpenCode server at ${shownUrl} is not healthy (GET /global/health). Check the URL and credentials.`);
250
+ logger.info(`Using OpenCode ${version || ''} at ${shownUrl}`.replace(' ', ' '));
250
251
  logger.warn('Attached mode: the gateway rejects tool permissions for its own sessions, but the server\'s own configuration still applies. Prefer the managed backend.');
251
252
  timer = setInterval(probe, 10000);
252
253
  timer.unref();
@@ -19,11 +19,25 @@ export function basicAuthHeader(username, password) {
19
19
  return `Basic ${Buffer.from(`${username || 'opencode'}:${password}`).toString('base64')}`;
20
20
  }
21
21
 
22
+ /**
23
+ * A backend URL as it may appear in logs and errors: scheme, host and port.
24
+ * Credentials, a path and a query string can all carry secrets.
25
+ * @param {string} url
26
+ */
27
+ export function backendOrigin(url) {
28
+ try {
29
+ return new URL(url).origin;
30
+ } catch {
31
+ return '(invalid URL)';
32
+ }
33
+ }
34
+
22
35
  /**
23
36
  * @param {{ baseUrl: string, username?: string, password?: string, fetchImpl?: typeof fetch }} options
24
37
  */
25
38
  export function createOpencodeClient({ baseUrl, username, password, fetchImpl = fetch }) {
26
39
  const base = String(baseUrl).replace(/\/+$/, '');
40
+ const shownBase = backendOrigin(base);
27
41
  const auth = basicAuthHeader(username, password);
28
42
  const headers = (extra) => ({ accept: 'application/json', ...(auth ? { authorization: auth } : {}), ...extra });
29
43
 
@@ -41,7 +55,7 @@ export function createOpencodeClient({ baseUrl, username, password, fetchImpl =
41
55
  });
42
56
  } catch (error) {
43
57
  if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
44
- throw new BackendError(`OpenCode backend unreachable at ${base}: ${error?.cause?.code || error?.message}`, { cause: error });
58
+ throw new BackendError(`OpenCode backend unreachable at ${shownBase}: ${error?.cause?.code || error?.message}`, { cause: error });
45
59
  }
46
60
  const text = await response.text();
47
61
  let parsed = null;