@commonlyai/cli 0.1.183 → 0.1.184

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.
@@ -371,7 +371,13 @@ const CHECKS = {
371
371
  optionalArgsDigest(data, 'tool.call');
372
372
  return data;
373
373
  },
374
- 'credential.granted': (data) => ({ ...data, names: envNames(data.names ?? [], 'credential.granted.names') }),
374
+ 'credential.granted': (data) => {
375
+ const names = envNames(data.names ?? [], 'credential.granted.names');
376
+ if (data.injected === undefined) return { ...data, names };
377
+ const injected = envNames(data.injected, 'credential.granted.injected');
378
+ if (!injected.every((name) => names.includes(name))) fail('credential.granted.injected must be a subset of names');
379
+ return { ...data, names, injected };
380
+ },
375
381
  'credential.denied': (data) => {
376
382
  envNames([data.name], 'credential.denied.name');
377
383
  return data;
@@ -23,8 +23,8 @@ export const EVENT_TYPES = Object.freeze([
23
23
  'usage', // { inputTokens?, outputTokens?, totalTokens?, cost? } null-not-zero when unknown
24
24
  'liveness.stalled', // { idleMs, windowMs }
25
25
  'liveness.recovery', // { action: 'cancel'|'kill'|'retry', attempt }
26
- 'credential.granted', // { names: [...] } names only, never values
27
- 'credential.denied', // { name, reason?: 'not_granted'|'unresolved'|'too_short' }
26
+ 'credential.granted', // { names: [...], injected?: [...] } names only, never values
27
+ 'credential.denied', // { name, reason?: 'not_granted'|'unresolved'|'too_short'|'no_upstream' }
28
28
  'process.exited', // { code, signal }
29
29
  // Record kinds with no earlier trace name (docs/record-v1.md has the full data).
30
30
  'task.claimed', // { leaseUntil? } mirrors the board's claim; the board stays the source of truth
@@ -19,6 +19,7 @@ import { traceOf } from '../trace/events.js';
19
19
  import { runFailure } from '../failures.js';
20
20
  import { MIN_CREDENTIAL_LENGTH } from '../upstream-refusal.js';
21
21
  import { validateGrant } from './grant.js';
22
+ import { startProviderProxy } from '../wire/provider-proxy.js';
22
23
 
23
24
  // Same spirit as the goose adapter's SAFE_ENV: locale, terminal and TLS trust
24
25
  // only. USER/LOGNAME are left out on purpose; they name the account whose
@@ -104,8 +105,8 @@ const grantIssued = (meta) => {
104
105
  };
105
106
 
106
107
  /**
107
- * prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta? })
108
- * → { env, home, cleanup, granted: [names], grantId? }
108
+ * prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta?, upstreams? })
109
+ * → { env, home, cleanup, granted: [names], injected: { NAME: { baseURL, wire } }, grantId? }
109
110
  *
110
111
  * `require` lists credential names the adapter cannot run without; one the
111
112
  * grant does not name fails the run as credential_denied before any spawn.
@@ -114,14 +115,25 @@ const grantIssued = (meta) => {
114
115
  * silently lacks its key fails later and less legibly, and a value that short
115
116
  * would slip past the refusal-text reader's redaction.
116
117
  *
118
+ * A credential the grant marks `inject: true` never reaches the env. The host
119
+ * names its provider in `upstreams[NAME]` ({ baseURL, wire }, wire defaulting
120
+ * to openai-chat); the run gets a loopback provider proxy holding the value in
121
+ * memory, env[NAME] becomes that proxy's token, and `injected[NAME].baseURL`
122
+ * is where the host points the CLI. cleanup() closes the proxies. One with no
123
+ * upstream fails as credential_denied (no_upstream) before it is resolved.
124
+ * The proxy listens on 127.0.0.1, so a run without the host's network
125
+ * (bwrap --unshare-net) cannot reach it yet.
126
+ *
117
127
  * Events go to `trace`, else ctx.trace: grant.issued (only with grantMeta,
118
- * first), then credential.granted {names} or credential.denied {name, reason}.
128
+ * first), then credential.granted {names, injected?} or credential.denied
129
+ * {name, reason}. `injected` lists the names held by a proxy; the rest of
130
+ * `names` are held by the run.
119
131
  * Values never enter an event or a message, and a hook that offers
120
- * redact(values) is handed the resolved values so the record can scrub them
121
- * out of agent text too.
132
+ * redact(values) is handed the resolved values and each proxy token, so the
133
+ * record can scrub them out of agent text too.
122
134
  */
123
135
  export const prepareRunEnv = async ({
124
- grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta,
136
+ grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta, upstreams = {},
125
137
  } = {}) => {
126
138
  const valid = validateGrant(grant);
127
139
  const hook = trace && typeof trace.emit === 'function' ? trace : traceOf(ctx);
@@ -132,6 +144,7 @@ export const prepareRunEnv = async ({
132
144
  not_granted: 'is not in the run grant',
133
145
  unresolved: 'could not be resolved',
134
146
  too_short: `resolved to a value shorter than ${MIN_CREDENTIAL_LENGTH} characters`,
147
+ no_upstream: 'is marked inject, but the host named no upstream for it',
135
148
  };
136
149
  const deny = (name, reason, values) => {
137
150
  hook.emit('credential.denied', { name, reason });
@@ -143,11 +156,18 @@ export const prepareRunEnv = async ({
143
156
  if (missing) throw deny(missing, 'not_granted', []);
144
157
 
145
158
  const { home, dirs } = await makeHome(root);
146
- const cleanup = () => rm(home, { recursive: true, force: true });
159
+ const proxies = [];
160
+ const cleanup = async () => {
161
+ await Promise.all(proxies.splice(0).map((proxy) => proxy.close()));
162
+ await rm(home, { recursive: true, force: true });
163
+ };
147
164
  const env = { ...pickBaseEnv(baseEnv, realHomes), HOME: home, ...dirs };
148
165
  const values = [];
166
+ const injected = {};
149
167
  try {
150
168
  for (const spec of valid.credentials) {
169
+ const upstream = spec.inject ? upstreams[spec.name] : undefined;
170
+ if (spec.inject && !upstream) throw deny(spec.name, 'no_upstream', values);
151
171
  let value;
152
172
  // A resolver's error text may quote the secret it failed on; drop it.
153
173
  try { value = await resolveOne(spec, { baseEnv, resolveSecret }); } catch { value = undefined; }
@@ -156,14 +176,26 @@ export const prepareRunEnv = async ({
156
176
  // refusal-text reader would not redact it. Refuse it here, before spawn.
157
177
  if (value.length < MIN_CREDENTIAL_LENGTH) throw deny(spec.name, 'too_short', values);
158
178
  values.push(value);
159
- env[spec.name] = value;
179
+ if (upstream) {
180
+ const wire = upstream.wire ?? 'openai-chat';
181
+ const proxy = await startProviderProxy({
182
+ provider: { baseURL: upstream.baseURL }, keyMaterial: value, wire, adapterLabel: 'Valet',
183
+ });
184
+ proxies.push(proxy);
185
+ env[spec.name] = proxy.token;
186
+ injected[spec.name] = { baseURL: proxy.baseURL, wire };
187
+ } else {
188
+ env[spec.name] = value;
189
+ }
160
190
  }
161
191
  } catch (error) {
162
192
  await cleanup();
163
193
  throw error;
164
194
  }
165
- if (typeof hook.redact === 'function') hook.redact(values);
195
+ // A proxy token authorizes provider calls until cleanup, so the record scrubs it too.
196
+ if (typeof hook.redact === 'function') hook.redact([...values, ...proxies.map((proxy) => proxy.token)]);
166
197
  const names = valid.credentials.map((c) => c.name);
167
- hook.emit('credential.granted', { names });
168
- return { env, home, cleanup, granted: names, ...(issued ? { grantId: issued.grantId } : {}) };
198
+ const proxied = Object.keys(injected);
199
+ hook.emit('credential.granted', proxied.length ? { names, injected: proxied } : { names });
200
+ return { env, home, cleanup, granted: names, injected, ...(issued ? { grantId: issued.grantId } : {}) };
169
201
  };
@@ -3,7 +3,7 @@
3
3
  * and which tool calls it may make without asking, only after asking, or never.
4
4
  *
5
5
  * {
6
- * credentials: [{ name: 'OPENAI_API_KEY', from: 'env'|'file'|'resolver', ref? }],
6
+ * credentials: [{ name: 'OPENAI_API_KEY', from: 'env'|'file'|'resolver', ref?, inject? }],
7
7
  * tools: { allow: [pattern], ask: [pattern], deny: [pattern] },
8
8
  * }
9
9
  *
@@ -11,6 +11,10 @@
11
11
  * path in `ref`; `from: 'resolver'` asks the host's resolveSecret(name, spec),
12
12
  * with `ref` as an opaque hint for it.
13
13
  *
14
+ * `inject: true` keeps the value out of the run: its env gets a per-run proxy
15
+ * token under the name instead, and a loopback provider proxy adds the real
16
+ * key to outbound requests. Default false: the run holds the value.
17
+ *
14
18
  * A tool pattern is `<kind>` or `<kind>:<title glob>`, where kind is one of
15
19
  * ACP's ToolKind values or `*`. `execute` matches every execute call;
16
20
  * `execute:git status*` matches execute calls whose title starts that way;
@@ -61,8 +65,8 @@ const parsePattern = (pattern, where) => {
61
65
  const validateCredential = (credential, index, seen) => {
62
66
  const where = `credentials[${index}]`;
63
67
  if (!isPlainObject(credential)) fail(`${where} must be an object`);
64
- refuseUnknownKeys(credential, ['name', 'from', 'ref'], `${where}.`);
65
- const { name, from, ref } = credential;
68
+ refuseUnknownKeys(credential, ['name', 'from', 'ref', 'inject'], `${where}.`);
69
+ const { name, from, ref, inject } = credential;
66
70
  if (typeof name !== 'string' || !ENV_NAME.test(name)) fail(`${where}.name must be an ENV_NAME (A-Z, 0-9, _)`);
67
71
  if (RESERVED_NAME.test(name)) fail(`${where}.name ${name} is reserved for the run env`);
68
72
  if (seen.has(name)) fail(`${where}.name ${name} is granted twice`);
@@ -71,7 +75,8 @@ const validateCredential = (credential, index, seen) => {
71
75
  if (ref !== undefined && (typeof ref !== 'string' || !ref)) fail(`${where}.ref must be a non-empty string`);
72
76
  if (from === 'env' && ref !== undefined && !ENV_NAME.test(ref)) fail(`${where}.ref must be an ENV_NAME for from: env`);
73
77
  if (from === 'file' && (ref === undefined || !ref.startsWith('/'))) fail(`${where}.ref must be an absolute path for from: file`);
74
- return Object.freeze(ref === undefined ? { name, from } : { name, from, ref });
78
+ if (inject !== undefined && typeof inject !== 'boolean') fail(`${where}.inject must be true or false`);
79
+ return Object.freeze({ name, from, ...(ref === undefined ? {} : { ref }), ...(inject ? { inject } : {}) });
75
80
  };
76
81
 
77
82
  /** Validate and freeze a grant. Throws on the first problem; never repairs one. */
@@ -1,6 +1,6 @@
1
1
  import { Transform } from 'node:stream';
2
2
  import { StringDecoder } from 'node:string_decoder';
3
- /** The per-spawn loopback proxy shared by OpenAI-compatible CLI adapters. */
3
+ /** The per-spawn loopback proxy that holds a provider key for a CLI (OpenAI chat, Anthropic Messages, OpenAI Responses). */
4
4
  import { randomBytes, timingSafeEqual } from 'crypto';
5
5
  import { constants as fsConstants, realpathSync } from 'fs';
6
6
  import { createServer, request as httpRequest } from 'http';
@@ -167,11 +167,30 @@ const readProviderKey = async (keyFile, adapterLabel) => {
167
167
  }
168
168
  };
169
169
 
170
+ // The routes a CLI on each wire may call, relative to the configured base
171
+ // path, and the header the real key goes out in. Anything else is refused.
172
+ const WIRES = Object.freeze({
173
+ 'openai-chat': { routes: [['POST', 'chat/completions'], ['GET', 'models']], auth: 'bearer' },
174
+ 'anthropic-messages': {
175
+ routes: [['POST', 'v1/messages'], ['POST', 'v1/messages/count_tokens'], ['GET', 'v1/models']],
176
+ auth: 'x-api-key',
177
+ },
178
+ 'openai-responses': { routes: [['POST', 'responses'], ['GET', 'models']], auth: 'bearer' },
179
+ });
180
+ export const PROVIDER_WIRES = Object.freeze(Object.keys(WIRES));
181
+
182
+ const presents = (value, expected) => {
183
+ const supplied = Buffer.from(typeof value === 'string' ? value : '');
184
+ return supplied.length === expected.length && timingSafeEqual(supplied, expected);
185
+ };
186
+
170
187
  export const startProviderProxy = async ({
171
- provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode',
188
+ provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode', wire = 'openai-chat',
172
189
  // When set, every completion must request this model and be served by it.
173
190
  pinnedModel,
174
191
  }) => {
192
+ const wireSpec = WIRES[wire];
193
+ if (!wireSpec) throw new Error(`${adapterLabel} provider wire must be one of ${PROVIDER_WIRES.join(', ')}`);
175
194
  if (pinnedModel !== undefined && (typeof pinnedModel !== 'string' || !pinnedModel.trim())) {
176
195
  throw new Error(`${adapterLabel} pinnedModel must be a non-empty string`);
177
196
  }
@@ -189,8 +208,13 @@ export const startProviderProxy = async ({
189
208
  const upstreamKey = keyMaterial === undefined ? await readProviderKey(keyFile, adapterLabel) : keyMaterial;
190
209
  if (typeof upstreamKey !== 'string' || !upstreamKey || /[\r\n]/.test(upstreamKey)) throw new Error('Invalid provider key');
191
210
  const proxyToken = randomBytes(32).toString('base64url');
192
- await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 });
193
- await chmod(tokenFile, 0o600);
211
+ // A caller that hands the token to its child in memory needs no file.
212
+ if (tokenFile) {
213
+ await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 });
214
+ await chmod(tokenFile, 0o600);
215
+ }
216
+ const bearerToken = Buffer.from(`Bearer ${proxyToken}`);
217
+ const bareToken = Buffer.from(proxyToken);
194
218
 
195
219
  const logRefusedRequest = (method, requestPath) => {
196
220
  let normalizedPath = '/';
@@ -204,29 +228,34 @@ export const startProviderProxy = async ({
204
228
  process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy refused ${safeMethod} ${normalizedPath}\n`);
205
229
  };
206
230
 
231
+ // A refusal answers before the request body has been read. A keep-alive
232
+ // client that reuses that socket can have its next request reset, so every
233
+ // refusal on every wire also closes the connection.
234
+ const refuse = (incoming, outgoing, status, headers = {}, body = undefined) => {
235
+ incoming.resume();
236
+ outgoing.writeHead(status, { ...headers, connection: 'close' });
237
+ outgoing.end(body);
238
+ };
239
+
207
240
  const server = createServer((incoming, outgoing) => {
208
- const supplied = Buffer.from(incoming.headers.authorization || '');
209
- const expected = Buffer.from(`Bearer ${proxyToken}`);
210
- if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
211
- incoming.resume();
212
- outgoing.writeHead(401).end();
241
+ // Anthropic clients send their key as x-api-key, the others as a Bearer.
242
+ if (!presents(incoming.headers.authorization, bearerToken)
243
+ && !presents(incoming.headers['x-api-key'], bareToken)) {
244
+ refuse(incoming, outgoing, 401);
213
245
  return;
214
246
  }
215
247
  if (!['GET', 'POST'].includes(incoming.method || '')) {
216
- incoming.resume();
217
- outgoing.writeHead(405).end();
248
+ refuse(incoming, outgoing, 405);
218
249
  return;
219
250
  }
220
251
  const rejectRoute = (path) => {
221
252
  logRefusedRequest(incoming.method, path);
222
- incoming.resume();
223
- outgoing.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
224
- outgoing.end(`Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`);
253
+ refuse(incoming, outgoing, 403, { 'content-type': 'text/plain; charset=utf-8' },
254
+ `Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`);
225
255
  };
226
256
  const rejectBadRequest = () => {
227
257
  logRefusedRequest(incoming.method, incoming.url);
228
- incoming.resume();
229
- outgoing.writeHead(400).end();
258
+ refuse(incoming, outgoing, 400);
230
259
  };
231
260
 
232
261
  let target;
@@ -241,8 +270,8 @@ export const startProviderProxy = async ({
241
270
  return;
242
271
  }
243
272
  // The CLI owns this local request path, so never use it as an outbound
244
- // URL. The OpenAI-compatible chat provider needs only these fixed routes;
245
- // parse the incoming URL against a loopback sentinel, then select a
273
+ // URL. Each wire needs only the fixed routes in WIRES; parse the
274
+ // incoming URL against a loopback sentinel, then select a
246
275
  // constant endpoint after matching the configured base path exactly.
247
276
  // This keeps a CLI request from turning the proxy into an arbitrary
248
277
  // path/URL fetcher (including paths on a local provider host).
@@ -252,17 +281,13 @@ export const startProviderProxy = async ({
252
281
  return;
253
282
  }
254
283
  const configuredPath = basePath === '/' ? '' : basePath;
255
- let endpoint;
256
- if (incoming.method === 'POST'
257
- && requested.pathname === `${configuredPath}/chat/completions`) {
258
- endpoint = 'chat/completions';
259
- } else if (incoming.method === 'GET'
260
- && requested.pathname === `${configuredPath}/models`) {
261
- endpoint = 'models';
262
- } else {
284
+ const route = wireSpec.routes.find(([method, path]) => incoming.method === method
285
+ && requested.pathname === `${configuredPath}/${path}`);
286
+ if (!route) {
263
287
  rejectRoute(requested.pathname);
264
288
  return;
265
289
  }
290
+ const endpoint = route[1];
266
291
  target = {
267
292
  path: `${configuredPath}/${endpoint}`,
268
293
  protocol: upstream.protocol,
@@ -281,7 +306,8 @@ export const startProviderProxy = async ({
281
306
  'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade',
282
307
  'x-api-key',
283
308
  ].includes(name.toLowerCase())));
284
- headers.authorization = `Bearer ${upstreamKey}`;
309
+ if (wireSpec.auth === 'x-api-key') headers['x-api-key'] = upstreamKey;
310
+ else headers.authorization = `Bearer ${upstreamKey}`;
285
311
  headers.host = target.host;
286
312
  headers['accept-encoding'] = 'identity';
287
313
  const pinned = pinnedModel !== undefined && target.path.endsWith('/chat/completions');
@@ -378,6 +404,12 @@ export const startProviderProxy = async ({
378
404
  });
379
405
  });
380
406
 
407
+ // A WebSocket client (codex on the Responses wire) falls back to HTTP SSE on
408
+ // 426. Nothing is relayed over an upgrade, so no token check is needed first.
409
+ server.on('upgrade', (_incoming, socket) => {
410
+ socket.end('HTTP/1.1 426 Upgrade Required\r\nconnection: close\r\ncontent-length: 0\r\n\r\n');
411
+ });
412
+
381
413
  await new Promise((resolve, reject) => {
382
414
  server.once('error', reject);
383
415
  const started = () => {
@@ -399,6 +431,7 @@ export const startProviderProxy = async ({
399
431
  const proxyBaseURL = new URL(upstream.pathname, `http://127.0.0.1:${port}`).toString();
400
432
  return {
401
433
  baseURL: proxyBaseURL,
434
+ token: proxyToken,
402
435
  tokenFile,
403
436
  socketPath,
404
437
  port,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.183",
3
+ "version": "0.1.184",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",