@commonlyai/cli 0.1.207 → 0.1.209

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.207",
3
+ "version": "0.1.209",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -0,0 +1,48 @@
1
+ /** Repository verification entry point; scenario implementations live in backend. */
2
+ import { existsSync } from 'fs';
3
+ import { resolve, join } from 'path';
4
+ import { spawnSync } from 'child_process';
5
+
6
+ export const SCENARIOS = {
7
+ wave1: '__tests__/service/verify.wave1.test.js',
8
+ 'connector-grant': '__tests__/service/verify.connectorGrant.test.js',
9
+ };
10
+ const testDatabase = (name) => /^(test|verify)([-_0-9]|$)|[-_]test$/.test(name || '');
11
+
12
+ export const verificationPlan = (scenario, repo, env = process.env) => {
13
+ const suite = SCENARIOS[scenario];
14
+ if (!suite) throw new Error(`Unknown scenario: ${scenario}. Choose ${Object.keys(SCENARIOS).join(', ')}.`);
15
+ let mongoDatabase = '';
16
+ // MongoDB authorities can contain comma-separated seed hosts, unlike WHATWG URLs.
17
+ const mongoPath = /^mongodb(?:\+srv)?:\/\/[^/?#]+\/([^?#]*)/.exec(env.MONGO_URI || '');
18
+ try { mongoDatabase = decodeURIComponent(mongoPath?.[1] || ''); } catch { /* refused below */ }
19
+ if (!env.PG_HOST || !testDatabase(env.PG_DATABASE) || !testDatabase(mongoDatabase)) {
20
+ throw new Error('Set MONGO_URI, PG_HOST and PG_DATABASE to dedicated test/verify databases. Fixture setup clears their data.');
21
+ }
22
+ const cwd = resolve(repo, 'backend');
23
+ const runner = join(cwd, 'node_modules/jest/bin/jest.js');
24
+ if (!existsSync(join(cwd, suite)) || !existsSync(runner)) {
25
+ throw new Error('Use --repo with a Commonly checkout containing the scenario and installed backend dependencies.');
26
+ }
27
+ return { command: process.execPath, args: [runner, '--runInBand', '--forceExit', '--runTestsByPath', suite,
28
+ ...(env.COMMONLY_VERIFY_CACHE_DIR ? ['--cacheDirectory', env.COMMONLY_VERIFY_CACHE_DIR] : [])],
29
+ options: { cwd, stdio: 'inherit', env: { ...env, INTEGRATION_TEST: 'true' } } };
30
+ };
31
+
32
+ export const registerVerify = (program, run = spawnSync) => {
33
+ program.command('verify <scenario>')
34
+ .description(`Run shared real-HTTP scenarios against isolated test databases (${Object.keys(SCENARIOS).join(', ')})`)
35
+ .option('--repo <path>', 'Commonly checkout with backend test dependencies', process.cwd())
36
+ .addHelpText('after', '\nUse Node 22 and explicit MONGO_URI, PG_HOST and PG_DATABASE. Fixture databases are cleared.\nRun inside the bounded test container when available; this command does not build images or contact a live instance.\n')
37
+ .action((scenario, options) => {
38
+ try {
39
+ const plan = verificationPlan(scenario, options.repo);
40
+ const result = run(plan.command, plan.args, plan.options);
41
+ if (result.error) throw result.error;
42
+ process.exitCode = result.status === null ? 1 : result.status;
43
+ } catch (error) {
44
+ console.error(error.message);
45
+ process.exitCode = 1;
46
+ }
47
+ });
48
+ };
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ import { registerAgent } from './commands/agent.js';
20
20
  import { registerDaemon } from './commands/daemon.js';
21
21
  import { registerPod } from './commands/pod.js';
22
22
  import { registerDev } from './commands/dev.js';
23
+ import { registerVerify } from './commands/verify.js';
23
24
 
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
26
  const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
@@ -51,6 +52,7 @@ registerPod(program);
51
52
 
52
53
  // Local dev environment
53
54
  registerDev(program);
55
+ registerVerify(program);
54
56
 
55
57
  // Each subcommand owns its own `--instance <url>` flag; a program-level
56
58
  // duplicate shadowed the subcommand value on commander v12, causing
@@ -43,7 +43,7 @@ import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process
43
43
  import { requireSeatModel, seatModelRequired } from './seat-model.js';
44
44
  import { spawn as childSpawn, spawnSync } from 'child_process';
45
45
  import { createHash } from 'crypto';
46
- import { accessSync, constants } from 'fs';
46
+ import { accessSync, constants, existsSync, realpathSync } from 'fs';
47
47
  import {
48
48
  chmod,
49
49
  lstat,
@@ -51,6 +51,7 @@ import {
51
51
  mkdtemp,
52
52
  readFile,
53
53
  readlink,
54
+ realpath,
54
55
  rename,
55
56
  rm,
56
57
  symlink,
@@ -58,10 +59,15 @@ import {
58
59
  writeFile,
59
60
  } from 'fs/promises';
60
61
  import { homedir, tmpdir } from 'os';
62
+ import { createRequire } from 'module';
61
63
  import {
64
+ basename,
65
+ delimiter,
62
66
  dirname,
67
+ isAbsolute,
63
68
  join,
64
69
  resolve as pathResolve,
70
+ sep,
65
71
  } from 'path';
66
72
  import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
67
73
  import { supportedGitHooks } from '../commit-attribution.js';
@@ -279,20 +285,116 @@ const statOrNull = async (path) => {
279
285
  // therefore expose global private instructions even though shell reads of
280
286
  // ~/.codex are denied. Give every public wrapper identity its own persistent
281
287
  // Codex home for sessions/state, with only an auth symlink back to the
282
- // operator credential. The symlink itself lives under ~/.commonly, which the
283
- // permission profile denies to model-generated commands.
284
- const preparePublicCodexHome = async (ctx) => {
288
+ // operator credential. On macOS the symlink lives under ~/.commonly, which the
289
+ // permission profile denies to model-generated commands; on Linux (below) its
290
+ // target, ~/.codex, is denied instead.
291
+ //
292
+ // On Linux the home cannot sit under ~/.commonly. Codex (0.164 and later)
293
+ // runs its file helper by exec'ing `$CODEX_HOME/tmp/arg0-linux/.../
294
+ // codex-linux-sandbox` inside bwrap, and grants read on that one file, but a
295
+ // denied ancestor always wins, so the profile's ~/.commonly deny made every
296
+ // public session fail at bootstrap with "bwrap: execvp ... Permission denied".
297
+ // Outside every granted root the home is still unreadable to model commands:
298
+ // Codex binds the helper file alone, not the home around it (measured on
299
+ // codex-cli 0.164.0, where a home there reached the model call). That holds
300
+ // only while the workspace does not contain the home, so such a workspace is
301
+ // refused. macOS has no arg0 helper and keeps its homes where they were.
302
+ const LEGACY_CODEX_HOMES = (home) => join(home, '.commonly', 'codex-homes');
303
+
304
+ const contains = (outer, inner) => inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
305
+
306
+ const stateHomeUnderCommonly = (ctx, home) => new Error(
307
+ `XDG_STATE_HOME (${ctx.env?.XDG_STATE_HOME}) puts the public Codex home under ${join(home, '.commonly')}, `
308
+ + 'which the public sandbox denies on Linux. Point it elsewhere or unset it.',
309
+ );
310
+
311
+ const publicCodexHomesRoot = (ctx) => {
312
+ const home = ctx._homeDir || homedir();
313
+ if ((ctx._platform || process.platform) !== 'linux') return LEGACY_CODEX_HOMES(home);
314
+ const stateHome = ctx.env?.XDG_STATE_HOME;
315
+ const root = join(stateHome && isAbsolute(stateHome) ? stateHome : join(home, '.local', 'state'),
316
+ 'commonly', 'codex-homes');
317
+ // An override that lands back under the denied tree would bring the
318
+ // bootstrap failure above back with it. Checked here by name, and again
319
+ // through symlinks before anything is created (preparePublicCodexHome).
320
+ if (contains(pathResolve(home, '.commonly'), pathResolve(root))) throw stateHomeUnderCommonly(ctx, home);
321
+ return root;
322
+ };
323
+
324
+ // The real path of a path that may not exist yet: its nearest existing
325
+ // ancestor resolved, with the rest appended.
326
+ const realpathOfNearest = async (path) => {
327
+ const rest = [];
328
+ let current = pathResolve(path);
329
+ for (;;) {
330
+ try {
331
+ return join(await realpath(current), ...rest);
332
+ } catch (err) {
333
+ // Only a missing path walks up. Anything else (EACCES, ELOOP) would leave
334
+ // the containment check comparing a path nobody resolved: fail closed.
335
+ if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') throw err;
336
+ const parent = dirname(current);
337
+ if (parent === current) return join(current, ...rest);
338
+ rest.unshift(basename(current));
339
+ current = parent;
340
+ }
341
+ }
342
+ };
343
+
344
+ const preparePublicCodexHome = async (ctx, childEnv) => {
285
345
  const operatorHome = ctx.env?.CODEX_HOME
286
346
  || process.env.CODEX_HOME
287
347
  || join(homedir(), '.codex');
288
348
  const identity = ctx.agentName || ctx.cwd || 'anonymous';
289
349
  const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
290
- const publicHome = ctx._publicCodexHome
291
- || join(homedir(), '.commonly', 'codex-homes', identityHash);
350
+ const homesRoot = publicCodexHomesRoot(ctx);
351
+ const publicHome = ctx._publicCodexHome || join(homesRoot, identityHash);
292
352
  if (pathResolve(publicHome) === pathResolve(operatorHome)) {
293
353
  throw new Error('public codex home must be isolated from the operator CODEX_HOME');
294
354
  }
295
355
 
356
+ // Checked before anything is created or moved, so a refused workspace
357
+ // never receives the home or a migrated seat's sessions.
358
+ if (!ctx._publicCodexHome && homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) {
359
+ const home = ctx._homeDir || homedir();
360
+ const [realRoot, realCommonly] = await Promise.all([realpathOfNearest(homesRoot), realpathOfNearest(join(home, '.commonly'))]);
361
+ if (contains(realCommonly, realRoot)) throw stateHomeUnderCommonly(ctx, home);
362
+ const workspacePath = pathResolve(ctx.cwd || process.cwd());
363
+ const [workspace, realHome] = await Promise.all([
364
+ // As in realpathOfNearest: only a missing workspace falls back to its own path.
365
+ realpath(workspacePath).catch((err) => {
366
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return workspacePath;
367
+ throw err;
368
+ }),
369
+ realpathOfNearest(publicHome),
370
+ ]);
371
+ if (contains(workspace, realHome)) {
372
+ throw new Error(`public codex agents cannot run in ${ctx.cwd}: it contains the agent's own `
373
+ + `Codex home (${publicHome}), which the sandbox would then expose. Run it in a project folder.`);
374
+ }
375
+ }
376
+
377
+ // A Linux seat that ran before the move keeps its sessions, so a resumed
378
+ // thread still resumes. Across filesystems it starts fresh instead, and
379
+ // says so: the old sessions stay where they were, untouched, and a copy
380
+ // that failed halfway would leave a home later runs never migrate again.
381
+ const legacyHome = join(LEGACY_CODEX_HOMES(ctx._homeDir || homedir()), identityHash);
382
+ if (!ctx._publicCodexHome && legacyHome !== publicHome && !(await statOrNull(publicHome))) {
383
+ const legacy = await statOrNull(legacyHome);
384
+ if (legacy?.isDirectory()) {
385
+ await mkdir(homesRoot, { recursive: true, mode: 0o700 });
386
+ try {
387
+ await rename(legacyHome, publicHome);
388
+ } catch (err) {
389
+ if (err?.code !== 'EXDEV') throw err;
390
+ console.warn(
391
+ `[codex] ${legacyHome} is on another filesystem from ${publicHome}; starting a fresh Codex home. `
392
+ + 'Earlier sessions stay in the old home and will not resume; move it there by hand to keep them.',
393
+ );
394
+ }
395
+ }
396
+ }
397
+
296
398
  await mkdir(publicHome, { recursive: true, mode: 0o700 });
297
399
  await chmod(publicHome, 0o700);
298
400
 
@@ -317,7 +419,53 @@ const preparePublicCodexHome = async (ctx) => {
317
419
  // fail closed with its normal "not logged in" error.
318
420
  await unlink(targetAuth);
319
421
  }
320
- return publicHome;
422
+ // Model commands are sandboxed through the arg0 helper in this folder, a
423
+ // symlink to Codex's own binary, and unlike the file helper that path grants
424
+ // itself neither the folder nor the binary. Without both, every command
425
+ // fails with "bwrap: execvp ... No such file or directory" (measured on
426
+ // codex-cli 0.164.0 with a user-prefix npm install). Only where the home
427
+ // sits outside every deny can the folder's grant take effect.
428
+ const helperReadPaths = [];
429
+ if (homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) {
430
+ const helperDirectory = join(publicHome, 'tmp', 'arg0-linux');
431
+ await mkdir(helperDirectory, { recursive: true, mode: 0o700 });
432
+ helperReadPaths.push(helperDirectory);
433
+ // Looked up on the PATH the child gets, so the grant names the binary it runs.
434
+ const binary = (ctx._codexNativeBinary || codexNativeBinary)(childEnv);
435
+ if (binary) helperReadPaths.push(binary);
436
+ }
437
+ return { home: publicHome, helperReadPaths };
438
+ };
439
+
440
+ // The native binary `codex` on PATH runs: itself when it is one, or the one
441
+ // the npm launcher (`bin/codex.js`) picks, resolved the way that launcher does.
442
+ // Only the Linux targets are named because only the Linux helper needs this.
443
+ const LINUX_CODEX_TARGETS = { x64: 'x86_64-unknown-linux-musl', arm64: 'aarch64-unknown-linux-musl' };
444
+
445
+ export const codexNativeBinary = (env) => {
446
+ const onPath = String(env?.PATH || '').split(delimiter).filter(Boolean)
447
+ .map((dir) => join(dir, 'codex'))
448
+ .find((candidate) => {
449
+ try {
450
+ accessSync(candidate, constants.X_OK);
451
+ return true;
452
+ } catch {
453
+ return false;
454
+ }
455
+ });
456
+ if (!onPath) return null;
457
+ const real = realpathSync(onPath);
458
+ if (basename(real) !== 'codex.js') return real;
459
+ const target = LINUX_CODEX_TARGETS[process.arch];
460
+ if (!target) return null;
461
+ const packageRoot = dirname(dirname(real));
462
+ let vendorRoot = join(packageRoot, 'vendor');
463
+ try {
464
+ const platformPackage = createRequire(real).resolve(`@openai/codex-linux-${process.arch}/package.json`);
465
+ vendorRoot = join(dirname(platformPackage), 'vendor');
466
+ } catch { /* the launcher falls back to its own vendor folder too */ }
467
+ const binary = join(vendorRoot, target, 'bin', 'codex');
468
+ return existsSync(binary) ? realpathSync(binary) : null;
321
469
  };
322
470
 
323
471
  // A public seat ran with none of its declared skills: this adapter never read
@@ -385,6 +533,7 @@ const publicPermissionProfileFlags = (
385
533
  mode,
386
534
  commitAttributionEnv = null,
387
535
  mcpReadDirectories = [],
536
+ codexHelperReadPaths = [],
388
537
  ) => {
389
538
  if (!PUBLIC_SANDBOX_MODES.has(mode)) {
390
539
  throw new Error(
@@ -427,6 +576,7 @@ const publicPermissionProfileFlags = (
427
576
  ...(commitHooksPath ? [`${toml(commitHooksPath)}="read"`] : []),
428
577
  ...originalHookPaths.map((hookPath) => `${toml(hookPath)}="read"`),
429
578
  ...mcpReadDirectories.map((path) => `${toml(path)}="read"`),
579
+ ...codexHelperReadPaths.map((path) => `${toml(path)}="read"`),
430
580
  ].join(',');
431
581
  const shellEnvironment = [
432
582
  'PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_*',
@@ -477,6 +627,7 @@ const buildArgs = ({
477
627
  effort = null,
478
628
  commitAttributionEnv = null,
479
629
  mcpReadDirectories = [],
630
+ codexHelperReadPaths = [],
480
631
  }) => {
481
632
  const publicSandbox = publicSandboxMode !== null;
482
633
  // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
@@ -500,6 +651,7 @@ const buildArgs = ({
500
651
  publicSandboxMode,
501
652
  commitAttributionEnv,
502
653
  mcpReadDirectories,
654
+ codexHelperReadPaths,
503
655
  ),
504
656
  ]
505
657
  : ['--dangerously-bypass-approvals-and-sandbox'];
@@ -712,6 +864,10 @@ export default {
712
864
  const publicSandboxMode = sandbox?.trust === 'public'
713
865
  ? sandbox?.mode ?? 'workspace'
714
866
  : null;
867
+ const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv };
868
+ const publicCodexHome = publicSandboxMode !== null
869
+ ? await preparePublicCodexHome(ctx, childEnv)
870
+ : null;
715
871
  const args = buildArgs({
716
872
  sessionId: ctx.sessionId || null,
717
873
  prompt: fullPrompt,
@@ -722,9 +878,9 @@ export default {
722
878
  model: seatModel,
723
879
  effort: ctx.environment?.effort,
724
880
  commitAttributionEnv: ctx.commitAttributionEnv,
881
+ codexHelperReadPaths: publicCodexHome?.helperReadPaths || [],
725
882
  });
726
- const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv };
727
- // Derived from the process environment, so the bootstrap export has to be
883
+ // childEnv is derived from the process environment, so the bootstrap export has to be
728
884
  // taken back out: the declaration above moved our server onto the file,
729
885
  // and a value left here is a value codex's own children inherit. Kept only
730
886
  // when the substitution genuinely needed it (`forwardedEnv` names that
@@ -737,7 +893,7 @@ export default {
737
893
  Object.assign(childEnv, ctx.commitAttributionEnv);
738
894
  }
739
895
  if (publicSandboxMode !== null) {
740
- childEnv.CODEX_HOME = await preparePublicCodexHome(ctx);
896
+ childEnv.CODEX_HOME = publicCodexHome.home;
741
897
  await writePublicCodexInstructions(childEnv.CODEX_HOME, ctx);
742
898
  }
743
899