@fitlab-ai/agent-infra 0.9.5 → 0.9.6
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/bin/internal-cli.js +3 -3
- package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +126 -16
- package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +48 -4
- package/dist/lib/agent-clients/adapters/codex.js +1 -1
- package/dist/lib/git/workflow.js +0 -1
- package/dist/lib/internal/codex-lifecycle.js +100 -2
- package/dist/lib/internal/release-workflow.js +108 -11
- package/dist/lib/internal/sandbox-control.js +43 -6
- package/dist/lib/internal/task-create.js +12 -4
- package/dist/lib/internal/task-orchestration.js +5 -4
- package/dist/lib/platform/issue-comments.js +2 -1
- package/dist/lib/release/workflow.js +29 -21
- package/dist/lib/sandbox/commands/create.js +15 -16
- package/dist/lib/sandbox/commands/prune.js +40 -16
- package/dist/lib/sandbox/commands/rm.js +318 -89
- package/dist/lib/sandbox/control/client.js +138 -22
- package/dist/lib/sandbox/control/executor.js +136 -0
- package/dist/lib/sandbox/control/lifecycle.js +348 -0
- package/dist/lib/sandbox/control/protocol.js +62 -26
- package/dist/lib/sandbox/control/server.js +222 -102
- package/dist/lib/sandbox/control/state.js +181 -0
- package/dist/lib/sandbox/index.js +2 -2
- package/dist/lib/sandbox/managed-fs.js +14 -2
- package/dist/lib/sandbox/mounts.js +35 -24
- package/dist/lib/sandbox/recovery.js +262 -45
- package/dist/lib/sandbox/workspace-view.js +73 -10
- package/dist/lib/sandbox/worktree-safety.js +196 -0
- package/dist/lib/server/process-control.js +6 -0
- package/dist/lib/task/artifact-lifecycle.js +5 -4
- package/dist/lib/task/codex-orchestration.js +161 -0
- package/dist/lib/task/commands/status.js +3 -0
- package/dist/lib/task/commands/validate.js +289 -0
- package/dist/lib/task/commit-finalization.js +36 -9
- package/dist/lib/task/delegation-receipts.js +25 -0
- package/dist/lib/task/events.js +10 -4
- package/dist/lib/task/finalization-artifacts.js +2 -1
- package/dist/lib/task/implementation-inputs.js +1 -7
- package/dist/lib/task/index.js +9 -0
- package/dist/lib/task/orchestration.js +133 -4
- package/dist/lib/task/verification-engine.js +38 -6
- package/dist/lib/task/verification.js +1 -0
- package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +142 -15
- package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +51 -3
- package/lib/agent-clients/adapters/codex.ts +1 -1
- package/lib/git/workflow.ts +0 -1
- package/lib/internal/codex-lifecycle.ts +107 -3
- package/lib/internal/release-workflow.ts +107 -11
- package/lib/internal/sandbox-control.ts +38 -6
- package/lib/internal/task-create.ts +16 -4
- package/lib/internal/task-orchestration.ts +4 -4
- package/lib/platform/issue-comments.ts +2 -1
- package/lib/release/workflow.ts +69 -20
- package/lib/sandbox/commands/create.ts +17 -21
- package/lib/sandbox/commands/prune.ts +53 -15
- package/lib/sandbox/commands/rm.ts +368 -103
- package/lib/sandbox/control/client.ts +152 -28
- package/lib/sandbox/control/executor.ts +163 -0
- package/lib/sandbox/control/lifecycle.ts +342 -0
- package/lib/sandbox/control/protocol.ts +96 -69
- package/lib/sandbox/control/server.ts +239 -110
- package/lib/sandbox/control/state.ts +196 -0
- package/lib/sandbox/index.ts +2 -2
- package/lib/sandbox/managed-fs.ts +17 -2
- package/lib/sandbox/mounts.ts +31 -13
- package/lib/sandbox/recovery.ts +301 -60
- package/lib/sandbox/workspace-view.ts +81 -11
- package/lib/sandbox/worktree-safety.ts +234 -0
- package/lib/server/process-control.ts +11 -0
- package/lib/task/artifact-lifecycle.ts +5 -4
- package/lib/task/codex-orchestration.ts +233 -0
- package/lib/task/commands/status.ts +2 -0
- package/lib/task/commands/validate.ts +282 -0
- package/lib/task/commit-finalization.ts +52 -9
- package/lib/task/delegation-receipts.ts +52 -3
- package/lib/task/events.ts +9 -4
- package/lib/task/finalization-artifacts.ts +2 -1
- package/lib/task/implementation-inputs.ts +1 -5
- package/lib/task/index.ts +8 -0
- package/lib/task/orchestration.ts +199 -4
- package/lib/task/verification-engine.ts +42 -8
- package/lib/task/verification.ts +2 -1
- package/package.json +1 -1
- package/templates/.agents/hooks/lifecycle-delegation.js +48 -12
- package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
- package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
- package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
- package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
- package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
- package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
- package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
- package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
- package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
- package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
- package/templates/.claude/commands/run-manual-validation.en.md +9 -0
- package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
- package/templates/.codex/hooks.json +8 -8
- package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
- package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
|
@@ -2,18 +2,158 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import {
|
|
5
|
+
import { nodeEntryArgs } from './control/executor.js';
|
|
6
|
+
import { containerNameCandidates, sandboxBranchLabel, sandboxLabel, sandboxRuntimeCapabilityLabel, sandboxTaskIdLabel, sandboxWorkspaceModeLabel, worktreeDirCandidates } from './constants.js';
|
|
6
7
|
import { isAgentClientId } from '../agent-clients/types.js';
|
|
7
8
|
import { createSandboxCapabilityPlan } from './agent-client-reconciler.js';
|
|
8
9
|
import { toEnginePath } from './engines/wsl2-paths.js';
|
|
9
10
|
import { sandboxCoreBindMounts } from './mounts.js';
|
|
10
11
|
import { assertSandboxTaskSource, sandboxControlPaths, sandboxWorkspaceViewPaths } from './workspace-view.js';
|
|
11
12
|
import { parseSandboxWorkspaceIdentity, sameSandboxWorkspaceIdentity } from './workspace-identity.js';
|
|
13
|
+
import { inspectWorktree } from './worktree-safety.js';
|
|
12
14
|
import { runEngine, runOkEngine, runVerboseEngine } from './shell.js';
|
|
13
15
|
import { declaredTmpfsSeedEntries, resolveTools, toolConfigDirCandidates } from './tools.js';
|
|
14
16
|
import { fetchSandboxRows, selectSandboxContainer, startSandboxContainer } from './commands/list-running.js';
|
|
15
|
-
import { processIdentityMatches } from '../server/process-state.js';
|
|
16
|
-
|
|
17
|
+
import { getProcessStartTime, processIdentityMatches, removePidFileIfMatches } from '../server/process-state.js';
|
|
18
|
+
import { acquireSandboxControlBrokerStartup, isSandboxControlRootQuiescing } from './control/lifecycle.js';
|
|
19
|
+
import { appendSandboxControlAudit, readSandboxControlStatus } from './control/state.js';
|
|
20
|
+
import { SANDBOX_CONTROL_FUTURE_SKEW_MS, SANDBOX_CONTROL_STATUS_STALE_MS } from './control/protocol.js';
|
|
21
|
+
async function waitForSandboxControlBrokerStatus(params, timeoutMs = 5_000) {
|
|
22
|
+
const deadline = Date.now() + timeoutMs;
|
|
23
|
+
while (Date.now() < deadline) {
|
|
24
|
+
if (isSandboxControlRootQuiescing(params.root))
|
|
25
|
+
throw new Error('SANDBOX_CONTROL_QUIESCING');
|
|
26
|
+
try {
|
|
27
|
+
const status = readSandboxControlStatus(params.statusDir);
|
|
28
|
+
if (status.generation === params.generation && status.broker.pid === params.pid
|
|
29
|
+
&& (!params.startTime || status.broker.startTime === params.startTime)
|
|
30
|
+
&& Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
|
|
31
|
+
&& status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS)
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// The broker may still be publishing its initial atomic status.
|
|
36
|
+
}
|
|
37
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
38
|
+
}
|
|
39
|
+
throw new Error('SANDBOX_CONTROL_BROKER_START_TIMEOUT');
|
|
40
|
+
}
|
|
41
|
+
export async function startSandboxControlBroker(repoRoot, manifestPath) {
|
|
42
|
+
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const extension = path.extname(fileURLToPath(import.meta.url));
|
|
44
|
+
const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
|
|
45
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
46
|
+
const root = path.dirname(manifestPath);
|
|
47
|
+
if (isSandboxControlRootQuiescing(root))
|
|
48
|
+
throw new Error('SANDBOX_CONTROL_QUIESCING');
|
|
49
|
+
const brokerPath = path.join(root, 'broker.json');
|
|
50
|
+
let brokerSnapshot = null;
|
|
51
|
+
let replacedBrokerRecord = false;
|
|
52
|
+
try {
|
|
53
|
+
brokerSnapshot = fs.readFileSync(brokerPath, 'utf8');
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code !== 'ENOENT')
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
if (brokerSnapshot !== null) {
|
|
60
|
+
let broker = null;
|
|
61
|
+
try {
|
|
62
|
+
broker = JSON.parse(brokerSnapshot);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Malformed owner records are stale and replaced below.
|
|
66
|
+
}
|
|
67
|
+
const owner = broker !== null && typeof broker.pid === 'number' && typeof broker.startTime === 'string'
|
|
68
|
+
? { version: 1, pid: broker.pid, startTime: broker.startTime }
|
|
69
|
+
: null;
|
|
70
|
+
const live = owner !== null && processIdentityMatches(owner);
|
|
71
|
+
if (live
|
|
72
|
+
&& owner !== null
|
|
73
|
+
&& broker !== null
|
|
74
|
+
&& broker.version === 2
|
|
75
|
+
&& broker.token === manifest.token
|
|
76
|
+
&& broker.generation === manifest.generation) {
|
|
77
|
+
await waitForSandboxControlBrokerStatus({
|
|
78
|
+
root,
|
|
79
|
+
statusDir: manifest.publicStatusDir,
|
|
80
|
+
generation: manifest.generation,
|
|
81
|
+
pid: owner.pid,
|
|
82
|
+
startTime: owner.startTime
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (live && owner) {
|
|
87
|
+
const deadline = Date.now() + 5_000;
|
|
88
|
+
while (Date.now() < deadline) {
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
90
|
+
try {
|
|
91
|
+
if (fs.readFileSync(brokerPath, 'utf8') !== brokerSnapshot) {
|
|
92
|
+
return startSandboxControlBroker(repoRoot, manifestPath);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error.code === 'ENOENT') {
|
|
97
|
+
return startSandboxControlBroker(repoRoot, manifestPath);
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (!processIdentityMatches(owner)) {
|
|
102
|
+
removePidFileIfMatches(brokerPath, brokerSnapshot);
|
|
103
|
+
return startSandboxControlBroker(repoRoot, manifestPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
throw new Error('SANDBOX_CONTROL_BROKER_OWNER_ACTIVE');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const startupStartTime = getProcessStartTime(process.pid);
|
|
110
|
+
if (!startupStartTime)
|
|
111
|
+
throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
|
|
112
|
+
const releaseStartup = await acquireSandboxControlBrokerStartup(root, {
|
|
113
|
+
pid: process.pid,
|
|
114
|
+
startTime: startupStartTime
|
|
115
|
+
});
|
|
116
|
+
let child;
|
|
117
|
+
try {
|
|
118
|
+
const currentBroker = fs.existsSync(brokerPath) ? fs.readFileSync(brokerPath, 'utf8') : null;
|
|
119
|
+
if (currentBroker !== brokerSnapshot)
|
|
120
|
+
return startSandboxControlBroker(repoRoot, manifestPath);
|
|
121
|
+
if (brokerSnapshot !== null) {
|
|
122
|
+
replacedBrokerRecord = removePidFileIfMatches(brokerPath, brokerSnapshot);
|
|
123
|
+
if (!replacedBrokerRecord && fs.existsSync(brokerPath)) {
|
|
124
|
+
throw new Error('SANDBOX_CONTROL_BROKER_OWNER_TRANSITION');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (replacedBrokerRecord) {
|
|
128
|
+
appendSandboxControlAudit(manifest, 'broker-observed-crash');
|
|
129
|
+
appendSandboxControlAudit(manifest, 'broker-restart');
|
|
130
|
+
}
|
|
131
|
+
child = spawn(process.execPath, nodeEntryArgs(internalCli, ['sandbox-control', 'serve', '--manifest', manifestPath]), { cwd: repoRoot, detached: true, stdio: 'ignore' });
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
releaseStartup();
|
|
135
|
+
}
|
|
136
|
+
let spawnError = null;
|
|
137
|
+
child.once('error', (error) => { spawnError = error; });
|
|
138
|
+
child.unref();
|
|
139
|
+
if (!child.pid)
|
|
140
|
+
throw new Error('SANDBOX_CONTROL_BROKER_START_FAILED');
|
|
141
|
+
try {
|
|
142
|
+
await waitForSandboxControlBrokerStatus({
|
|
143
|
+
root,
|
|
144
|
+
statusDir: manifest.publicStatusDir,
|
|
145
|
+
generation: manifest.generation,
|
|
146
|
+
pid: child.pid
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
child.kill('SIGTERM');
|
|
151
|
+
if (spawnError)
|
|
152
|
+
throw spawnError;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function ensureSandboxControlBroker(params) {
|
|
17
157
|
const control = sandboxControlPaths({
|
|
18
158
|
base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
|
|
19
159
|
project: params.config.project,
|
|
@@ -23,30 +163,43 @@ function ensureSandboxControlBroker(params) {
|
|
|
23
163
|
if (!fs.existsSync(control.manifestPath))
|
|
24
164
|
return;
|
|
25
165
|
const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8'));
|
|
166
|
+
if (manifest.version !== 3) {
|
|
167
|
+
throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 3; container-only recreation is required');
|
|
168
|
+
}
|
|
26
169
|
const brokerPath = path.join(control.root, 'broker.json');
|
|
27
170
|
try {
|
|
28
171
|
const broker = JSON.parse(fs.readFileSync(brokerPath, 'utf8'));
|
|
29
|
-
if (broker.version ===
|
|
172
|
+
if (broker.version === 2
|
|
30
173
|
&& typeof broker.pid === 'number'
|
|
31
174
|
&& typeof broker.startTime === 'string'
|
|
32
175
|
&& broker.token === manifest.token
|
|
33
|
-
&&
|
|
176
|
+
&& broker.generation === manifest.generation
|
|
177
|
+
&& processIdentityMatches({ version: 1, pid: broker.pid, startTime: broker.startTime })) {
|
|
178
|
+
try {
|
|
179
|
+
const status = readSandboxControlStatus(control.statusDir);
|
|
180
|
+
if (status.generation === manifest.generation && status.broker.pid === broker.pid
|
|
181
|
+
&& status.broker.startTime === broker.startTime
|
|
182
|
+
&& Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
|
|
183
|
+
&& status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS)
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// A verified live owner may still be publishing its initial status.
|
|
188
|
+
}
|
|
189
|
+
await waitForSandboxControlBrokerStatus({
|
|
190
|
+
root: control.root,
|
|
191
|
+
statusDir: control.statusDir,
|
|
192
|
+
generation: manifest.generation,
|
|
193
|
+
pid: broker.pid,
|
|
194
|
+
startTime: broker.startTime
|
|
195
|
+
});
|
|
34
196
|
return;
|
|
197
|
+
}
|
|
35
198
|
}
|
|
36
199
|
catch {
|
|
37
200
|
// A missing, stale, or malformed broker record is replaced below.
|
|
38
201
|
}
|
|
39
|
-
|
|
40
|
-
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
-
const extension = path.extname(fileURLToPath(import.meta.url));
|
|
42
|
-
const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
|
|
43
|
-
const child = spawn(process.execPath, [internalCli, 'sandbox-control', 'serve', '--manifest', control.manifestPath], { cwd: params.config.repoRoot, detached: true, stdio: 'ignore' });
|
|
44
|
-
child.once('error', () => {
|
|
45
|
-
// Readiness remains fail-closed; a later control request reports the
|
|
46
|
-
// unavailable broker without turning this detached spawn into an
|
|
47
|
-
// unhandled process error.
|
|
48
|
-
});
|
|
49
|
-
child.unref();
|
|
202
|
+
await startSandboxControlBroker(params.config.repoRoot, control.manifestPath);
|
|
50
203
|
}
|
|
51
204
|
function findingKey(finding) {
|
|
52
205
|
return `${finding.repairKind}:${finding.path ?? finding.seed?.targetPath ?? finding.message}`;
|
|
@@ -54,9 +207,22 @@ function findingKey(finding) {
|
|
|
54
207
|
export function classifySandboxRecovery(snapshot) {
|
|
55
208
|
const findings = [];
|
|
56
209
|
if (!snapshot.identityOk) {
|
|
210
|
+
const branchOnly = snapshot.containerIdValid
|
|
211
|
+
&& snapshot.actualBranch === snapshot.expectedBranch
|
|
212
|
+
&& snapshot.expectedWorkspace.mode === 'task-bound'
|
|
213
|
+
&& snapshot.actualWorkspace.mode === 'branch-only';
|
|
214
|
+
const expectedIdentity = snapshot.expectedWorkspace.mode === 'task-bound'
|
|
215
|
+
? `task-bound:${snapshot.expectedWorkspace.taskId}`
|
|
216
|
+
: snapshot.expectedWorkspace.mode;
|
|
217
|
+
const actualIdentity = snapshot.actualWorkspace.mode === 'task-bound'
|
|
218
|
+
? `task-bound:${snapshot.actualWorkspace.taskId}`
|
|
219
|
+
: snapshot.actualWorkspace.mode;
|
|
57
220
|
findings.push({
|
|
58
221
|
repairKind: 'hard-failure',
|
|
59
|
-
|
|
222
|
+
code: branchOnly ? 'SANDBOX_CONTROL_BRANCH_ONLY' : 'SANDBOX_WORKSPACE_IDENTITY_CONFLICT',
|
|
223
|
+
message: branchOnly
|
|
224
|
+
? `Container is branch-only but the requested sandbox is ${expectedIdentity}.`
|
|
225
|
+
: `Container identity ${actualIdentity} on branch ${snapshot.actualBranch ?? 'unknown'} does not match requested ${expectedIdentity} on branch ${snapshot.expectedBranch}.`
|
|
60
226
|
});
|
|
61
227
|
}
|
|
62
228
|
if (snapshot.runtimeCapabilityOk === false) {
|
|
@@ -211,6 +377,7 @@ function expectedMounts(params) {
|
|
|
211
377
|
const core = sandboxCoreBindMounts(config, branch, {
|
|
212
378
|
workspaceViewRoot: view.root,
|
|
213
379
|
controlDir: control.channelDir,
|
|
380
|
+
controlStatusDir: control.statusDir,
|
|
214
381
|
...(params.workspace.mode === 'task-bound'
|
|
215
382
|
? {
|
|
216
383
|
taskSource: assertSandboxTaskSource(config.repoRoot, params.workspace.taskId),
|
|
@@ -379,8 +546,12 @@ export function collectSandboxRecoverySnapshot(params) {
|
|
|
379
546
|
};
|
|
380
547
|
});
|
|
381
548
|
return {
|
|
382
|
-
|
|
383
|
-
|
|
549
|
+
containerIdValid: typeof inspection.Id === 'string' && inspection.Id.length > 0,
|
|
550
|
+
expectedBranch: params.branch,
|
|
551
|
+
actualBranch: typeof branchLabel === 'string' ? branchLabel : null,
|
|
552
|
+
expectedWorkspace: workspace,
|
|
553
|
+
actualWorkspace: containerWorkspace,
|
|
554
|
+
identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
|
|
384
555
|
&& branchLabel === params.branch
|
|
385
556
|
&& sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
|
|
386
557
|
runtimeCapabilityOk: enforceCanonicalPlan
|
|
@@ -487,7 +658,7 @@ function repairFindings(params) {
|
|
|
487
658
|
}
|
|
488
659
|
}
|
|
489
660
|
function describeFindings(findings) {
|
|
490
|
-
return findings.map((finding) => finding.message).join(' ');
|
|
661
|
+
return findings.map((finding) => finding.code ? `${finding.code}: ${finding.message}` : finding.message).join(' ');
|
|
491
662
|
}
|
|
492
663
|
function assess(params) {
|
|
493
664
|
const snapshot = collectSandboxRecoverySnapshot(params);
|
|
@@ -547,12 +718,17 @@ export async function ensureSandboxReady(params) {
|
|
|
547
718
|
const startFn = deps?.start ?? startSandboxContainer;
|
|
548
719
|
const warnings = [];
|
|
549
720
|
let failure = null;
|
|
550
|
-
ensureSandboxControlBroker({
|
|
551
|
-
config: params.config,
|
|
552
|
-
container: params.row.name,
|
|
553
|
-
workspace: params.workspace ?? { mode: 'branch-only' }
|
|
554
|
-
});
|
|
555
721
|
try {
|
|
722
|
+
if (deps?.ensureControlBroker) {
|
|
723
|
+
await deps.ensureControlBroker();
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
await ensureSandboxControlBroker({
|
|
727
|
+
config: params.config,
|
|
728
|
+
container: params.row.name,
|
|
729
|
+
workspace: params.workspace ?? { mode: 'branch-only' }
|
|
730
|
+
});
|
|
731
|
+
}
|
|
556
732
|
if (!params.row.running) {
|
|
557
733
|
startFn(params.engine, params.row.name);
|
|
558
734
|
const initial = assess({
|
|
@@ -668,34 +844,75 @@ export async function ensureSandboxReady(params) {
|
|
|
668
844
|
failure = error instanceof Error ? error : new Error(String(error));
|
|
669
845
|
}
|
|
670
846
|
const dataBoundary = 'The existing container writable layer, ordinary /tmp data, processes, tmux sessions, and RAM state may be lost by replacement; worktree and host-managed sandbox data are preserved.';
|
|
847
|
+
const recoveryTarget = params.workspace?.mode === 'task-bound'
|
|
848
|
+
? params.workspace.taskId
|
|
849
|
+
: params.branch;
|
|
671
850
|
if (!params.allowRecreate || !params.recreate) {
|
|
672
|
-
throw new Error(
|
|
851
|
+
throw new Error([
|
|
852
|
+
`Sandbox recovery failed. Run 'ai sandbox start --recreate ${recoveryTarget}' on the host to authorize container-only replacement.`,
|
|
853
|
+
dataBoundary,
|
|
854
|
+
`Details: ${failure.message}`
|
|
855
|
+
].join('\n'));
|
|
856
|
+
}
|
|
857
|
+
const existingWorktrees = worktreeDirCandidates(params.config, params.branch).filter((candidate) => fs.existsSync(candidate));
|
|
858
|
+
if (existingWorktrees.length !== 1) {
|
|
859
|
+
const remediation = existingWorktrees.length === 0
|
|
860
|
+
? `Restore the worktree for '${params.branch}' before recovering the container.`
|
|
861
|
+
: `Found ${existingWorktrees.map((candidate) => JSON.stringify(candidate)).join(', ')}; `
|
|
862
|
+
+ 'keep the worktree in use, remove the stale directory, then retry.';
|
|
863
|
+
throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected exactly one existing worktree for '${params.branch}', found ${existingWorktrees.length}. ${remediation}`);
|
|
864
|
+
}
|
|
865
|
+
const beforeInspection = inspectWorktree(existingWorktrees[0]);
|
|
866
|
+
if (beforeInspection.status === 'failed') {
|
|
867
|
+
throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: ${beforeInspection.message}`);
|
|
868
|
+
}
|
|
869
|
+
const before = beforeInspection.snapshot;
|
|
870
|
+
if (before.branch !== params.branch) {
|
|
871
|
+
throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected branch '${params.branch}', found '${before.branch}'. `
|
|
872
|
+
+ `Run 'git -C ${JSON.stringify(before.worktree)} checkout ${params.branch}' to restore the expected branch, then retry.`);
|
|
673
873
|
}
|
|
674
874
|
const warning = `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
|
|
675
875
|
warnings.push(warning);
|
|
676
876
|
(params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
|
|
677
877
|
const runVerboseFn = deps?.runVerbose ?? runVerboseEngine;
|
|
678
|
-
|
|
679
|
-
|
|
878
|
+
let replacementResult = null;
|
|
879
|
+
let replacementFailure = null;
|
|
880
|
+
try {
|
|
881
|
+
if (params.row.running) {
|
|
882
|
+
runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
|
|
883
|
+
}
|
|
884
|
+
runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
|
|
885
|
+
await params.recreate(params.branch);
|
|
886
|
+
const rows = (deps?.fetchRows ?? fetchSandboxRows)(params.engine, sandboxLabel(params.config), sandboxBranchLabel(params.config));
|
|
887
|
+
const replacement = selectSandboxContainer([...rows.running, ...rows.nonRunning], containerNameCandidates(params.config, params.branch));
|
|
888
|
+
if (!replacement?.running) {
|
|
889
|
+
throw new Error('Replacement sandbox container was not found in a running state.');
|
|
890
|
+
}
|
|
891
|
+
const final = assess({
|
|
892
|
+
config: params.config,
|
|
893
|
+
engine: params.engine,
|
|
894
|
+
branch: params.branch,
|
|
895
|
+
workspace: params.workspace,
|
|
896
|
+
container: replacement.name,
|
|
897
|
+
deps
|
|
898
|
+
});
|
|
899
|
+
if (final.findings.length > 0) {
|
|
900
|
+
throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
|
|
901
|
+
}
|
|
902
|
+
replacementResult = { container: replacement.name, path: 'recreated', warnings };
|
|
680
903
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const rows = (deps?.fetchRows ?? fetchSandboxRows)(params.engine, sandboxLabel(params.config), sandboxBranchLabel(params.config));
|
|
684
|
-
const replacement = selectSandboxContainer([...rows.running, ...rows.nonRunning], containerNameCandidates(params.config, params.branch));
|
|
685
|
-
if (!replacement?.running) {
|
|
686
|
-
throw new Error('Replacement sandbox container was not found in a running state.');
|
|
904
|
+
catch (error) {
|
|
905
|
+
replacementFailure = error;
|
|
687
906
|
}
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
deps
|
|
695
|
-
});
|
|
696
|
-
if (final.findings.length > 0) {
|
|
697
|
-
throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
|
|
907
|
+
const afterInspection = inspectWorktree(before.worktree);
|
|
908
|
+
if (afterInspection.status === 'failed' || afterInspection.snapshot.identity !== before.identity) {
|
|
909
|
+
const detail = afterInspection.status === 'failed'
|
|
910
|
+
? afterInspection.message
|
|
911
|
+
: `snapshot changed from ${before.identity} to ${afterInspection.snapshot.identity}`;
|
|
912
|
+
throw new Error(`SANDBOX_RECOVERY_WORKTREE_CHANGED: ${detail}`, replacementFailure === null ? undefined : { cause: replacementFailure });
|
|
698
913
|
}
|
|
699
|
-
|
|
914
|
+
if (replacementFailure !== null)
|
|
915
|
+
throw replacementFailure;
|
|
916
|
+
return replacementResult;
|
|
700
917
|
}
|
|
701
918
|
//# sourceMappingURL=recovery.js.map
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
export const SANDBOX_WORKSPACE_VIEW_STATES = Object.freeze([
|
|
5
|
+
'active',
|
|
6
|
+
'completed',
|
|
7
|
+
'blocked',
|
|
8
|
+
'archive'
|
|
9
|
+
]);
|
|
10
|
+
export function sandboxWorkspaceViewStatePaths(root) {
|
|
11
|
+
return SANDBOX_WORKSPACE_VIEW_STATES.map((state) => ({
|
|
12
|
+
state,
|
|
13
|
+
hostPath: path.join(root, state)
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
4
16
|
export function sandboxWorkspaceViewPaths(params) {
|
|
5
17
|
const identityKey = params.identity.mode === 'task-bound'
|
|
6
18
|
? `task-bound:${params.identity.taskId}`
|
|
@@ -21,7 +33,13 @@ export function sandboxControlPaths(params) {
|
|
|
21
33
|
: 'branch-only';
|
|
22
34
|
const digest = createHash('sha256').update(identityKey).digest('hex').slice(0, 16);
|
|
23
35
|
const root = path.resolve(params.base, params.project, params.container, digest);
|
|
24
|
-
return {
|
|
36
|
+
return {
|
|
37
|
+
root,
|
|
38
|
+
channelDir: path.join(root, 'channel'),
|
|
39
|
+
statusDir: path.join(root, 'public'),
|
|
40
|
+
processingDir: path.join(root, 'processing'),
|
|
41
|
+
manifestPath: path.join(root, 'manifest.json')
|
|
42
|
+
};
|
|
25
43
|
}
|
|
26
44
|
function assertSafeDirectory(directory, expectedBase) {
|
|
27
45
|
const relative = path.relative(expectedBase, directory);
|
|
@@ -32,6 +50,44 @@ function assertSafeDirectory(directory, expectedBase) {
|
|
|
32
50
|
throw new Error(`Sandbox workspace view must not be a symbolic link: ${directory}`);
|
|
33
51
|
}
|
|
34
52
|
}
|
|
53
|
+
function assertSafeMountTargetPath(target, worktreeRoot, canonicalWorktreeRoot) {
|
|
54
|
+
const relative = path.relative(worktreeRoot, target);
|
|
55
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
56
|
+
throw new Error(`Sandbox workspace mount target escapes its worktree: ${target}`);
|
|
57
|
+
}
|
|
58
|
+
let current = worktreeRoot;
|
|
59
|
+
for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
|
|
60
|
+
current = segment ? path.join(current, segment) : current;
|
|
61
|
+
try {
|
|
62
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
63
|
+
throw new Error(`Sandbox workspace mount target must not contain a symbolic link: ${current}`);
|
|
64
|
+
}
|
|
65
|
+
const canonicalRelative = path.relative(canonicalWorktreeRoot, fs.realpathSync.native(current));
|
|
66
|
+
if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
|
|
67
|
+
throw new Error(`Sandbox workspace mount target escapes its real worktree: ${current}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== 'ENOENT')
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function prepareSandboxWorkspaceMountTargets(worktreeRoot) {
|
|
77
|
+
const resolvedWorktreeRoot = path.resolve(worktreeRoot);
|
|
78
|
+
const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
|
|
79
|
+
const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
|
|
80
|
+
const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
|
|
81
|
+
const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
|
|
82
|
+
for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
|
|
83
|
+
assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
|
|
84
|
+
}
|
|
85
|
+
fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
|
|
86
|
+
for (const { hostPath } of statePaths) {
|
|
87
|
+
fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
|
|
88
|
+
}
|
|
89
|
+
fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
|
|
90
|
+
}
|
|
35
91
|
export function assertSandboxTaskSource(repoRoot, taskId) {
|
|
36
92
|
if (!/^TASK-\d{8}-\d{6}$/.test(taskId))
|
|
37
93
|
throw new Error('SANDBOX_TASK_SOURCE_INVALID');
|
|
@@ -53,11 +109,10 @@ export function materializeSandboxWorkspaceView(params) {
|
|
|
53
109
|
assertSafeDirectory(projectRoot, path.resolve(params.base));
|
|
54
110
|
assertSafeDirectory(root, projectRoot);
|
|
55
111
|
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
56
|
-
for (const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
fs.
|
|
60
|
-
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
112
|
+
for (const { hostPath } of sandboxWorkspaceViewStatePaths(root)) {
|
|
113
|
+
assertSafeDirectory(hostPath, root);
|
|
114
|
+
fs.rmSync(hostPath, { recursive: true, force: true });
|
|
115
|
+
fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
|
|
61
116
|
}
|
|
62
117
|
const active = path.join(root, 'active');
|
|
63
118
|
const registry = {
|
|
@@ -77,12 +132,16 @@ export function materializeSandboxWorkspaceView(params) {
|
|
|
77
132
|
return { root, taskMountPath };
|
|
78
133
|
}
|
|
79
134
|
export function materializeSandboxControl(params) {
|
|
80
|
-
const { root, channelDir, manifestPath } = sandboxControlPaths(params);
|
|
135
|
+
const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
|
|
81
136
|
assertSafeDirectory(root, path.resolve(params.base));
|
|
82
137
|
const consumedDir = path.join(root, 'consumed');
|
|
83
138
|
assertSafeDirectory(consumedDir, root);
|
|
84
139
|
fs.rmSync(consumedDir, { recursive: true, force: true });
|
|
85
140
|
fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
|
|
141
|
+
for (const directory of [statusDir, processingDir]) {
|
|
142
|
+
assertSafeDirectory(directory, root);
|
|
143
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
144
|
+
}
|
|
86
145
|
for (const queue of ['requests', 'responses']) {
|
|
87
146
|
const directory = path.join(channelDir, queue);
|
|
88
147
|
assertSafeDirectory(directory, root);
|
|
@@ -90,9 +149,10 @@ export function materializeSandboxControl(params) {
|
|
|
90
149
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
91
150
|
}
|
|
92
151
|
const token = randomBytes(32).toString('hex');
|
|
152
|
+
const generation = randomBytes(16).toString('hex');
|
|
93
153
|
const repoRoot = fs.realpathSync.native(params.repoRoot);
|
|
94
154
|
const manifest = {
|
|
95
|
-
version:
|
|
155
|
+
version: 3,
|
|
96
156
|
repoRoot,
|
|
97
157
|
worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
|
|
98
158
|
project: params.project,
|
|
@@ -101,9 +161,12 @@ export function materializeSandboxControl(params) {
|
|
|
101
161
|
mode: params.identity.mode,
|
|
102
162
|
taskId: params.identity.mode === 'task-bound' ? params.identity.taskId : null,
|
|
103
163
|
token,
|
|
104
|
-
|
|
164
|
+
generation,
|
|
165
|
+
channelDir,
|
|
166
|
+
publicStatusDir: statusDir,
|
|
167
|
+
processingDir
|
|
105
168
|
};
|
|
106
169
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
|
|
107
|
-
return { channelDir, manifestPath, token };
|
|
170
|
+
return { channelDir, statusDir, manifestPath, token, generation };
|
|
108
171
|
}
|
|
109
172
|
//# sourceMappingURL=workspace-view.js.map
|