@fitlab-ai/agent-infra 0.9.7 → 0.9.9
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/bin/internal-cli.js +11 -1
- package/dist/lib/agent-clients/adapters/claude-code.js +3 -3
- package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +60 -22
- package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +8 -1
- package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +175 -11
- package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +147 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +86 -69
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +2 -1
- package/dist/lib/decide.js +68 -14
- package/dist/lib/git/workflow.js +10 -1
- package/dist/lib/init.js +1 -1
- package/dist/lib/internal/codex-lifecycle.js +19 -5
- package/dist/lib/internal/codex-sandbox-controller.js +6 -13
- package/dist/lib/internal/git-workflow.js +5 -7
- package/dist/lib/internal/platform-pr.js +20 -7
- package/dist/lib/internal/release-workflow.js +3 -4
- package/dist/lib/internal/sandbox-control.js +79 -2
- package/dist/lib/internal/task-activity.js +57 -7
- package/dist/lib/internal/task-event.js +57 -4
- package/dist/lib/internal/task-finalization.js +54 -0
- package/dist/lib/internal/task-ledger.js +59 -5
- package/dist/lib/internal/task-lifecycle.js +59 -8
- package/dist/lib/internal/task-orchestration.js +161 -288
- package/dist/lib/internal/task-override.js +131 -0
- package/dist/lib/internal/task-review.js +64 -6
- package/dist/lib/internal/task-verify.js +65 -3
- package/dist/lib/internal/task-warning.js +57 -4
- package/dist/lib/platform/pr-summary.js +53 -13
- package/dist/lib/platform/pull-requests.js +205 -41
- package/dist/lib/runtime/agent-runtime.js +56 -0
- package/dist/lib/sandbox/commands/create.js +389 -311
- package/dist/lib/sandbox/commands/rm.js +316 -55
- package/dist/lib/sandbox/control/client.js +190 -6
- package/dist/lib/sandbox/control/container-identity.js +1 -1
- package/dist/lib/sandbox/control/controller-registration.js +273 -0
- package/dist/lib/sandbox/control/executor.js +145 -5
- package/dist/lib/sandbox/control/lifecycle.js +395 -73
- package/dist/lib/sandbox/control/protocol.js +67 -6
- package/dist/lib/sandbox/control/state.js +27 -11
- package/dist/lib/sandbox/engine.js +14 -7
- package/dist/lib/sandbox/index.js +5 -3
- package/dist/lib/sandbox/managed-fs.js +5 -0
- package/dist/lib/sandbox/mounts.js +20 -5
- package/dist/lib/sandbox/recovery.js +78 -10
- package/dist/lib/sandbox/task-resolver.js +50 -6
- package/dist/lib/sandbox/workspace-identity.js +27 -0
- package/dist/lib/sandbox/workspace-view.js +62 -40
- package/dist/lib/sandbox/worktree-safety.js +215 -2
- package/dist/lib/server/process-state.js +51 -1
- package/dist/lib/task/activity-intent.js +4 -1
- package/dist/lib/task/codex-orchestration.js +158 -33
- package/dist/lib/task/commands/status.js +59 -1
- package/dist/lib/task/commit-operation.js +335 -0
- package/dist/lib/task/commit-policy.js +18 -0
- package/dist/lib/task/decision-intents.js +4 -2
- package/dist/lib/task/delegation-receipts.js +279 -17
- package/dist/lib/task/events.js +9 -5
- package/dist/lib/task/finalization.js +567 -0
- package/dist/lib/task/guard-override.js +31 -0
- package/dist/lib/task/human-override.js +1240 -0
- package/dist/lib/task/ledger-intents.js +6 -3
- package/dist/lib/task/lifecycle.js +69 -14
- package/dist/lib/task/operation-outcome.js +33 -0
- package/dist/lib/task/orchestration.js +199 -719
- package/dist/lib/task/review-finalization.js +3 -2
- package/dist/lib/task/short-id.js +59 -1
- package/dist/lib/task/task-execution-lock.js +6 -1
- package/dist/lib/task/verification-engine.js +138 -59
- package/dist/lib/task/verification.js +15 -2
- package/dist/lib/task/workflow-warning-intents.js +54 -3
- package/dist/lib/task/write.js +2 -1
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/claude-code.ts +3 -3
- package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +63 -20
- package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +12 -0
- package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +278 -13
- package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +180 -0
- package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -0
- package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +9 -1
- package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +112 -93
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +2 -1
- package/lib/decide.ts +59 -14
- package/lib/git/workflow.ts +11 -2
- package/lib/init.ts +1 -1
- package/lib/internal/codex-lifecycle.ts +18 -5
- package/lib/internal/codex-sandbox-controller.ts +6 -12
- package/lib/internal/git-workflow.ts +5 -6
- package/lib/internal/platform-pr.ts +14 -7
- package/lib/internal/release-workflow.ts +3 -4
- package/lib/internal/sandbox-control.ts +92 -2
- package/lib/internal/task-activity.ts +46 -6
- package/lib/internal/task-event.ts +53 -4
- package/lib/internal/task-finalization.ts +68 -0
- package/lib/internal/task-ledger.ts +48 -4
- package/lib/internal/task-lifecycle.ts +56 -12
- package/lib/internal/task-orchestration.ts +32 -107
- package/lib/internal/task-override.ts +138 -0
- package/lib/internal/task-review.ts +51 -6
- package/lib/internal/task-verify.ts +52 -3
- package/lib/internal/task-warning.ts +44 -3
- package/lib/platform/pr-summary.ts +65 -14
- package/lib/platform/pull-requests.ts +211 -41
- package/lib/runtime/agent-runtime.ts +76 -0
- package/lib/sandbox/commands/create.ts +88 -4
- package/lib/sandbox/commands/rm.ts +413 -61
- package/lib/sandbox/config.ts +2 -1
- package/lib/sandbox/control/client.ts +255 -6
- package/lib/sandbox/control/container-identity.ts +3 -3
- package/lib/sandbox/control/controller-registration.ts +347 -0
- package/lib/sandbox/control/executor.ts +172 -6
- package/lib/sandbox/control/lifecycle.ts +465 -77
- package/lib/sandbox/control/protocol.ts +85 -9
- package/lib/sandbox/control/state.ts +35 -18
- package/lib/sandbox/engine.ts +20 -9
- package/lib/sandbox/index.ts +5 -3
- package/lib/sandbox/managed-fs.ts +5 -0
- package/lib/sandbox/mounts.ts +23 -5
- package/lib/sandbox/recovery.ts +90 -9
- package/lib/sandbox/task-resolver.ts +58 -7
- package/lib/sandbox/workspace-identity.ts +50 -2
- package/lib/sandbox/workspace-view.ts +73 -43
- package/lib/sandbox/worktree-safety.ts +236 -2
- package/lib/server/process-state.ts +48 -1
- package/lib/task/activity-intent.ts +4 -2
- package/lib/task/codex-orchestration.ts +167 -33
- package/lib/task/commands/status.ts +78 -1
- package/lib/task/commit-operation.ts +430 -0
- package/lib/task/commit-policy.ts +30 -0
- package/lib/task/decision-intents.ts +4 -2
- package/lib/task/delegation-receipts.ts +304 -19
- package/lib/task/events.ts +10 -5
- package/lib/task/finalization.ts +719 -0
- package/lib/task/guard-override.ts +50 -0
- package/lib/task/human-override.ts +1503 -0
- package/lib/task/ledger-intents.ts +6 -3
- package/lib/task/lifecycle.ts +71 -14
- package/lib/task/operation-outcome.ts +48 -0
- package/lib/task/orchestration.ts +239 -922
- package/lib/task/review-finalization.ts +6 -2
- package/lib/task/short-id.ts +65 -1
- package/lib/task/task-execution-lock.ts +10 -1
- package/lib/task/verification-engine.ts +145 -64
- package/lib/task/verification.ts +18 -3
- package/lib/task/workflow-warning-intents.ts +61 -4
- package/lib/task/write.ts +4 -1
- package/package.json +2 -2
- package/templates/.agents/QUICKSTART.en.md +2 -2
- package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
- package/templates/.agents/hooks/lifecycle-delegation.js +9 -24
- package/templates/.agents/rules/lifecycle-orchestration.en.md +4 -3
- package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +4 -3
- package/templates/.agents/rules/task-management.en.md +2 -2
- package/templates/.agents/rules/task-management.zh-CN.md +2 -2
- package/templates/.agents/skills/block-task/SKILL.en.md +2 -4
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -4
- package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/close-codescan/SKILL.en.md +6 -2
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +6 -2
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +6 -2
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +6 -2
- package/templates/.agents/skills/commit/SKILL.en.md +41 -125
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +52 -123
- package/templates/.agents/skills/commit/reference/commit-message.en.md +1 -1
- package/templates/.agents/skills/commit/reference/commit-message.zh-CN.md +1 -1
- package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +14 -43
- package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +14 -49
- package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +1 -1
- package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +1 -1
- package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -35
- package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -35
- package/templates/.agents/skills/complete-task/SKILL.en.md +31 -38
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +31 -38
- package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
- package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
- package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
- package/templates/.agents/skills/create-pr/SKILL.en.md +6 -4
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +6 -4
- package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +2 -2
- package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +2 -2
- package/templates/.agents/skills/run-task/SKILL.en.md +5 -5
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +5 -5
- package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -1
- package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -1
- package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +1 -1
- package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +1 -1
- package/templates/.codex/hooks.json +4 -4
- package/dist/lib/task/commit-finalization.js +0 -305
- package/dist/lib/task/commit-intent.js +0 -165
- package/lib/task/commit-finalization.ts +0 -419
- package/lib/task/commit-intent.ts +0 -230
|
@@ -5,7 +5,14 @@ import { isRemovedHashShortIdInput } from '../task/short-id.ts';
|
|
|
5
5
|
|
|
6
6
|
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
7
7
|
const SHORT_ID_RE = /^\d+$/;
|
|
8
|
-
const
|
|
8
|
+
export const TASK_WORKSPACE_STATES = ['active', 'completed', 'blocked', 'archive'] as const;
|
|
9
|
+
export type TaskWorkspaceState = typeof TASK_WORKSPACE_STATES[number];
|
|
10
|
+
export type TaskWorkspace = Readonly<{
|
|
11
|
+
taskId: string;
|
|
12
|
+
branch: string;
|
|
13
|
+
state: TaskWorkspaceState;
|
|
14
|
+
taskMd: string;
|
|
15
|
+
}>;
|
|
9
16
|
|
|
10
17
|
function resolveShortIdStrict(arg: string, repoRoot: string): string {
|
|
11
18
|
const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
|
|
@@ -27,16 +34,30 @@ function stripQuotes(value: string): string {
|
|
|
27
34
|
return value.replace(/^(["'])(.*)\1$/, '$2');
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
function readTaskContent(repoRoot: string, taskId: string): string {
|
|
31
|
-
for (const
|
|
32
|
-
const taskPath = path.join(repoRoot, '.agents', 'workspace',
|
|
37
|
+
function readTaskContent(repoRoot: string, taskId: string): { content: string; state: TaskWorkspaceState; taskMd: string } {
|
|
38
|
+
for (const state of TASK_WORKSPACE_STATES) {
|
|
39
|
+
const taskPath = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
|
|
33
40
|
if (fs.existsSync(taskPath)) {
|
|
34
|
-
return fs.readFileSync(taskPath, 'utf8');
|
|
41
|
+
return { content: fs.readFileSync(taskPath, 'utf8'), state, taskMd: taskPath };
|
|
35
42
|
}
|
|
36
43
|
}
|
|
37
44
|
throw new Error(`Task not found: ${taskId}`);
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
function taskWorkspaceCandidates(repoRoot: string, taskId: string): TaskWorkspace[] {
|
|
48
|
+
return TASK_WORKSPACE_STATES.flatMap((state) => {
|
|
49
|
+
const taskMd = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
|
|
50
|
+
if (!fs.existsSync(taskMd)) return [];
|
|
51
|
+
const content = fs.readFileSync(taskMd, 'utf8');
|
|
52
|
+
return [{
|
|
53
|
+
taskId,
|
|
54
|
+
branch: resolveBranchFromTaskContent(content, taskId),
|
|
55
|
+
state,
|
|
56
|
+
taskMd
|
|
57
|
+
}];
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
40
61
|
function resolveBranchFromTaskContent(content: string, taskId: string): string {
|
|
41
62
|
const frontmatterBranch = content.match(/^branch:\s*(.+)$/m);
|
|
42
63
|
if (frontmatterBranch?.[1]?.trim()) {
|
|
@@ -57,12 +78,42 @@ export function resolveTaskBranch(arg: string, repoRoot: string): string {
|
|
|
57
78
|
}
|
|
58
79
|
if (SHORT_ID_RE.test(arg)) {
|
|
59
80
|
const taskId = resolveShortIdStrict(arg, repoRoot);
|
|
60
|
-
const content = readTaskContent(repoRoot, taskId);
|
|
81
|
+
const { content } = readTaskContent(repoRoot, taskId);
|
|
61
82
|
return resolveBranchFromTaskContent(content, taskId);
|
|
62
83
|
}
|
|
63
84
|
if (!TASK_ID_RE.test(arg)) {
|
|
64
85
|
return arg;
|
|
65
86
|
}
|
|
66
|
-
const content = readTaskContent(repoRoot, arg);
|
|
87
|
+
const { content } = readTaskContent(repoRoot, arg);
|
|
67
88
|
return resolveBranchFromTaskContent(content, arg);
|
|
68
89
|
}
|
|
90
|
+
|
|
91
|
+
export function resolveTaskWorkspace(taskId: string, repoRoot: string): TaskWorkspace {
|
|
92
|
+
if (!TASK_ID_RE.test(taskId)) throw new Error(`Invalid task id: ${taskId}`);
|
|
93
|
+
const matches = taskWorkspaceCandidates(repoRoot, taskId);
|
|
94
|
+
if (matches.length === 0) throw new Error(`Task not found: ${taskId}`);
|
|
95
|
+
if (matches.length !== 1) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`SANDBOX_TASK_STATE_AMBIGUOUS: task ${taskId} exists in ${matches.map((match) => match.state).join(', ')}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return matches[0]!;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function listTaskWorkspaces(repoRoot: string, state: TaskWorkspaceState): TaskWorkspace[] {
|
|
104
|
+
const stateRoot = path.join(repoRoot, '.agents', 'workspace', state);
|
|
105
|
+
if (!fs.existsSync(stateRoot)) return [];
|
|
106
|
+
return fs.readdirSync(stateRoot, { withFileTypes: true })
|
|
107
|
+
.filter((entry) => entry.isDirectory() && TASK_ID_RE.test(entry.name))
|
|
108
|
+
.flatMap((entry) => {
|
|
109
|
+
const taskMd = path.join(stateRoot, entry.name, 'task.md');
|
|
110
|
+
if (!fs.existsSync(taskMd)) return [];
|
|
111
|
+
const content = fs.readFileSync(taskMd, 'utf8');
|
|
112
|
+
return [{
|
|
113
|
+
taskId: entry.name,
|
|
114
|
+
branch: resolveBranchFromTaskContent(content, entry.name),
|
|
115
|
+
state,
|
|
116
|
+
taskMd
|
|
117
|
+
}];
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { isRemovedHashShortIdInput } from '../task/short-id.ts';
|
|
4
|
+
import {
|
|
5
|
+
resolveTaskWorkspace,
|
|
6
|
+
type TaskWorkspaceState
|
|
7
|
+
} from './task-resolver.ts';
|
|
4
8
|
|
|
5
9
|
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
6
10
|
const SHORT_ID_RE = /^\d+$/;
|
|
@@ -9,6 +13,17 @@ export type SandboxWorkspaceIdentity =
|
|
|
9
13
|
| Readonly<{ mode: 'task-bound'; taskId: string; shortId: string }>
|
|
10
14
|
| Readonly<{ mode: 'branch-only' }>;
|
|
11
15
|
|
|
16
|
+
export type SandboxWorkspaceKey =
|
|
17
|
+
| Readonly<{ mode: 'task-bound'; taskId: string }>
|
|
18
|
+
| Readonly<{ mode: 'branch-only' }>;
|
|
19
|
+
|
|
20
|
+
export type SandboxCleanupTarget = Readonly<{
|
|
21
|
+
requestedRef: string;
|
|
22
|
+
branch: string;
|
|
23
|
+
workspace: SandboxWorkspaceKey;
|
|
24
|
+
taskState: TaskWorkspaceState | 'branch-only';
|
|
25
|
+
}>;
|
|
26
|
+
|
|
12
27
|
export type SandboxContainerWorkspaceIdentity =
|
|
13
28
|
| Readonly<{ mode: 'task-bound'; taskId: string }>
|
|
14
29
|
| Readonly<{ mode: 'branch-only' }>
|
|
@@ -101,6 +116,39 @@ export function resolveSandboxTarget(requestedRef: string, repoRoot: string): Sa
|
|
|
101
116
|
return { requestedRef, branch: requestedRef, workspace: { mode: 'branch-only' } };
|
|
102
117
|
}
|
|
103
118
|
|
|
119
|
+
export function resolveSandboxCleanupTarget(
|
|
120
|
+
requestedRef: string,
|
|
121
|
+
repoRoot: string,
|
|
122
|
+
options: Readonly<{ allowProtected?: boolean }> = {}
|
|
123
|
+
): SandboxCleanupTarget {
|
|
124
|
+
if (isRemovedHashShortIdInput(requestedRef)) {
|
|
125
|
+
throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (TASK_ID_RE.test(requestedRef)) {
|
|
129
|
+
const task = resolveTaskWorkspace(requestedRef, repoRoot);
|
|
130
|
+
if (!options.allowProtected && (task.state === 'blocked' || task.state === 'archive')) {
|
|
131
|
+
throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${requestedRef} is ${task.state}`);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
requestedRef,
|
|
135
|
+
branch: task.branch,
|
|
136
|
+
workspace: { mode: 'task-bound', taskId: task.taskId },
|
|
137
|
+
taskState: task.state
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const target = resolveSandboxTarget(requestedRef, repoRoot);
|
|
142
|
+
return {
|
|
143
|
+
requestedRef,
|
|
144
|
+
branch: target.branch,
|
|
145
|
+
workspace: target.workspace.mode === 'task-bound'
|
|
146
|
+
? { mode: 'task-bound', taskId: target.workspace.taskId }
|
|
147
|
+
: { mode: 'branch-only' },
|
|
148
|
+
taskState: target.workspace.mode === 'task-bound' ? 'active' : 'branch-only'
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
104
152
|
export function parseSandboxWorkspaceIdentity(
|
|
105
153
|
labels: Readonly<Record<string, string>>,
|
|
106
154
|
keys: Readonly<{ mode: string; taskId: string }>
|
|
@@ -115,8 +163,8 @@ export function parseSandboxWorkspaceIdentity(
|
|
|
115
163
|
}
|
|
116
164
|
|
|
117
165
|
export function sameSandboxWorkspaceIdentity(
|
|
118
|
-
left: SandboxWorkspaceIdentity | SandboxContainerWorkspaceIdentity,
|
|
119
|
-
right: SandboxWorkspaceIdentity | SandboxContainerWorkspaceIdentity
|
|
166
|
+
left: SandboxWorkspaceIdentity | SandboxWorkspaceKey | SandboxContainerWorkspaceIdentity,
|
|
167
|
+
right: SandboxWorkspaceIdentity | SandboxWorkspaceKey | SandboxContainerWorkspaceIdentity
|
|
120
168
|
): boolean {
|
|
121
169
|
return left.mode === right.mode
|
|
122
170
|
&& (left.mode !== 'task-bound' || (right.mode === 'task-bound' && left.taskId === right.taskId));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import type { SandboxWorkspaceIdentity } from './workspace-identity.ts';
|
|
4
|
+
import type { SandboxWorkspaceIdentity, SandboxWorkspaceKey } from './workspace-identity.ts';
|
|
5
5
|
import type { SandboxControlManifest } from './control/protocol.ts';
|
|
6
6
|
|
|
7
7
|
export type SandboxWorkspaceView = Readonly<{
|
|
@@ -30,7 +30,7 @@ export function sandboxWorkspaceViewPaths(params: Readonly<{
|
|
|
30
30
|
base: string;
|
|
31
31
|
project: string;
|
|
32
32
|
container: string;
|
|
33
|
-
identity: SandboxWorkspaceIdentity;
|
|
33
|
+
identity: SandboxWorkspaceIdentity | SandboxWorkspaceKey;
|
|
34
34
|
}>): SandboxWorkspaceView {
|
|
35
35
|
const identityKey = params.identity.mode === 'task-bound'
|
|
36
36
|
? `task-bound:${params.identity.taskId}`
|
|
@@ -50,6 +50,7 @@ export type SandboxControlSetup = Readonly<{
|
|
|
50
50
|
root: string;
|
|
51
51
|
channelDir: string;
|
|
52
52
|
statusDir: string;
|
|
53
|
+
runtimeDir: string;
|
|
53
54
|
manifestPath: string;
|
|
54
55
|
token: string;
|
|
55
56
|
generation: string;
|
|
@@ -59,8 +60,8 @@ export function sandboxControlPaths(params: Readonly<{
|
|
|
59
60
|
base: string;
|
|
60
61
|
project: string;
|
|
61
62
|
container: string;
|
|
62
|
-
identity: SandboxWorkspaceIdentity;
|
|
63
|
-
}>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; manifestPath: string }> {
|
|
63
|
+
identity: SandboxWorkspaceIdentity | SandboxWorkspaceKey;
|
|
64
|
+
}>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; runtimeDir: string; manifestPath: string }> {
|
|
64
65
|
const identityKey = params.identity.mode === 'task-bound'
|
|
65
66
|
? `task-bound:${params.identity.taskId}`
|
|
66
67
|
: 'branch-only';
|
|
@@ -71,6 +72,7 @@ export function sandboxControlPaths(params: Readonly<{
|
|
|
71
72
|
channelDir: path.join(root, 'channel'),
|
|
72
73
|
statusDir: path.join(root, 'public'),
|
|
73
74
|
processingDir: path.join(root, 'processing'),
|
|
75
|
+
runtimeDir: path.join(root, 'runtime'),
|
|
74
76
|
manifestPath: path.join(root, 'manifest.json')
|
|
75
77
|
};
|
|
76
78
|
}
|
|
@@ -85,6 +87,32 @@ function assertSafeDirectory(directory: string, expectedBase: string): void {
|
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
function assertSafeMountTargetPath(
|
|
91
|
+
target: string,
|
|
92
|
+
worktreeRoot: string,
|
|
93
|
+
canonicalWorktreeRoot: string
|
|
94
|
+
): void {
|
|
95
|
+
const relative = path.relative(worktreeRoot, target);
|
|
96
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
97
|
+
throw new Error(`Sandbox workspace mount target escapes its worktree: ${target}`);
|
|
98
|
+
}
|
|
99
|
+
let current = worktreeRoot;
|
|
100
|
+
for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
|
|
101
|
+
current = segment ? path.join(current, segment) : current;
|
|
102
|
+
try {
|
|
103
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
104
|
+
throw new Error(`Sandbox workspace mount target must not contain a symbolic link: ${current}`);
|
|
105
|
+
}
|
|
106
|
+
const canonicalRelative = path.relative(canonicalWorktreeRoot, fs.realpathSync.native(current));
|
|
107
|
+
if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
|
|
108
|
+
throw new Error(`Sandbox workspace mount target escapes its real worktree: ${current}`);
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
export function assertSandboxTaskSource(repoRoot: string, taskId: string): string {
|
|
89
117
|
if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
|
|
90
118
|
const activeRoot = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace', 'active'));
|
|
@@ -100,41 +128,22 @@ export function assertSandboxTaskSource(repoRoot: string, taskId: string): strin
|
|
|
100
128
|
return canonical;
|
|
101
129
|
}
|
|
102
130
|
|
|
103
|
-
export function
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
} else {
|
|
113
|
-
fs.mkdirSync(agentsRoot, { mode: 0o700 });
|
|
131
|
+
export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void {
|
|
132
|
+
const resolvedWorktreeRoot = path.resolve(worktreeRoot);
|
|
133
|
+
const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
|
|
134
|
+
const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
|
|
135
|
+
const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
|
|
136
|
+
const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
|
|
137
|
+
for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
|
|
138
|
+
assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
|
|
114
139
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const target = path.join(agentsRoot, 'workspace');
|
|
121
|
-
if (fs.existsSync(target)) {
|
|
122
|
-
const stat = fs.lstatSync(target);
|
|
123
|
-
if (stat.isSymbolicLink()) {
|
|
124
|
-
throw new Error(`Sandbox workspace mount target must not be a symbolic link: ${target}`);
|
|
125
|
-
}
|
|
126
|
-
if (!stat.isDirectory()) {
|
|
127
|
-
throw new Error(`Sandbox workspace mount target must be a directory: ${target}`);
|
|
128
|
-
}
|
|
129
|
-
if (path.dirname(fs.realpathSync.native(target)) !== canonicalAgents) {
|
|
130
|
-
throw new Error(`Sandbox workspace mount target escapes the worktree: ${target}`);
|
|
131
|
-
}
|
|
132
|
-
} else {
|
|
133
|
-
fs.mkdirSync(target, { mode: 0o700 });
|
|
140
|
+
fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
|
|
141
|
+
for (const { hostPath } of statePaths) {
|
|
142
|
+
fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
|
|
143
|
+
fs.chmodSync(hostPath, 0o700);
|
|
134
144
|
}
|
|
135
|
-
fs.chmodSync(
|
|
136
|
-
fs.
|
|
137
|
-
return target;
|
|
145
|
+
fs.chmodSync(workspaceRoot, 0o700);
|
|
146
|
+
fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
export function materializeSandboxWorkspaceView(params: Readonly<{
|
|
@@ -181,16 +190,36 @@ export function materializeSandboxControl(params: Readonly<{
|
|
|
181
190
|
branch: string;
|
|
182
191
|
identity: SandboxWorkspaceIdentity;
|
|
183
192
|
engine?: string;
|
|
193
|
+
replacementLease?: Readonly<{
|
|
194
|
+
root: string;
|
|
195
|
+
assertOwned(): void;
|
|
196
|
+
}>;
|
|
184
197
|
}>): SandboxControlSetup {
|
|
185
|
-
const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
|
|
198
|
+
const { root, channelDir, statusDir, processingDir, runtimeDir, manifestPath } = sandboxControlPaths(params);
|
|
186
199
|
assertSafeDirectory(root, path.resolve(params.base));
|
|
200
|
+
if (fs.existsSync(root)) {
|
|
201
|
+
if (!params.replacementLease || path.resolve(params.replacementLease.root) !== root) {
|
|
202
|
+
throw new Error('SANDBOX_CONTROL_REPLACEMENT_REQUIRED');
|
|
203
|
+
}
|
|
204
|
+
params.replacementLease.assertOwned();
|
|
205
|
+
}
|
|
187
206
|
const consumedDir = path.join(root, 'consumed');
|
|
188
207
|
assertSafeDirectory(consumedDir, root);
|
|
189
208
|
fs.rmSync(consumedDir, { recursive: true, force: true });
|
|
190
209
|
fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
|
|
191
|
-
for (const directory of [statusDir, processingDir]) {
|
|
210
|
+
for (const directory of [statusDir, processingDir, runtimeDir]) {
|
|
192
211
|
assertSafeDirectory(directory, root);
|
|
212
|
+
if (directory === runtimeDir) fs.rmSync(directory, { recursive: true, force: true });
|
|
213
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
214
|
+
}
|
|
215
|
+
for (const directory of [
|
|
216
|
+
path.join(runtimeDir, 'clients'),
|
|
217
|
+
path.join(runtimeDir, 'clients', 'codex', 'capabilities'),
|
|
218
|
+
path.join(runtimeDir, 'clients', 'codex', 'lifecycle')
|
|
219
|
+
]) {
|
|
220
|
+
assertSafeDirectory(directory, runtimeDir);
|
|
193
221
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
222
|
+
fs.chmodSync(directory, 0o700);
|
|
194
223
|
}
|
|
195
224
|
for (const queue of ['requests', 'responses']) {
|
|
196
225
|
const directory = path.join(channelDir, queue);
|
|
@@ -202,7 +231,7 @@ export function materializeSandboxControl(params: Readonly<{
|
|
|
202
231
|
const generation = randomBytes(16).toString('hex');
|
|
203
232
|
const repoRoot = fs.realpathSync.native(params.repoRoot);
|
|
204
233
|
const manifest: SandboxControlManifest = {
|
|
205
|
-
version:
|
|
234
|
+
version: 5,
|
|
206
235
|
engine: params.engine ?? 'docker',
|
|
207
236
|
repoRoot,
|
|
208
237
|
worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
|
|
@@ -216,10 +245,11 @@ export function materializeSandboxControl(params: Readonly<{
|
|
|
216
245
|
generation,
|
|
217
246
|
channelDir,
|
|
218
247
|
publicStatusDir: statusDir,
|
|
219
|
-
processingDir
|
|
248
|
+
processingDir,
|
|
249
|
+
runtimeDir
|
|
220
250
|
};
|
|
221
251
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
|
|
222
|
-
return { root, channelDir, statusDir, manifestPath, token, generation };
|
|
252
|
+
return { root, channelDir, statusDir, runtimeDir, manifestPath, token, generation };
|
|
223
253
|
}
|
|
224
254
|
|
|
225
255
|
export function finalizeSandboxControlManifest(
|
|
@@ -228,7 +258,7 @@ export function finalizeSandboxControlManifest(
|
|
|
228
258
|
): void {
|
|
229
259
|
if (!identity.engine || !identity.id) throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
|
|
230
260
|
const manifest = JSON.parse(fs.readFileSync(setup.manifestPath, 'utf8')) as Record<string, unknown>;
|
|
231
|
-
manifest.version =
|
|
261
|
+
manifest.version = 5;
|
|
232
262
|
manifest.engine = identity.engine;
|
|
233
263
|
manifest.containerIdentity = { id: identity.id, labels: { ...identity.labels } };
|
|
234
264
|
const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { runProbe } from './shell.ts';
|
|
6
|
+
import { resolveSandboxTarget } from './workspace-identity.ts';
|
|
5
7
|
|
|
6
8
|
type WorktreeChange = {
|
|
7
9
|
indexStatus: string;
|
|
@@ -16,6 +18,8 @@ type WorktreeSnapshot = {
|
|
|
16
18
|
head: string;
|
|
17
19
|
changes: readonly WorktreeChange[];
|
|
18
20
|
identity: string;
|
|
21
|
+
source?: 'registered' | 'recovered';
|
|
22
|
+
recovery?: WorktreeRecoveryContext;
|
|
19
23
|
};
|
|
20
24
|
|
|
21
25
|
type WorktreeInspection =
|
|
@@ -28,6 +32,14 @@ type WorktreeRemovalPermit = {
|
|
|
28
32
|
snapshot: WorktreeSnapshot;
|
|
29
33
|
};
|
|
30
34
|
|
|
35
|
+
type WorktreeRecoveryContext = Readonly<{
|
|
36
|
+
repoRoot: string;
|
|
37
|
+
worktreeBase: string;
|
|
38
|
+
branch: string;
|
|
39
|
+
identitySource: 'branch-only' | 'task-bound';
|
|
40
|
+
taskId: string | null;
|
|
41
|
+
}>;
|
|
42
|
+
|
|
31
43
|
type Probe = typeof runProbe;
|
|
32
44
|
|
|
33
45
|
function probeText(probe: Probe, cwd: string, args: string[]): string {
|
|
@@ -164,6 +176,203 @@ function captureOnce(worktree: string, probe: Probe): WorktreeSnapshot {
|
|
|
164
176
|
return { worktree: resolvedWorktree, branch, head, changes, identity: hash.digest('hex') };
|
|
165
177
|
}
|
|
166
178
|
|
|
179
|
+
function probeTextWithEnv(
|
|
180
|
+
probe: Probe,
|
|
181
|
+
repoRoot: string,
|
|
182
|
+
args: string[],
|
|
183
|
+
env: NodeJS.ProcessEnv
|
|
184
|
+
): string {
|
|
185
|
+
const result = probe('git', args, { cwd: repoRoot, encoding: 'utf8', env });
|
|
186
|
+
if (result.error) throw result.error;
|
|
187
|
+
if (result.status !== 0) {
|
|
188
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : result.stderr.toString('utf8').trim();
|
|
189
|
+
throw new Error(stderr || `git ${args[0] ?? 'command'} failed with exit code ${result.status ?? 'unknown'}`);
|
|
190
|
+
}
|
|
191
|
+
return typeof result.stdout === 'string' ? result.stdout : result.stdout.toString('utf8');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function probeMetadataCommand(
|
|
195
|
+
probe: Probe,
|
|
196
|
+
repoRoot: string,
|
|
197
|
+
args: string[],
|
|
198
|
+
env: NodeJS.ProcessEnv
|
|
199
|
+
): boolean {
|
|
200
|
+
const result = probe('git', args, { cwd: repoRoot, encoding: 'utf8', env });
|
|
201
|
+
if (result.error) throw result.error;
|
|
202
|
+
return result.status === 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function hasValidHeadContent(content: string): boolean {
|
|
206
|
+
return /^(?:ref:\s+refs\/\S+|[0-9a-f]{40}|[0-9a-f]{64})$/i.test(content.trim());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function assertRecoverableMetadata(worktree: string, repoRoot: string, probe: Probe): void {
|
|
210
|
+
const dotGit = path.join(worktree, '.git');
|
|
211
|
+
let dotGitStat: fs.Stats;
|
|
212
|
+
try {
|
|
213
|
+
dotGitStat = fs.lstatSync(dotGit);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
if (dotGitStat.isSymbolicLink() || dotGitStat.isDirectory()) {
|
|
219
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_NOT_ELIGIBLE');
|
|
220
|
+
}
|
|
221
|
+
if (!dotGitStat.isFile()) throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
222
|
+
|
|
223
|
+
const content = fs.readFileSync(dotGit, 'utf8').trim();
|
|
224
|
+
const match = /^gitdir:\s*(.+)$/i.exec(content);
|
|
225
|
+
if (!match) throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
226
|
+
|
|
227
|
+
const commonDir = fs.realpathSync.native(path.resolve(repoRoot, probeText(probe, repoRoot, [
|
|
228
|
+
'rev-parse', '--git-common-dir'
|
|
229
|
+
]).trim()));
|
|
230
|
+
const adminRoot = path.join(commonDir, 'worktrees');
|
|
231
|
+
const adminPath = path.resolve(path.dirname(dotGit), match[1]!);
|
|
232
|
+
const relative = path.relative(adminRoot, adminPath);
|
|
233
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || relative.includes(path.sep)) {
|
|
234
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!fs.existsSync(adminPath)) return;
|
|
238
|
+
const adminStat = fs.lstatSync(adminPath);
|
|
239
|
+
if (adminStat.isSymbolicLink()) throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
240
|
+
if (!adminStat.isDirectory()) return;
|
|
241
|
+
|
|
242
|
+
const requiredFiles = ['HEAD', 'gitdir', 'commondir', 'index'];
|
|
243
|
+
if (requiredFiles.some((name) => !fs.existsSync(path.join(adminPath, name)))) return;
|
|
244
|
+
for (const name of requiredFiles) {
|
|
245
|
+
const filePath = path.join(adminPath, name);
|
|
246
|
+
const stat = fs.lstatSync(filePath);
|
|
247
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const adminGitdir = fs.readFileSync(path.join(adminPath, 'gitdir'), 'utf8').trim();
|
|
251
|
+
const adminCommonDir = fs.readFileSync(path.join(adminPath, 'commondir'), 'utf8').trim();
|
|
252
|
+
const adminGitdirPath = adminGitdir
|
|
253
|
+
? fs.realpathSync.native(path.resolve(adminPath, adminGitdir))
|
|
254
|
+
: '';
|
|
255
|
+
const canonicalDotGit = fs.realpathSync.native(dotGit);
|
|
256
|
+
const adminCommonDirPath = adminCommonDir
|
|
257
|
+
? fs.realpathSync.native(path.resolve(adminPath, adminCommonDir))
|
|
258
|
+
: '';
|
|
259
|
+
if (!adminGitdir || adminGitdirPath !== canonicalDotGit
|
|
260
|
+
|| !adminCommonDir || adminCommonDirPath !== commonDir) {
|
|
261
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const headContent = fs.readFileSync(path.join(adminPath, 'HEAD'), 'utf8');
|
|
265
|
+
const indexContent = fs.readFileSync(path.join(adminPath, 'index'));
|
|
266
|
+
if (!hasValidHeadContent(headContent) || indexContent.subarray(0, 4).toString('ascii') !== 'DIRC') return;
|
|
267
|
+
|
|
268
|
+
const validationEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
269
|
+
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_WORK_TREE', 'GIT_PREFIX']) {
|
|
270
|
+
delete validationEnv[key];
|
|
271
|
+
}
|
|
272
|
+
validationEnv.GIT_DIR = adminPath;
|
|
273
|
+
validationEnv.GIT_WORK_TREE = path.resolve(worktree);
|
|
274
|
+
if (!probeMetadataCommand(probe, repoRoot, ['rev-parse', '--verify', 'HEAD^{commit}'], validationEnv)) return;
|
|
275
|
+
if (!probeMetadataCommand(probe, repoRoot, ['ls-files', '--stage', '-z'], validationEnv)) return;
|
|
276
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_REGISTERED');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parseRecoveredStatus(raw: string): WorktreeChange[] {
|
|
280
|
+
const records = raw.split('\0');
|
|
281
|
+
const changes: WorktreeChange[] = [];
|
|
282
|
+
for (let index = 0; index < records.length;) {
|
|
283
|
+
const status = records[index++] ?? '';
|
|
284
|
+
if (!status) continue;
|
|
285
|
+
const code = status[0] ?? '';
|
|
286
|
+
if (code === 'R' || code === 'C') {
|
|
287
|
+
const originalPath = records[index++];
|
|
288
|
+
const nextPath = records[index++];
|
|
289
|
+
if (!originalPath || !nextPath) throw new Error('Unable to parse recovered Git rename record');
|
|
290
|
+
changes.push({ indexStatus: code, worktreeStatus: ' ', path: nextPath, originalPath });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const filePath = records[index++];
|
|
294
|
+
if (!filePath) throw new Error('Unable to parse recovered Git status record');
|
|
295
|
+
changes.push({
|
|
296
|
+
indexStatus: code === 'D' ? ' ' : code,
|
|
297
|
+
worktreeStatus: code === 'D' ? 'D' : ' ',
|
|
298
|
+
path: filePath
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return changes;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function captureRecoveredOnce(
|
|
305
|
+
worktree: string,
|
|
306
|
+
recovery: WorktreeRecoveryContext,
|
|
307
|
+
probe: Probe
|
|
308
|
+
): WorktreeSnapshot {
|
|
309
|
+
const resolvedWorktree = path.resolve(worktree);
|
|
310
|
+
const resolvedBase = path.resolve(recovery.worktreeBase);
|
|
311
|
+
const relative = path.relative(resolvedBase, resolvedWorktree);
|
|
312
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
313
|
+
throw new Error(`WORKTREE_RECOVERY_PATH_INVALID: ${resolvedWorktree}`);
|
|
314
|
+
}
|
|
315
|
+
const stat = fs.lstatSync(resolvedWorktree);
|
|
316
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('WORKTREE_RECOVERY_PATH_INVALID');
|
|
317
|
+
const canonicalBase = fs.realpathSync.native(resolvedBase);
|
|
318
|
+
const canonicalWorktree = fs.realpathSync.native(resolvedWorktree);
|
|
319
|
+
const canonicalRelative = path.relative(canonicalBase, canonicalWorktree);
|
|
320
|
+
if (!canonicalRelative || canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
|
|
321
|
+
throw new Error('WORKTREE_RECOVERY_PATH_INVALID');
|
|
322
|
+
}
|
|
323
|
+
const resolvedWorkspace = resolveSandboxTarget(recovery.branch, recovery.repoRoot).workspace;
|
|
324
|
+
if (recovery.identitySource === 'branch-only' && resolvedWorkspace.mode !== 'branch-only') {
|
|
325
|
+
throw new Error('WORKTREE_RECOVERY_IDENTITY_CHANGED');
|
|
326
|
+
}
|
|
327
|
+
if (recovery.identitySource === 'task-bound'
|
|
328
|
+
&& (resolvedWorkspace.mode !== 'task-bound' || resolvedWorkspace.taskId !== recovery.taskId)) {
|
|
329
|
+
throw new Error('WORKTREE_RECOVERY_IDENTITY_CHANGED');
|
|
330
|
+
}
|
|
331
|
+
assertRecoverableMetadata(resolvedWorktree, recovery.repoRoot, probe);
|
|
332
|
+
|
|
333
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-infra-recovered-index-'));
|
|
334
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
335
|
+
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_PREFIX']) delete env[key];
|
|
336
|
+
env.GIT_INDEX_FILE = path.join(tempDir, 'index');
|
|
337
|
+
env.GIT_WORK_TREE = resolvedWorktree;
|
|
338
|
+
try {
|
|
339
|
+
const baseline = probeTextWithEnv(probe, recovery.repoRoot, [
|
|
340
|
+
'rev-parse', '--verify', `refs/heads/${recovery.branch}^{commit}`
|
|
341
|
+
], env).trim();
|
|
342
|
+
if (!baseline) throw new Error(`WORKTREE_RECOVERY_BRANCH_INVALID: ${recovery.branch}`);
|
|
343
|
+
probeTextWithEnv(probe, recovery.repoRoot, ['read-tree', baseline], env);
|
|
344
|
+
probeTextWithEnv(probe, recovery.repoRoot, ['add', '-A', '--', '.'], env);
|
|
345
|
+
const status = probeTextWithEnv(probe, recovery.repoRoot, [
|
|
346
|
+
'diff', '--cached', '--name-status', '--find-renames', '-z', '--', '.'
|
|
347
|
+
], env);
|
|
348
|
+
const changes = parseRecoveredStatus(status);
|
|
349
|
+
const hash = createHash('sha256');
|
|
350
|
+
updatePart(hash, 'source', 'recovered');
|
|
351
|
+
updatePart(hash, 'worktree', resolvedWorktree);
|
|
352
|
+
updatePart(hash, 'repo-root', path.resolve(recovery.repoRoot));
|
|
353
|
+
updatePart(hash, 'branch', recovery.branch);
|
|
354
|
+
updatePart(hash, 'baseline', baseline);
|
|
355
|
+
updatePart(hash, 'identity-source', recovery.identitySource);
|
|
356
|
+
updatePart(hash, 'task-id', recovery.taskId ?? '');
|
|
357
|
+
updatePart(hash, 'status', status);
|
|
358
|
+
for (const change of changes) {
|
|
359
|
+
hashPath(hash, resolvedWorktree, change.path, probe);
|
|
360
|
+
if (change.originalPath) hashPath(hash, resolvedWorktree, change.originalPath, probe);
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
worktree: resolvedWorktree,
|
|
364
|
+
branch: recovery.branch,
|
|
365
|
+
head: baseline,
|
|
366
|
+
changes,
|
|
367
|
+
identity: hash.digest('hex'),
|
|
368
|
+
source: 'recovered',
|
|
369
|
+
recovery: { ...recovery }
|
|
370
|
+
};
|
|
371
|
+
} finally {
|
|
372
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
167
376
|
function inspectWorktree(worktree: string, { probe = runProbe }: { probe?: Probe } = {}): WorktreeInspection {
|
|
168
377
|
const resolvedWorktree = path.resolve(worktree);
|
|
169
378
|
try {
|
|
@@ -182,6 +391,28 @@ function inspectWorktree(worktree: string, { probe = runProbe }: { probe?: Probe
|
|
|
182
391
|
}
|
|
183
392
|
}
|
|
184
393
|
|
|
394
|
+
function inspectRecoveredWorktree(
|
|
395
|
+
worktree: string,
|
|
396
|
+
recovery: WorktreeRecoveryContext,
|
|
397
|
+
{ probe = runProbe }: { probe?: Probe } = {}
|
|
398
|
+
): WorktreeInspection {
|
|
399
|
+
const resolvedWorktree = path.resolve(worktree);
|
|
400
|
+
try {
|
|
401
|
+
const first = captureRecoveredOnce(resolvedWorktree, recovery, probe);
|
|
402
|
+
const second = captureRecoveredOnce(resolvedWorktree, recovery, probe);
|
|
403
|
+
if (first.identity !== second.identity) {
|
|
404
|
+
return { status: 'failed', worktree: resolvedWorktree, message: 'Snapshot changed while recovering worktree' };
|
|
405
|
+
}
|
|
406
|
+
return { status: second.changes.length === 0 ? 'clean' : 'dirty', snapshot: second };
|
|
407
|
+
} catch (error) {
|
|
408
|
+
return {
|
|
409
|
+
status: 'failed',
|
|
410
|
+
worktree: resolvedWorktree,
|
|
411
|
+
message: error instanceof Error ? error.message : String(error)
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
185
416
|
function inspectWorktrees(worktrees: readonly string[]): WorktreeInspection[] {
|
|
186
417
|
return [...new Set(worktrees.map((worktree) => path.resolve(worktree)))].sort()
|
|
187
418
|
.map((worktree) => inspectWorktree(worktree));
|
|
@@ -201,7 +432,9 @@ function verifyWorktreePermit(
|
|
|
201
432
|
permit: WorktreeRemovalPermit,
|
|
202
433
|
options: { probe?: Probe } = {}
|
|
203
434
|
): WorktreeSnapshot {
|
|
204
|
-
const current =
|
|
435
|
+
const current = permit.snapshot.source === 'recovered' && permit.snapshot.recovery
|
|
436
|
+
? inspectRecoveredWorktree(permit.snapshot.worktree, permit.snapshot.recovery, options)
|
|
437
|
+
: inspectWorktree(permit.snapshot.worktree, options);
|
|
205
438
|
if (current.status === 'failed') throw new Error(`Unable to verify worktree permit: ${current.message}`);
|
|
206
439
|
if (current.snapshot.identity !== permit.snapshot.identity || (permit.mode === 'clean' && current.status !== 'clean')) {
|
|
207
440
|
throw new Error(`Worktree changed after authorization: ${permit.snapshot.worktree}`);
|
|
@@ -227,8 +460,9 @@ export {
|
|
|
227
460
|
createCleanPermit,
|
|
228
461
|
createDiscardPermit,
|
|
229
462
|
formatWorktreeSnapshot,
|
|
463
|
+
inspectRecoveredWorktree,
|
|
230
464
|
inspectWorktree,
|
|
231
465
|
inspectWorktrees,
|
|
232
466
|
verifyWorktreePermit
|
|
233
467
|
};
|
|
234
|
-
export type { WorktreeChange, WorktreeInspection, WorktreeRemovalPermit, WorktreeSnapshot };
|
|
468
|
+
export type { WorktreeChange, WorktreeInspection, WorktreeRecoveryContext, WorktreeRemovalPermit, WorktreeSnapshot };
|