@commonlyai/cli 0.1.80 → 0.1.84
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/commands/inbox.js +31 -14
- package/src/lib/adapters/claude.js +120 -21
- package/src/lib/adapters/codex.js +66 -5
- package/src/lib/commit-attribution.js +201 -0
- package/src/lib/inbox.js +103 -29
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,
|
package/src/commands/inbox.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
formatItemLine,
|
|
22
22
|
nextCursorFrom,
|
|
23
23
|
parseCursor,
|
|
24
|
+
parseWindowMs,
|
|
24
25
|
readCursorFile,
|
|
25
26
|
readInboxToken,
|
|
26
27
|
resolveAccountLabel,
|
|
@@ -84,6 +85,9 @@ export const runInboxList = async (opts = {}, deps = {}) => {
|
|
|
84
85
|
// Validated before the request: a typo in --kind must not cost a network
|
|
85
86
|
// round trip, and must not read a queue the caller did not ask for.
|
|
86
87
|
const kind = normalizeKind(opts.kind);
|
|
88
|
+
// Validated here with --kind, for the same reason: a typo must not cost a
|
|
89
|
+
// request, and a caller who asked for a window must not silently get another.
|
|
90
|
+
const windowMs = parseWindowMs(opts.window);
|
|
87
91
|
// Read and check the cursor BEFORE connecting: this is a local-file fact, and
|
|
88
92
|
// a misuse of the flags must not cost even the identity request that
|
|
89
93
|
// resolves the account.
|
|
@@ -103,23 +107,28 @@ export const runInboxList = async (opts = {}, deps = {}) => {
|
|
|
103
107
|
+ `--kind ${cursorIn.kind} --cursor-file <path>.${cursorIn.kind}.`,
|
|
104
108
|
);
|
|
105
109
|
}
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
// The window: W behind the cursor's own stamp, not at it. A row stamped before
|
|
111
|
+
// the cursor can commit after the read that wrote it, and it is that read's
|
|
112
|
+
// `since` — not the row — that made it invisible; a read starting W earlier
|
|
113
|
+
// sees it. The ids already delivered inside the window are what keep the
|
|
114
|
+
// re-read from printing the old rows again (see lib/inbox.js).
|
|
115
|
+
const sinceMs = cursorIn ? cursorIn.ms - windowMs : null;
|
|
116
|
+
const since = sinceMs === null ? null : new Date(sinceMs).toISOString();
|
|
108
117
|
|
|
109
118
|
const {
|
|
110
119
|
baseUrl, request, account,
|
|
111
120
|
} = await connect(opts, { env, fetchImpl, sleep });
|
|
112
121
|
|
|
113
|
-
const { items, meta } = await fetchQueue(request, { podId: opts.pod });
|
|
122
|
+
const { items, meta } = await fetchQueue(request, { podId: opts.pod, since });
|
|
114
123
|
const selected = filterItems(items, {
|
|
115
124
|
kind,
|
|
116
125
|
sinceMs,
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
126
|
+
// Everything already delivered inside the window. An item of the same id in
|
|
127
|
+
// the response is a re-delivery, not news; an item that merely shares its
|
|
128
|
+
// instant with one is news, which is why this dedupes by id and not by time.
|
|
120
129
|
seenIds: cursorIn ? cursorIn.ids : [],
|
|
121
130
|
});
|
|
122
|
-
const cursor = nextCursorFrom(selected, cursorIn, kind);
|
|
131
|
+
const cursor = nextCursorFrom(selected, cursorIn, kind, windowMs);
|
|
123
132
|
|
|
124
133
|
if (opts.json) {
|
|
125
134
|
log(JSON.stringify({
|
|
@@ -138,7 +147,7 @@ export const runInboxList = async (opts = {}, deps = {}) => {
|
|
|
138
147
|
const counts = countsLine(meta?.countsByKind);
|
|
139
148
|
log(`open: ${Number.isInteger(meta?.count) ? meta.count : items.length}${counts ? ` (${counts})` : ''}`);
|
|
140
149
|
if (selected.length === 0) {
|
|
141
|
-
log(since ? `(
|
|
150
|
+
log(since ? `(nothing new; the window since ${since} was re-read)` : '(queue is empty)');
|
|
142
151
|
}
|
|
143
152
|
for (const item of selected) log(formatItemLine(item));
|
|
144
153
|
log(`cursor: ${cursor ? cursor.at : ''}`);
|
|
@@ -226,16 +235,23 @@ Examples:
|
|
|
226
235
|
$ commonly inbox list --json | jq '.items[].title'
|
|
227
236
|
$ commonly inbox list --kind decision --pod <podId>
|
|
228
237
|
$ commonly inbox list --cursor-file ~/.commonly/inbox/connector-ops.cursor # only what is new
|
|
238
|
+
$ commonly inbox list --cursor-file ~/.commonly/inbox/c.cursor --window 300 # widen the re-read to 5m
|
|
229
239
|
$ commonly inbox ack <attentionItemId>
|
|
230
240
|
$ commonly inbox choose <decisionId> 2
|
|
231
241
|
|
|
232
|
-
Cost: \`list\` reads the WHOLE queue, one request per 50 items —
|
|
233
|
-
items is 11 requests
|
|
242
|
+
Cost: with no cursor, \`list\` reads the WHOLE queue, one request per 50 items —
|
|
243
|
+
550 open items is 11 requests. /api/activity is limited to 60 a minute **keyed on
|
|
234
244
|
the caller's IP**, so every session on one host (and the operator's browser)
|
|
235
|
-
shares that budget
|
|
236
|
-
\`Retry-After\`, then reported.
|
|
237
|
-
|
|
238
|
-
|
|
245
|
+
shares that budget; a 429 is backed off and retried twice on the server's own
|
|
246
|
+
\`Retry-After\`, then reported.
|
|
247
|
+
|
|
248
|
+
With \`--cursor-file\`, the read instead asks for \`createdAt >= cursor - W\`
|
|
249
|
+
(\`--window\`, default 60s), so a watcher tick is ONE request. W is the margin
|
|
250
|
+
over write-commit latency plus the writer's clock offset — a row is stamped before
|
|
251
|
+
its write commits, so resuming exactly at the cursor would lose it. Rows already
|
|
252
|
+
delivered inside W are dropped by id, which is the re-delivery the window is for.
|
|
253
|
+
The counts printed in the header still describe the whole open set; only the rows
|
|
254
|
+
inside the window are printed.
|
|
239
255
|
|
|
240
256
|
\`ack\` deliberately ships WITHOUT an \`--all\`: a burst of acks shares that same
|
|
241
257
|
per-IP budget, so a paced loop is the caller's decision and a bulk
|
|
@@ -253,6 +269,7 @@ their attention as runtime events, not from this queue.
|
|
|
253
269
|
.option('--token-file <path>', INBOX_TOKEN_HELP)
|
|
254
270
|
.option('--since <cursor>', 'ISO-8601 timestamp; only items created after it')
|
|
255
271
|
.option('--cursor-file <path>', 'Read the cursor from this file and write the advanced cursor back')
|
|
272
|
+
.option('--window <seconds>', 'Re-read this far behind the cursor, dropping ids already delivered (default 60; 0 = exact cursor)')
|
|
256
273
|
.option('--kind <kind>', `Only this kind: ${INBOX_KINDS.join('|')}`)
|
|
257
274
|
.option('--pod <podId>', 'Only this pod')
|
|
258
275
|
.option('--json', 'Emit one JSON object (with the resolved account and the cursor)')
|
|
@@ -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
|
}
|
|
@@ -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];
|
package/src/lib/inbox.js
CHANGED
|
@@ -24,20 +24,30 @@
|
|
|
24
24
|
* `list` prints both, labelled, so neither command has to be discovered by
|
|
25
25
|
* failing first.
|
|
26
26
|
*
|
|
27
|
-
* The cursor is a TIMESTAMP PLUS the ids delivered
|
|
28
|
-
* compared against `createdAt` — the only field on an item that never moves.
|
|
29
|
-
* queue's ORDER moves as items are acknowledged (priority, then newest first),
|
|
27
|
+
* The cursor is a TIMESTAMP PLUS the ids delivered INSIDE A WINDOW that ends at
|
|
28
|
+
* it, compared against `createdAt` — the only field on an item that never moves.
|
|
29
|
+
* The queue's ORDER moves as items are acknowledged (priority, then newest first),
|
|
30
30
|
* so an offset cursor would skip rows; `createdAt` cannot.
|
|
31
31
|
*
|
|
32
|
-
* The
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
32
|
+
* The window is what makes the cursor safe, and the ids are not decoration. A row
|
|
33
|
+
* is stamped from the WRITER's clock at the moment of the write and becomes
|
|
34
|
+
* visible only when that write commits, so a row can carry a stamp before the
|
|
35
|
+
* cursor and appear after the read that wrote it — and it is not late, it is
|
|
36
|
+
* simply invisible to a reader who resumes at the cursor. Starting a read W
|
|
37
|
+
* behind the cursor (`--window`, default 60s) makes that row visible on the next
|
|
38
|
+
* tick; the ids already delivered inside that window are then dropped, so the
|
|
39
|
+
* re-read prints only what the caller has not seen. A bare `ms > since` cursor
|
|
40
|
+
* loses such a row silently and forever — the one failure this queue (576 open
|
|
41
|
+
* items, none ever read) cannot afford, which is why the recoverable direction
|
|
42
|
+
* (re-printing, deduped by `attentionItemId`) is always the one taken here. A
|
|
43
|
+
* legacy plain-timestamp cursor parses with an EMPTY set, which degrades toward
|
|
44
|
+
* re-printing rather than skipping.
|
|
45
|
+
*
|
|
46
|
+
* The window is NOT a guess about clock skew alone: W must exceed the write's
|
|
47
|
+
* commit latency PLUS the writer's clock offset, and a caller that sets it to 0
|
|
48
|
+
* gets the exact-cursor behaviour (resume at the cursor, dedupe only the ids
|
|
49
|
+
* stamped with it). The ids are pruned to the window on every write, so the
|
|
50
|
+
* cursor file stays the size of one window of deliveries instead of growing.
|
|
41
51
|
*
|
|
42
52
|
* THE GENERAL RULE, which both cursor bugs above are instances of: a resume
|
|
43
53
|
* cursor is valid only for the query that wrote it. `--kind` is a DIFFERENT
|
|
@@ -168,6 +178,24 @@ export const parseCursor = (value) => {
|
|
|
168
178
|
};
|
|
169
179
|
};
|
|
170
180
|
|
|
181
|
+
/**
|
|
182
|
+
* `--window <seconds>`: how far behind the cursor a read starts. The default is
|
|
183
|
+
* the spec's 60s — the margin over in-cluster write latency and clock offset —
|
|
184
|
+
* and 0 restores the exact-cursor behaviour. Refused rather than clamped: a
|
|
185
|
+
* negative or unreadable window would silently change which rows a read can see,
|
|
186
|
+
* and that is the failure this whole module is built to avoid.
|
|
187
|
+
*/
|
|
188
|
+
export const DEFAULT_WINDOW_MS = 60 * 1000;
|
|
189
|
+
|
|
190
|
+
export const parseWindowMs = (value, fallback = DEFAULT_WINDOW_MS) => {
|
|
191
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
192
|
+
const seconds = Number(String(value).trim());
|
|
193
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
194
|
+
throw new InboxRefusal(`--window must be a number of seconds >= 0, not ${value}.`);
|
|
195
|
+
}
|
|
196
|
+
return Math.round(seconds * 1000);
|
|
197
|
+
};
|
|
198
|
+
|
|
171
199
|
/** A missing cursor file is "nothing seen yet", not an error. */
|
|
172
200
|
export const readCursorFile = (filePath) => {
|
|
173
201
|
if (!filePath) return null;
|
|
@@ -217,28 +245,46 @@ export const cursorKeyOf = (item) => String(item?.attentionItemId || item?.id ||
|
|
|
217
245
|
/**
|
|
218
246
|
* An item with an unparseable `createdAt` is KEPT under a cursor: dropping it
|
|
219
247
|
* would lose the row silently, and re-printing is the recoverable error. The same
|
|
220
|
-
* argument decides the
|
|
248
|
+
* argument decides the boundary — see the module header.
|
|
249
|
+
*
|
|
250
|
+
* `seenIds` is the set delivered inside the window and is dropped BY ID, whatever
|
|
251
|
+
* the item's timestamp: the window exists to re-serve those rows to the server
|
|
252
|
+
* call, and the id is the only thing that can say "the caller already saw this
|
|
253
|
+
* one". `sinceMs` is the window's own start and is inclusive, matching the
|
|
254
|
+
* route's bound, so a row stamped exactly at it still prints.
|
|
221
255
|
*/
|
|
222
256
|
export const filterItems = (items, { kind = null, sinceMs = null, seenIds = [] } = {}) => {
|
|
223
257
|
const delivered = seenIds instanceof Set ? seenIds : new Set(seenIds);
|
|
224
258
|
return (items || []).filter((item) => {
|
|
225
259
|
if (kind && item?.kind !== kind) return false;
|
|
260
|
+
if (delivered.has(cursorKeyOf(item))) return false;
|
|
226
261
|
if (sinceMs === null) return true;
|
|
227
262
|
const ms = itemCreatedAtMs(item);
|
|
228
263
|
if (ms === null) return true;
|
|
229
|
-
|
|
230
|
-
return ms === sinceMs && !delivered.has(cursorKeyOf(item));
|
|
264
|
+
return ms >= sinceMs;
|
|
231
265
|
});
|
|
232
266
|
};
|
|
233
267
|
|
|
234
268
|
/**
|
|
235
|
-
* Newest timestamp
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
269
|
+
* Newest timestamp this query has covered — the previous cursor's stamp or the
|
|
270
|
+
* newest item printed, whichever is later — so the next call resumes from W behind
|
|
271
|
+
* it, plus every id delivered at or after that window's start, which is what the
|
|
272
|
+
* next read's window will re-serve and must therefore not print twice.
|
|
273
|
+
*
|
|
274
|
+
* The max is taken over the PRINTED items, not the server's `nextSince`, because
|
|
275
|
+
* `--kind` is filtered on this side: the server's window may hold rows of other
|
|
276
|
+
* kinds that this read will never print, and advancing the cursor onto them would
|
|
277
|
+
* move the high-water mark past a row of THIS kind that a later page has not
|
|
278
|
+
* delivered yet. When nothing printed, the cursor stays where it was — the
|
|
279
|
+
* fallback — so a quiet tick does not move the mark at all.
|
|
280
|
+
*
|
|
281
|
+
* The set is recomputed from the window plus the ids the previous cursor had not
|
|
282
|
+
* yet aged out of it, and both halves are load-bearing: this read's items are
|
|
283
|
+
* post-dedupe, so a row it dropped is absent from them and would be forgotten
|
|
284
|
+
* without the carry. The prune is what bounds the file at one window of
|
|
285
|
+
* deliveries instead of a list of everything ever printed.
|
|
240
286
|
*/
|
|
241
|
-
export const nextCursorFrom = (items, fallback = null, kind = null) => {
|
|
287
|
+
export const nextCursorFrom = (items, fallback = null, kind = null, windowMs = DEFAULT_WINDOW_MS) => {
|
|
242
288
|
let max = null;
|
|
243
289
|
for (const item of items || []) {
|
|
244
290
|
const ms = itemCreatedAtMs(item);
|
|
@@ -246,15 +292,33 @@ export const nextCursorFrom = (items, fallback = null, kind = null) => {
|
|
|
246
292
|
if (max === null || ms > max) max = ms;
|
|
247
293
|
}
|
|
248
294
|
if (max === null) return fallback;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
295
|
+
// A high-water mark: the newest item printed, or the cursor that came in,
|
|
296
|
+
// whichever is later. A row printed from inside the window is older than the
|
|
297
|
+
// mark that delimited that window, and letting it drag the mark back would move
|
|
298
|
+
// the next read's `since` back with it — reopening ground this query has already
|
|
299
|
+
// covered and moving the prune cutoff that bounds the id set.
|
|
300
|
+
const mark = fallback && Number.isFinite(fallback.ms) && fallback.ms > max ? fallback.ms : max;
|
|
301
|
+
const at = new Date(mark).toISOString();
|
|
302
|
+
const cutoff = mark - windowMs;
|
|
303
|
+
const inWindow = (items || []).filter((item) => {
|
|
304
|
+
const ms = itemCreatedAtMs(item);
|
|
305
|
+
return ms !== null && ms >= cutoff;
|
|
306
|
+
}).map(cursorKeyOf).filter(Boolean);
|
|
307
|
+
// The ids from the previous cursor are NOT dead weight, and dropping them is a
|
|
308
|
+
// measurable bug: `items` is what this read SELECTED, so a row delivered last
|
|
309
|
+
// tick has already been filtered out of it, and the set built from `items` alone
|
|
310
|
+
// would forget it — then the read after that would print it again, and the
|
|
311
|
+
// dedupe would alternate every tick. They are carried only while the previous
|
|
312
|
+
// cursor's own stamp is still inside this window; past that the next read's
|
|
313
|
+
// `since` is later than every one of them, the server can never re-serve them,
|
|
314
|
+
// and keeping them would grow the file for nothing.
|
|
315
|
+
const carried = fallback && Number.isFinite(fallback.ms) && fallback.ms >= cutoff
|
|
316
|
+
? (fallback.ids || []) : [];
|
|
253
317
|
// The kind is the CURRENT read's, never inherited: the cursor describes the
|
|
254
318
|
// query that just ran, and a mismatch with the file's own kind is what the
|
|
255
319
|
// command refuses on before it makes a request.
|
|
256
320
|
return {
|
|
257
|
-
at, ms:
|
|
321
|
+
at, ms: mark, ids: [...new Set([...carried, ...inWindow])], kind: kind || null,
|
|
258
322
|
};
|
|
259
323
|
};
|
|
260
324
|
|
|
@@ -371,21 +435,31 @@ export const createInboxRequest = ({
|
|
|
371
435
|
* that reports `hasMore: true` beside an empty page would otherwise spin
|
|
372
436
|
* forever, and one that lies about `count` would page past the end.
|
|
373
437
|
*/
|
|
374
|
-
export const fetchQueue = async (request, { podId } = {}) => {
|
|
438
|
+
export const fetchQueue = async (request, { podId, since } = {}) => {
|
|
375
439
|
const items = [];
|
|
376
440
|
let meta = {};
|
|
377
441
|
let offset = 0;
|
|
378
442
|
for (;;) {
|
|
379
443
|
// eslint-disable-next-line no-await-in-loop
|
|
380
444
|
const page = await request('/api/activity/decision-queue', {
|
|
381
|
-
params: {
|
|
445
|
+
params: {
|
|
446
|
+
limit: API_PAGE_LIMIT,
|
|
447
|
+
offset,
|
|
448
|
+
...(podId ? { podId } : {}),
|
|
449
|
+
// Only sent when there is a cursor: a first read is asking for everything
|
|
450
|
+
// the account has, and a bound there would silently hide its older half.
|
|
451
|
+
...(since ? { since } : {}),
|
|
452
|
+
},
|
|
382
453
|
});
|
|
383
454
|
const batch = Array.isArray(page?.items) ? page.items : [];
|
|
384
455
|
items.push(...batch);
|
|
385
456
|
meta = page || {};
|
|
386
457
|
if (!meta.hasMore) break;
|
|
387
458
|
if (batch.length === 0) break;
|
|
388
|
-
|
|
459
|
+
// A bounded call reports the WHOLE open set in `count` and the window in
|
|
460
|
+
// `windowCount`, so stopping on `count` would page past the window's end.
|
|
461
|
+
const reported = since && Number.isInteger(meta.windowCount) ? meta.windowCount : meta.count;
|
|
462
|
+
if (Number.isInteger(reported) && items.length >= reported) break;
|
|
389
463
|
offset += batch.length;
|
|
390
464
|
}
|
|
391
465
|
return { items, meta };
|