@bridge4dev/runner 0.52.0 → 0.54.0
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/dist/adapters/claude.js +111 -33
- package/dist/adapters/codex-protocol.d.ts +11 -0
- package/dist/adapters/codex-protocol.js +41 -3
- package/dist/adapters/codex.js +14 -26
- package/dist/adapters/types.d.ts +45 -1
- package/dist/adapters/types.js +53 -0
- package/dist/checkpoints.d.ts +62 -0
- package/dist/checkpoints.js +50 -1
- package/dist/git.d.ts +64 -0
- package/dist/git.js +487 -36
- package/dist/gitops.d.ts +5 -0
- package/dist/gitops.js +7 -8
- package/dist/host-load.d.ts +156 -0
- package/dist/host-load.js +223 -0
- package/dist/index.js +190 -40
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +228 -7
- package/dist/process-priority.d.ts +55 -0
- package/dist/process-priority.js +99 -0
- package/dist/protocol.d.ts +42 -20
- package/dist/recipe-schema.d.ts +6 -6
- package/dist/self-update.js +43 -2
- package/dist/service-unit.d.ts +232 -10
- package/dist/service-unit.js +372 -43
- package/dist/session-cage.d.ts +297 -0
- package/dist/session-cage.js +755 -0
- package/dist/supervisor.d.ts +156 -3
- package/dist/supervisor.js +351 -32
- package/dist/systemd-memory.d.ts +35 -0
- package/dist/systemd-memory.js +115 -0
- package/dist/verify.js +28 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { AsyncQueue } from '../async-queue.js';
|
|
5
6
|
import { log } from '../log.js';
|
|
6
7
|
import { mcpConfigPath } from '../paths.js';
|
|
8
|
+
import { lowerPriority } from '../process-priority.js';
|
|
9
|
+
import { cageSpawn, releaseSessionScope } from '../session-cage.js';
|
|
7
10
|
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
8
|
-
import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
11
|
+
import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
9
12
|
import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
|
|
10
13
|
import { applyUsagePercentages, parseUsageText, probeUsageText } from './claude-usage.js';
|
|
11
14
|
import { claudeExecutableOption, sessionClaudePath } from '../agent-binary.js';
|
|
12
15
|
/** How often `/usage` may be read. Free, but still a process. */
|
|
13
16
|
const USAGE_PROBE_INTERVAL_MS = 3 * 60 * 1000;
|
|
17
|
+
/** Same 2KB the SDK keeps: enough for the CLI's last words, not a log sink. */
|
|
18
|
+
const STDERR_TAIL_LIMIT = 2048;
|
|
14
19
|
import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
|
|
15
20
|
// Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
|
|
16
21
|
// 1. Bare tool names in `allowedTools` auto-approve BEFORE canUseTool — we
|
|
@@ -180,6 +185,9 @@ function systemAppendFor(spec) {
|
|
|
180
185
|
'- The user is not in a terminal, but they DO answer: when you need a decision, use the AskUserQuestion tool. It is rendered as a card in the DevBridge dashboard and the call waits — however long it takes — until a human answers it. Only ask in plain text if the tool is unavailable.',
|
|
181
186
|
'- Never decide for the user when you asked them a question. If the tool comes back saying the question was withdrawn, stop and wait rather than guessing.',
|
|
182
187
|
'- Never print secrets (tokens, API keys, private keys) in your output.',
|
|
188
|
+
// #361 п. 5 — only where the folder is shared. Layer 1 asks about these
|
|
189
|
+
// commands anyway; this is so the agent learns the rule before a card.
|
|
190
|
+
...(spec.workMode === 'DIRECT' ? [DIRECT_BRANCH_RULE] : []),
|
|
183
191
|
].join('\n');
|
|
184
192
|
}
|
|
185
193
|
/** DevBridge's own rules, then whatever this workspace adds (session 13). */
|
|
@@ -290,6 +298,17 @@ class ClaudeSession {
|
|
|
290
298
|
mcpConfigFile = null;
|
|
291
299
|
/** Set when the write failed and the key went into argv after all. */
|
|
292
300
|
mcpFallbackNotice = null;
|
|
301
|
+
/**
|
|
302
|
+
* The last few KB the CLI wrote to stderr, kept because we spawn it ourselves.
|
|
303
|
+
*
|
|
304
|
+
* The SDK's own spawn keeps this tail and appends it to the error it throws
|
|
305
|
+
* when the process dies (`. stderr: …`); a custom `spawnClaudeCodeProcess`
|
|
306
|
+
* gets no such service, and that text is load-bearing here — «no conversation
|
|
307
|
+
* found» and «No message found with message.uuid» reach `classifyRunError`
|
|
308
|
+
* exactly that way. Keeping our own copy is what makes the priority hook cost
|
|
309
|
+
* nothing diagnostically.
|
|
310
|
+
*/
|
|
311
|
+
stderrTail = '';
|
|
293
312
|
/** Guards against overlapping capability probes. */
|
|
294
313
|
capabilitiesInFlight = false;
|
|
295
314
|
/**
|
|
@@ -505,6 +524,12 @@ class ClaudeSession {
|
|
|
505
524
|
// bundled binary, exactly as before. After C3 this pins the system
|
|
506
525
|
// `claude`, which is the file the card measures and the button installs.
|
|
507
526
|
...claudeExecutableOption(),
|
|
527
|
+
// Stage 1a of the host-resources plan: the CLI, and therefore everything
|
|
528
|
+
// it starts, is spawned at nice 10 so the daemon that supervises it keeps
|
|
529
|
+
// the processor. The SDK has no post-spawn hook — replacing the spawn is
|
|
530
|
+
// the only way in, which is why `spawnAgentProcess` has to re-create the
|
|
531
|
+
// one thing the default spawn did for us (the stderr tail).
|
|
532
|
+
spawnClaudeCodeProcess: (spawnOptions) => this.spawnAgentProcess(spawnOptions),
|
|
508
533
|
/**
|
|
509
534
|
* Everything the machine's own Claude has (owner's call, 2026-07-30).
|
|
510
535
|
*
|
|
@@ -620,6 +645,75 @@ class ClaudeSession {
|
|
|
620
645
|
// model's window) from the moment the process is up.
|
|
621
646
|
this.refreshContextUsage();
|
|
622
647
|
}
|
|
648
|
+
/**
|
|
649
|
+
* Start the Claude CLI ourselves, one notch below the daemon.
|
|
650
|
+
*
|
|
651
|
+
* The SDK offers no «after spawn» callback, so the only place to renice the
|
|
652
|
+
* process is a spawn we own. Two things the default spawn did have to be done
|
|
653
|
+
* here, and neither is optional:
|
|
654
|
+
*
|
|
655
|
+
* - **stderr must be read.** With `stdio: 'pipe'` and nobody draining it, the
|
|
656
|
+
* CLI blocks on a full pipe after ~64KB — a session that hangs for no
|
|
657
|
+
* visible reason. That is a worse outcome than anything nice(2) can buy.
|
|
658
|
+
* - **the tail must be kept**, because the SDK appends it to the error it
|
|
659
|
+
* throws on process death and `classifyRunError` reads that text. Without
|
|
660
|
+
* it, «the resume id is unknown» comes back as a bare exit code and the
|
|
661
|
+
* supervisor's recovery never fires.
|
|
662
|
+
*
|
|
663
|
+
* `signal` is the SDK's own forwarded abort, not the caller's: it fires only
|
|
664
|
+
* after stdin EOF and the ~2s grace, so handing it to `spawn()` kills a CLI
|
|
665
|
+
* that already had its chance to shut down cleanly.
|
|
666
|
+
*/
|
|
667
|
+
spawnAgentProcess(options) {
|
|
668
|
+
// The same hook carries stage 2: the CLI, and everything it starts, goes
|
|
669
|
+
// into this session's own cgroup with its own memory ceiling. On a machine
|
|
670
|
+
// where the cage was not proved to work `cageSpawn` hands the command back
|
|
671
|
+
// untouched, and the renice below is the whole of the containment — which
|
|
672
|
+
// is exactly the state of every machine before this release.
|
|
673
|
+
const caged = cageSpawn({
|
|
674
|
+
id: this.spec.sessionId,
|
|
675
|
+
command: options.command,
|
|
676
|
+
args: options.args,
|
|
677
|
+
});
|
|
678
|
+
const child = spawn(caged.command, caged.args, {
|
|
679
|
+
cwd: options.cwd,
|
|
680
|
+
env: { ...options.env, ...caged.env },
|
|
681
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
682
|
+
signal: options.signal,
|
|
683
|
+
windowsHide: true,
|
|
684
|
+
});
|
|
685
|
+
// `systemd-run --scope` execs into the same pid and nice survives `exec`,
|
|
686
|
+
// so this still lands on the CLI itself.
|
|
687
|
+
lowerPriority(child.pid);
|
|
688
|
+
// Read `Result` and clear the unit once the process is gone. Only an
|
|
689
|
+
// `exit` listener: stdout belongs to the SDK, and attaching a reader to it
|
|
690
|
+
// here would put the stream in flowing mode and steal the conversation.
|
|
691
|
+
child.on('exit', () => {
|
|
692
|
+
void releaseSessionScope(caged.unit, this.spec.sessionId);
|
|
693
|
+
});
|
|
694
|
+
// `setEncoding` puts a StringDecoder on the stream, so a multi-byte
|
|
695
|
+
// character split across two reads survives — the same reason `verify.ts`
|
|
696
|
+
// decodes rather than `toString`s.
|
|
697
|
+
child.stderr.setEncoding('utf8');
|
|
698
|
+
child.stderr.on('data', (chunk) => this.appendStderr(chunk));
|
|
699
|
+
// A read error on stderr is not a session error: the process itself is
|
|
700
|
+
// still on stdout, which is where the conversation lives.
|
|
701
|
+
child.stderr.on('error', (error) => {
|
|
702
|
+
log.debug('claude: stderr read failed', { error: String(error) });
|
|
703
|
+
});
|
|
704
|
+
return child;
|
|
705
|
+
}
|
|
706
|
+
appendStderr(chunk) {
|
|
707
|
+
if (!chunk)
|
|
708
|
+
return;
|
|
709
|
+
const next = this.stderrTail + chunk;
|
|
710
|
+
this.stderrTail = next.length > STDERR_TAIL_LIMIT ? next.slice(-STDERR_TAIL_LIMIT) : next;
|
|
711
|
+
}
|
|
712
|
+
/** What the CLI said on its way out, in the SDK's own wording. */
|
|
713
|
+
stderrSuffix() {
|
|
714
|
+
const tail = this.stderrTail.trim();
|
|
715
|
+
return tail ? `. stderr: ${tail}` : '';
|
|
716
|
+
}
|
|
623
717
|
/**
|
|
624
718
|
* Write this session's MCP config to a 0600 file and return its path, or null
|
|
625
719
|
* if anything went wrong (the caller then keeps the old inline shape).
|
|
@@ -1207,21 +1301,10 @@ class ClaudeSession {
|
|
|
1207
1301
|
for (const [requestId, pending] of [...this.pending]) {
|
|
1208
1302
|
if (!pending.fromPolicy)
|
|
1209
1303
|
continue;
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
? {}
|
|
1215
|
-
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
1216
|
-
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
1217
|
-
// The fourth entry point into `evaluateToolUse`, and it was the one
|
|
1218
|
-
// that did not carry the git policy (QA-134 MINOR-1). Safe direction —
|
|
1219
|
-
// without the fields everything resolves to «refused» and this function
|
|
1220
|
-
// only ever releases — but a project that allows `git clean` would have
|
|
1221
|
-
// left a parked card unreleased in Claude while Codex released it.
|
|
1222
|
-
...(this.spec.gitPolicy ?? {}),
|
|
1223
|
-
worktreePath: this.spec.cwd,
|
|
1224
|
-
});
|
|
1304
|
+
// The fourth entry point into `evaluateToolUse`, and the one that was
|
|
1305
|
+
// once assembled by hand without the git policy (QA-134 MINOR-1). All
|
|
1306
|
+
// four read the same builder now, so a field can only be forgotten once.
|
|
1307
|
+
const verdict = evaluateToolUse(pending.toolName, pending.input, policyContextFor(this.spec, this.mode));
|
|
1225
1308
|
if (verdict.decision !== 'allow')
|
|
1226
1309
|
continue;
|
|
1227
1310
|
this.emit({
|
|
@@ -1737,21 +1820,7 @@ class ClaudeSession {
|
|
|
1737
1820
|
});
|
|
1738
1821
|
return this.waitForAnswer(opts, toolName);
|
|
1739
1822
|
}
|
|
1740
|
-
const verdict = evaluateToolUse(toolName, input,
|
|
1741
|
-
trustMode: this.spec.trustMode,
|
|
1742
|
-
// Ticket #156: the missing argument. Everything else in this object was
|
|
1743
|
-
// already here; the session's own mode was not, so «Auto» decided nothing.
|
|
1744
|
-
mode: this.mode,
|
|
1745
|
-
...(this.spec.agentAutoCommit === undefined
|
|
1746
|
-
? {}
|
|
1747
|
-
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
1748
|
-
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
1749
|
-
// Session 18. Spread WHOLE rather than field by field: `resolveGitPolicy`
|
|
1750
|
-
// gives every absent field its safe reading, and an object assembled here
|
|
1751
|
-
// with three of the four would be a fourth place to get a polarity wrong.
|
|
1752
|
-
...(this.spec.gitPolicy ?? {}),
|
|
1753
|
-
worktreePath: this.spec.cwd,
|
|
1754
|
-
});
|
|
1823
|
+
const verdict = evaluateToolUse(toolName, input, policyContextFor(this.spec, this.mode));
|
|
1755
1824
|
if (verdict.decision === 'allow') {
|
|
1756
1825
|
return { behavior: 'allow', updatedInput: input };
|
|
1757
1826
|
}
|
|
@@ -1771,7 +1840,12 @@ class ClaudeSession {
|
|
|
1771
1840
|
requestId: opts.requestId,
|
|
1772
1841
|
toolName,
|
|
1773
1842
|
title: opts.title ?? `Allow ${toolName}?`,
|
|
1774
|
-
|
|
1843
|
+
// The policy's own sentence first: it is the reason this card exists, and
|
|
1844
|
+
// without it the person is asked to approve a command with no hint of
|
|
1845
|
+
// what the runner objected to (#361 п. 5).
|
|
1846
|
+
...(cardDescription(verdict, opts.description)
|
|
1847
|
+
? { description: cardDescription(verdict, opts.description) }
|
|
1848
|
+
: {}),
|
|
1775
1849
|
input: truncateInput(input),
|
|
1776
1850
|
});
|
|
1777
1851
|
return this.waitForAnswer(opts, toolName, input);
|
|
@@ -2364,7 +2438,11 @@ class ClaudeSession {
|
|
|
2364
2438
|
}
|
|
2365
2439
|
}
|
|
2366
2440
|
catch (error) {
|
|
2367
|
-
|
|
2441
|
+
// The stderr tail rides along because we spawn the CLI ourselves now (see
|
|
2442
|
+
// `spawnAgentProcess`) — the SDK used to add it and cannot any more. This
|
|
2443
|
+
// catch IS the process falling over, which is the only case the SDK
|
|
2444
|
+
// appended it to as well.
|
|
2445
|
+
const message = maskString(String(error instanceof Error ? error.message : error) + this.stderrSuffix());
|
|
2368
2446
|
const code = errorCode(message);
|
|
2369
2447
|
this.emit({
|
|
2370
2448
|
type: 'error',
|
|
@@ -30,6 +30,13 @@ export interface AppServerOptions {
|
|
|
30
30
|
args: string[];
|
|
31
31
|
cwd?: string;
|
|
32
32
|
env: Record<string, string>;
|
|
33
|
+
/**
|
|
34
|
+
* Whose session this app-server belongs to — the name of its cgroup cage
|
|
35
|
+
* (`session-cage.ts`). Required rather than optional: a spawn with no id
|
|
36
|
+
* cannot be caged, and a session running outside the cage while everything
|
|
37
|
+
* reports that the cage is on is the one failure this feature must not have.
|
|
38
|
+
*/
|
|
39
|
+
sessionId: string;
|
|
33
40
|
onNotification: (method: string, params: Record<string, unknown>) => void;
|
|
34
41
|
onServerRequest: (request: ServerRequest) => void;
|
|
35
42
|
/** Fired once when the child is gone — the session's end-of-stream signal. */
|
|
@@ -47,6 +54,10 @@ export declare class AppServerClient {
|
|
|
47
54
|
private nextId;
|
|
48
55
|
private stdoutBuffer;
|
|
49
56
|
private exited;
|
|
57
|
+
/** The scope this process runs in, or null when the machine has no cage. */
|
|
58
|
+
private readonly scopeUnit;
|
|
59
|
+
/** Has the far end said anything at all? Reads the start window — see below. */
|
|
60
|
+
private sawOutput;
|
|
50
61
|
constructor(opts: AppServerOptions);
|
|
51
62
|
get pid(): number | undefined;
|
|
52
63
|
get alive(): boolean;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { log } from '../log.js';
|
|
3
|
+
import { lowerPriority } from '../process-priority.js';
|
|
4
|
+
import { cageSpawn, killedBeforeExec, releaseSessionScope } from '../session-cage.js';
|
|
3
5
|
export class RpcError extends Error {
|
|
4
6
|
code;
|
|
5
7
|
method;
|
|
@@ -36,15 +38,32 @@ export class AppServerClient {
|
|
|
36
38
|
nextId = 1;
|
|
37
39
|
stdoutBuffer = '';
|
|
38
40
|
exited = false;
|
|
41
|
+
/** The scope this process runs in, or null when the machine has no cage. */
|
|
42
|
+
scopeUnit;
|
|
43
|
+
/** Has the far end said anything at all? Reads the start window — see below. */
|
|
44
|
+
sawOutput = false;
|
|
39
45
|
constructor(opts) {
|
|
40
46
|
this.opts = opts;
|
|
41
|
-
this
|
|
47
|
+
// `codex app-server` is the root of everything this session will run, so
|
|
48
|
+
// this is where the cage goes: the scope holds the whole tree below it, and
|
|
49
|
+
// the ceiling is per session rather than per machine.
|
|
50
|
+
const caged = cageSpawn({ id: opts.sessionId, command: opts.command, args: opts.args });
|
|
51
|
+
this.scopeUnit = caged.unit;
|
|
52
|
+
this.child = spawn(caged.command, caged.args, {
|
|
42
53
|
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
43
|
-
env: opts.env,
|
|
54
|
+
env: { ...opts.env, ...caged.env },
|
|
44
55
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
45
56
|
});
|
|
57
|
+
// Before a single byte of protocol, and still worth doing inside a scope:
|
|
58
|
+
// nice is inherited at fork AND across `exec`, so renicing `systemd-run`
|
|
59
|
+
// renices the app-server it turns into. On a machine with no cage this is
|
|
60
|
+
// the only containment there is.
|
|
61
|
+
lowerPriority(this.child.pid);
|
|
46
62
|
this.child.stdout.setEncoding('utf8');
|
|
47
|
-
this.child.stdout.on('data', (chunk) =>
|
|
63
|
+
this.child.stdout.on('data', (chunk) => {
|
|
64
|
+
this.sawOutput = true;
|
|
65
|
+
this.onStdout(chunk);
|
|
66
|
+
});
|
|
48
67
|
this.child.stderr.setEncoding('utf8');
|
|
49
68
|
this.child.stderr.on('data', (chunk) => opts.onStderr?.(chunk));
|
|
50
69
|
this.child.on('error', (error) => {
|
|
@@ -67,6 +86,25 @@ export class AppServerClient {
|
|
|
67
86
|
if (this.exited)
|
|
68
87
|
return;
|
|
69
88
|
this.exited = true;
|
|
89
|
+
if (killedBeforeExec({
|
|
90
|
+
code: info.code,
|
|
91
|
+
signal: info.signal,
|
|
92
|
+
sawOutput: this.sawOutput,
|
|
93
|
+
caged: this.scopeUnit !== null,
|
|
94
|
+
})) {
|
|
95
|
+
// Not a silent death of the agent. Starting a scope takes 0.07–2.5 s (up
|
|
96
|
+
// to 2741 ms on a loaded machine), and a stop inside that window signals
|
|
97
|
+
// `systemd-run` before it has exec'd — so the process that got SIGTERM
|
|
98
|
+
// was the wrapper, and `codex app-server` never existed. Said plainly
|
|
99
|
+
// here, because from the outside it is indistinguishable from a crash
|
|
100
|
+
// with an empty stdout.
|
|
101
|
+
log.warn('codex: the session was stopped before its process started', {
|
|
102
|
+
unit: this.scopeUnit,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// Read the cause, THEN let systemd forget the unit: `Result=oom-kill` is
|
|
106
|
+
// only readable while the failed scope is still there (`session-cage.ts`).
|
|
107
|
+
void releaseSessionScope(this.scopeUnit, this.opts.sessionId);
|
|
70
108
|
this.opts.onExit(info);
|
|
71
109
|
}
|
|
72
110
|
failAll(error) {
|
package/dist/adapters/codex.js
CHANGED
|
@@ -5,7 +5,7 @@ import { RUNNER_VERSION } from '../version.js';
|
|
|
5
5
|
import { repairCodexAuth } from './codex-home.js';
|
|
6
6
|
import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
|
|
7
7
|
import { truncate } from './claude.js';
|
|
8
|
-
import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
8
|
+
import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
9
9
|
import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
|
|
10
10
|
import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
|
|
11
11
|
// Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
|
|
@@ -83,6 +83,9 @@ function systemAppendFor(spec) {
|
|
|
83
83
|
'- If DevBridge MCP tools are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
|
|
84
84
|
'- The user is not in a terminal: if you need a decision, use your question tool or ask in plain text and end your turn.',
|
|
85
85
|
'- Never print secrets (tokens, API keys, private keys) in your output.',
|
|
86
|
+
// #361 п. 5 — only where the folder is shared. Layer 1 asks about these
|
|
87
|
+
// commands anyway; this is so the agent learns the rule before a card.
|
|
88
|
+
...(spec.workMode === 'DIRECT' ? [DIRECT_BRANCH_RULE] : []),
|
|
86
89
|
].join('\n');
|
|
87
90
|
}
|
|
88
91
|
/** DevBridge's own rules, then whatever this workspace adds (session 13). */
|
|
@@ -217,6 +220,10 @@ class CodexSession {
|
|
|
217
220
|
command: CODEX_BIN,
|
|
218
221
|
args: ['app-server'],
|
|
219
222
|
cwd: spec.cwd,
|
|
223
|
+
// Names this session's cgroup cage (`session-cage.ts`). Not part of
|
|
224
|
+
// `wiring`: an injected client is a scripted object with no process
|
|
225
|
+
// under it, and there is nothing to cage.
|
|
226
|
+
sessionId: spec.sessionId,
|
|
220
227
|
...wiring,
|
|
221
228
|
});
|
|
222
229
|
if (this.modeRefusedAtLaunch) {
|
|
@@ -805,17 +812,7 @@ class CodexSession {
|
|
|
805
812
|
for (const [requestId, pending] of [...this.approvals]) {
|
|
806
813
|
if (!pending.policy)
|
|
807
814
|
continue;
|
|
808
|
-
const verdict = evaluateToolUse(pending.policy.tool, pending.policy.input,
|
|
809
|
-
trustMode: this.spec.trustMode,
|
|
810
|
-
mode: this.mode,
|
|
811
|
-
...(this.spec.agentAutoCommit === undefined
|
|
812
|
-
? {}
|
|
813
|
-
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
814
|
-
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
815
|
-
// Session 18 — spread whole; see the note in the Claude adapter.
|
|
816
|
-
...(this.spec.gitPolicy ?? {}),
|
|
817
|
-
worktreePath: this.spec.cwd,
|
|
818
|
-
});
|
|
815
|
+
const verdict = evaluateToolUse(pending.policy.tool, pending.policy.input, policyContextFor(this.spec, this.mode));
|
|
819
816
|
if (verdict.decision !== 'allow')
|
|
820
817
|
continue;
|
|
821
818
|
this.approvals.delete(requestId);
|
|
@@ -930,19 +927,7 @@ class CodexSession {
|
|
|
930
927
|
const enriched = this.describeApproval(request);
|
|
931
928
|
const verdict = enriched.forceAsk
|
|
932
929
|
? { decision: 'ask', reason: 'details unavailable' }
|
|
933
|
-
: evaluateToolUse(enriched.policyTool, enriched.policyInput,
|
|
934
|
-
trustMode: this.spec.trustMode,
|
|
935
|
-
// Ticket #156, the same missing argument as in the Claude adapter —
|
|
936
|
-
// both bridges call one policy, so both have to hand it the mode.
|
|
937
|
-
mode: this.mode,
|
|
938
|
-
...(this.spec.agentAutoCommit === undefined
|
|
939
|
-
? {}
|
|
940
|
-
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
941
|
-
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
942
|
-
// Session 18 — spread whole; see the note in the Claude adapter.
|
|
943
|
-
...(this.spec.gitPolicy ?? {}),
|
|
944
|
-
worktreePath: this.spec.cwd,
|
|
945
|
-
});
|
|
930
|
+
: evaluateToolUse(enriched.policyTool, enriched.policyInput, policyContextFor(this.spec, this.mode));
|
|
946
931
|
if (verdict.decision === 'allow') {
|
|
947
932
|
this.client.respond(request.id, { decision: 'accept' });
|
|
948
933
|
return;
|
|
@@ -968,7 +953,10 @@ class CodexSession {
|
|
|
968
953
|
requestId,
|
|
969
954
|
toolName: enriched.toolName,
|
|
970
955
|
title: enriched.title,
|
|
971
|
-
|
|
956
|
+
// See the note in the Claude adapter — one policy, two bridges, one card.
|
|
957
|
+
...(cardDescription(verdict, enriched.description)
|
|
958
|
+
? { description: cardDescription(verdict, enriched.description) }
|
|
959
|
+
: {}),
|
|
972
960
|
input: enriched.input,
|
|
973
961
|
});
|
|
974
962
|
}
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentGitPolicy, TrustMode } from '../policy.js';
|
|
1
|
+
import type { AgentGitPolicy, PolicyContext, PolicyDecision, TrustMode } from '../policy.js';
|
|
2
2
|
export interface McpConfig {
|
|
3
3
|
url: string;
|
|
4
4
|
token: string;
|
|
@@ -256,6 +256,16 @@ export interface SessionSpec {
|
|
|
256
256
|
* part `agentAutoCommit` does not do.
|
|
257
257
|
*/
|
|
258
258
|
gitPolicy?: AgentGitPolicy;
|
|
259
|
+
/**
|
|
260
|
+
* Does this session work in the project folder itself (`DIRECT`) or in a
|
|
261
|
+
* worktree of its own (`BRANCH`)? Same reason to be here as
|
|
262
|
+
* `agentPromptFile`: it lands in `PolicyContext` and answers the same
|
|
263
|
+
* question — may this Bash call go through (#361 п. 5, ADR 0004).
|
|
264
|
+
*
|
|
265
|
+
* It also decides one line of the system prompt, which `agentPromptFile`
|
|
266
|
+
* does not do.
|
|
267
|
+
*/
|
|
268
|
+
workMode?: 'DIRECT' | 'BRANCH';
|
|
259
269
|
mode?: AgentMode;
|
|
260
270
|
model?: string;
|
|
261
271
|
effort?: string;
|
|
@@ -278,6 +288,40 @@ export interface SessionSpec {
|
|
|
278
288
|
mcp?: McpConfig;
|
|
279
289
|
maxBudgetUsd?: number;
|
|
280
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* The one place a `SessionSpec` becomes a `PolicyContext`.
|
|
293
|
+
*
|
|
294
|
+
* There are four entry points into `evaluateToolUse` — two per adapter — and
|
|
295
|
+
* every one of them used to assemble this object by hand. QA-134 MINOR-1 is
|
|
296
|
+
* what that costs: one of the four had been written before the git policy
|
|
297
|
+
* existed and never learned about it, so a project that allowed `git clean`
|
|
298
|
+
* left a parked card unreleased in Claude while Codex released it. The next
|
|
299
|
+
* field to be added would have had four chances to be forgotten; now it has
|
|
300
|
+
* one place to be added.
|
|
301
|
+
*
|
|
302
|
+
* Optional fields are spread rather than set to `undefined`, because
|
|
303
|
+
* `resolveGitPolicy` and its neighbours read «absent» as a decision.
|
|
304
|
+
*/
|
|
305
|
+
export declare function policyContextFor(spec: SessionSpec, mode: AgentMode | undefined): PolicyContext;
|
|
306
|
+
/**
|
|
307
|
+
* What the permission card should say under its title.
|
|
308
|
+
*
|
|
309
|
+
* The policy's explanation first, the tool's own description after it: the
|
|
310
|
+
* person is answering «may this run», and «why are you asking me» is the half
|
|
311
|
+
* that was missing (#361 п. 5). Returns undefined when there is nothing to add.
|
|
312
|
+
*/
|
|
313
|
+
export declare function cardDescription(verdict: PolicyDecision, own: string | undefined): string | undefined;
|
|
314
|
+
/**
|
|
315
|
+
* The one sentence of the system prompt that only DIRECT sessions get
|
|
316
|
+
* (#361 п. 5, ADR 0004).
|
|
317
|
+
*
|
|
318
|
+
* A constant rather than a line in each adapter, because the two
|
|
319
|
+
* `systemAppendFor` texts are already two hand-synced copies of each other and
|
|
320
|
+
* a third divergence was not worth having. Layer 1 asks about these commands
|
|
321
|
+
* anyway — this exists so the agent does not have to learn the rule by walking
|
|
322
|
+
* into a card.
|
|
323
|
+
*/
|
|
324
|
+
export declare const DIRECT_BRANCH_RULE = "- This session works directly in the project folder, which other sessions and people share. Stay on the current branch: do not `git checkout <branch>` or `git switch` here. To put a file back use `git checkout -- <path>` or `git restore <path>`.";
|
|
281
325
|
/**
|
|
282
326
|
* One question inside an agent's question call (session 12).
|
|
283
327
|
*
|
package/dist/adapters/types.js
CHANGED
|
@@ -33,6 +33,59 @@ export const MODE_WITHDRAWN_TEXT = 'This project was just set to Strict trust, s
|
|
|
33
33
|
export function availableModes(trustMode) {
|
|
34
34
|
return trustMode === 'STRICT' ? AGENT_MODES.filter((mode) => mode !== 'full') : [...AGENT_MODES];
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The one place a `SessionSpec` becomes a `PolicyContext`.
|
|
38
|
+
*
|
|
39
|
+
* There are four entry points into `evaluateToolUse` — two per adapter — and
|
|
40
|
+
* every one of them used to assemble this object by hand. QA-134 MINOR-1 is
|
|
41
|
+
* what that costs: one of the four had been written before the git policy
|
|
42
|
+
* existed and never learned about it, so a project that allowed `git clean`
|
|
43
|
+
* left a parked card unreleased in Claude while Codex released it. The next
|
|
44
|
+
* field to be added would have had four chances to be forgotten; now it has
|
|
45
|
+
* one place to be added.
|
|
46
|
+
*
|
|
47
|
+
* Optional fields are spread rather than set to `undefined`, because
|
|
48
|
+
* `resolveGitPolicy` and its neighbours read «absent» as a decision.
|
|
49
|
+
*/
|
|
50
|
+
export function policyContextFor(spec, mode) {
|
|
51
|
+
return {
|
|
52
|
+
trustMode: spec.trustMode,
|
|
53
|
+
// Ticket #156: the session's own mode. For four sessions it was missing
|
|
54
|
+
// here, so «Auto» moved a value that the function deciding whether to ask
|
|
55
|
+
// had never heard of.
|
|
56
|
+
...(mode ? { mode } : {}),
|
|
57
|
+
...(spec.agentAutoCommit === undefined ? {} : { agentAutoCommit: spec.agentAutoCommit }),
|
|
58
|
+
...(spec.agentPromptFile ? { agentPromptFile: spec.agentPromptFile } : {}),
|
|
59
|
+
...(spec.workMode ? { workMode: spec.workMode } : {}),
|
|
60
|
+
// Session 18. Spread WHOLE rather than field by field: `resolveGitPolicy`
|
|
61
|
+
// gives every absent field its safe reading, and an object assembled here
|
|
62
|
+
// with three of the four would be a fourth place to get a polarity wrong.
|
|
63
|
+
...(spec.gitPolicy ?? {}),
|
|
64
|
+
worktreePath: spec.cwd,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What the permission card should say under its title.
|
|
69
|
+
*
|
|
70
|
+
* The policy's explanation first, the tool's own description after it: the
|
|
71
|
+
* person is answering «may this run», and «why are you asking me» is the half
|
|
72
|
+
* that was missing (#361 п. 5). Returns undefined when there is nothing to add.
|
|
73
|
+
*/
|
|
74
|
+
export function cardDescription(verdict, own) {
|
|
75
|
+
const parts = [verdict.explain, own].filter((part) => Boolean(part));
|
|
76
|
+
return parts.length > 0 ? parts.join('\n\n') : undefined;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The one sentence of the system prompt that only DIRECT sessions get
|
|
80
|
+
* (#361 п. 5, ADR 0004).
|
|
81
|
+
*
|
|
82
|
+
* A constant rather than a line in each adapter, because the two
|
|
83
|
+
* `systemAppendFor` texts are already two hand-synced copies of each other and
|
|
84
|
+
* a third divergence was not worth having. Layer 1 asks about these commands
|
|
85
|
+
* anyway — this exists so the agent does not have to learn the rule by walking
|
|
86
|
+
* into a card.
|
|
87
|
+
*/
|
|
88
|
+
export const DIRECT_BRANCH_RULE = '- This session works directly in the project folder, which other sessions and people share. Stay on the current branch: do not `git checkout <branch>` or `git switch` here. To put a file back use `git checkout -- <path>` or `git restore <path>`.';
|
|
36
89
|
/**
|
|
37
90
|
* The name our MCP server is registered under inside an agent session.
|
|
38
91
|
*
|
package/dist/checkpoints.d.ts
CHANGED
|
@@ -44,6 +44,21 @@ export interface CheckpointRecord {
|
|
|
44
44
|
agentSession?: string;
|
|
45
45
|
/** Feed seq of the user message this point sits in front of. */
|
|
46
46
|
messageSeq?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Other sessions that were mid-turn in this FOLDER while the snapshot was
|
|
49
|
+
* taken (#310).
|
|
50
|
+
*
|
|
51
|
+
* A DIRECT session shares its working tree with every other session on the
|
|
52
|
+
* same project, so a point taken while a neighbour was writing files holds
|
|
53
|
+
* half of that neighbour's work — rewinding files to it would throw away
|
|
54
|
+
* whatever they did after the shutter and before the shutter closed. The
|
|
55
|
+
* conversation is still safe to rewind, and that is the whole distinction.
|
|
56
|
+
*
|
|
57
|
+
* Absent means «the folder was quiet», which is also what every record
|
|
58
|
+
* written before this release means — those were only ever taken in a quiet
|
|
59
|
+
* folder, because a busy one refused to take them at all.
|
|
60
|
+
*/
|
|
61
|
+
busySessions?: string[];
|
|
47
62
|
}
|
|
48
63
|
export interface CreateCheckpointInput {
|
|
49
64
|
worktreePath: string;
|
|
@@ -53,6 +68,18 @@ export interface CreateCheckpointInput {
|
|
|
53
68
|
messageSeq?: number;
|
|
54
69
|
agentAnchor?: string;
|
|
55
70
|
agentSession?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Who else is working in this folder — asked as a QUESTION, not handed as an
|
|
73
|
+
* answer (#310).
|
|
74
|
+
*
|
|
75
|
+
* A getter, because the honest reading is «while the snapshot was being
|
|
76
|
+
* taken», and taking it is not instantaneous: `buildIndex` walks the tree and
|
|
77
|
+
* `write-tree` hashes it. Called at both ends and unioned, so a neighbour who
|
|
78
|
+
* started or stopped in between is still counted. It cannot be recorded
|
|
79
|
+
* afterwards either — the metadata is the commit message, and a commit's
|
|
80
|
+
* message cannot be appended to once it exists.
|
|
81
|
+
*/
|
|
82
|
+
busySessions?: () => string[];
|
|
56
83
|
}
|
|
57
84
|
export type CreateCheckpointResult = {
|
|
58
85
|
created: true;
|
|
@@ -97,6 +124,20 @@ export interface RewindPreview {
|
|
|
97
124
|
/** The change is larger than the dialog can honestly list — see `blockedReason`. */
|
|
98
125
|
truncated?: boolean;
|
|
99
126
|
totalChanges?: number;
|
|
127
|
+
/**
|
|
128
|
+
* This point was taken while somebody else was working in the folder, so its
|
|
129
|
+
* FILES cannot be trusted — only the conversation can be rewound to it (#310).
|
|
130
|
+
*
|
|
131
|
+
* Deliberately not a `blockedReason`: that field is a closed enum on the API
|
|
132
|
+
* and a new member would be rejected there, and in the dialog it hides the
|
|
133
|
+
* «History only» button — which is the one thing that still works here.
|
|
134
|
+
*
|
|
135
|
+
* The presence of the key carries the warning; the ids are a courtesy that
|
|
136
|
+
* may resolve to no names at all (gotcha 433).
|
|
137
|
+
*/
|
|
138
|
+
untrustedFiles?: {
|
|
139
|
+
sessions: string[];
|
|
140
|
+
};
|
|
100
141
|
}
|
|
101
142
|
/**
|
|
102
143
|
* Take a restore point for this worktree.
|
|
@@ -106,6 +147,15 @@ export interface RewindPreview {
|
|
|
106
147
|
*/
|
|
107
148
|
export declare function createCheckpoint(input: CreateCheckpointInput): Promise<CreateCheckpointResult>;
|
|
108
149
|
export declare function listCheckpoints(worktreePath: string, sessionId: string): Promise<CheckpointRecord[]>;
|
|
150
|
+
/**
|
|
151
|
+
* How many neighbour ids a restore point carries (#310).
|
|
152
|
+
*
|
|
153
|
+
* The same number as the API's schema and the runner's event: the mark itself
|
|
154
|
+
* is the warning, the names are a courtesy, and a folder with eleven busy
|
|
155
|
+
* sessions is not eleven times more dangerous than one with ten. Gotcha 433 —
|
|
156
|
+
* a cap on a courtesy must degrade, never reject.
|
|
157
|
+
*/
|
|
158
|
+
export declare const MAX_BUSY_SESSIONS = 10;
|
|
109
159
|
/** What a rewind to this checkpoint would do, without doing any of it. */
|
|
110
160
|
export declare function previewRewind(input: {
|
|
111
161
|
worktreePath: string;
|
|
@@ -152,7 +202,19 @@ export declare function applyRewind(input: {
|
|
|
152
202
|
* that is doing nothing wrong. Present ⇒ checked.
|
|
153
203
|
*/
|
|
154
204
|
expectedTreeOid?: string;
|
|
205
|
+
/**
|
|
206
|
+
* Who else is working in this folder — for the SAFETY point this rewind takes
|
|
207
|
+
* on its way out (#310). Same getter, same reason as in
|
|
208
|
+
* {@link CreateCheckpointInput}.
|
|
209
|
+
*/
|
|
210
|
+
busySessions?: () => string[];
|
|
155
211
|
}): Promise<ApplyRewindResult>;
|
|
212
|
+
/**
|
|
213
|
+
* Said when the files of a restore point cannot be trusted (#310). Its own
|
|
214
|
+
* sentence rather than a `blockedReason`, for the reason written on
|
|
215
|
+
* `RewindPreview.untrustedFiles`.
|
|
216
|
+
*/
|
|
217
|
+
export declare const UNTRUSTED_FILES_MESSAGE = "The files at this restore point cannot be trusted: another session was working in this folder when it was taken. Only the conversation can be rewound to it.";
|
|
156
218
|
export declare function rewindBlockMessage(reason: NonNullable<RewindPreview['blockedReason']>): string;
|
|
157
219
|
/** Drop every restore point of one session (session deleted or purged). */
|
|
158
220
|
export declare function dropCheckpoints(worktreePath: string, sessionId: string): Promise<void>;
|