@commonlyai/cli 0.1.100 → 0.1.101

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.100",
3
+ "version": "0.1.101",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -75,7 +75,7 @@ import {
75
75
  import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
76
76
  import { supportedGitHooks } from '../commit-attribution.js';
77
77
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
78
- import { prepareMcpSpawn } from '../mcp-home.js';
78
+ import { prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_NOTICE, selectedMcpVersionDir } from '../mcp-home.js';
79
79
  import { buildMemoryPreamble } from '../memory-bridge.js';
80
80
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
81
81
 
@@ -412,8 +412,14 @@ const buildMcpConfig = (mcpServers, ctx = {}) => {
412
412
  // credential channel below is decided on that same command, so the two can
413
413
  // never disagree about which release is running.
414
414
  const spawnCommand = server.command
415
- ? prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl })
415
+ ? prepareMcpSpawn(server.command, {
416
+ apiUrl: ctx.instanceUrl, publicSeat: ctx.publicSeat,
417
+ })
416
418
  : null;
419
+ if (server.command && !spawnCommand) {
420
+ ctx.unavailableMcp?.push(server.name);
421
+ continue;
422
+ }
417
423
  const executed = spawnCommand ? { ...server, command: spawnCommand } : server;
418
424
  const entry = { type: server.transport || 'stdio' };
419
425
  if (server.url) entry.url = server.url;
@@ -439,6 +445,8 @@ const buildMcpConfig = (mcpServers, ctx = {}) => {
439
445
  }
440
446
  if (headerHelper) entry.headersHelper = headerHelper.command;
441
447
  mcpServersMap[server.name] = entry;
448
+ const versionDir = ctx.publicSeat && selectedMcpVersionDir(spawnCommand);
449
+ if (versionDir) ctx.mcpReadDirectories?.push(versionDir);
442
450
  }
443
451
  return { mcpServers: mcpServersMap };
444
452
  };
@@ -462,10 +470,14 @@ const createMcpConfig = async (mcpServers, ctx = {}) => {
462
470
  const dynamicHeaderHelpers = credential?.path
463
471
  ? await createTokenHeaderHelpers(mcpServers, dir)
464
472
  : new Map();
473
+ const unavailableMcp = [];
474
+ const mcpReadDirectories = [];
465
475
  const config = buildMcpConfig(mcpServers, {
466
476
  ...ctx,
467
477
  credentialFile: credential?.path || null,
468
478
  dynamicHeaderHelpers,
479
+ unavailableMcp,
480
+ mcpReadDirectories,
469
481
  });
470
482
  await writeFile(
471
483
  file,
@@ -489,7 +501,7 @@ const createMcpConfig = async (mcpServers, ctx = {}) => {
489
501
  + `inherits it. Put the credential on the entry's env to get the file channel.`,
490
502
  );
491
503
  }
492
- return { dir, file, credential, expansionEnv };
504
+ return { dir, file, credential, expansionEnv, unavailableMcp, mcpReadDirectories };
493
505
  } catch (err) {
494
506
  try { await rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
495
507
  throw err;
@@ -636,6 +648,7 @@ const prepareArgv = async (innerArgv, ctx) => {
636
648
  mcpConfigDir: ctx.mcpConfigDir,
637
649
  executablePaths: [...mcpExecutables, ...commitHookExecutables, ...originalHookExecutables],
638
650
  allowClaudeRuntimeAccess: true,
651
+ readOnlyDirectories: ctx.mcpReadDirectories || [],
639
652
  });
640
653
  return {
641
654
  cmd: wrapped[0],
@@ -664,7 +677,7 @@ const prepareArgv = async (innerArgv, ctx) => {
664
677
  const claudeBin = resolveClaudePath(claudeEnv);
665
678
  const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
666
679
  workspacePath: ctx.cwd,
667
- readOnlyPaths: [ctx.mcpConfigDir, commitHooksPath, originalHooksPath].filter(Boolean),
680
+ readOnlyPaths: [ctx.mcpConfigDir, commitHooksPath, originalHooksPath, ...(ctx.mcpReadDirectories || [])].filter(Boolean),
668
681
  });
669
682
  return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv };
670
683
  }
@@ -785,8 +798,12 @@ export default {
785
798
  runtimeToken: ctx.runtimeToken,
786
799
  instanceUrl: ctx.instanceUrl,
787
800
  agentName: ctx.agentName,
801
+ publicSeat: publicNativeSandbox,
788
802
  });
789
803
  }
804
+ if (mcpConfig?.unavailableMcp.length) {
805
+ baseArgs[1] = `${fullPrompt}\n\n${PUBLIC_MCP_UNAVAILABLE_NOTICE}`;
806
+ }
790
807
  const claudeEnv = buildClaudeEnv(
791
808
  ctx.env,
792
809
  publicClaudeState,
@@ -800,6 +817,7 @@ export default {
800
817
  ...ctx,
801
818
  mcpConfigPath: mcpConfig?.file || null,
802
819
  mcpConfigDir: mcpConfig?.dir || null,
820
+ mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
803
821
  publicClaudeState,
804
822
  claudeEnv,
805
823
  };
@@ -829,11 +847,14 @@ export default {
829
847
  // as fresh too: otherwise a session-recovery path is the one fresh
830
848
  // session that misses the durable-state cue.
831
849
  const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: true });
832
- const retryBase = ['-p', freshPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs];
850
+ const retryBase = ['-p', mcpConfig?.unavailableMcp.length
851
+ ? `${freshPrompt}\n\n${PUBLIC_MCP_UNAVAILABLE_NOTICE}` : freshPrompt,
852
+ '--output-format', 'text', '--session-id', freshId, ...modelArgs];
833
853
  const retryCtx = {
834
854
  ...ctx,
835
855
  mcpConfigPath: mcpConfig?.file || null,
836
856
  mcpConfigDir: mcpConfig?.dir || null,
857
+ mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
837
858
  publicClaudeState,
838
859
  claudeEnv: buildClaudeEnv(
839
860
  ctx.env,
@@ -60,7 +60,7 @@ import {
60
60
  import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
61
61
  import { supportedGitHooks } from '../commit-attribution.js';
62
62
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
63
- import { prepareMcpSpawn } from '../mcp-home.js';
63
+ import { prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_NOTICE, selectedMcpVersionDir } from '../mcp-home.js';
64
64
  import { buildMemoryPreamble } from '../memory-bridge.js';
65
65
  import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
66
66
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
@@ -140,6 +140,8 @@ const toml = (s) => JSON.stringify(String(s));
140
140
  const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
141
141
  const flags = [];
142
142
  const forwardedEnv = {};
143
+ const unavailableMcp = [];
144
+ const mcpReadDirectories = [];
143
145
  for (const server of mcpServers || []) {
144
146
  const transport = typeof server?.transport === 'string' ? server.transport.trim().toLowerCase() : 'stdio';
145
147
  if (!server?.name || transport !== 'stdio'
@@ -149,7 +151,13 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
149
151
  // same command, so the two can never disagree about which release runs. The
150
152
  // rewrite is before `substitutePlaceholders` because it introduces no
151
153
  // placeholder: it is a path this host resolved.
152
- const spawnCommand = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl });
154
+ const spawnCommand = prepareMcpSpawn(server.command, {
155
+ apiUrl: ctx.instanceUrl, publicSeat: ctx.publicSeat,
156
+ });
157
+ if (!spawnCommand) {
158
+ unavailableMcp.push(server.name);
159
+ continue;
160
+ }
153
161
  // A declared credential is rewritten to the file channel before anything is
154
162
  // substituted, so for our server the token is not token-bearing at all: it
155
163
  // lands in `env={...}` as a path and never reaches `env_vars`, which is the
@@ -185,6 +193,8 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
185
193
  continue;
186
194
  }
187
195
  flags.push('-c', `mcp_servers.${server.name}.command=${toml(command)}`);
196
+ const versionDir = ctx.publicSeat && selectedMcpVersionDir(spawnCommand);
197
+ if (versionDir) mcpReadDirectories.push(versionDir);
188
198
  // The user opted into every server present in the environment spec.
189
199
  // Public permission profiles + approval_policy=never otherwise auto-deny
190
200
  // side-effecting MCP calls, silently removing the agent's Commonly tools.
@@ -237,7 +247,7 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
237
247
  + 'get the file channel.',
238
248
  );
239
249
  }
240
- return { flags, forwardedEnv };
250
+ return { flags, forwardedEnv, unavailableMcp, mcpReadDirectories };
241
251
  };
242
252
 
243
253
  const PUBLIC_PERMISSION_PROFILE = 'commonly_public';
@@ -301,6 +311,7 @@ const preparePublicCodexHome = async (ctx) => {
301
311
  const publicPermissionProfileFlags = (
302
312
  mode,
303
313
  commitAttributionEnv = null,
314
+ mcpReadDirectories = [],
304
315
  ) => {
305
316
  if (!PUBLIC_SANDBOX_MODES.has(mode)) {
306
317
  throw new Error(
@@ -339,9 +350,10 @@ const publicPermissionProfileFlags = (
339
350
  '"~/.aws"="deny"',
340
351
  '"~/.config"="deny"',
341
352
  '"/private/tmp"="deny"',
342
- `":workspace_roots"={"."="${workspaceAccess}",".commonly/**"="deny",".codex/**"="deny","*.env"="deny","*/*.env"="deny","*/*/*.env"="deny"}`,
353
+ `":workspace_roots"={"."="${workspaceAccess}",".commonly/**"="deny",".codex/**"="deny","*.env"="deny","*/*.env"="deny","*/*/*.env"="deny","**/.npmrc"="deny","**/.yarnrc*"="deny"}`,
343
354
  ...(commitHooksPath ? [`${toml(commitHooksPath)}="read"`] : []),
344
355
  ...originalHookPaths.map((hookPath) => `${toml(hookPath)}="read"`),
356
+ ...mcpReadDirectories.map((path) => `${toml(path)}="read"`),
345
357
  ].join(',');
346
358
  const shellEnvironment = [
347
359
  'PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_*',
@@ -391,6 +403,7 @@ const buildArgs = ({
391
403
  model = null,
392
404
  effort = null,
393
405
  commitAttributionEnv = null,
406
+ mcpReadDirectories = [],
394
407
  }) => {
395
408
  const publicSandbox = publicSandboxMode !== null;
396
409
  // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
@@ -413,6 +426,7 @@ const buildArgs = ({
413
426
  ...publicPermissionProfileFlags(
414
427
  publicSandboxMode,
415
428
  commitAttributionEnv,
429
+ mcpReadDirectories,
416
430
  ),
417
431
  ]
418
432
  : ['--dangerously-bypass-approvals-and-sandbox'];
@@ -588,10 +602,12 @@ export default {
588
602
  });
589
603
 
590
604
  try {
605
+ const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox);
591
606
  const mcp = buildMcpOverrideArgs(ctx.environment?.mcp, {
592
607
  runtimeToken: ctx.runtimeToken,
593
608
  instanceUrl: ctx.instanceUrl,
594
609
  credentialFile: credential?.path || null,
610
+ publicSeat: sandbox?.trust === 'public',
595
611
  });
596
612
  // A derived record stores `trust: 'public'` and no mode (the block is
597
613
  // platform-independent; see cli/src/lib/default-environment.js). Codex's
@@ -607,15 +623,16 @@ export default {
607
623
  + '(Wren 69585).',
608
624
  );
609
625
  }
610
- const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox);
611
626
  const publicSandboxMode = sandbox?.trust === 'public'
612
627
  ? sandbox?.mode ?? 'workspace'
613
628
  : null;
614
629
  const args = buildArgs({
615
630
  sessionId: ctx.sessionId || null,
616
- prompt: fullPrompt,
631
+ prompt: mcp.unavailableMcp.length
632
+ ? `${fullPrompt}\n\n${PUBLIC_MCP_UNAVAILABLE_NOTICE}` : fullPrompt,
617
633
  outputFile,
618
634
  mcpFlags: mcp.flags,
635
+ mcpReadDirectories: mcp.mcpReadDirectories,
619
636
  publicSandboxMode,
620
637
  model: ctx.environment?.model,
621
638
  effort: ctx.environment?.effort,
@@ -39,7 +39,7 @@ import {
39
39
  } from 'path';
40
40
  import { writeCredentialFile } from '../credential-file.js';
41
41
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
42
- import { prepareMcpSpawn } from '../mcp-home.js';
42
+ import { prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_NOTICE, selectedMcpVersionDir } from '../mcp-home.js';
43
43
  import { buildMemoryPreamble } from '../memory-bridge.js';
44
44
  import {
45
45
  isLegacySandboxTrust,
@@ -82,6 +82,7 @@ const PUBLIC_READ_DENIES = [
82
82
  '**/.env.*',
83
83
  '**/*.env.*',
84
84
  ];
85
+ const PUBLIC_EDIT_DENIES = [...PUBLIC_READ_DENIES, '**/.npmrc', '**/.yarnrc*'];
85
86
  const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
86
87
 
87
88
  const statOrNull = async (path) => {
@@ -119,7 +120,13 @@ const buildMcpConfig = (servers, ctx = {}) => {
119
120
 
120
121
  if (transport === 'stdio') {
121
122
  if (!Array.isArray(server.command) || server.command.length === 0) continue;
122
- const command = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl });
123
+ const command = prepareMcpSpawn(server.command, {
124
+ apiUrl: ctx.instanceUrl, publicSeat: ctx.publicSeat,
125
+ });
126
+ if (!command) {
127
+ ctx.unavailableMcp?.push(server.name);
128
+ continue;
129
+ }
123
130
  if (runtimeToken && command.some((part) => typeof part === 'string' && part.includes(runtimeToken))) {
124
131
  // A bearer token in argv is readable by same-user processes.
125
132
  // eslint-disable-next-line no-console
@@ -158,6 +165,8 @@ const buildMcpConfig = (servers, ctx = {}) => {
158
165
  ...(Object.keys(env).length ? { environment: env } : {}),
159
166
  ...(typeof server.cwd === 'string' ? { cwd: server.cwd } : {}),
160
167
  };
168
+ const versionDir = ctx.publicSeat && selectedMcpVersionDir(command);
169
+ if (versionDir) ctx.mcpReadDirectories?.push(versionDir);
161
170
  continue;
162
171
  }
163
172
 
@@ -207,8 +216,8 @@ const publicPermissions = (mode, mcpNames) => {
207
216
  const edit = { '*': mode === 'read-only' ? 'deny' : 'allow' };
208
217
  for (const pattern of PUBLIC_READ_DENIES) {
209
218
  read[pattern] = 'deny';
210
- edit[pattern] = 'deny';
211
219
  }
220
+ for (const pattern of PUBLIC_EDIT_DENIES) edit[pattern] = 'deny';
212
221
  return {
213
222
  '*': 'deny',
214
223
  read,
@@ -666,10 +675,15 @@ export default {
666
675
  ...(stateHome ? [mkdir(stateHome, { recursive: true, mode: 0o700 })] : []),
667
676
  ...(cacheHome ? [mkdir(cacheHome, { recursive: true, mode: 0o700 })] : []),
668
677
  ]);
678
+ const unavailableMcp = [];
679
+ const mcpReadDirectories = [];
669
680
  const mcp = buildMcpConfig(ctx.environment?.mcp, {
670
681
  runtimeToken: ctx.runtimeToken,
671
682
  instanceUrl: ctx.instanceUrl,
672
683
  credentialFile: credential?.path || null,
684
+ publicSeat: isPublic,
685
+ unavailableMcp,
686
+ mcpReadDirectories,
673
687
  });
674
688
  const config = {
675
689
  permission: isPublic
@@ -703,7 +717,8 @@ export default {
703
717
  credentialFile: credential?.path || null,
704
718
  });
705
719
  const args = buildArgs({
706
- prompt: fullPrompt,
720
+ prompt: unavailableMcp.length
721
+ ? `${fullPrompt}\n\n${PUBLIC_MCP_UNAVAILABLE_NOTICE}` : fullPrompt,
707
722
  sessionId: ctx.sessionId,
708
723
  cwd: ctx.cwd,
709
724
  model: qualifiedProviderModel(provider, ctx.environment?.model),
@@ -738,6 +753,7 @@ export default {
738
753
  tempDir,
739
754
  binary,
740
755
  process.execPath,
756
+ ...mcpReadDirectories,
741
757
  ],
742
758
  });
743
759
  [cmd, ...spawnArgs] = wrapped;
@@ -753,6 +769,7 @@ export default {
753
769
  mcpConfigDir: tempDir,
754
770
  loopbackNetworkPorts: providerProxy ? [providerProxy.port] : [],
755
771
  readOnlyPaths: [],
772
+ readOnlyDirectories: mcpReadDirectories,
756
773
  });
757
774
  [cmd, ...spawnArgs] = wrapped;
758
775
  }
@@ -6,7 +6,7 @@ import { tmpdir } from 'os';
6
6
  import { basename, dirname, isAbsolute, join } from 'path';
7
7
  import { buildMemoryPreamble } from '../memory-bridge.js';
8
8
  import { deliverSeatCredential } from '../mcp-credential-delivery.js';
9
- import { prepareMcpSpawn } from '../mcp-home.js';
9
+ import { prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_NOTICE, selectedMcpVersionDir } from '../mcp-home.js';
10
10
  import { writeCredentialFile } from '../credential-file.js';
11
11
  import { normalizeSandboxTrust, validateEnvironmentSpec } from '../environment.js';
12
12
  import { resolvePublicSandboxMode } from '../sandbox/mode.js';
@@ -88,7 +88,13 @@ export const buildQwenMcpConfig = (servers, ctx) => {
88
88
  const transport = server.transport || 'stdio';
89
89
  if (transport === 'stdio') {
90
90
  if (!Array.isArray(server.command) || !server.command.length) continue;
91
- const command = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl });
91
+ const command = prepareMcpSpawn(server.command, {
92
+ apiUrl: ctx.instanceUrl, publicSeat: ctx.publicSeat,
93
+ });
94
+ if (!command) {
95
+ ctx.unavailableMcp?.push(server.name);
96
+ continue;
97
+ }
92
98
  const delivery = deliverSeatCredential({ ...server, command }, {
93
99
  credentialFile: ctx.credentialFile,
94
100
  label: 'qwen',
@@ -105,6 +111,8 @@ export const buildQwenMcpConfig = (servers, ctx) => {
105
111
  command: command[0], args, env,
106
112
  ...(server.cwd ? { cwd: server.cwd } : {}),
107
113
  };
114
+ const versionDir = ctx.publicSeat && selectedMcpVersionDir(command);
115
+ if (versionDir) ctx.mcpReadDirectories?.push(versionDir);
108
116
  continue;
109
117
  }
110
118
  if (transport !== 'http' || typeof server.url !== 'string') {
@@ -271,10 +279,15 @@ export default {
271
279
  provider, keyFile, tokenFile: join(tempDir, 'provider-proxy-token'), adapterLabel: 'Qwen',
272
280
  });
273
281
  const proxyWithBearer = { ...proxy, bearer: (await readFile(proxy.tokenFile, 'utf8')).trim() };
282
+ const unavailableMcp = [];
283
+ const mcpReadDirectories = [];
274
284
  const mcp = buildQwenMcpConfig(ctx.environment?.mcp, {
275
285
  credentialFile: credential?.path || '',
276
286
  instanceUrl: ctx.instanceUrl || '',
277
287
  runtimeToken: ctx.runtimeToken || '',
288
+ publicSeat: isPublic,
289
+ unavailableMcp,
290
+ mcpReadDirectories,
278
291
  });
279
292
  await Promise.all([
280
293
  writeFile(settingsPath, JSON.stringify({
@@ -294,7 +307,8 @@ export default {
294
307
  '--mcp-config', mcpPath, '--exclude-tools', [
295
308
  ...EXCLUDED_TOOLS, ...(isPublic ? PUBLIC_READ_TOOLS : []),
296
309
  ].join(','),
297
- '-o', 'stream-json', '-p', fullPrompt,
310
+ '-o', 'stream-json', '-p', unavailableMcp.length
311
+ ? `${fullPrompt}\n\n${PUBLIC_MCP_UNAVAILABLE_NOTICE}` : fullPrompt,
298
312
  ];
299
313
  let cmd = executable;
300
314
  let spawnArgs = args;
@@ -313,7 +327,7 @@ export default {
313
327
  ],
314
328
  },
315
329
  },
316
- }, { workspacePath: ctx.cwd, homePath: home, readOnlyPaths: [tempDir, executable, packageDir, process.execPath].filter(Boolean) });
330
+ }, { workspacePath: ctx.cwd, homePath: home, readOnlyPaths: [tempDir, executable, packageDir, process.execPath, ...mcpReadDirectories].filter(Boolean) });
317
331
  } else if (isPublic) {
318
332
  if ((ctx._platform || process.platform) !== 'darwin') {
319
333
  throw new Error(`public Qwen sandbox.mode=${mode} requires Seatbelt on macOS or bwrap on Linux`);
@@ -325,7 +339,7 @@ export default {
325
339
  statePath: home,
326
340
  mcpConfigDir: tempDir,
327
341
  loopbackNetworkPorts: [proxy.port],
328
- readOnlyDirectories: packageDir ? [packageDir] : [],
342
+ readOnlyDirectories: [...(packageDir ? [packageDir] : []), ...mcpReadDirectories],
329
343
  });
330
344
  }
331
345
  const requestedTimeout = Number(ctx.timeoutMs);
@@ -45,13 +45,14 @@ import {
45
45
  existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync,
46
46
  } from 'node:fs';
47
47
  import { homedir } from 'node:os';
48
- import { dirname, join, sep } from 'node:path';
48
+ import { dirname, join, relative, sep } from 'node:path';
49
49
  import { fileURLToPath } from 'node:url';
50
50
  import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
51
51
  import { MCP_PACKAGE, exactVersion, parseVersion } from './mcp-server-version.js';
52
52
 
53
53
  /** The env override, so tests (and an operator) can point the home elsewhere. */
54
54
  export const MCP_HOME_ENV = 'COMMONLY_MCP_HOME';
55
+ export const PUBLIC_MCP_UNAVAILABLE_NOTICE = 'Commonly runtime notice: An MCP server was not started this turn because no host-prepared executable was ready. Do not claim to have used its tools.';
55
56
 
56
57
  export const WARM_LOCK_NAME = '.warm.lock';
57
58
  export const REGISTRY_CACHE_NAME = '.registry.json';
@@ -324,6 +325,14 @@ export const readBinPath = (home, version, options = {}) => {
324
325
  return readBinInPackageDir(packageDirFor(home, version), options);
325
326
  };
326
327
 
328
+ /** Read grant for only the verified warm build selected for this spawn. */
329
+ export const selectedMcpVersionDir = (command, home = mcpHomeDir()) => {
330
+ if (!Array.isArray(command) || command.length !== 2 || command[0] !== 'node') return null;
331
+ const version = relative(home, command[1]).split(sep)[0];
332
+ if (exactVersion(version) !== version || readBinPath(home, version) !== command[1]) return null;
333
+ return versionDirFor(home, version);
334
+ };
335
+
327
336
  /**
328
337
  * The bin of an npm PREFIX dir, which is the shape the warm installs into:
329
338
  * `npm install --prefix <dir>` leaves `<dir>/node_modules/...`, with no version
@@ -532,11 +541,14 @@ const lockPresent = (home, { exists = defaultExists } = {}) => exists(lockPathFo
532
541
  *
533
542
  * Sync and network-free by construction — the registry check, the install and
534
543
  * the probe all live in the child. A lock already held means another spawn is
535
- * warming, so this spawn just runs the current version.
544
+ * warming, so this spawn just runs the current version. Public seats receive
545
+ * null if that version is unavailable and the fallback is a package runner;
546
+ * adapters omit that MCP declaration and tell the seat.
536
547
  */
537
548
  export const prepareMcpSpawn = (command, {
538
549
  home,
539
550
  apiUrl = null,
551
+ publicSeat = false,
540
552
  spawnImpl,
541
553
  childPath,
542
554
  now = Date.now(),
@@ -566,10 +578,15 @@ export const prepareMcpSpawn = (command, {
566
578
  const due = !lockPresent(resolvedHome, { exists })
567
579
  && warmLooksWanted(resolvedHome, plan.version, { now, readFile });
568
580
  if (due) kickWarm({ home: resolvedHome, apiUrl, spawnImpl, childPath });
581
+ if (publicSeat && resolved[0] === 'npx') {
582
+ warn('commonly: an MCP server is unavailable for this public turn; no host-prepared executable was ready');
583
+ return null;
584
+ }
569
585
  return resolved;
570
586
  } catch {
571
- // A rewrite that throws must not cost the seat its tools: the declaration
572
- // is what the guard approved, and it still runs.
587
+ // Trusted seats retain the declared fallback. A public seat must not
588
+ // execute a package runner from the workspace even on this failure path.
589
+ if (publicSeat && command?.[0] === 'npx') return null;
573
590
  return command;
574
591
  }
575
592
  };
@@ -107,7 +107,7 @@ export const acquireWarmLock = (home, {
107
107
  };
108
108
 
109
109
  /** Default `npm` runner. Resolves `{ ok, stdout }`; never rejects. */
110
- export const runCommand = (bin, args, { timeout = REGISTRY_TIMEOUT_MS } = {}) => new Promise((resolve) => {
110
+ export const runCommand = (bin, args, { timeout = REGISTRY_TIMEOUT_MS, cwd } = {}) => new Promise((resolve) => {
111
111
  let settled = false;
112
112
  let child;
113
113
  const done = (result) => {
@@ -116,7 +116,7 @@ export const runCommand = (bin, args, { timeout = REGISTRY_TIMEOUT_MS } = {}) =>
116
116
  resolve(result);
117
117
  };
118
118
  try {
119
- child = spawnChild(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
119
+ child = spawnChild(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env, cwd });
120
120
  } catch (error) {
121
121
  done({ ok: false, stdout: '', stderr: String(error && error.message) });
122
122
  return;
@@ -153,7 +153,9 @@ export const resolveRegistryLatest = async (home, {
153
153
  }
154
154
  } catch { /* unreadable cache is just a miss */ }
155
155
  }
156
- const result = await exec('npm', ['view', MCP_PACKAGE, 'version', '--json'], { timeout: REGISTRY_TIMEOUT_MS });
156
+ const result = await exec('npm', ['view', MCP_PACKAGE, 'version', '--json'], {
157
+ timeout: REGISTRY_TIMEOUT_MS, cwd: home,
158
+ });
157
159
  const raw = result && result.ok ? String(result.stdout || '').trim() : '';
158
160
  let version = null;
159
161
  if (raw) {
@@ -199,6 +201,7 @@ export const probeBin = (binPath, {
199
201
  spawnImpl = spawnChild,
200
202
  timeoutMs = PROBE_TIMEOUT_MS,
201
203
  apiUrl = process.env.COMMONLY_WARM_API_URL || PROBE_API_URL_PLACEHOLDER,
204
+ cwd,
202
205
  } = {}) => new Promise((resolve) => {
203
206
  let settled = false;
204
207
  let child;
@@ -213,6 +216,7 @@ export const probeBin = (binPath, {
213
216
  try {
214
217
  child = spawnImpl(process.execPath, [binPath], {
215
218
  stdio: ['pipe', 'pipe', 'pipe'],
219
+ cwd,
216
220
  env: {
217
221
  PATH: process.env.PATH,
218
222
  HOME: process.env.HOME,
@@ -343,7 +347,7 @@ export const warmMcpHome = async (home, {
343
347
  mkdir(temp);
344
348
  const install = await exec('npm', [
345
349
  'install', '--prefix', temp, `${MCP_PACKAGE}@${target}`, '--no-save', '--no-audit', '--no-fund',
346
- ], { timeout: 5 * 60 * 1000 });
350
+ ], { timeout: 5 * 60 * 1000, cwd: home });
347
351
  if (!install || !install.ok) {
348
352
  remove(temp);
349
353
  return WARM_RESULTS.INSTALL_FAILED;
@@ -353,7 +357,9 @@ export const warmMcpHome = async (home, {
353
357
  remove(temp);
354
358
  return WARM_RESULTS.INSTALL_FAILED;
355
359
  }
356
- const started = await (spawnProbe || probe)(bin, { apiUrl: process.env.COMMONLY_WARM_API_URL });
360
+ const started = await (spawnProbe || probe)(bin, {
361
+ apiUrl: process.env.COMMONLY_WARM_API_URL, cwd: home,
362
+ });
357
363
  if (!started) {
358
364
  remove(temp);
359
365
  return WARM_RESULTS.PROBE_FAILED;