@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 +1 -1
- package/src/commands/agent.js +33 -6
- package/src/lib/adapters/claude.js +120 -21
- package/src/lib/adapters/codex.js +66 -5
- package/src/lib/api.js +22 -2
- package/src/lib/commit-attribution.js +201 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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/
|
|
293
|
-
//
|
|
294
|
-
//
|
|
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
|
|
327
|
-
// merely contains the placeholder) keeps
|
|
328
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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 = (
|
|
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
|
-
//
|
|
323
|
-
|
|
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',
|
|
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(
|
|
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
|
-
|
|
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
|
-
:
|
|
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];
|