@commonlyai/cli 0.1.81 → 0.1.85

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.81",
3
+ "version": "0.1.85",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -48,6 +48,7 @@ import {
48
48
  readPodFocus,
49
49
  } from '../lib/pod-focus.js';
50
50
  import { claimReleaseFor, ackResultFor } from '../lib/claim-outcome.js';
51
+ import { prepareCommitAttribution } from '../lib/commit-attribution.js';
51
52
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
52
53
  import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
53
54
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
@@ -190,6 +191,7 @@ export const bootstrapAgentRecordFromEnv = async ({
190
191
 
191
192
  return {
192
193
  agentName,
194
+ displayName: identity?.displayName || primary?.displayName || agentName,
193
195
  instanceId: identity?.instanceId || primary?.instanceId || 'default',
194
196
  podId: primary?.podId || null,
195
197
  instanceUrl,
@@ -731,6 +733,7 @@ export const performAttach = async ({
731
733
  runtimeToken,
732
734
  detected,
733
735
  wrappedCli: adapter.name,
736
+ displayName: installation.displayName || displayName || agentName,
734
737
  environment,
735
738
  workspace,
736
739
  };
@@ -907,6 +910,7 @@ export const performRun = ({
907
910
  token,
908
911
  adapter,
909
912
  agentName,
913
+ displayName = null,
910
914
  instanceId = 'default',
911
915
  podId = null,
912
916
  environment = null,
@@ -1370,11 +1374,28 @@ export const performRun = ({
1370
1374
  };
1371
1375
  }
1372
1376
  let result;
1377
+ let commitAttribution = null;
1373
1378
  try {
1379
+ // The local CLI is a wrapper for a Commonly seat, so its Git commits
1380
+ // carry the seat and the adapter's selected model/effort. `stub` exists
1381
+ // only for tests and has no commit-making child process.
1382
+ if (adapter?.name !== 'stub') {
1383
+ commitAttribution = prepareCommitAttribution({
1384
+ cwd: agentCwd,
1385
+ env: process.env,
1386
+ agentName,
1387
+ displayName,
1388
+ instanceId,
1389
+ adapter: adapter?.name,
1390
+ model: seatEnvironment?.model,
1391
+ effort: seatEnvironment?.effort,
1392
+ });
1393
+ }
1374
1394
  result = await adapter.spawn(frameDecisionForkRule(promptWithFocus), {
1375
1395
  sessionId,
1376
1396
  cwd: agentCwd,
1377
- env: process.env,
1397
+ env: commitAttribution?.env || process.env,
1398
+ commitAttributionEnv: commitAttribution?.sandboxEnv || null,
1378
1399
  memoryLongTerm,
1379
1400
  environment: seatEnvironment,
1380
1401
  // Runtime context the Claude/Codex adapters expose only to their
@@ -1389,10 +1410,14 @@ export const performRun = ({
1389
1410
  metadata: { event },
1390
1411
  });
1391
1412
  } finally {
1392
- // Best-effort by construction: `close` logs and returns on a failed
1393
- // revoke, so a turn that already produced an answer is not failed by
1394
- // cleanup. The boot sweep collects anything left behind.
1395
- await lease.close();
1413
+ try {
1414
+ commitAttribution?.cleanup();
1415
+ } finally {
1416
+ // Best-effort by construction: `close` logs and returns on a failed
1417
+ // revoke, so a turn that already produced an answer is not failed by
1418
+ // cleanup. The boot sweep collects anything left behind.
1419
+ await lease.close();
1420
+ }
1396
1421
  }
1397
1422
 
1398
1423
  if (result.newSessionId) {
@@ -2511,7 +2536,7 @@ Docs:
2511
2536
  const envAbsPath = opts.env ? pathResolve(opts.env) : null;
2512
2537
  const {
2513
2538
  installation, instanceId, runtimeToken, detected, wrappedCli,
2514
- environment, workspace,
2539
+ displayName, environment, workspace,
2515
2540
  } = await performAttach({
2516
2541
  client,
2517
2542
  adapterName,
@@ -2525,6 +2550,7 @@ Docs:
2525
2550
 
2526
2551
  saveAgentToken(opts.name, {
2527
2552
  agentName: opts.name,
2553
+ displayName,
2528
2554
  instanceId,
2529
2555
  podId: opts.pod,
2530
2556
  instanceUrl,
@@ -2717,6 +2743,7 @@ Docs:
2717
2743
  token: record.runtimeToken,
2718
2744
  adapter,
2719
2745
  agentName: record.agentName,
2746
+ displayName: record.displayName || record.agentName,
2720
2747
  instanceId: record.instanceId,
2721
2748
  podId: record.podId,
2722
2749
  environment: record.environment || null,
@@ -45,7 +45,7 @@
45
45
 
46
46
  import { spawn as childSpawn, spawnSync } from 'child_process';
47
47
  import { createHash, randomUUID } from 'crypto';
48
- import { realpathSync } from 'fs';
48
+ import { accessSync, constants, realpathSync } from 'fs';
49
49
  import {
50
50
  chmod,
51
51
  lstat,
@@ -73,6 +73,7 @@ import {
73
73
  wrapArgvWithSeatbelt,
74
74
  } from '../sandbox/seatbelt.js';
75
75
  import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
76
+ import { supportedGitHooks } from '../commit-attribution.js';
76
77
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
77
78
  import { prepareMcpSpawn } from '../mcp-home.js';
78
79
  import { buildMemoryPreamble } from '../memory-bridge.js';
@@ -90,6 +91,22 @@ const DEFAULT_TIMEOUT_MS = (() => {
90
91
  })();
91
92
 
92
93
  const buildPrompt = buildMemoryPreamble;
94
+ const RUNTIME_CREDENTIAL_PLACEHOLDER = '${COMMONLY_AGENT_TOKEN}';
95
+
96
+ const QUOTE_SHELL_ARGUMENT = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
97
+
98
+ const HEADER_TOKEN_HELPER_SOURCE = `import { readFileSync } from 'node:fs';
99
+
100
+ const credentialFile = process.env.${CREDENTIAL_FILE_VAR};
101
+ if (!credentialFile) throw new Error('missing ${CREDENTIAL_FILE_VAR}');
102
+ const token = readFileSync(credentialFile, 'utf8').trim();
103
+ if (!token) throw new Error('empty ${CREDENTIAL_FILE_VAR}');
104
+ const templates = JSON.parse(readFileSync(process.argv[2], 'utf8'));
105
+ const headers = Object.fromEntries(
106
+ Object.entries(templates).map(([name, parts]) => [name, parts.join(token)]),
107
+ );
108
+ process.stdout.write(JSON.stringify(headers) + '\\n');
109
+ `;
93
110
 
94
111
  const PUBLIC_DENIED_TOOLS = [
95
112
  'WebSearch',
@@ -289,9 +306,9 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl
289
306
 
290
307
  // Keep Commonly placeholders in the MCP JSON and expose their values only in
291
308
  // Claude's per-spawn environment. Claude Code natively expands ${VAR} in MCP
292
- // command/args/env/url/headers fields. Substituting here used to materialize the raw
293
- // cm_agent_* bearer token in a transient JSON file, which made the token
294
- // readable to any co-confined child allowed to read that config directory.
309
+ // command/args/env/url/header fields. Token-bearing HTTP headers are routed
310
+ // through headersHelper below, because a literal expansion would put the
311
+ // credential in Claude's environment and every MCP child's environment.
295
312
  //
296
313
  // Recognised placeholders:
297
314
  // ${COMMONLY_TOKEN_FILE} — the PATH of this spawn's credential file
@@ -323,20 +340,67 @@ const buildMcpExpansionEnv = (mcpConfig, ctx) => {
323
340
  // added only when the value is non-empty AND the declaration still references
324
341
  // it — and `serialized` is the config AFTER the credential rewrite, which is
325
342
  // what moves the default declaration off this value and onto the file. A
326
- // reference the rewrite cannot move (args, url, headers, or a string that
327
- // merely contains the placeholder) keeps the value for that spawn, because
328
- // claude substitutes those literally and there is no file channel for them.
329
- // That is the measured carve-out, and createMcpConfig warns when it applies
330
- // so the seat still carrying the value is named rather than assumed fixed.
343
+ // reference that cannot use the file channel (args, url, unsupported or
344
+ // non-HTTP headers, or a string that merely contains the placeholder) keeps
345
+ // the value for that spawn. createMcpConfig warns when this carve-out applies.
331
346
  COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
332
347
  };
333
348
  const output = {};
334
349
  for (const [key, value] of Object.entries(values)) {
335
- if (value && serialized.includes(`\${${key}}`)) output[key] = value;
350
+ const referenced = serialized.includes(`\${${key}}`)
351
+ || (key === CREDENTIAL_FILE_VAR && ctx.headersHelperUsesCredentialFile === true);
352
+ if (value && referenced) output[key] = value;
336
353
  }
337
354
  return output;
338
355
  };
339
356
 
357
+ const isHttpTransport = (server) => {
358
+ const transport = String(server?.transport || server?.type || '').trim().toLowerCase();
359
+ return transport === 'http' || transport === 'streamable-http';
360
+ };
361
+
362
+ const getTokenHeaderTemplates = (server) => {
363
+ if (!isHttpTransport(server) || !server.headers || typeof server.headers !== 'object') return null;
364
+ const templates = Object.fromEntries(
365
+ Object.entries(server.headers)
366
+ .filter(([, value]) => typeof value === 'string' && value.includes(RUNTIME_CREDENTIAL_PLACEHOLDER))
367
+ .map(([name, value]) => [name, value.split(RUNTIME_CREDENTIAL_PLACEHOLDER)]),
368
+ );
369
+ return Object.keys(templates).length > 0 ? templates : null;
370
+ };
371
+
372
+ const writePrivateFile = async (path, contents) => {
373
+ await writeFile(path, contents, { encoding: 'utf8', mode: 0o600 });
374
+ await chmod(path, 0o600);
375
+ };
376
+
377
+ // Claude's headersHelper is invoked when an HTTP connection is established and
378
+ // again after an authentication retry. Keep the token out of its config and
379
+ // environment: the helper reads the current per-spawn file on every invocation.
380
+ const createTokenHeaderHelpers = async (mcpServers, dir) => {
381
+ const servers = mcpServers
382
+ .map((server, index) => ({ server, index, templates: getTokenHeaderTemplates(server) }))
383
+ .filter((item) => item.templates);
384
+ if (servers.length === 0) return new Map();
385
+
386
+ const scriptPath = join(dir, 'headers-helper.mjs');
387
+ await writePrivateFile(scriptPath, HEADER_TOKEN_HELPER_SOURCE);
388
+ const helpers = new Map();
389
+ for (const { server, index, templates } of servers) {
390
+ const templatesPath = join(dir, `headers-${index}.json`);
391
+ await writePrivateFile(templatesPath, JSON.stringify(templates));
392
+ helpers.set(server.name, {
393
+ command: [
394
+ QUOTE_SHELL_ARGUMENT(process.execPath),
395
+ QUOTE_SHELL_ARGUMENT(scriptPath),
396
+ QUOTE_SHELL_ARGUMENT(templatesPath),
397
+ ].join(' '),
398
+ headerNames: Object.keys(templates),
399
+ });
400
+ }
401
+ return helpers;
402
+ };
403
+
340
404
  const buildMcpConfig = (mcpServers, ctx = {}) => {
341
405
  // Shape: `{ mcpServers: { <name>: { ... } } }` — the standard MCP client
342
406
  // config, which claude's `--mcp-config` reads directly.
@@ -367,7 +431,13 @@ const buildMcpConfig = (mcpServers, ctx = {}) => {
367
431
  label: 'claude',
368
432
  }).env;
369
433
  }
370
- if (server.headers) entry.headers = { ...server.headers };
434
+ const headerHelper = ctx.dynamicHeaderHelpers?.get(server.name);
435
+ if (server.headers) {
436
+ const headers = { ...server.headers };
437
+ for (const headerName of headerHelper?.headerNames || []) delete headers[headerName];
438
+ if (Object.keys(headers).length > 0 || !headerHelper) entry.headers = headers;
439
+ }
440
+ if (headerHelper) entry.headersHelper = headerHelper.command;
371
441
  mcpServersMap[server.name] = entry;
372
442
  }
373
443
  return { mcpServers: mcpServersMap };
@@ -389,7 +459,14 @@ const createMcpConfig = async (mcpServers, ctx = {}) => {
389
459
  // config — no second lifecycle to get wrong — and so it is inside the one
390
460
  // non-workspace path the Seatbelt profile admits.
391
461
  credential = writeCredentialFile(ctx.runtimeToken, { agentName: ctx.agentName || 'agent', root: dir });
392
- const config = buildMcpConfig(mcpServers, { ...ctx, credentialFile: credential?.path || null });
462
+ const dynamicHeaderHelpers = credential?.path
463
+ ? await createTokenHeaderHelpers(mcpServers, dir)
464
+ : new Map();
465
+ const config = buildMcpConfig(mcpServers, {
466
+ ...ctx,
467
+ credentialFile: credential?.path || null,
468
+ dynamicHeaderHelpers,
469
+ });
393
470
  await writeFile(
394
471
  file,
395
472
  JSON.stringify(config, null, 2),
@@ -401,12 +478,13 @@ const createMcpConfig = async (mcpServers, ctx = {}) => {
401
478
  const expansionEnv = buildMcpExpansionEnv(config, {
402
479
  ...ctx,
403
480
  credentialFile: credential?.path || null,
481
+ headersHelperUsesCredentialFile: dynamicHeaderHelpers.size > 0,
404
482
  });
405
483
  if (expansionEnv[CREDENTIAL_KEY]) {
406
484
  // eslint-disable-next-line no-console
407
485
  console.warn(
408
486
  `[claude] this declaration references ${CREDENTIAL_KEY} in a field claude `
409
- + 'substitutes literally (args, url, headers, or a larger string), so the '
487
+ + 'substitutes literally (args, url, unsupported headers, or a larger string), so the '
410
488
  + "value stays in claude's environment for this spawn and every MCP child "
411
489
  + `inherits it. Put the credential on the entry's env to get the file channel.`,
412
490
  );
@@ -501,6 +579,23 @@ const prepareArgv = async (innerArgv, ctx) => {
501
579
  // get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on
502
580
  // the operator's own Claude settings inside the namespace (Vera 69578).
503
581
  const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap';
582
+ const commitHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null;
583
+ const originalHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null;
584
+ const commitHookExecutables = commitHooksPath
585
+ ? supportedGitHooks().map((hookName) => join(commitHooksPath, hookName))
586
+ : [];
587
+ const originalHookExecutables = originalHooksPath
588
+ ? supportedGitHooks()
589
+ .map((hookName) => join(originalHooksPath, hookName))
590
+ .filter((hookPath) => {
591
+ try {
592
+ accessSync(hookPath, constants.X_OK);
593
+ return true;
594
+ } catch {
595
+ return false;
596
+ }
597
+ })
598
+ : [];
504
599
  // A public trust MUST resolve to an enforced mode. Absent mode is resolved
505
600
  // above; anything else that lands here (the literal 'none', a typo, a
506
601
  // non-string) used to fall through to the bare `claude` spawn below — an
@@ -539,7 +634,7 @@ const prepareArgv = async (innerArgv, ctx) => {
539
634
  claudePath: claudeBin,
540
635
  statePath: ctx.publicClaudeState.statePath,
541
636
  mcpConfigDir: ctx.mcpConfigDir,
542
- executablePaths: mcpExecutables,
637
+ executablePaths: [...mcpExecutables, ...commitHookExecutables, ...originalHookExecutables],
543
638
  });
544
639
  return {
545
640
  cmd: wrapped[0],
@@ -568,7 +663,7 @@ const prepareArgv = async (innerArgv, ctx) => {
568
663
  const claudeBin = resolveClaudePath(claudeEnv);
569
664
  const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
570
665
  workspacePath: ctx.cwd,
571
- readOnlyPaths: ctx.mcpConfigDir ? [ctx.mcpConfigDir] : [],
666
+ readOnlyPaths: [ctx.mcpConfigDir, commitHooksPath, originalHooksPath].filter(Boolean),
572
667
  });
573
668
  return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv };
574
669
  }
@@ -691,17 +786,21 @@ export default {
691
786
  agentName: ctx.agentName,
692
787
  });
693
788
  }
789
+ const claudeEnv = buildClaudeEnv(
790
+ ctx.env,
791
+ publicClaudeState,
792
+ mcpConfig?.expansionEnv,
793
+ mcpConfig?.credential?.path || null,
794
+ );
795
+ if (publicNativeSandbox && ctx.commitAttributionEnv) {
796
+ Object.assign(claudeEnv, ctx.commitAttributionEnv);
797
+ }
694
798
  const spawnCtx = {
695
799
  ...ctx,
696
800
  mcpConfigPath: mcpConfig?.file || null,
697
801
  mcpConfigDir: mcpConfig?.dir || null,
698
802
  publicClaudeState,
699
- claudeEnv: buildClaudeEnv(
700
- ctx.env,
701
- publicClaudeState,
702
- mcpConfig?.expansionEnv,
703
- mcpConfig?.credential?.path || null,
704
- ),
803
+ claudeEnv,
705
804
  };
706
805
  const { cmd, args, env } = await prepareArgv(baseArgs, spawnCtx);
707
806
  const stdout = await runClaude({
@@ -39,6 +39,7 @@
39
39
 
40
40
  import { spawn as childSpawn, spawnSync } from 'child_process';
41
41
  import { createHash } from 'crypto';
42
+ import { accessSync, constants } from 'fs';
42
43
  import {
43
44
  chmod,
44
45
  lstat,
@@ -57,6 +58,7 @@ import {
57
58
  resolve as pathResolve,
58
59
  } from 'path';
59
60
  import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
61
+ import { supportedGitHooks } from '../commit-attribution.js';
60
62
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
61
63
  import { prepareMcpSpawn } from '../mcp-home.js';
62
64
  import { buildMemoryPreamble } from '../memory-bridge.js';
@@ -296,13 +298,38 @@ const preparePublicCodexHome = async (ctx) => {
296
298
  return publicHome;
297
299
  };
298
300
 
299
- const publicPermissionProfileFlags = (mode) => {
301
+ const publicPermissionProfileFlags = (
302
+ mode,
303
+ commitAttributionEnv = null,
304
+ ) => {
300
305
  if (!PUBLIC_SANDBOX_MODES.has(mode)) {
301
306
  throw new Error(
302
307
  `public codex agents require sandbox.mode=workspace or read-only, got ${mode || 'unset'}`,
303
308
  );
304
309
  }
305
310
  const workspaceAccess = mode === 'read-only' ? 'read' : 'write';
311
+ const commitHooksPath = commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null;
312
+ const originalHooksPath = commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null;
313
+ const originalHookPaths = originalHooksPath
314
+ ? supportedGitHooks()
315
+ .map((hookName) => join(originalHooksPath, hookName))
316
+ .filter((hookPath) => {
317
+ try {
318
+ accessSync(hookPath, constants.X_OK);
319
+ return true;
320
+ } catch {
321
+ return false;
322
+ }
323
+ })
324
+ : [];
325
+ // Preserve caller-supplied git config entries alongside the appended
326
+ // hooksPath entry. The explicit allowlist below admits only these concrete
327
+ // variable names, not arbitrary environment variables.
328
+ const gitConfigEnv = Object.keys(commitAttributionEnv || {})
329
+ .filter((name) => /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(name));
330
+ const gitConfigKeySet = Object.entries(commitAttributionEnv || {})
331
+ .filter(([name]) => /^GIT_CONFIG_KEY_\d+$/.test(name))
332
+ .map(([name, value]) => `${toml(name)}=${toml(value)}`);
306
333
  const filesystem = [
307
334
  '":minimal"="read"',
308
335
  '"~/.commonly"="deny"',
@@ -313,16 +340,42 @@ const publicPermissionProfileFlags = (mode) => {
313
340
  '"~/.config"="deny"',
314
341
  '"/private/tmp"="deny"',
315
342
  `":workspace_roots"={"."="${workspaceAccess}",".commonly/**"="deny",".codex/**"="deny","*.env"="deny","*/*.env"="deny","*/*/*.env"="deny"}`,
343
+ ...(commitHooksPath ? [`${toml(commitHooksPath)}="read"`] : []),
344
+ ...originalHookPaths.map((hookPath) => `${toml(hookPath)}="read"`),
316
345
  ].join(',');
346
+ const shellEnvironment = [
347
+ 'PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_*',
348
+ ...(commitHooksPath ? [
349
+ ...gitConfigEnv,
350
+ 'COMMONLY_AGENT_GIT_CONFIG_INDEX',
351
+ 'COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT',
352
+ 'COMMONLY_AGENT_HOOKS_PATH',
353
+ 'COMMONLY_AGENT_ORIGINAL_HOOKS_PATH',
354
+ 'COMMONLY_AGENT_SEAT_NAME',
355
+ 'COMMONLY_AGENT_SEAT_ID',
356
+ 'COMMONLY_AGENT_ADAPTER',
357
+ 'COMMONLY_AGENT_MODEL',
358
+ 'COMMONLY_AGENT_EFFORT',
359
+ ] : []),
360
+ ];
317
361
  return [
318
362
  '-c', `default_permissions=${toml(PUBLIC_PERMISSION_PROFILE)}`,
319
363
  '-c', `permissions.${PUBLIC_PERMISSION_PROFILE}.filesystem={${filesystem}}`,
320
364
  '-c', `permissions.${PUBLIC_PERMISSION_PROFILE}.network.enabled=false`,
321
365
  // The MCP launcher receives explicitly forwarded env_vars separately.
322
- // Model-generated shell commands inherit only a small non-secret core.
323
- '-c', 'shell_environment_policy.inherit="core"',
366
+ // `core` drops explicit GIT_CONFIG_* and hook metadata before include_only
367
+ // can admit them. When attribution is active, inherit all then constrain
368
+ // model shells to the exact allowlist below; keep Codex's default secret
369
+ // filters enabled.
370
+ '-c', `shell_environment_policy.inherit="${commitHooksPath ? 'all' : 'core'}"`,
324
371
  '-c', 'shell_environment_policy.ignore_default_excludes=false',
325
- '-c', 'shell_environment_policy.include_only=["PATH","HOME","TMPDIR","LANG","LC_*"]',
372
+ '-c', `shell_environment_policy.include_only=${JSON.stringify(shellEnvironment)}`,
373
+ // Codex's default name filter treats GIT_CONFIG_KEY_n as sensitive. Set
374
+ // only those key names after filtering; their corresponding values stay
375
+ // in the allowlisted environment instead of riding argv.
376
+ ...(gitConfigKeySet.length
377
+ ? ['-c', `shell_environment_policy.set={${gitConfigKeySet.join(',')}}`]
378
+ : []),
326
379
  ];
327
380
  };
328
381
 
@@ -337,6 +390,7 @@ const buildArgs = ({
337
390
  publicSandboxMode = null,
338
391
  model = null,
339
392
  effort = null,
393
+ commitAttributionEnv = null,
340
394
  }) => {
341
395
  const publicSandbox = publicSandboxMode !== null;
342
396
  // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
@@ -356,7 +410,10 @@ const buildArgs = ({
356
410
  ? [
357
411
  '--ignore-user-config',
358
412
  '--ignore-rules',
359
- ...publicPermissionProfileFlags(publicSandboxMode),
413
+ ...publicPermissionProfileFlags(
414
+ publicSandboxMode,
415
+ commitAttributionEnv,
416
+ ),
360
417
  ]
361
418
  : ['--dangerously-bypass-approvals-and-sandbox'];
362
419
  const common = [
@@ -562,6 +619,7 @@ export default {
562
619
  publicSandboxMode,
563
620
  model: ctx.environment?.model,
564
621
  effort: ctx.environment?.effort,
622
+ commitAttributionEnv: ctx.commitAttributionEnv,
565
623
  });
566
624
  const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv };
567
625
  // Derived from the process environment, so the bootstrap export has to be
@@ -573,6 +631,9 @@ export default {
573
631
  credentialFile: credential?.path || null,
574
632
  keepsValue: mcp.forwardedEnv[CREDENTIAL_KEY] !== undefined,
575
633
  });
634
+ if (publicSandboxMode !== null && ctx.commitAttributionEnv) {
635
+ Object.assign(childEnv, ctx.commitAttributionEnv);
636
+ }
576
637
  if (publicSandboxMode !== null) {
577
638
  childEnv.CODEX_HOME = await preparePublicCodexHome(ctx);
578
639
  }
package/src/lib/api.js CHANGED
@@ -20,14 +20,34 @@ export const sessionExpiredMessage = ({ instanceKey, baseUrl }) => (
20
20
  `Session for ${instanceKey} (${baseUrl}) has expired.\nRun: commonly login --instance ${instanceKey}`
21
21
  );
22
22
 
23
+ // A non-JSON error body is usually an edge proxy's HTML page; a Cloudflare
24
+ // tunnel error is ~120 lines, and the message used to be all of them, so every
25
+ // failed poll dumped a whole page into the agent log. The message names the
26
+ // page by its <title>; err.body keeps the full text for callers that classify it.
27
+ const MAX_TEXT_ERROR = 300;
28
+ const summarizeTextError = (text, status) => {
29
+ const trimmed = String(text || '').trim();
30
+ if (!trimmed) return null;
31
+ if (/^<(?:!doctype|html)/i.test(trimmed) || /<html[\s>]/i.test(trimmed)) {
32
+ const title = trimmed.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, ' ').trim();
33
+ return title ? `HTTP ${status}: ${title}` : `HTTP ${status} (HTML error page)`;
34
+ }
35
+ const line = trimmed.replace(/\s+/g, ' ');
36
+ return line.length > MAX_TEXT_ERROR ? `${line.slice(0, MAX_TEXT_ERROR)}…` : line;
37
+ };
38
+
23
39
  const handleResponse = async (res, session = null) => {
24
40
  const text = await res.text();
25
41
  let body;
26
- try { body = JSON.parse(text); } catch { body = { message: text }; }
42
+ let isJson = true;
43
+ try { body = JSON.parse(text); } catch { body = { message: text }; isJson = false; }
27
44
  if (!res.ok) {
45
+ const serverMessage = isJson
46
+ ? body?.error || body?.message || body?.msg
47
+ : summarizeTextError(text, res.status);
28
48
  const msg = res.status === 401 && session && knownSessionFailure(body)
29
49
  ? sessionExpiredMessage(session)
30
- : body?.error || body?.message || body?.msg || `HTTP ${res.status}`;
50
+ : serverMessage || `HTTP ${res.status}`;
31
51
  const err = new Error(msg);
32
52
  err.status = res.status;
33
53
  err.body = body;
@@ -0,0 +1,201 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ accessSync,
5
+ constants,
6
+ mkdtempSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join, resolve } from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ const GIT_HOOKS = [
16
+ 'applypatch-msg',
17
+ 'pre-applypatch',
18
+ 'post-applypatch',
19
+ 'pre-commit',
20
+ 'pre-merge-commit',
21
+ 'prepare-commit-msg',
22
+ 'commit-msg',
23
+ 'post-commit',
24
+ 'pre-rebase',
25
+ 'post-checkout',
26
+ 'post-merge',
27
+ 'pre-push',
28
+ 'pre-receive',
29
+ 'update',
30
+ 'proc-receive',
31
+ 'post-receive',
32
+ 'post-update',
33
+ 'reference-transaction',
34
+ 'push-to-checkout',
35
+ 'pre-auto-gc',
36
+ 'post-rewrite',
37
+ 'sendemail-validate',
38
+ 'fsmonitor-watchman',
39
+ 'p4-pre-submit',
40
+ 'p4-prepare-changelist',
41
+ 'p4-changelist',
42
+ 'p4-post-changelist',
43
+ 'post-index-change',
44
+ ];
45
+
46
+ const cleanSingleLine = (value, fallback) => {
47
+ let cleaned = '';
48
+ for (const character of String(value ?? '')) {
49
+ const codePoint = character.codePointAt(0);
50
+ cleaned += codePoint <= 0x1f || codePoint === 0x7f ? ' ' : character;
51
+ }
52
+ cleaned = cleaned
53
+ .replace(/[<>]/g, '')
54
+ .replace(/\s+/g, ' ')
55
+ .trim();
56
+ return cleaned || fallback;
57
+ };
58
+
59
+ // Use an email-local-part-safe reversible id. URI escapes introduce `%`, which
60
+ // Git does not accept in a co-author trailer address. Keep the reversible form
61
+ // while it fits the 64-character email local-part limit; longer identities use
62
+ // a stable hash so GitHub can still lift the co-author on squash.
63
+ const seatAddressId = (agentName, instanceId) => {
64
+ const identity = JSON.stringify([
65
+ cleanSingleLine(agentName, 'agent'),
66
+ cleanSingleLine(instanceId, 'default'),
67
+ ]);
68
+ const reversibleId = Buffer.from(identity).toString('base64url');
69
+ if (reversibleId.length <= 64) return reversibleId;
70
+ return `sha256-${createHash('sha256').update(identity).digest('base64url')}`;
71
+ };
72
+
73
+ const resolveOriginalHooksPath = (cwd, env) => {
74
+ const result = spawnSync(
75
+ 'git',
76
+ ['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
77
+ { cwd, env, encoding: 'utf8' },
78
+ );
79
+ if (result.status !== 0 || !result.stdout.trim()) return null;
80
+ const hooksPath = resolve(result.stdout.trim());
81
+ try {
82
+ return statSync(hooksPath).isDirectory() ? hooksPath : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+
88
+ const resolveGitConfigCount = (env) => {
89
+ const raw = env.GIT_CONFIG_COUNT ?? '0';
90
+ if (!/^(0|[1-9]\d*)$/.test(String(raw))) {
91
+ throw new Error(`cannot enable commit attribution: GIT_CONFIG_COUNT is invalid (${raw})`);
92
+ }
93
+ const count = Number(raw);
94
+ if (!Number.isSafeInteger(count)) {
95
+ throw new Error('cannot enable commit attribution: GIT_CONFIG_COUNT is out of range');
96
+ }
97
+ return count;
98
+ };
99
+
100
+ const wrapperHook = () => `#!/bin/sh
101
+ # Commonly CLI commit attribution. Preserve the repository's configured hooks.
102
+ hook_name=\${0##*/}
103
+ config_index=$COMMONLY_AGENT_GIT_CONFIG_INDEX
104
+ base_count=$COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT
105
+ case "$config_index:$base_count" in
106
+ *[!0-9:]*|:*) exit 1 ;;
107
+ esac
108
+ GIT_CONFIG_COUNT=$base_count
109
+ export GIT_CONFIG_COUNT
110
+ unset "GIT_CONFIG_KEY_$config_index" "GIT_CONFIG_VALUE_$config_index"
111
+
112
+ original_hooks=\${COMMONLY_AGENT_ORIGINAL_HOOKS_PATH:-}
113
+ if [ -n "$original_hooks" ] && [ "$original_hooks" != "$COMMONLY_AGENT_HOOKS_PATH" ]; then
114
+ original_hook="$original_hooks/$hook_name"
115
+ if [ -x "$original_hook" ]; then
116
+ "$original_hook" "$@"
117
+ hook_status=$?
118
+ if [ "$hook_status" -ne 0 ]; then
119
+ exit "$hook_status"
120
+ fi
121
+ fi
122
+ fi
123
+
124
+ if [ "$hook_name" = "prepare-commit-msg" ] && [ -n "$1" ]; then
125
+ git interpret-trailers --in-place --if-exists=addIfDifferent \\
126
+ --trailer "Co-authored-by: $COMMONLY_AGENT_SEAT_NAME <$COMMONLY_AGENT_SEAT_ID@agents.commonly.invalid>" \\
127
+ --trailer "Agent-Model: $COMMONLY_AGENT_ADAPTER/$COMMONLY_AGENT_MODEL/$COMMONLY_AGENT_EFFORT" \\
128
+ "$1"
129
+ exit $?
130
+ fi
131
+ exit 0
132
+ `;
133
+
134
+ /**
135
+ * Install a per-spawn Git hook proxy and return an environment for the CLI
136
+ * child. The proxy delegates every standard hook to the repository's original
137
+ * hook directory, then adds Commonly trailers after an existing
138
+ * prepare-commit-msg hook has had its say.
139
+ */
140
+ export const prepareCommitAttribution = ({
141
+ cwd,
142
+ env = process.env,
143
+ agentName,
144
+ displayName,
145
+ instanceId = 'default',
146
+ adapter,
147
+ model,
148
+ effort,
149
+ }) => {
150
+ const configIndex = resolveGitConfigCount(env);
151
+ const originalHooksPath = resolveOriginalHooksPath(cwd, env);
152
+ const hooksDirectory = mkdtempSync(join(tmpdir(), 'commonly-agent-hooks-'));
153
+ try {
154
+ const hookSource = wrapperHook();
155
+ for (const hookName of GIT_HOOKS) {
156
+ const hookPath = join(hooksDirectory, hookName);
157
+ writeFileSync(hookPath, hookSource, { encoding: 'utf8', mode: 0o700 });
158
+ accessSync(hookPath, constants.X_OK);
159
+ }
160
+ } catch (error) {
161
+ rmSync(hooksDirectory, { recursive: true, force: true });
162
+ throw error;
163
+ }
164
+
165
+ const safeAdapter = cleanSingleLine(adapter, 'unknown');
166
+ const metadataEnv = {
167
+ COMMONLY_AGENT_GIT_CONFIG_INDEX: String(configIndex),
168
+ COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT: String(configIndex),
169
+ COMMONLY_AGENT_HOOKS_PATH: hooksDirectory,
170
+ ...(originalHooksPath ? { COMMONLY_AGENT_ORIGINAL_HOOKS_PATH: originalHooksPath } : {}),
171
+ COMMONLY_AGENT_SEAT_NAME: cleanSingleLine(displayName || agentName, 'agent'),
172
+ COMMONLY_AGENT_SEAT_ID: seatAddressId(agentName, instanceId),
173
+ COMMONLY_AGENT_ADAPTER: safeAdapter,
174
+ COMMONLY_AGENT_MODEL: cleanSingleLine(model, 'default'),
175
+ COMMONLY_AGENT_EFFORT: cleanSingleLine(effort, 'default'),
176
+ };
177
+ const attributionEnv = {
178
+ ...env,
179
+ GIT_CONFIG_COUNT: String(configIndex + 1),
180
+ [`GIT_CONFIG_KEY_${configIndex}`]: 'core.hooksPath',
181
+ [`GIT_CONFIG_VALUE_${configIndex}`]: hooksDirectory,
182
+ ...metadataEnv,
183
+ };
184
+ const sandboxEnv = {
185
+ ...metadataEnv,
186
+ GIT_CONFIG_COUNT: '1',
187
+ GIT_CONFIG_KEY_0: 'core.hooksPath',
188
+ GIT_CONFIG_VALUE_0: hooksDirectory,
189
+ COMMONLY_AGENT_GIT_CONFIG_INDEX: '0',
190
+ COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT: '0',
191
+ };
192
+
193
+ return {
194
+ env: attributionEnv,
195
+ sandboxEnv,
196
+ hooksDirectory,
197
+ cleanup: () => rmSync(hooksDirectory, { recursive: true, force: true }),
198
+ };
199
+ };
200
+
201
+ export const supportedGitHooks = () => [...GIT_HOOKS];