@bridge4dev/runner 0.61.0 → 0.63.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/README.md +12 -6
- package/dist/adapters/claude.js +11 -6
- package/dist/adapters/codex.js +80 -15
- package/dist/adapters/types.d.ts +50 -0
- package/dist/adapters/types.js +59 -0
- package/dist/checkpoints.d.ts +11 -0
- package/dist/checkpoints.js +64 -5
- package/dist/git.d.ts +56 -0
- package/dist/git.js +78 -7
- package/dist/gitops.d.ts +29 -0
- package/dist/gitops.js +74 -19
- package/dist/index.js +25 -0
- package/dist/policy.d.ts +27 -0
- package/dist/policy.js +38 -5
- package/dist/protocol.d.ts +57 -2
- package/dist/protocol.js +15 -0
- package/dist/supervisor.js +14 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,12 +49,18 @@ it. If you would rather run it in the foreground, or under your own supervisor,
|
|
|
49
49
|
|
|
50
50
|
## What it does on your machine
|
|
51
51
|
|
|
52
|
-
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
- Runs a session either **in the project folder itself** — the default: the branch it is
|
|
53
|
+
already on, nothing to apply afterwards — or in a **git worktree on a session branch of
|
|
54
|
+
its own**, which DevBridge asks for when the folder is busy or a branch was named. Which
|
|
55
|
+
one a session gets is decided per session, in the dashboard.
|
|
56
|
+
- Enforces a local policy layer. Part of it the dashboard **cannot** raise: secret files and
|
|
57
|
+
keys are never read, nothing is written into any repository's `.git`, and `sudo`, service
|
|
58
|
+
control, docker and the firewall are refused in every trust level. Part of it is the bound
|
|
59
|
+
project's own setting, chosen by a manager in DevBridge: whether the agent may `git push`
|
|
60
|
+
and to which branches, and whether it may read and change files **outside the project
|
|
61
|
+
folder** — other projects on the same machine included. Out of the box the agent pushes
|
|
62
|
+
nothing and stays in its folder. Plus secret masking in everything it streams, and its own
|
|
63
|
+
ceiling on how many sessions may run at once.
|
|
58
64
|
- Streams the session journal with sequence numbers and acknowledgements, so a restart of
|
|
59
65
|
either side resumes instead of losing work.
|
|
60
66
|
- Never uploads your source. Diffs and file views are requested per file, and files
|
package/dist/adapters/claude.js
CHANGED
|
@@ -8,7 +8,7 @@ import { mcpConfigPath } from '../paths.js';
|
|
|
8
8
|
import { lowerPriority } from '../process-priority.js';
|
|
9
9
|
import { cageSpawn, noteSessionAgentPid, memoryDeathSentence, releaseSessionScope, sessionMemoryEnv, sessionMemoryPromptLine, } from '../session-cage.js';
|
|
10
10
|
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
11
|
-
import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
11
|
+
import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
12
12
|
import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
|
|
13
13
|
import { applyUsagePercentages, lastUsageRows, readUsageRows } from './claude-usage.js';
|
|
14
14
|
import { assertClaudeInstalled, claudeExecutableOption, sessionClaudePath, } from '../agent-binary.js';
|
|
@@ -166,14 +166,19 @@ const MODE_TO_PERMISSION = {
|
|
|
166
166
|
* no line: the agent plans around a restriction that is not there, or walks
|
|
167
167
|
* into one it was told did not exist.
|
|
168
168
|
*/
|
|
169
|
-
function systemAppendFor(spec) {
|
|
169
|
+
function systemAppendFor(spec, mode) {
|
|
170
170
|
const pushBanned = spec.gitPolicy?.agentPushBan !== false;
|
|
171
|
+
// #418: which folder line this session gets, if any — see `folderRuleFor`.
|
|
172
|
+
const folderRule = folderRuleFor(spec, mode);
|
|
171
173
|
const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
|
|
172
174
|
const memoryLine = sessionMemoryPromptLine(spec.sessionId);
|
|
173
175
|
return [
|
|
174
176
|
'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
|
|
175
177
|
'Rules:',
|
|
176
|
-
|
|
178
|
+
// #418. The three cases live in `folderRuleFor` — one copy for both
|
|
179
|
+
// adapters, and the Codex adapter asks the same function whether a mode
|
|
180
|
+
// switch would change what the agent was told.
|
|
181
|
+
...(folderRule === null ? [] : [folderRule]),
|
|
177
182
|
// Session 13: pushing was refused outright by layer 1 in every trust mode,
|
|
178
183
|
// and saying so here saved the agent a turn spent discovering it. Session
|
|
179
184
|
// 18 makes it the project's decision — so the sentence has to follow the
|
|
@@ -203,8 +208,8 @@ function systemAppendFor(spec) {
|
|
|
203
208
|
].join('\n');
|
|
204
209
|
}
|
|
205
210
|
/** DevBridge's own rules, then whatever this workspace adds (session 13). */
|
|
206
|
-
function composeSystemAppend(spec) {
|
|
207
|
-
const base = systemAppendFor(spec);
|
|
211
|
+
function composeSystemAppend(spec, mode) {
|
|
212
|
+
const base = systemAppendFor(spec, mode);
|
|
208
213
|
return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
|
|
209
214
|
}
|
|
210
215
|
/**
|
|
@@ -585,7 +590,7 @@ class ClaudeSession {
|
|
|
585
590
|
systemPrompt: {
|
|
586
591
|
type: 'preset',
|
|
587
592
|
preset: 'claude_code',
|
|
588
|
-
append: composeSystemAppend(spec),
|
|
593
|
+
append: composeSystemAppend(spec, this.mode),
|
|
589
594
|
},
|
|
590
595
|
canUseTool: (toolName, input, opts) => this.onCanUseTool(toolName, input, opts),
|
|
591
596
|
// Ticket #113: a running subagent forks its own conversation every ~30s
|
package/dist/adapters/codex.js
CHANGED
|
@@ -6,7 +6,7 @@ import { RUNNER_VERSION } from '../version.js';
|
|
|
6
6
|
import { repairCodexAuth } from './codex-home.js';
|
|
7
7
|
import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
|
|
8
8
|
import { truncate } from './claude.js';
|
|
9
|
-
import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
9
|
+
import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
10
10
|
import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
|
|
11
11
|
import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
|
|
12
12
|
// Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
|
|
@@ -60,6 +60,37 @@ const MODE_POLICY = {
|
|
|
60
60
|
// exactly like Claude's bypassPermissions. The dashboard says so.
|
|
61
61
|
full: { approvalPolicy: 'never', sandbox: 'danger-full-access', plan: false },
|
|
62
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* Where a `workspace-write` turn may write (#418).
|
|
65
|
+
*
|
|
66
|
+
* Codex confines writes in the KERNEL, not in our policy: under `auto` the
|
|
67
|
+
* sandbox is `workspace-write` with `writableRoots: [cwd]`, so a write outside
|
|
68
|
+
* fails inside the CLI, comes back as a «retry without sandbox?» approval, and
|
|
69
|
+
* only then reaches layer 1. With the project's permission on, that round trip
|
|
70
|
+
* is exactly the refusal the ticket exists to remove — so the ROOT is widened.
|
|
71
|
+
*
|
|
72
|
+
* **The sandbox is widened, never dropped**, and that is the whole safety of
|
|
73
|
+
* this function. `danger-full-access` looks like the simpler answer and is the
|
|
74
|
+
* wrong one: for Codex the kernel sandbox refusing something IS the only
|
|
75
|
+
* channel that reaches `evaluateToolUse` (the adapter calls it from
|
|
76
|
+
* `onApproval` and nowhere else). Remove the sandbox and nothing ever
|
|
77
|
+
* escalates, so `sudo`, docker, the firewall, secret paths, any repository's
|
|
78
|
+
* `.git`, the project's prompt file and the project's own git policy would all
|
|
79
|
+
* stop being enforced for Codex — the very list the setting promises to keep.
|
|
80
|
+
* Keeping `workspaceWrite` keeps that channel, and keeps `networkAccess: false`
|
|
81
|
+
* with it, so the permission moves one boundary and no other.
|
|
82
|
+
*
|
|
83
|
+
* **STRICT does not widen.** There a step outside must still raise a card, and
|
|
84
|
+
* the only way to raise one for Codex is to let the sandbox refuse it first.
|
|
85
|
+
*
|
|
86
|
+
* Read on every turn rather than captured at launch, because the setting can be
|
|
87
|
+
* switched while the session runs.
|
|
88
|
+
*/
|
|
89
|
+
function writableRootsFor(spec) {
|
|
90
|
+
if (spec.trustMode === 'STRICT')
|
|
91
|
+
return [spec.cwd];
|
|
92
|
+
return spec.gitPolicy?.agentAllowOutsideFolder === true ? ['/'] : [spec.cwd];
|
|
93
|
+
}
|
|
63
94
|
/**
|
|
64
95
|
* DevBridge's own rules — composed per session since session 18, and kept
|
|
65
96
|
* deliberately in step with `systemAppendFor` in the Claude adapter.
|
|
@@ -69,14 +100,19 @@ const MODE_POLICY = {
|
|
|
69
100
|
* `workMode: DIRECT` (the default since session 16), and the push sentence is
|
|
70
101
|
* only true when the project has «Принудительно запретить push» switched on.
|
|
71
102
|
*/
|
|
72
|
-
function systemAppendFor(spec) {
|
|
103
|
+
function systemAppendFor(spec, mode) {
|
|
73
104
|
const pushBanned = spec.gitPolicy?.agentPushBan !== false;
|
|
105
|
+
// #418: which folder line this session gets, if any — see `folderRuleFor`.
|
|
106
|
+
const folderRule = folderRuleFor(spec, mode);
|
|
74
107
|
const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
|
|
75
108
|
const memoryLine = sessionMemoryPromptLine(spec.sessionId);
|
|
76
109
|
return [
|
|
77
110
|
'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
|
|
78
111
|
'Rules:',
|
|
79
|
-
|
|
112
|
+
// #418. The three cases live in `folderRuleFor` — one copy for both
|
|
113
|
+
// adapters, and the Codex adapter asks the same function whether a mode
|
|
114
|
+
// switch would change what the agent was told.
|
|
115
|
+
...(folderRule === null ? [] : [folderRule]),
|
|
80
116
|
pushBanned
|
|
81
117
|
? '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked for this project. A human presses «Push» and «Apply» in DevBridge when the branch is ready.'
|
|
82
118
|
: guarded.length > 0
|
|
@@ -96,8 +132,8 @@ function systemAppendFor(spec) {
|
|
|
96
132
|
].join('\n');
|
|
97
133
|
}
|
|
98
134
|
/** DevBridge's own rules, then whatever this workspace adds (session 13). */
|
|
99
|
-
function composeSystemAppend(spec) {
|
|
100
|
-
const base = systemAppendFor(spec);
|
|
135
|
+
function composeSystemAppend(spec, mode) {
|
|
136
|
+
const base = systemAppendFor(spec, mode);
|
|
101
137
|
return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
|
|
102
138
|
}
|
|
103
139
|
// Allowlist, not denylist: whatever secrets live in the daemon's environment
|
|
@@ -534,8 +570,14 @@ class CodexSession {
|
|
|
534
570
|
return {
|
|
535
571
|
cwd: this.spec.cwd,
|
|
536
572
|
approvalPolicy: policy.approvalPolicy,
|
|
573
|
+
// #418 does NOT change this one, and that is deliberate. The thread-level
|
|
574
|
+
// channel takes the CLI-style MODE string, which has no way to say «this
|
|
575
|
+
// root»; the mode itself is unchanged (`workspace-write` stays
|
|
576
|
+
// `workspace-write`), and the roots travel with every turn below. Both
|
|
577
|
+
// channels therefore still describe the same sandbox, which is what R10
|
|
578
|
+
// of the plan asks for — there is no second shape to keep in step.
|
|
537
579
|
sandbox: policy.sandbox,
|
|
538
|
-
developerInstructions: composeSystemAppend(this.spec),
|
|
580
|
+
developerInstructions: composeSystemAppend(this.spec, this.mode),
|
|
539
581
|
...(this.model ? { model: this.model } : {}),
|
|
540
582
|
...(this.spec.mcp ? { config: this.mcpOverlay() } : {}),
|
|
541
583
|
};
|
|
@@ -582,7 +624,8 @@ class CodexSession {
|
|
|
582
624
|
// [] is accepted, null is rejected outright.
|
|
583
625
|
input: [{ type: 'text', text, text_elements: [] }],
|
|
584
626
|
approvalPolicy: policy.approvalPolicy,
|
|
585
|
-
|
|
627
|
+
// #418: the roots live here and only here — see `writableRootsFor`.
|
|
628
|
+
sandboxPolicy: sandboxPolicyFor(policy.sandbox, this.spec),
|
|
586
629
|
...(this.model ? { model: this.model } : {}),
|
|
587
630
|
// "Override the reasoning effort for this turn and subsequent turns" —
|
|
588
631
|
// the same sticky-override channel the model uses (there is still no
|
|
@@ -793,12 +836,29 @@ class CodexSession {
|
|
|
793
836
|
this.refreshCapabilities();
|
|
794
837
|
}
|
|
795
838
|
/**
|
|
796
|
-
* Codex needs no new process for
|
|
797
|
-
*
|
|
798
|
-
*
|
|
839
|
+
* Codex needs no new process for its POLICY: `approvalPolicy` and the sandbox
|
|
840
|
+
* travel with the next `turn/start`, so the change is in force from the next
|
|
841
|
+
* turn whatever it is (ticket #156).
|
|
842
|
+
*
|
|
843
|
+
* Its RULES are the other half, and they cannot follow (#418). DevBridge's
|
|
844
|
+
* own instructions reach Codex ONLY as the thread's `developerInstructions`,
|
|
845
|
+
* fixed when the thread opens: this protocol version has no `settings/update`,
|
|
846
|
+
* and putting the text on the turn instead is the duplicated-prompt defect of
|
|
847
|
+
* ticket #179. So a line that has to DISAPPEAR in «Unrestricted» — where
|
|
848
|
+
* layer 1 is never consulted and the sentence would be the only thing left
|
|
849
|
+
* enforcing it — can only disappear with a new thread.
|
|
850
|
+
*
|
|
851
|
+
* Asked precisely rather than by hard-coding the `full` boundary: would the
|
|
852
|
+
* folder line actually change? With «may work outside the project folder» ON
|
|
853
|
+
* the answer is never — the sentence is then the same in every mode — so
|
|
854
|
+
* those projects keep Codex's free, instant mode switching. Only a session
|
|
855
|
+
* that would be told something different pays for a new process, and it pays
|
|
856
|
+
* exactly what a Claude session has always paid for the same move.
|
|
799
857
|
*/
|
|
800
|
-
modeSwitchNeedsRelaunch(
|
|
801
|
-
|
|
858
|
+
modeSwitchNeedsRelaunch(mode) {
|
|
859
|
+
if (!availableModes(this.spec.trustMode).includes(mode))
|
|
860
|
+
return false;
|
|
861
|
+
return folderRuleFor(this.spec, mode) !== folderRuleFor(this.spec, this.mode);
|
|
802
862
|
}
|
|
803
863
|
async setMode(mode) {
|
|
804
864
|
if (!availableModes(this.spec.trustMode).includes(mode)) {
|
|
@@ -2255,9 +2315,10 @@ class CodexSession {
|
|
|
2255
2315
|
}
|
|
2256
2316
|
class ResumeFailed extends Error {
|
|
2257
2317
|
}
|
|
2258
|
-
function sandboxPolicyFor(mode,
|
|
2318
|
+
function sandboxPolicyFor(mode, spec) {
|
|
2259
2319
|
// turn/start takes the structured SandboxPolicy, while thread/start takes the
|
|
2260
|
-
// CLI-style SandboxMode string. Same intent, two shapes
|
|
2320
|
+
// CLI-style SandboxMode string. Same intent, two shapes — and only this one
|
|
2321
|
+
// can name the writable roots, which is why #418 lives here.
|
|
2261
2322
|
switch (mode) {
|
|
2262
2323
|
case 'read-only':
|
|
2263
2324
|
return { type: 'readOnly', networkAccess: false };
|
|
@@ -2266,7 +2327,11 @@ function sandboxPolicyFor(mode, cwd) {
|
|
|
2266
2327
|
default:
|
|
2267
2328
|
return {
|
|
2268
2329
|
type: 'workspaceWrite',
|
|
2269
|
-
writableRoots:
|
|
2330
|
+
writableRoots: writableRootsFor(spec),
|
|
2331
|
+
// Unchanged by #418, on purpose: the permission is about the file
|
|
2332
|
+
// system, and letting the network out with it would be a second
|
|
2333
|
+
// boundary nobody asked to move. A network command still fails in the
|
|
2334
|
+
// sandbox, still escalates, and still ends at layer 1.
|
|
2270
2335
|
networkAccess: false,
|
|
2271
2336
|
excludeTmpdirEnvVar: false,
|
|
2272
2337
|
excludeSlashTmp: false,
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -322,6 +322,56 @@ export declare function cardDescription(verdict: PolicyDecision, own: string | u
|
|
|
322
322
|
* into a card.
|
|
323
323
|
*/
|
|
324
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>`.";
|
|
325
|
+
/**
|
|
326
|
+
* «Stay in this folder» — the line layer 1 used to back up (#418).
|
|
327
|
+
*
|
|
328
|
+
* Here, beside `DIRECT_BRANCH_RULE` and for the same reason: the two
|
|
329
|
+
* `systemAppendFor` texts are hand-synced copies of each other, and a rule that
|
|
330
|
+
* has to appear in one of them and not the other is the kind of thing that
|
|
331
|
+
* drifts silently. Exported so the tests can name the exact string rather than
|
|
332
|
+
* a substring of it.
|
|
333
|
+
*
|
|
334
|
+
* NOT written when the session is «Unrestricted» — there the CLI never asks
|
|
335
|
+
* layer 1 anything, so this sentence was the only thing left enforcing it, and
|
|
336
|
+
* a rule with nothing underneath is a rule the agent refuses itself by — nor
|
|
337
|
+
* when the project has switched «may work outside the project folder» on, where
|
|
338
|
+
* `outsideFolderRule` takes its place.
|
|
339
|
+
*/
|
|
340
|
+
export declare const WORKING_DIRECTORY_RULE = "- Work ONLY inside the current working directory.";
|
|
341
|
+
/**
|
|
342
|
+
* What replaces the line above when the project allows work outside the folder
|
|
343
|
+
* (#418).
|
|
344
|
+
*
|
|
345
|
+
* It says three things and no more: where the agent is, that it may go
|
|
346
|
+
* elsewhere when the task needs it, and what it gives up by doing so — nothing
|
|
347
|
+
* outside the folder reaches «Changes», «Apply» or a restore point. That last
|
|
348
|
+
* half is the part the agent cannot discover for itself and the part a person
|
|
349
|
+
* would otherwise find out from a diff that is missing a file.
|
|
350
|
+
*/
|
|
351
|
+
export declare function outsideFolderRule(cwd: string): string;
|
|
352
|
+
/**
|
|
353
|
+
* Which of the two lines above this session gets, or neither (#418).
|
|
354
|
+
*
|
|
355
|
+
* One function rather than the same three-way choice written out in both
|
|
356
|
+
* `systemAppendFor` texts — and it has a second caller that made it worth
|
|
357
|
+
* extracting: the Codex adapter asks it whether a MODE SWITCH would change what
|
|
358
|
+
* the agent was told, because Codex's developer instructions are fixed when the
|
|
359
|
+
* thread opens and cannot be edited afterwards.
|
|
360
|
+
*
|
|
361
|
+
* The three cases, in order:
|
|
362
|
+
* - the project allows work outside the folder → say where the folder is and
|
|
363
|
+
* what leaving it costs, in EVERY mode (the permission does not depend on
|
|
364
|
+
* the mode, so neither does the sentence);
|
|
365
|
+
* - «Unrestricted» → `null`, nothing at all. Layer 1 is never consulted there,
|
|
366
|
+
* so this line would be the only thing enforcing the rule: the agent refuses
|
|
367
|
+
* itself by a sentence nothing backs up, which is the complaint #418 came
|
|
368
|
+
* from;
|
|
369
|
+
* - otherwise → the rule as it has always been, with layer 1 behind it.
|
|
370
|
+
*
|
|
371
|
+
* The mode passed in must be the one the session is actually IN, not the one it
|
|
372
|
+
* was asked for: a `full` refused on a STRICT workspace keeps the line.
|
|
373
|
+
*/
|
|
374
|
+
export declare function folderRuleFor(spec: SessionSpec, mode: AgentMode | undefined): string | null;
|
|
325
375
|
/**
|
|
326
376
|
* One question inside an agent's question call (session 12).
|
|
327
377
|
*
|
package/dist/adapters/types.js
CHANGED
|
@@ -100,6 +100,65 @@ export function cardDescription(verdict, own) {
|
|
|
100
100
|
* into a card.
|
|
101
101
|
*/
|
|
102
102
|
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>`.';
|
|
103
|
+
/**
|
|
104
|
+
* «Stay in this folder» — the line layer 1 used to back up (#418).
|
|
105
|
+
*
|
|
106
|
+
* Here, beside `DIRECT_BRANCH_RULE` and for the same reason: the two
|
|
107
|
+
* `systemAppendFor` texts are hand-synced copies of each other, and a rule that
|
|
108
|
+
* has to appear in one of them and not the other is the kind of thing that
|
|
109
|
+
* drifts silently. Exported so the tests can name the exact string rather than
|
|
110
|
+
* a substring of it.
|
|
111
|
+
*
|
|
112
|
+
* NOT written when the session is «Unrestricted» — there the CLI never asks
|
|
113
|
+
* layer 1 anything, so this sentence was the only thing left enforcing it, and
|
|
114
|
+
* a rule with nothing underneath is a rule the agent refuses itself by — nor
|
|
115
|
+
* when the project has switched «may work outside the project folder» on, where
|
|
116
|
+
* `outsideFolderRule` takes its place.
|
|
117
|
+
*/
|
|
118
|
+
export const WORKING_DIRECTORY_RULE = '- Work ONLY inside the current working directory.';
|
|
119
|
+
/**
|
|
120
|
+
* What replaces the line above when the project allows work outside the folder
|
|
121
|
+
* (#418).
|
|
122
|
+
*
|
|
123
|
+
* It says three things and no more: where the agent is, that it may go
|
|
124
|
+
* elsewhere when the task needs it, and what it gives up by doing so — nothing
|
|
125
|
+
* outside the folder reaches «Changes», «Apply» or a restore point. That last
|
|
126
|
+
* half is the part the agent cannot discover for itself and the part a person
|
|
127
|
+
* would otherwise find out from a diff that is missing a file.
|
|
128
|
+
*/
|
|
129
|
+
export function outsideFolderRule(cwd) {
|
|
130
|
+
return (`- Your working directory is ${cwd}; you may read and change files elsewhere on this server ` +
|
|
131
|
+
`when the task needs it. Only what is inside ${cwd} shows up in the DevBridge diff and restore points.`);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Which of the two lines above this session gets, or neither (#418).
|
|
135
|
+
*
|
|
136
|
+
* One function rather than the same three-way choice written out in both
|
|
137
|
+
* `systemAppendFor` texts — and it has a second caller that made it worth
|
|
138
|
+
* extracting: the Codex adapter asks it whether a MODE SWITCH would change what
|
|
139
|
+
* the agent was told, because Codex's developer instructions are fixed when the
|
|
140
|
+
* thread opens and cannot be edited afterwards.
|
|
141
|
+
*
|
|
142
|
+
* The three cases, in order:
|
|
143
|
+
* - the project allows work outside the folder → say where the folder is and
|
|
144
|
+
* what leaving it costs, in EVERY mode (the permission does not depend on
|
|
145
|
+
* the mode, so neither does the sentence);
|
|
146
|
+
* - «Unrestricted» → `null`, nothing at all. Layer 1 is never consulted there,
|
|
147
|
+
* so this line would be the only thing enforcing the rule: the agent refuses
|
|
148
|
+
* itself by a sentence nothing backs up, which is the complaint #418 came
|
|
149
|
+
* from;
|
|
150
|
+
* - otherwise → the rule as it has always been, with layer 1 behind it.
|
|
151
|
+
*
|
|
152
|
+
* The mode passed in must be the one the session is actually IN, not the one it
|
|
153
|
+
* was asked for: a `full` refused on a STRICT workspace keeps the line.
|
|
154
|
+
*/
|
|
155
|
+
export function folderRuleFor(spec, mode) {
|
|
156
|
+
// `=== true` — silence keeps the agent in, exactly as `policy.ts` resolves the
|
|
157
|
+
// same field.
|
|
158
|
+
if (spec.gitPolicy?.agentAllowOutsideFolder === true)
|
|
159
|
+
return outsideFolderRule(spec.cwd);
|
|
160
|
+
return mode === 'full' ? null : WORKING_DIRECTORY_RULE;
|
|
161
|
+
}
|
|
103
162
|
/**
|
|
104
163
|
* The name our MCP server is registered under inside an agent session.
|
|
105
164
|
*
|
package/dist/checkpoints.d.ts
CHANGED
|
@@ -18,6 +18,17 @@ export interface CheckpointRecord {
|
|
|
18
18
|
kind: CheckpointKind;
|
|
19
19
|
/** HEAD of the worktree when the checkpoint was taken. */
|
|
20
20
|
headSha: string;
|
|
21
|
+
/**
|
|
22
|
+
* #137: there was no commit at all when this point was taken — an empty
|
|
23
|
+
* `headSha` that MEANS something.
|
|
24
|
+
*
|
|
25
|
+
* Written down rather than inferred, because the empty string is already the
|
|
26
|
+
* value `decodeMeta` invents for a record whose metadata it could not read.
|
|
27
|
+
* Those two states need opposite treatment: «nothing was committed yet» can
|
|
28
|
+
* say truthfully what arrived since (all of it), while «we do not know what
|
|
29
|
+
* HEAD was» must not claim the whole history arrived after the point.
|
|
30
|
+
*/
|
|
31
|
+
unborn?: boolean;
|
|
21
32
|
/** Paths that were staged in the project's index at that moment. */
|
|
22
33
|
stagedPaths: string[];
|
|
23
34
|
createdAt: number;
|
package/dist/checkpoints.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { checkpointsDir } from './paths.js';
|
|
7
|
+
import { EMPTY_TREE_SHA } from './gitops.js';
|
|
7
8
|
import { isSecretPath } from './policy.js';
|
|
8
9
|
import { log } from './log.js';
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
@@ -410,8 +411,30 @@ async function runBatched(store, worktreePath, indexFile, args, paths) {
|
|
|
410
411
|
async function buildIndex(store, worktreePath, indexFile) {
|
|
411
412
|
fs.mkdirSync(path.dirname(indexFile), { recursive: true, mode: 0o700 });
|
|
412
413
|
fs.rmSync(indexFile, { force: true });
|
|
413
|
-
|
|
414
|
-
|
|
414
|
+
// #137: a repository with no commits has no HEAD to build the index from,
|
|
415
|
+
// and the bare `rev-parse HEAD` threw git's manual page — which the refusal
|
|
416
|
+
// classifier then read as «this folder is not a git repository», so every
|
|
417
|
+
// single step of every session in a fresh `git init` folder wrote that
|
|
418
|
+
// sentence into the feed. It is the wrong sentence twice over: the folder IS
|
|
419
|
+
// a repository, and a restore point in it is perfectly possible.
|
|
420
|
+
//
|
|
421
|
+
// The empty tree is what git itself compares a first commit against, so it is
|
|
422
|
+
// the honest starting index — and `gitStatus` in DIRECT mode has been using
|
|
423
|
+
// exactly this constant for the same reason since session 16.
|
|
424
|
+
//
|
|
425
|
+
// The exit code is READ, not caught away — the same rule `headState` follows
|
|
426
|
+
// in `git.ts`, and for a bigger reason here. A bare `.catch` would read a
|
|
427
|
+
// timeout, an OOM kill or a lost `safe.directory` as «no commits yet» on a
|
|
428
|
+
// repository that has plenty: the point would then be built from the empty
|
|
429
|
+
// tree, look perfectly normal in the feed, and a rewind to it would offer to
|
|
430
|
+
// DELETE every file the checkpoint did not happen to cover. «git could not
|
|
431
|
+
// look» must abort the point, exactly as it did before #137.
|
|
432
|
+
const headSha = await gitIn(worktreePath, 'rev-parse', '--verify', '--quiet', 'HEAD').then((sha) => sha, (error) => {
|
|
433
|
+
if (error.code === 1)
|
|
434
|
+
return '';
|
|
435
|
+
throw error;
|
|
436
|
+
});
|
|
437
|
+
await gitStore(store, worktreePath, indexFile, 'read-tree', headSha || EMPTY_TREE_SHA);
|
|
415
438
|
const { paths, secrets } = await changedPaths(worktreePath);
|
|
416
439
|
const excluded = new Set(secrets);
|
|
417
440
|
const included = [];
|
|
@@ -496,6 +519,7 @@ function decodeMeta(message) {
|
|
|
496
519
|
return {
|
|
497
520
|
kind: kind === 'SAFETY' || kind === 'MANUAL' ? kind : 'TURN',
|
|
498
521
|
headSha: typeof parsed['headSha'] === 'string' ? parsed['headSha'] : '',
|
|
522
|
+
...(parsed['unborn'] === true ? { unborn: true } : {}),
|
|
499
523
|
stagedPaths: Array.isArray(parsed['stagedPaths'])
|
|
500
524
|
? parsed['stagedPaths'].filter((p) => typeof p === 'string')
|
|
501
525
|
: [],
|
|
@@ -549,7 +573,13 @@ export async function createCheckpoint(input) {
|
|
|
549
573
|
/** An error on the way to a restore point, read as a reason to report. */
|
|
550
574
|
function checkpointRefusal(sessionId, error) {
|
|
551
575
|
const detail = String(error instanceof Error ? error.message : error).slice(0, 300);
|
|
552
|
-
|
|
576
|
+
// #137: «ambiguous argument 'HEAD'» is git's answer to a repository with no
|
|
577
|
+
// commits in it, and reading it as «not a git repository» produced the one
|
|
578
|
+
// sentence in the feed that was flatly untrue — on a folder the person had
|
|
579
|
+
// just created and bound. An unborn HEAD does not reach here at all any more
|
|
580
|
+
// (`buildIndex` starts from the empty tree), and if some other unborn-HEAD
|
|
581
|
+
// read ever does, «git refused» is the honest bucket for it, not «not a repo».
|
|
582
|
+
if (/not a git repository/i.test(detail)) {
|
|
553
583
|
return { created: false, reason: 'not-a-repo', detail };
|
|
554
584
|
}
|
|
555
585
|
log.warn('checkpoints: could not create a restore point', { sessionId, error: detail });
|
|
@@ -573,6 +603,10 @@ async function takeCheckpoint(store, input) {
|
|
|
573
603
|
const meta = {
|
|
574
604
|
kind,
|
|
575
605
|
headSha,
|
|
606
|
+
// Only when it is true: an absent key is what every record written before
|
|
607
|
+
// this release carries, and those were all taken on a repository with a
|
|
608
|
+
// HEAD.
|
|
609
|
+
...(headSha ? {} : { unborn: true }),
|
|
576
610
|
stagedPaths,
|
|
577
611
|
createdAt: Date.now(),
|
|
578
612
|
fileCount: included.length,
|
|
@@ -721,10 +755,35 @@ async function buildPreview(store, indexFile, input) {
|
|
|
721
755
|
const total = restore.length + remove.length + recreate.length;
|
|
722
756
|
let blockedReason = total > MAX_PREVIEW_ENTRIES ? 'too-many-changes' : undefined;
|
|
723
757
|
const commitsSince = [];
|
|
724
|
-
|
|
758
|
+
// #137: an empty string is «there was no commit at all when this point was
|
|
759
|
+
// taken» — a real state now that a repository with no commits can hold a
|
|
760
|
+
// session. It must still count as movement: the first commit arriving between
|
|
761
|
+
// the point and the rewind moves HEAD exactly as any later one does, and the
|
|
762
|
+
// old `record.headSha &&` guard read that case as «HEAD has not moved» and
|
|
763
|
+
// let the rewind run without a word.
|
|
764
|
+
if (!blockedReason && record.headSha !== headSha) {
|
|
725
765
|
blockedReason = 'head-moved';
|
|
726
766
|
try {
|
|
727
|
-
|
|
767
|
+
// A range needs two ends. With no commit at all behind the point, «what
|
|
768
|
+
// arrived since» is the whole history that exists — the first commit and
|
|
769
|
+
// whatever followed it — so the range collapses to one end. An empty
|
|
770
|
+
// left side would NOT do that: `..<sha>` means `HEAD..<sha>` to git,
|
|
771
|
+
// which is a different question and usually an empty answer.
|
|
772
|
+
//
|
|
773
|
+
// The one-ended form is used ONLY for a point that recorded «there was
|
|
774
|
+
// nothing here yet». A record whose metadata could not be read carries
|
|
775
|
+
// the same empty `headSha` and means something else entirely — listing
|
|
776
|
+
// the repository's whole history under «what arrived since this point»
|
|
777
|
+
// would be a confident, wrong answer. That record still refuses the
|
|
778
|
+
// rewind, which is the safe direction; it simply names no commits.
|
|
779
|
+
const range = record.headSha
|
|
780
|
+
? `${record.headSha}..${headSha}`
|
|
781
|
+
: record.unborn
|
|
782
|
+
? headSha
|
|
783
|
+
: null;
|
|
784
|
+
const listed = headSha && range
|
|
785
|
+
? await gitIn(worktreePath, 'log', '--format=%h%x00%s', '--max-count=20', range)
|
|
786
|
+
: '';
|
|
728
787
|
for (const line of listed.split('\n')) {
|
|
729
788
|
if (!line.trim())
|
|
730
789
|
continue;
|
package/dist/git.d.ts
CHANGED
|
@@ -30,11 +30,67 @@ export declare class WorktreePrepareError extends Error {
|
|
|
30
30
|
baseSha?: string;
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* A repository with no commits cannot hand out a copy of itself (#137).
|
|
35
|
+
*
|
|
36
|
+
* The mirror of `NO_COMMITS_YET_MESSAGE` in `@devbridge/shared`, which this
|
|
37
|
+
* package cannot import — it is published to npm on its own, and the import
|
|
38
|
+
* would make the tarball unresolvable (`recipe-schema.ts` explains it in full).
|
|
39
|
+
* Same arrangement as the level-event constants and the session limits: the
|
|
40
|
+
* text lives twice and a test pins the two copies together, because the API
|
|
41
|
+
* refuses this case before a session exists and the runner refuses it again if
|
|
42
|
+
* one ever gets that far — and the person must read one sentence, not two.
|
|
43
|
+
*
|
|
44
|
+
* `git.test.ts` compares this against the shared copy byte for byte.
|
|
45
|
+
*/
|
|
46
|
+
export declare const NO_COMMITS_YET_MESSAGE = "This repository has no commits yet \u2014 start a session in the project folder itself; a branch of its own becomes possible after the first commit";
|
|
47
|
+
/**
|
|
48
|
+
* Where HEAD points — with the three states told apart properly (#137).
|
|
49
|
+
*
|
|
50
|
+
* `rev-parse --abbrev-ref HEAD` cannot answer this and never could. On a
|
|
51
|
+
* repository straight out of `git init` it exits 128 with git's own manual
|
|
52
|
+
* page («ambiguous argument 'HEAD'»), and it ALSO prints the literal word
|
|
53
|
+
* `HEAD` — the same word it prints on a detached HEAD. So the one call that
|
|
54
|
+
* everything here used to be built on both throws where it should not and,
|
|
55
|
+
* caught, answers «detached» for a folder that is plainly on a branch. That is
|
|
56
|
+
* the whole of #137: a directory with no commits in it could not be bound, its
|
|
57
|
+
* first session refused to start, and its restore points reported «this is not
|
|
58
|
+
* a git repository».
|
|
59
|
+
*
|
|
60
|
+
* Two calls answer it honestly, and both are cheap:
|
|
61
|
+
*
|
|
62
|
+
* `symbolic-ref --short HEAD` — the branch NAME, whether or not it has a
|
|
63
|
+
* commit; fails only on a detached HEAD.
|
|
64
|
+
* `rev-parse --verify --quiet HEAD` — is there a commit at all.
|
|
65
|
+
*
|
|
66
|
+
* branch + commit → an ordinary checkout
|
|
67
|
+
* branch, no commit → unborn: a legitimate, fully workable state. The person
|
|
68
|
+
* ran `git init` and has not committed yet; nobody makes
|
|
69
|
+
* that first commit for them (#137, plan S3a).
|
|
70
|
+
* no branch, commit → detached: the refusal that was always meant here.
|
|
71
|
+
* neither → not a repository. Every caller establishes that
|
|
72
|
+
* separately, so this simply reports nothing.
|
|
73
|
+
*/
|
|
74
|
+
export interface HeadState {
|
|
75
|
+
/** The branch HEAD is on, including one that has no commit yet. */
|
|
76
|
+
branch: string | null;
|
|
77
|
+
/** On a branch that has no commit yet — a fresh `git init`. */
|
|
78
|
+
unborn: boolean;
|
|
79
|
+
/** On a commit rather than on a branch. */
|
|
80
|
+
detached: boolean;
|
|
81
|
+
}
|
|
82
|
+
export declare function headState(workspacePath: string): Promise<HeadState>;
|
|
33
83
|
export interface PathValidation {
|
|
34
84
|
ok: boolean;
|
|
35
85
|
exists: boolean;
|
|
36
86
|
isGitRepo: boolean;
|
|
37
87
|
branch?: string;
|
|
88
|
+
/**
|
|
89
|
+
* #137: the branch has no commit on it yet. `branch` above is still its name
|
|
90
|
+
* and the directory still binds — this says «do not expect a HEAD here», and
|
|
91
|
+
* it is what lets every reader downstream stop guessing «detached».
|
|
92
|
+
*/
|
|
93
|
+
unborn?: boolean;
|
|
38
94
|
/** The repository's own main branch, as this machine can see it without the network. */
|
|
39
95
|
defaultBranch?: string;
|
|
40
96
|
error?: string;
|