@commonlyai/cli 0.1.225 → 0.1.227

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.
@@ -17,6 +17,6 @@
17
17
  "node": ">=20.0.0"
18
18
  },
19
19
  "scripts": {
20
- "test": "node --test \"test/**/*.test.js\""
20
+ "test": "node --test $([ -z \"$CI\" ] && echo --test-concurrency=4) \"test/**/*.test.js\""
21
21
  }
22
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.225",
3
+ "version": "0.1.227",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -1,16 +1,20 @@
1
- /** Goose 1.54.0 wrapper. Public seats wait for TASK-236's deny-by-default proof. */
1
+ /** Goose 1.54.0 wrapper. Public seats expose only the Commonly MCP extension. */
2
2
  import { requireSeatModel } from './seat-model.js';
3
3
  import { spawn as childSpawn, spawnSync } from 'node:child_process';
4
- import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
5
5
  import { tmpdir } from 'node:os';
6
- import { join } from 'node:path';
6
+ import { isAbsolute, join } from 'node:path';
7
7
  import { StringDecoder } from 'node:string_decoder';
8
8
  import { buildMemoryPreamble } from '../memory-bridge.js';
9
9
  import { effectiveSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec } from '../environment.js';
10
10
  import { writeCredentialFile } from '../credential-file.js';
11
11
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
12
- import { prepareMcpSpawn } from '../mcp-home.js';
13
- import { resolveProviderKeyFile } from '../provider-proxy.js';
12
+ import { prepareMcpSpawn, selectedMcpVersionDir } from '../mcp-home.js';
13
+ import { describeMcpCommand } from '../mcp-server-version.js';
14
+ import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js';
15
+ import { resolvePublicSandboxMode } from '../sandbox/mode.js';
16
+ import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
17
+ import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
14
18
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
15
19
 
16
20
  // A version enters this list only after a real headless turn with that binary.
@@ -21,14 +25,51 @@ const DEFAULT_PROVIDER_HOST = 'https://litellm.commonly.me';
21
25
  const DEFAULT_PROVIDER_KEY = 'COMMONLY_LITELLM_KEY';
22
26
  const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
23
27
  const SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
28
+ const safeEnv = (env) => Object.fromEntries(Object.entries(env).filter(([name]) => SAFE_ENV.test(name)));
24
29
 
25
- export const assertNoSandboxDeclared = (environment = {}) => {
30
+ // Goose renders a recipe file as a template before it reads it, so a stranger's
31
+ // `{{ … }}` or `{% include %}` in the prompt would run. Every `{` is written as
32
+ // an expression that prints one, which renders back to exactly the given text.
33
+ const templateLiteral = (value) => {
34
+ if (typeof value === 'string') return value.replace(/\{/g, "{{ '{' }}");
35
+ if (Array.isArray(value)) return value.map(templateLiteral);
36
+ if (value && typeof value === 'object') {
37
+ return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, templateLiteral(item)]));
38
+ }
39
+ return value;
40
+ };
41
+ // Explicit API-only list: new MCP tools do not silently gain public authority.
42
+ // attach_file reads this process's filesystem, including its credential file.
43
+ export const PUBLIC_GOOSE_TOOLS = Object.freeze([
44
+ 'commonly_get_started', 'commonly_post_message', 'commonly_claim_message',
45
+ 'commonly_release_claim', 'commonly_get_messages', 'commonly_get_context',
46
+ 'commonly_list_files', 'commonly_list_artifacts', 'commonly_read_file',
47
+ 'commonly_get_posts', 'commonly_post_thread_comment', 'commonly_get_tasks',
48
+ 'commonly_create_task', 'commonly_claim_task', 'commonly_watch_task',
49
+ 'commonly_complete_task', 'commonly_update_task', 'commonly_create_pod',
50
+ 'commonly_list_pods', 'commonly_self_install_into_pod', 'commonly_read_agent_memory',
51
+ 'commonly_write_agent_memory', 'commonly_save_my_memory', 'commonly_log_cycle',
52
+ 'commonly_dm_agent', 'commonly_ask_agent', 'commonly_request_decision',
53
+ 'commonly_respond_to_ask', 'commonly_react_to_message',
54
+ ]);
55
+
56
+ export const assertSandboxSupported = (environment = {}) => {
26
57
  const sandbox = environment?.sandbox || {};
27
58
  refuseWorkspaceTrust(sandbox, 'goose');
28
59
  if (effectiveSandboxTrust(sandbox.trust) === 'public') {
29
- throw new Error('goose adapter: public-trust seats are not supported until the deny-by-default attack test passes');
30
- }
31
- if (sandbox.mode && sandbox.mode !== 'none') {
60
+ if (sandbox.filesystem?.['read-outside']?.length) {
61
+ throw new Error('goose adapter: public seats do not allow sandbox.filesystem.read-outside');
62
+ }
63
+ if (!['workspace', 'read-only', 'bwrap'].includes(resolvePublicSandboxMode(sandbox))) {
64
+ throw new Error('goose adapter: public seats require an enforced sandbox');
65
+ }
66
+ if (!environment.provider || !environment.model) {
67
+ throw new Error('goose adapter: public seats require an explicit provider and model');
68
+ }
69
+ if (sandbox.network?.policy === 'restricted') {
70
+ throw new Error('goose adapter: restricted networking is not supported');
71
+ }
72
+ } else if (sandbox.mode && sandbox.mode !== 'none') {
32
73
  throw new Error(`goose adapter: sandbox.mode=${sandbox.mode} cannot be enforced by Goose`);
33
74
  }
34
75
  };
@@ -51,7 +92,14 @@ const substitute = (value, ctx) => String(value).replace(PLACEHOLDER_RE, (whole,
51
92
  /** The flag contains paths and URLs only. Literal MCP secrets cannot ride argv. */
52
93
  export const extensionArgs = (servers, ctx) => {
53
94
  const args = [];
95
+ if (ctx.publicSeat && (!Array.isArray(servers) || servers.length !== 1)) {
96
+ throw new Error('goose adapter: public seats require only the Commonly MCP extension');
97
+ }
54
98
  for (const server of servers || []) {
99
+ if (ctx.publicSeat && (server.name !== 'commonly' || (server.transport || 'stdio') !== 'stdio'
100
+ || !describeMcpCommand(server.command)?.isCommonly)) {
101
+ throw new Error('goose adapter: public seats require only the Commonly stdio MCP extension');
102
+ }
55
103
  if (!/^[A-Za-z0-9_-]{2,}$/.test(server?.name || '')) {
56
104
  throw new Error('goose adapter: MCP server needs a safe name');
57
105
  }
@@ -75,7 +123,13 @@ export const extensionArgs = (servers, ctx) => {
75
123
  if (transport !== 'stdio' || !Array.isArray(server.command) || !server.command.length) {
76
124
  throw new Error(`goose adapter: MCP server ${server.name} has an unsupported transport or command`);
77
125
  }
78
- const command = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl });
126
+ const command = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl, publicSeat: ctx.publicSeat });
127
+ if (!command) throw new Error('goose adapter: Commonly MCP is not prepared for this public turn');
128
+ if (ctx.publicSeat) {
129
+ const tree = selectedMcpVersionDir(command);
130
+ if (!tree || !isAbsolute(tree)) throw new Error('goose adapter: public MCP requires an absolute prepared entry');
131
+ ctx.mcpReadDirectories?.push(tree);
132
+ }
79
133
  const delivery = deliverSeatCredential({ ...server, command }, {
80
134
  credentialFile: ctx.credentialFile, label: 'goose',
81
135
  });
@@ -94,7 +148,15 @@ export const extensionArgs = (servers, ctx) => {
94
148
  return gooseWord(`${name}=${resolved}`);
95
149
  });
96
150
  const commandWords = command.map((part) => gooseWord(substitute(part, ctx)));
97
- args.push('--with-extension', `${server.name}:${[...env, ...commandWords].join(' ')}`);
151
+ if (ctx.publicSeat) {
152
+ ctx.publicExtensions.push({ type: 'stdio', name: server.name, cmd: command[0],
153
+ args: command.slice(1).map((part) => substitute(part, ctx)),
154
+ envs: Object.fromEntries(Object.entries(delivery.env).map(([name, value]) => [name, substitute(value, ctx)])),
155
+ available_tools: PUBLIC_GOOSE_TOOLS,
156
+ });
157
+ } else {
158
+ args.push('--with-extension', `${server.name}:${[...env, ...commandWords].join(' ')}`);
159
+ }
98
160
  }
99
161
  return args;
100
162
  };
@@ -181,7 +243,7 @@ export default {
181
243
  supportsProvider: true,
182
244
 
183
245
  async validateEnvironment(environment, workspacePath) {
184
- assertNoSandboxDeclared(environment);
246
+ assertSandboxSupported(environment);
185
247
  const validation = validateEnvironmentSpec(environment || {});
186
248
  if (!validation.ok) throw new Error(validation.errors.join('; '));
187
249
  return resolveProviderKeyFile(environment?.provider, workspacePath, 'Goose');
@@ -197,61 +259,124 @@ export default {
197
259
  async spawn(prompt, ctx = {}) {
198
260
  const seatModel = requireSeatModel(ctx.environment, 'goose', true);
199
261
  const keyFile = await this.validateEnvironment(ctx.environment, ctx.cwd);
262
+ const isPublic = effectiveSandboxTrust(ctx.environment?.sandbox?.trust) === 'public';
200
263
  const sourceEnv = ctx.env || process.env;
201
264
  const configured = ctx.environment?.provider;
202
- const key = configured
265
+ const key = isPublic ? '' : configured
203
266
  ? (await readFile(keyFile, 'utf8')).trim() : sourceEnv[DEFAULT_PROVIDER_KEY];
204
- if (!key) throw new Error(`goose adapter: ${configured ? 'provider.keyFile' : DEFAULT_PROVIDER_KEY} is missing`);
267
+ if (!isPublic && !key) throw new Error(`goose adapter: ${configured ? 'provider.keyFile' : DEFAULT_PROVIDER_KEY} is missing`);
205
268
  const baseUrl = new URL(configured?.baseURL || `${DEFAULT_PROVIDER_HOST}/v1`);
206
269
  if (!/^https?:$/.test(baseUrl.protocol) || baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) {
207
270
  throw new Error('goose adapter: provider.baseURL must be a plain HTTP(S) URL');
208
271
  }
209
272
  const model = seatModel;
210
- const binary = ctx._binaryPath || 'goose';
211
- const version = ctx._version || versionOf(spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: 5000 }).stdout);
273
+ const binary = ctx._binaryPath || (isPublic
274
+ ? String(spawnSync('which', ['goose'], { encoding: 'utf8', env: sourceEnv }).stdout || '').trim()
275
+ : 'goose');
276
+ if (isPublic && !isAbsolute(binary)) throw new Error('goose adapter: public seats require an absolute Goose executable');
277
+ // A cold first launch can take several seconds while macOS scans the binary.
278
+ const version = ctx._version || versionOf(spawnSync(binary, ['--version'], {
279
+ encoding: 'utf8', timeout: 30000, env: safeEnv(sourceEnv),
280
+ }).stdout);
212
281
  if (!TESTED_GOOSE_VERSIONS.includes(version)) {
282
+ if (isPublic) throw new Error(`goose adapter: public seats refuse untested version ${version || 'unknown'}`);
213
283
  // eslint-disable-next-line no-console
214
284
  console.warn(`[goose] installed ${version || 'unknown'} differs from smoke-tested ${TESTED_GOOSE_VERSIONS.join(', ')}; private seat continues`);
215
285
  }
216
286
  const home = await mkdtemp(join(tmpdir(), 'commonly-goose-'));
287
+ let proxy;
217
288
  try {
218
289
  const credential = writeCredentialFile(ctx.runtimeToken, {
219
290
  root: home, agentName: ctx.agentName || 'agent',
220
291
  });
292
+ if (isPublic) proxy = await startProviderProxy({
293
+ provider: configured, keyFile, tokenFile: join(home, 'provider-proxy-token'), adapterLabel: 'Goose',
294
+ pinnedModel: model,
295
+ });
296
+ const mcpReadDirectories = [];
297
+ const publicExtensions = [];
298
+ // Node's compiled-in OpenSSL config can sit outside the permitted tree
299
+ // (including hostedtoolcache on CI). Use our empty config, retaining
300
+ // Node's bundled TLS roots, instead of granting host configuration reads.
301
+ const opensslConfig = join(home, 'openssl.cnf');
302
+ if (isPublic) await writeFile(opensslConfig, '', { mode: 0o600 });
303
+ // The recipe's own directory is the template include root, so it holds
304
+ // the recipe alone, never the proxy token or the credential file.
305
+ const recipeDir = join(home, 'recipe');
306
+ const recipePath = join(recipeDir, 'public-recipe.json');
221
307
  const args = [
222
- 'run', '--no-session', '--no-profile', '--with-builtin', 'developer',
308
+ 'run', '--no-session', ...(!isPublic ? ['--no-profile', '--with-builtin', 'developer'] : []),
309
+ ...(isPublic ? ['--recipe', recipePath] : []),
223
310
  '--output-format', 'stream-json', '--provider', 'openai', '--model', model,
224
311
  ...extensionArgs(ctx.environment?.mcp, {
225
312
  credentialFile: credential?.path || '', instanceUrl: ctx.instanceUrl || '',
226
313
  runtimeToken: ctx.runtimeToken || '',
314
+ publicSeat: isPublic, mcpReadDirectories, publicExtensions,
227
315
  }),
228
- '-t', buildMemoryPreamble(prompt, ctx.memoryLongTerm, { freshSession: true }),
316
+ ...(!isPublic ? ['-t', buildMemoryPreamble(prompt, ctx.memoryLongTerm, { freshSession: true })] : []),
229
317
  ];
318
+ if (isPublic) await mkdir(recipeDir, { mode: 0o700 });
319
+ if (isPublic) await writeFile(recipePath, JSON.stringify(templateLiteral({ version: '1.0.0',
320
+ title: 'Commonly public seat', description: 'Only audited Commonly API tools are available.',
321
+ instructions: 'Respond to the user using the available Commonly tools.',
322
+ extensions: publicExtensions.map((extension) => ({ ...extension,
323
+ envs: { ...extension.envs, OPENSSL_CONF: opensslConfig },
324
+ })),
325
+ prompt: buildMemoryPreamble(prompt, ctx.memoryLongTerm, { freshSession: true }),
326
+ })), { mode: 0o600 });
230
327
  const env = {
231
- ...Object.fromEntries(Object.entries(sourceEnv).filter(([name]) => SAFE_ENV.test(name))),
328
+ ...safeEnv(sourceEnv),
232
329
  HOME: home,
330
+ TMPDIR: home,
233
331
  XDG_CONFIG_HOME: join(home, 'config'),
234
332
  XDG_DATA_HOME: join(home, 'data'),
333
+ GOOSE_PATH_ROOT: home,
235
334
  GOOSE_PROVIDER: 'openai',
236
335
  GOOSE_MODEL: model,
237
336
  GOOSE_MODE: 'auto',
238
337
  GOOSE_TELEMETRY_ENABLED: 'false',
239
- OPENAI_HOST: baseUrl.origin,
240
- OPENAI_BASE_PATH: `${baseUrl.pathname.replace(/\/$/, '')}/chat/completions`.replace(/^\//, ''),
241
- OPENAI_API_KEY: key,
338
+ ...(isPublic ? { OPENSSL_CONF: opensslConfig } : {}),
339
+ OPENAI_HOST: proxy ? new URL(proxy.baseURL).origin : baseUrl.origin,
340
+ OPENAI_BASE_PATH: `${new URL(proxy?.baseURL || baseUrl).pathname.replace(/\/$/, '')}/chat/completions`.replace(/^\//, ''),
341
+ OPENAI_API_KEY: proxy ? (await readFile(proxy.tokenFile, 'utf8')).trim() : key,
242
342
  };
243
343
  withholdRuntimeCredential(env, { credentialFile: credential?.path || null });
244
344
  const requestedTimeout = Number(ctx.timeoutMs);
245
345
  const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
246
346
  ? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
347
+ let executable = binary;
348
+ let spawnArgs = args;
349
+ if (isPublic) {
350
+ const sandbox = { ...ctx.environment.sandbox, trust: 'public' };
351
+ const mode = resolvePublicSandboxMode(sandbox, ctx._platform || process.platform);
352
+ if (mode === 'bwrap') {
353
+ const available = (ctx._detectBwrap || detectBwrap)();
354
+ if (!available.available) throw new Error(`goose adapter: bwrap unavailable: ${available.error}`);
355
+ [executable, ...spawnArgs] = (ctx._wrapArgvWithBwrap || wrapArgvWithBwrap)([binary, ...args], {
356
+ ...ctx.environment, sandbox: { ...sandbox, filesystem: {
357
+ ...(sandbox.filesystem || {}), 'write-outside': [home],
358
+ } },
359
+ }, { workspacePath: ctx.cwd, homePath: home,
360
+ readOnlyPaths: [binary, process.execPath, ...mcpReadDirectories] });
361
+ } else {
362
+ if ((ctx._platform || process.platform) !== 'darwin') throw new Error('goose adapter: Seatbelt requires macOS');
363
+ [executable, ...spawnArgs] = (ctx._wrapArgvWithSeatbelt || wrapArgvWithSeatbelt)([binary, ...args], {
364
+ workspacePath: ctx.cwd, workspaceAccess: mode === 'read-only' ? 'read' : 'write',
365
+ executablePath: binary, statePath: home, mcpConfigDir: home,
366
+ loopbackNetworkPorts: [proxy.port], readOnlyDirectories: mcpReadDirectories,
367
+ });
368
+ }
369
+ }
247
370
  const text = await runGoose({
248
- binary, args, cwd: ctx.cwd, env, timeoutMs,
249
- credentials: [...spawnCredentials(ctx, [DEFAULT_PROVIDER_KEY]), key],
371
+ binary: executable, args: spawnArgs, cwd: ctx.cwd, env, timeoutMs,
372
+ credentials: [...spawnCredentials(ctx, [DEFAULT_PROVIDER_KEY]), key, env.OPENAI_API_KEY],
250
373
  spawnImpl: ctx._spawnImpl,
251
374
  });
252
375
  return { text, newSessionId: null };
253
376
  } finally {
254
- await rm(home, { recursive: true, force: true });
377
+ try { await proxy?.close(); } finally {
378
+ await rm(home, { recursive: true, force: true });
379
+ }
255
380
  }
256
381
  },
257
382
  };
@@ -34,8 +34,8 @@ export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi', 'open
34
34
  * The adapters that can ENFORCE the default sandbox — a strict subset of the
35
35
  * ones that consume `mcp[]`.
36
36
  *
37
- * `pi` and `goose` are absent deliberately. Neither can enforce the declared
38
- * sandbox; their adapters refuse effective public trust and non-none modes.
37
+ * `pi` is absent deliberately: it cannot enforce the declared sandbox.
38
+ * Goose uses Seatbelt/bwrap and exposes only its prepared Commonly MCP extension.
39
39
  * `pi` has no sandbox path until #1740's transport
40
40
  * work gives it one, and since #1727 it REFUSES TO START on a spec that
41
41
  * declares one: `assertNoSandboxDeclared` in adapters/pi.js throws on an
@@ -75,8 +75,10 @@ export const ADAPTERS_WITH_GRANT_BROKER = new Set(['claude', 'codex']);
75
75
  * moves a host fact into the database and breaks the day the seat is re-homed.
76
76
  * An explicit mode in a record still wins over the derived one.
77
77
  *
78
- * Confinement holds for claude, codex, opencode, and qwen, and only they are handed
79
- * this block (ADAPTERS_WITH_DEFAULT_SANDBOX). Grant-broker eligibility is
78
+ * Claude, codex, opencode and qwen receive this default block
79
+ * (ADAPTERS_WITH_DEFAULT_SANDBOX). Goose supports explicitly configured public
80
+ * confinement, but defaults cannot supply its required provider and model.
81
+ * Grant-broker eligibility is
80
82
  * narrower until the server admits OpenCode after its confinement proof.
81
83
  * A pi seat must never be handed this block: it fails closed on a declared
82
84
  * sandbox rather than run under a spec it cannot honour.