@fitlab-ai/agent-infra 0.9.3 → 0.9.5

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.
Files changed (203) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +3 -1
  3. package/bin/cli.ts +12 -0
  4. package/dist/bin/cli.js +14 -0
  5. package/dist/bin/internal-cli.js +144 -123
  6. package/dist/lib/agent-clients/adapter.js +94 -4
  7. package/dist/lib/{sandbox/credentials.js → agent-clients/adapters/claude-code-credentials.js} +4 -14
  8. package/dist/lib/agent-clients/adapters/claude-code-refresh.js +85 -0
  9. package/dist/lib/agent-clients/adapters/claude-code-sandbox.js +247 -0
  10. package/dist/lib/agent-clients/adapters/claude-code.js +17 -3
  11. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +382 -0
  12. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +163 -0
  13. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +211 -0
  14. package/dist/lib/agent-clients/adapters/codex.js +2 -2
  15. package/dist/lib/agent-clients/adapters/opencode-sandbox.js +108 -0
  16. package/dist/lib/agent-clients/adapters/opencode.js +15 -3
  17. package/dist/lib/agent-clients/adapters/runtimes/claude-code.dockerfile +65 -0
  18. package/dist/lib/agent-clients/registry.js +9 -1
  19. package/dist/lib/git/worktree-identity.js +64 -0
  20. package/dist/lib/internal/codex-lifecycle.js +202 -0
  21. package/dist/lib/internal/sandbox-control.js +24 -0
  22. package/dist/lib/internal/task-activity.js +45 -13
  23. package/dist/lib/internal/task-create.js +48 -0
  24. package/dist/lib/internal/task-ledger.js +12 -3
  25. package/dist/lib/internal/task-lifecycle.js +19 -1
  26. package/dist/lib/internal/task-orchestration.js +170 -27
  27. package/dist/lib/platform/github-client.js +19 -2
  28. package/dist/lib/platform/pr-checks.js +1 -1
  29. package/dist/lib/platform/pull-requests.js +47 -1
  30. package/dist/lib/platform/releases.js +13 -5
  31. package/dist/lib/process-data/index.js +239 -0
  32. package/dist/lib/process-data/normalize.js +198 -0
  33. package/dist/lib/process-data/privacy.js +33 -0
  34. package/dist/lib/process-data/reconcile.js +95 -0
  35. package/dist/lib/process-data/repair.js +58 -0
  36. package/dist/lib/process-data/sources.js +345 -0
  37. package/dist/lib/process-data/store.js +282 -0
  38. package/dist/lib/process-data/types.js +2 -0
  39. package/dist/lib/run/index.js +4 -3
  40. package/dist/lib/run/tui.js +7 -8
  41. package/dist/lib/sandbox/agent-client-reconciler.js +26 -2
  42. package/dist/lib/sandbox/capture.js +7 -2
  43. package/dist/lib/sandbox/commands/create.js +110 -228
  44. package/dist/lib/sandbox/commands/enter.js +15 -47
  45. package/dist/lib/sandbox/commands/list-running.js +16 -6
  46. package/dist/lib/sandbox/commands/ls.js +10 -6
  47. package/dist/lib/sandbox/commands/rebuild.js +4 -3
  48. package/dist/lib/sandbox/commands/refresh.js +4 -84
  49. package/dist/lib/sandbox/commands/rm.js +24 -3
  50. package/dist/lib/sandbox/commands/show.js +12 -3
  51. package/dist/lib/sandbox/commands/start.js +7 -4
  52. package/dist/lib/sandbox/config.js +2 -0
  53. package/dist/lib/sandbox/constants.js +6 -0
  54. package/dist/lib/sandbox/control/client.js +58 -0
  55. package/dist/lib/sandbox/control/protocol.js +54 -0
  56. package/dist/lib/sandbox/control/server.js +144 -0
  57. package/dist/lib/sandbox/dockerfile.js +36 -13
  58. package/dist/lib/sandbox/mounts.js +17 -4
  59. package/dist/lib/sandbox/recovery.js +128 -5
  60. package/dist/lib/sandbox/redaction.js +12 -0
  61. package/dist/lib/sandbox/runtimes/base.dockerfile +24 -74
  62. package/dist/lib/sandbox/workspace-identity.js +95 -0
  63. package/dist/lib/sandbox/workspace-view.js +109 -0
  64. package/dist/lib/task/activity-intent.js +257 -51
  65. package/dist/lib/task/activity-log.js +32 -5
  66. package/dist/lib/task/commit-finalization.js +278 -0
  67. package/dist/lib/task/commit-intent.js +165 -0
  68. package/dist/lib/task/create-service.js +137 -0
  69. package/dist/lib/task/create.js +333 -0
  70. package/dist/lib/task/decision-intents.js +31 -17
  71. package/dist/lib/task/delegation-receipts.js +2 -2
  72. package/dist/lib/task/implementation-inputs.js +54 -7
  73. package/dist/lib/task/ledger-intents.js +35 -1
  74. package/dist/lib/task/orchestration.js +505 -34
  75. package/dist/lib/task/resolve-ref.js +41 -1
  76. package/dist/lib/task/review-fingerprint.js +4 -0
  77. package/dist/lib/task/workspace-snapshot.js +78 -3
  78. package/lib/agent-clients/adapter.ts +130 -6
  79. package/lib/{sandbox/credentials.ts → agent-clients/adapters/claude-code-credentials.ts} +3 -18
  80. package/lib/agent-clients/adapters/claude-code-refresh.ts +128 -0
  81. package/lib/agent-clients/adapters/claude-code-sandbox.ts +295 -0
  82. package/lib/agent-clients/adapters/claude-code.ts +21 -3
  83. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +442 -0
  84. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +307 -0
  85. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +250 -0
  86. package/lib/agent-clients/adapters/codex.ts +2 -2
  87. package/lib/agent-clients/adapters/opencode-sandbox.ts +136 -0
  88. package/lib/agent-clients/adapters/opencode.ts +18 -3
  89. package/lib/agent-clients/adapters/runtimes/claude-code.dockerfile +65 -0
  90. package/lib/agent-clients/registry.ts +9 -1
  91. package/lib/git/worktree-identity.ts +86 -0
  92. package/lib/internal/codex-lifecycle.ts +204 -0
  93. package/lib/internal/sandbox-control.ts +24 -0
  94. package/lib/internal/task-activity.ts +50 -14
  95. package/lib/internal/task-create.ts +49 -0
  96. package/lib/internal/task-ledger.ts +12 -3
  97. package/lib/internal/task-lifecycle.ts +22 -1
  98. package/lib/internal/task-orchestration.ts +117 -21
  99. package/lib/platform/github-client.ts +20 -2
  100. package/lib/platform/pr-checks.ts +1 -1
  101. package/lib/platform/pull-requests.ts +49 -1
  102. package/lib/platform/releases.ts +16 -4
  103. package/lib/process-data/index.ts +224 -0
  104. package/lib/process-data/normalize.ts +202 -0
  105. package/lib/process-data/privacy.ts +41 -0
  106. package/lib/process-data/reconcile.ts +110 -0
  107. package/lib/process-data/repair.ts +64 -0
  108. package/lib/process-data/sources.ts +354 -0
  109. package/lib/process-data/store.ts +315 -0
  110. package/lib/process-data/types.ts +135 -0
  111. package/lib/run/index.ts +5 -3
  112. package/lib/run/tui.ts +11 -8
  113. package/lib/sandbox/agent-client-reconciler.ts +44 -2
  114. package/lib/sandbox/capture.ts +9 -2
  115. package/lib/sandbox/commands/create.ts +127 -260
  116. package/lib/sandbox/commands/enter.ts +13 -59
  117. package/lib/sandbox/commands/list-running.ts +23 -6
  118. package/lib/sandbox/commands/ls.ts +22 -6
  119. package/lib/sandbox/commands/rebuild.ts +4 -3
  120. package/lib/sandbox/commands/refresh.ts +4 -126
  121. package/lib/sandbox/commands/rm.ts +24 -3
  122. package/lib/sandbox/commands/show.ts +29 -3
  123. package/lib/sandbox/commands/start.ts +6 -4
  124. package/lib/sandbox/config.ts +4 -0
  125. package/lib/sandbox/constants.ts +8 -0
  126. package/lib/sandbox/control/client.ts +79 -0
  127. package/lib/sandbox/control/protocol.ts +110 -0
  128. package/lib/sandbox/control/server.ts +155 -0
  129. package/lib/sandbox/dockerfile.ts +66 -13
  130. package/lib/sandbox/mounts.ts +24 -4
  131. package/lib/sandbox/recovery.ts +163 -6
  132. package/lib/sandbox/redaction.ts +14 -0
  133. package/lib/sandbox/runtimes/base.dockerfile +24 -74
  134. package/lib/sandbox/tool-types.ts +16 -0
  135. package/lib/sandbox/workspace-identity.ts +123 -0
  136. package/lib/sandbox/workspace-view.ts +150 -0
  137. package/lib/task/activity-intent.ts +350 -61
  138. package/lib/task/activity-log.ts +46 -7
  139. package/lib/task/commit-finalization.ts +376 -0
  140. package/lib/task/commit-intent.ts +230 -0
  141. package/lib/task/create-service.ts +171 -0
  142. package/lib/task/create.ts +372 -0
  143. package/lib/task/decision-intents.ts +32 -12
  144. package/lib/task/delegation-receipts.ts +2 -2
  145. package/lib/task/implementation-inputs.ts +62 -8
  146. package/lib/task/ledger-intents.ts +43 -3
  147. package/lib/task/orchestration.ts +673 -41
  148. package/lib/task/resolve-ref.ts +46 -1
  149. package/lib/task/review-fingerprint.ts +9 -0
  150. package/lib/task/workspace-snapshot.ts +98 -4
  151. package/package.json +7 -7
  152. package/templates/.agents/hooks/lifecycle-delegation.js +47 -3
  153. package/templates/.agents/rules/README.en.md +1 -1
  154. package/templates/.agents/rules/README.zh-CN.md +1 -1
  155. package/templates/.agents/rules/human-decision-context.en.md +11 -2
  156. package/templates/.agents/rules/human-decision-context.zh-CN.md +11 -2
  157. package/templates/.agents/rules/pr-checks-commands.en.md +1 -1
  158. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  159. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +2 -2
  160. package/templates/.agents/rules/pr-checks-commands.zh-CN.md +1 -1
  161. package/templates/.agents/rules/review-handshake.zh-CN.md +1 -1
  162. package/templates/.agents/rules/task-management.en.md +3 -0
  163. package/templates/.agents/rules/task-management.zh-CN.md +3 -0
  164. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  165. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
  166. package/templates/.agents/skills/commit/SKILL.en.md +21 -17
  167. package/templates/.agents/skills/commit/SKILL.zh-CN.md +28 -16
  168. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +50 -0
  169. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +56 -0
  170. package/templates/.agents/skills/commit/reference/task-status-update.en.md +4 -4
  171. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +4 -4
  172. package/templates/.agents/skills/complete-task/SKILL.en.md +1 -1
  173. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -1
  174. package/templates/.agents/skills/create-pr/SKILL.en.md +4 -4
  175. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +4 -4
  176. package/templates/.agents/skills/create-task/SKILL.en.md +26 -43
  177. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +26 -43
  178. package/templates/.agents/skills/create-task/reference/context-capture.en.md +34 -0
  179. package/templates/.agents/skills/create-task/reference/context-capture.zh-CN.md +34 -0
  180. package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
  181. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
  182. package/templates/.agents/skills/review-code/reference/output-templates.en.md +1 -1
  183. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +1 -1
  184. package/templates/.agents/skills/review-pr/SKILL.en.md +36 -8
  185. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +36 -8
  186. package/templates/.agents/skills/review-pr/reference/report-template.en.md +1 -1
  187. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +1 -1
  188. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +67 -50
  189. package/templates/.agents/skills/watch-pr/SKILL.en.md +4 -4
  190. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +4 -4
  191. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +2 -2
  192. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +2 -2
  193. package/templates/.agents/templates/task.en.md +1 -1
  194. package/templates/.agents/templates/task.zh-CN.md +1 -1
  195. package/templates/.claude/commands/watch-pr.en.md +1 -1
  196. package/templates/.claude/commands/watch-pr.zh-CN.md +1 -1
  197. package/templates/.claude/rules/tool-preferences.en.md +20 -0
  198. package/templates/.claude/rules/tool-preferences.zh-CN.md +20 -0
  199. package/templates/.codex/hooks.json +50 -0
  200. package/templates/.opencode/commands/watch-pr.en.md +1 -1
  201. package/templates/.opencode/commands/watch-pr.zh-CN.md +1 -1
  202. package/templates/.gemini/commands/_project_/review-pr.en.toml +0 -10
  203. package/templates/.gemini/commands/_project_/review-pr.zh-CN.toml +0 -10
@@ -0,0 +1,155 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getProcessStartTime } from '../../server/process-state.ts';
5
+ import { createTask } from '../../task/create-service.ts';
6
+ import { assertGitWorktreeBinding } from '../../git/worktree-identity.ts';
7
+ import {
8
+ bindSandboxControlTask,
9
+ validateSandboxControlRequest,
10
+ type SandboxControlManifest,
11
+ type SandboxControlResponse
12
+ } from './protocol.ts';
13
+
14
+ function readManifest(manifestPath: string): SandboxControlManifest {
15
+ const stat = fs.lstatSync(manifestPath);
16
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
17
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as SandboxControlManifest;
18
+ if (manifest.version !== 2) {
19
+ throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 2; recreate the sandbox');
20
+ }
21
+ if (
22
+ typeof manifest.repoRoot !== 'string'
23
+ || typeof manifest.worktreeRoot !== 'string'
24
+ || typeof manifest.channelDir !== 'string'
25
+ || typeof manifest.token !== 'string'
26
+ ) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
27
+ if (path.resolve(manifest.channelDir) !== path.join(path.dirname(path.resolve(manifestPath)), 'channel')) {
28
+ throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
29
+ }
30
+ return manifest;
31
+ }
32
+
33
+ function assertRealDirectory(directory: string, parent?: string): void {
34
+ const stat = fs.lstatSync(directory);
35
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
36
+ if (parent && path.dirname(fs.realpathSync.native(directory)) !== fs.realpathSync.native(parent)) {
37
+ throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
38
+ }
39
+ }
40
+
41
+ function writeResponse(directory: string, response: SandboxControlResponse): void {
42
+ const temporary = path.join(directory, `.${response.id}.tmp`);
43
+ fs.writeFileSync(temporary, `${JSON.stringify(response)}\n`, { mode: 0o600, flag: 'wx' });
44
+ fs.renameSync(temporary, path.join(directory, `${response.id}.json`));
45
+ }
46
+
47
+ export function sandboxControlSafeEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
48
+ return Object.fromEntries(
49
+ Object.entries(env).filter(
50
+ ([key]) => !key.toUpperCase().startsWith('AGENT_INFRA_CONTROL_')
51
+ )
52
+ );
53
+ }
54
+
55
+ function consumeRequest(directory: string, id: string): void {
56
+ try {
57
+ fs.writeFileSync(path.join(directory, id), '', { flag: 'wx', mode: 0o600 });
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
60
+ throw new Error('SANDBOX_CONTROL_REQUEST_REPLAYED');
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ export function serveSandboxControl(manifestPath: string, signal: AbortSignal = new AbortController().signal): void {
67
+ const manifest = readManifest(manifestPath);
68
+ assertGitWorktreeBinding(manifest.repoRoot, manifest.worktreeRoot, manifest.branch);
69
+ const brokerPath = path.join(path.dirname(manifestPath), 'broker.json');
70
+ const startTime = getProcessStartTime(process.pid);
71
+ if (!startTime) throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
72
+ fs.writeFileSync(brokerPath, `${JSON.stringify({
73
+ version: 1,
74
+ pid: process.pid,
75
+ startTime,
76
+ token: manifest.token
77
+ })}\n`, { mode: 0o600, flag: 'wx' });
78
+ const requestsDir = path.join(manifest.channelDir, 'requests');
79
+ const responsesDir = path.join(manifest.channelDir, 'responses');
80
+ const consumedDir = path.join(path.dirname(manifestPath), 'consumed');
81
+ assertRealDirectory(manifest.channelDir);
82
+ fs.mkdirSync(requestsDir, { recursive: true, mode: 0o700 });
83
+ fs.mkdirSync(responsesDir, { recursive: true, mode: 0o700 });
84
+ fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
85
+ assertRealDirectory(requestsDir, manifest.channelDir);
86
+ assertRealDirectory(responsesDir, manifest.channelDir);
87
+ assertRealDirectory(consumedDir, path.dirname(manifestPath));
88
+ while (!signal.aborted) {
89
+ const current = readManifest(manifestPath);
90
+ if (current.token !== manifest.token) return;
91
+ assertGitWorktreeBinding(current.repoRoot, current.worktreeRoot, current.branch);
92
+ assertRealDirectory(requestsDir, manifest.channelDir);
93
+ assertRealDirectory(responsesDir, manifest.channelDir);
94
+ for (const name of fs.readdirSync(requestsDir).sort()) {
95
+ if (!/^[a-f0-9-]{16,64}\.json$/.test(name)) continue;
96
+ const requestPath = path.join(requestsDir, name);
97
+ let id = name.slice(0, -5);
98
+ let response: SandboxControlResponse;
99
+ try {
100
+ const stat = fs.lstatSync(requestPath);
101
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_REQUEST_INVALID');
102
+ const request = validateSandboxControlRequest(
103
+ JSON.parse(fs.readFileSync(requestPath, 'utf8')),
104
+ manifest
105
+ );
106
+ id = request.id;
107
+ consumeRequest(consumedDir, id);
108
+ if (request.family === 'task-create') {
109
+ const result = createTask(request.candidate, { repoRoot: manifest.repoRoot });
110
+ response = {
111
+ version: 1, id,
112
+ exitCode: result.status === 'blocked' ? 2 : result.status === 'failed' ? 1 : 0,
113
+ stdout: `${JSON.stringify(result)}\n`, stderr: ''
114
+ };
115
+ } else {
116
+ const boundArgs = bindSandboxControlTask(request, manifest.taskId!);
117
+ if (request.family === 'task-orchestration') {
118
+ boundArgs.push('--git-worktree-root', manifest.worktreeRoot);
119
+ }
120
+ const result = spawnSync(
121
+ process.execPath,
122
+ ['--experimental-strip-types', '--no-warnings', process.argv[1]!, request.family, ...boundArgs],
123
+ {
124
+ cwd: manifest.repoRoot,
125
+ encoding: 'utf8',
126
+ env: sandboxControlSafeEnv()
127
+ }
128
+ );
129
+ response = {
130
+ version: 1,
131
+ id,
132
+ exitCode: result.status ?? 1,
133
+ stdout: result.stdout ?? '',
134
+ stderr: result.stderr ?? ''
135
+ };
136
+ }
137
+ } catch (error) {
138
+ response = {
139
+ version: 1,
140
+ id,
141
+ exitCode: 1,
142
+ stdout: '',
143
+ stderr: `${error instanceof Error ? error.message : String(error)}\n`
144
+ };
145
+ }
146
+ try {
147
+ fs.unlinkSync(requestPath);
148
+ writeResponse(responsesDir, response);
149
+ } catch (error) {
150
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
151
+ }
152
+ }
153
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
154
+ }
155
+ }
@@ -9,6 +9,23 @@ const RUNTIMES_DIR = path.join(
9
9
  path.dirname(fileURLToPath(import.meta.url)),
10
10
  'runtimes'
11
11
  );
12
+ const AGENT_CLIENT_RUNTIMES_DIR = path.join(
13
+ path.dirname(fileURLToPath(import.meta.url)),
14
+ '..',
15
+ 'agent-clients',
16
+ 'adapters',
17
+ 'runtimes'
18
+ );
19
+
20
+ type DockerfileImageContribution = Readonly<{
21
+ dockerfileFragments: readonly string[];
22
+ dotfilesExclusions: readonly string[];
23
+ }>;
24
+
25
+ const EMPTY_IMAGE_CONTRIBUTION: DockerfileImageContribution = Object.freeze({
26
+ dockerfileFragments: Object.freeze([]),
27
+ dotfilesExclusions: Object.freeze([])
28
+ });
12
29
 
13
30
  type DockerfileConfig = {
14
31
  repoRoot: string;
@@ -29,7 +46,29 @@ export function availableRuntimes(): string[] {
29
46
  .sort();
30
47
  }
31
48
 
32
- function dockerfileContent(config: DockerfileConfig): string {
49
+ function exclusionsFragment(exclusions: readonly string[]): string {
50
+ return [
51
+ "RUN mkdir -p /etc/agent-infra && cat > /etc/agent-infra/dotfiles-exclusions <<'EXCLUSIONS'",
52
+ ...exclusions,
53
+ 'EXCLUSIONS'
54
+ ].join('\n');
55
+ }
56
+
57
+ function adapterFragmentPath(fragment: string): string {
58
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(fragment)) {
59
+ throw new Error(`Invalid Agent Client Dockerfile fragment: ${fragment}`);
60
+ }
61
+ const fragmentPath = path.join(AGENT_CLIENT_RUNTIMES_DIR, `${fragment}.dockerfile`);
62
+ if (!fs.existsSync(fragmentPath)) {
63
+ throw new Error(`Unknown Agent Client Dockerfile fragment: ${fragment}`);
64
+ }
65
+ return fragmentPath;
66
+ }
67
+
68
+ function dockerfileContent(
69
+ config: DockerfileConfig,
70
+ image: DockerfileImageContribution = EMPTY_IMAGE_CONTRIBUTION
71
+ ): string {
33
72
  if (config.dockerfile) {
34
73
  const customPath = path.resolve(config.repoRoot, config.dockerfile);
35
74
  if (!fs.existsSync(customPath)) {
@@ -48,26 +87,37 @@ function dockerfileContent(config: DockerfileConfig): string {
48
87
  }
49
88
 
50
89
  const fragments = [
51
- 'base.dockerfile',
52
- ...config.runtimes.map((runtime) => `${runtime}.dockerfile`),
53
- 'ai-tools.dockerfile'
90
+ fs.readFileSync(path.join(RUNTIMES_DIR, 'base.dockerfile'), 'utf8').trimEnd(),
91
+ exclusionsFragment(image.dotfilesExclusions),
92
+ ...image.dockerfileFragments.map((fragment) =>
93
+ fs.readFileSync(adapterFragmentPath(fragment), 'utf8').trimEnd()
94
+ ),
95
+ ...config.runtimes.map((runtime) =>
96
+ fs.readFileSync(path.join(RUNTIMES_DIR, `${runtime}.dockerfile`), 'utf8').trimEnd()
97
+ ),
98
+ fs.readFileSync(path.join(RUNTIMES_DIR, 'ai-tools.dockerfile'), 'utf8').trimEnd()
54
99
  ];
55
100
 
56
101
  const content = fragments
57
- .map((fragment) => fs.readFileSync(path.join(RUNTIMES_DIR, fragment), 'utf8').trimEnd())
58
102
  .join('\n\n');
59
103
 
60
104
  return `${content}\n`;
61
105
  }
62
106
 
63
- export function dockerfileSignature(config: DockerfileConfig): string {
107
+ export function dockerfileSignature(
108
+ config: DockerfileConfig,
109
+ image: DockerfileImageContribution = EMPTY_IMAGE_CONTRIBUTION
110
+ ): string {
64
111
  return createHash('sha256')
65
- .update(dockerfileContent(config))
112
+ .update(dockerfileContent(config, image))
66
113
  .digest('hex')
67
114
  .slice(0, 12);
68
115
  }
69
116
 
70
- export function prepareDockerfile(config: DockerfileConfig): { path: string; signature: string; cleanup: () => void } {
117
+ export function prepareDockerfile(
118
+ config: DockerfileConfig,
119
+ image: DockerfileImageContribution = EMPTY_IMAGE_CONTRIBUTION
120
+ ): { path: string; signature: string; cleanup: () => void } {
71
121
  if (config.dockerfile) {
72
122
  const customPath = path.resolve(config.repoRoot, config.dockerfile);
73
123
  if (!fs.existsSync(customPath)) {
@@ -76,26 +126,29 @@ export function prepareDockerfile(config: DockerfileConfig): { path: string; sig
76
126
 
77
127
  return {
78
128
  path: customPath,
79
- signature: dockerfileSignature(config),
129
+ signature: dockerfileSignature(config, image),
80
130
  cleanup() {}
81
131
  };
82
132
  }
83
133
 
84
134
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${config.project}-sandbox-`));
85
135
  const tempPath = path.join(tempDir, 'Dockerfile');
86
- fs.writeFileSync(tempPath, dockerfileContent(config), 'utf8');
136
+ fs.writeFileSync(tempPath, dockerfileContent(config, image), 'utf8');
87
137
 
88
138
  return {
89
139
  path: tempPath,
90
- signature: dockerfileSignature(config),
140
+ signature: dockerfileSignature(config, image),
91
141
  cleanup() {
92
142
  removeDirRecursive(tempDir);
93
143
  }
94
144
  };
95
145
  }
96
146
 
97
- export function composeDockerfile(config: DockerfileConfig): string {
98
- const content = dockerfileContent(config);
147
+ export function composeDockerfile(
148
+ config: DockerfileConfig,
149
+ image: DockerfileImageContribution = EMPTY_IMAGE_CONTRIBUTION
150
+ ): string {
151
+ const content = dockerfileContent(config, image);
99
152
 
100
153
  const tempPath = path.join(os.tmpdir(), `${config.project}-sandbox.Dockerfile`);
101
154
  fs.writeFileSync(tempPath, content, 'utf8');
@@ -19,9 +19,16 @@ export function sandboxCoreBindMounts(
19
19
  'repoRoot' | 'worktreeBase' | 'shareBase' | 'shellConfigBase'
20
20
  >,
21
21
  branch: string,
22
- overrides: { worktree?: string; shellConfigHostDir?: string } = {}
22
+ overrides: {
23
+ worktree?: string;
24
+ shellConfigHostDir?: string;
25
+ workspaceViewRoot: string;
26
+ controlDir: string;
27
+ taskSource?: string;
28
+ taskId?: string;
29
+ }
23
30
  ): SandboxBindMountDeclaration[] {
24
- return [
31
+ const mounts: SandboxBindMountDeclaration[] = [
25
32
  {
26
33
  hostPaths: overrides.worktree
27
34
  ? [overrides.worktree]
@@ -30,9 +37,9 @@ export function sandboxCoreBindMounts(
30
37
  readOnly: false
31
38
  },
32
39
  {
33
- hostPaths: [path.join(config.repoRoot, '.agents', 'workspace')],
40
+ hostPaths: [overrides.workspaceViewRoot],
34
41
  containerPath: '/workspace/.agents/workspace',
35
- readOnly: false
42
+ readOnly: true
36
43
  },
37
44
  {
38
45
  hostPaths: [shareCommonDir(config)],
@@ -52,4 +59,17 @@ export function sandboxCoreBindMounts(
52
59
  readOnly: true
53
60
  }
54
61
  ];
62
+ if (overrides.taskSource && overrides.taskId) {
63
+ mounts.splice(2, 0, {
64
+ hostPaths: [overrides.taskSource],
65
+ containerPath: path.posix.join('/workspace/.agents/workspace/active', overrides.taskId),
66
+ readOnly: false
67
+ });
68
+ }
69
+ mounts.push({
70
+ hostPaths: [overrides.controlDir],
71
+ containerPath: '/run/agent-infra/control',
72
+ readOnly: false
73
+ });
74
+ return mounts;
55
75
  }
@@ -1,10 +1,14 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
3
5
  import {
4
6
  containerNameCandidates,
5
7
  sandboxBranchLabel,
6
8
  sandboxLabel,
7
- sandboxRuntimeCapabilityLabel
9
+ sandboxRuntimeCapabilityLabel,
10
+ sandboxTaskIdLabel,
11
+ sandboxWorkspaceModeLabel
8
12
  } from './constants.ts';
9
13
  import { isAgentClientId } from '../agent-clients/types.ts';
10
14
  import {
@@ -17,6 +21,16 @@ import type {
17
21
  import type { SandboxConfig } from './config.ts';
18
22
  import { toEnginePath } from './engines/wsl2-paths.ts';
19
23
  import { sandboxCoreBindMounts } from './mounts.ts';
24
+ import {
25
+ assertSandboxTaskSource,
26
+ sandboxControlPaths,
27
+ sandboxWorkspaceViewPaths
28
+ } from './workspace-view.ts';
29
+ import {
30
+ parseSandboxWorkspaceIdentity,
31
+ sameSandboxWorkspaceIdentity,
32
+ type SandboxWorkspaceIdentity
33
+ } from './workspace-identity.ts';
20
34
  import { runEngine, runOkEngine, runVerboseEngine } from './shell.ts';
21
35
  import {
22
36
  declaredTmpfsSeedEntries,
@@ -31,6 +45,7 @@ import {
31
45
  startSandboxContainer,
32
46
  type SandboxRow
33
47
  } from './commands/list-running.ts';
48
+ import { processIdentityMatches } from '../server/process-state.ts';
34
49
 
35
50
  export type SandboxRecoveryFinding = {
36
51
  repairKind: 'permissions' | 'missing-seed' | 'builtin-link' | 'hard-failure';
@@ -109,6 +124,7 @@ type EnsureSandboxReadyParams = {
109
124
  config: SandboxConfig;
110
125
  engine: string;
111
126
  branch: string;
127
+ workspace?: SandboxWorkspaceIdentity;
112
128
  row: SandboxRow;
113
129
  allowRecreate?: boolean;
114
130
  recreate?: (branch: string) => Promise<void>;
@@ -123,6 +139,54 @@ type ExpectedMount = {
123
139
  expectedRW: boolean;
124
140
  };
125
141
 
142
+ function ensureSandboxControlBroker(params: {
143
+ config: SandboxConfig;
144
+ container: string;
145
+ workspace: SandboxWorkspaceIdentity;
146
+ }): void {
147
+ const control = sandboxControlPaths({
148
+ base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
149
+ project: params.config.project,
150
+ container: params.container,
151
+ identity: params.workspace
152
+ });
153
+ if (!fs.existsSync(control.manifestPath)) return;
154
+ const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8')) as { token?: unknown };
155
+ const brokerPath = path.join(control.root, 'broker.json');
156
+ try {
157
+ const broker = JSON.parse(fs.readFileSync(brokerPath, 'utf8')) as {
158
+ version?: unknown;
159
+ pid?: unknown;
160
+ startTime?: unknown;
161
+ token?: unknown;
162
+ };
163
+ if (
164
+ broker.version === 1
165
+ && typeof broker.pid === 'number'
166
+ && typeof broker.startTime === 'string'
167
+ && broker.token === manifest.token
168
+ && processIdentityMatches({ version: 1, pid: broker.pid, startTime: broker.startTime })
169
+ ) return;
170
+ } catch {
171
+ // A missing, stale, or malformed broker record is replaced below.
172
+ }
173
+ fs.rmSync(brokerPath, { force: true });
174
+ const directory = path.dirname(fileURLToPath(import.meta.url));
175
+ const extension = path.extname(fileURLToPath(import.meta.url));
176
+ const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
177
+ const child = spawn(
178
+ process.execPath,
179
+ [internalCli, 'sandbox-control', 'serve', '--manifest', control.manifestPath],
180
+ { cwd: params.config.repoRoot, detached: true, stdio: 'ignore' }
181
+ );
182
+ child.once('error', () => {
183
+ // Readiness remains fail-closed; a later control request reports the
184
+ // unavailable broker without turning this detached spawn into an
185
+ // unhandled process error.
186
+ });
187
+ child.unref();
188
+ }
189
+
126
190
  function findingKey(finding: SandboxRecoveryFinding): string {
127
191
  return `${finding.repairKind}:${finding.path ?? finding.seed?.targetPath ?? finding.message}`;
128
192
  }
@@ -297,11 +361,34 @@ function hostSourceAccessible(hostPath: string, writable: boolean): boolean {
297
361
  function expectedMounts(params: {
298
362
  config: SandboxConfig;
299
363
  branch: string;
364
+ container: string;
365
+ workspace: SandboxWorkspaceIdentity;
300
366
  tools: readonly SandboxTool[];
301
367
  actualMounts: Map<string, DockerMount>;
302
368
  }): ExpectedMount[] {
303
369
  const { config, branch, tools, actualMounts } = params;
304
- const core = sandboxCoreBindMounts(config, branch).map((mount) => ({
370
+ const view = sandboxWorkspaceViewPaths({
371
+ base: config.workspaceViewBase ?? path.join(config.home, '.agent-infra', 'workspace-views'),
372
+ project: config.project,
373
+ container: params.container,
374
+ identity: params.workspace
375
+ });
376
+ const control = sandboxControlPaths({
377
+ base: config.controlBase ?? path.join(config.home, '.agent-infra', 'sandbox-control'),
378
+ project: config.project,
379
+ container: params.container,
380
+ identity: params.workspace
381
+ });
382
+ const core = sandboxCoreBindMounts(config, branch, {
383
+ workspaceViewRoot: view.root,
384
+ controlDir: control.channelDir,
385
+ ...(params.workspace.mode === 'task-bound'
386
+ ? {
387
+ taskSource: assertSandboxTaskSource(config.repoRoot, params.workspace.taskId),
388
+ taskId: params.workspace.taskId
389
+ }
390
+ : {})
391
+ }).map((mount) => ({
305
392
  path: mount.containerPath,
306
393
  expectedType: 'bind' as const,
307
394
  hostPaths: mount.hostPaths,
@@ -399,6 +486,7 @@ export function collectSandboxRecoverySnapshot(params: {
399
486
  config: SandboxConfig;
400
487
  engine: string;
401
488
  branch: string;
489
+ workspace?: SandboxWorkspaceIdentity;
402
490
  container: string;
403
491
  deps?: RecoveryCommandDeps;
404
492
  }): SandboxRecoverySnapshot {
@@ -427,6 +515,7 @@ export function collectSandboxRecoverySnapshot(params: {
427
515
  }
428
516
  }
429
517
  const capabilityPlan = createSandboxCapabilityPlan(params.config);
518
+ const workspace = params.workspace ?? { mode: 'branch-only' as const };
430
519
  const tools = [...capabilityPlan.tools];
431
520
  const seeds = declaredTmpfsSeedEntries(tools).map((seed) => {
432
521
  const mounted = mountsByDestination.get(seed.stagingPath)?.Type === 'bind';
@@ -460,6 +549,10 @@ export function collectSandboxRecoverySnapshot(params: {
460
549
  }));
461
550
  const labels = inspection.Config?.Labels ?? {};
462
551
  const branchLabel = labels[sandboxBranchLabel(params.config)];
552
+ const containerWorkspace = parseSandboxWorkspaceIdentity(labels, {
553
+ mode: sandboxWorkspaceModeLabel(params.config),
554
+ taskId: sandboxTaskIdLabel(params.config)
555
+ });
463
556
  const selectedToolIds = new Set(tools.map((tool) => tool.id));
464
557
  const enforceCanonicalPlan = params.config.agentClientSource === 'canonical';
465
558
  const unexpectedCapabilityMounts = enforceCanonicalPlan
@@ -471,6 +564,8 @@ export function collectSandboxRecoverySnapshot(params: {
471
564
  const mountSnapshots = expectedMounts({
472
565
  config: params.config,
473
566
  branch: params.branch,
567
+ container: params.container,
568
+ workspace,
474
569
  tools,
475
570
  actualMounts: mountsByDestination
476
571
  }).map((expected) => {
@@ -510,7 +605,8 @@ export function collectSandboxRecoverySnapshot(params: {
510
605
  return {
511
606
  identityOk: typeof inspection.Id === 'string'
512
607
  && inspection.Id.length > 0
513
- && branchLabel === params.branch,
608
+ && branchLabel === params.branch
609
+ && sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
514
610
  runtimeCapabilityOk: enforceCanonicalPlan
515
611
  ? labels[sandboxRuntimeCapabilityLabel(params.config)] === capabilityPlan.runtimeSignature
516
612
  : undefined,
@@ -673,6 +769,7 @@ function assess(params: {
673
769
  config: SandboxConfig;
674
770
  engine: string;
675
771
  branch: string;
772
+ workspace?: SandboxWorkspaceIdentity;
676
773
  container: string;
677
774
  deps?: RecoveryCommandDeps;
678
775
  }): { snapshot: SandboxRecoverySnapshot; findings: SandboxRecoveryFinding[] } {
@@ -680,21 +777,64 @@ function assess(params: {
680
777
  return { snapshot, findings: classifySandboxRecovery(snapshot) };
681
778
  }
682
779
 
683
- export function assertFreshSandboxReady(params: {
780
+ function canRetryFreshReadiness(findings: SandboxRecoveryFinding[]): boolean {
781
+ return findings.length > 0 && findings.every((finding) =>
782
+ finding.repairKind === 'hard-failure'
783
+ && finding.path !== undefined
784
+ && (
785
+ finding.path.startsWith('/workspace/.agents/workspace')
786
+ || finding.path === '/home/devuser/.host-shell-config'
787
+ || finding.path === '/home/devuser/.bash_aliases'
788
+ )
789
+ );
790
+ }
791
+
792
+ export async function assertFreshSandboxReady(params: {
684
793
  config: SandboxConfig;
685
794
  engine: string;
686
795
  branch: string;
796
+ workspace?: SandboxWorkspaceIdentity;
687
797
  container: string;
688
798
  copiedEntries: TmpfsSeedEntry[];
689
799
  deps?: RecoveryCommandDeps;
690
- }): void {
800
+ }): Promise<void> {
691
801
  validateTmpfsSeedEntries({
692
802
  engine: params.engine,
693
803
  container: params.container,
694
804
  entries: params.copiedEntries,
695
805
  deps: params.deps
696
806
  });
697
- const { findings } = assess(params);
807
+ let { findings } = assess(params);
808
+ if (canRetryFreshReadiness(findings)) {
809
+ const runVerboseFn = params.deps?.runVerbose ?? runVerboseEngine;
810
+ runVerboseFn(params.engine, 'docker', ['restart', params.container]);
811
+ prepareTmpfsMounts({
812
+ engine: params.engine,
813
+ container: params.container,
814
+ mountPaths: resolveTools(params.config)
815
+ .filter((tool) => tool.tmpfs)
816
+ .map((tool) => tool.containerMount),
817
+ deps: params.deps
818
+ });
819
+ hydrateTmpfsSeedEntries({
820
+ engine: params.engine,
821
+ container: params.container,
822
+ entries: params.copiedEntries,
823
+ replace: true,
824
+ deps: params.deps
825
+ });
826
+ let retried = assess(params);
827
+ if (!retried.findings.some((finding) => finding.repairKind === 'hard-failure')) {
828
+ repairFindings({
829
+ engine: params.engine,
830
+ container: params.container,
831
+ findings: retried.findings,
832
+ deps: params.deps
833
+ });
834
+ retried = assess(params);
835
+ }
836
+ findings = retried.findings;
837
+ }
698
838
  if (findings.length > 0) {
699
839
  throw new Error(`Fresh sandbox readiness check failed: ${describeFindings(findings)}`);
700
840
  }
@@ -705,6 +845,11 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
705
845
  const startFn = deps?.start ?? startSandboxContainer;
706
846
  const warnings: string[] = [];
707
847
  let failure: Error | null = null;
848
+ ensureSandboxControlBroker({
849
+ config: params.config,
850
+ container: params.row.name,
851
+ workspace: params.workspace ?? { mode: 'branch-only' }
852
+ });
708
853
 
709
854
  try {
710
855
  if (!params.row.running) {
@@ -713,6 +858,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
713
858
  config: params.config,
714
859
  engine: params.engine,
715
860
  branch: params.branch,
861
+ workspace: params.workspace,
716
862
  container: params.row.name,
717
863
  deps
718
864
  });
@@ -739,6 +885,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
739
885
  config: params.config,
740
886
  engine: params.engine,
741
887
  branch: params.branch,
888
+ workspace: params.workspace,
742
889
  container: params.row.name,
743
890
  deps
744
891
  });
@@ -753,6 +900,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
753
900
  config: params.config,
754
901
  engine: params.engine,
755
902
  branch: params.branch,
903
+ workspace: params.workspace,
756
904
  container: params.row.name,
757
905
  deps
758
906
  });
@@ -767,6 +915,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
767
915
  config: params.config,
768
916
  engine: params.engine,
769
917
  branch: params.branch,
918
+ workspace: params.workspace,
770
919
  container: params.row.name,
771
920
  deps
772
921
  });
@@ -791,6 +940,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
791
940
  config: params.config,
792
941
  engine: params.engine,
793
942
  branch: params.branch,
943
+ workspace: params.workspace,
794
944
  container: params.row.name,
795
945
  deps
796
946
  });
@@ -811,6 +961,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
811
961
  config: params.config,
812
962
  engine: params.engine,
813
963
  branch: params.branch,
964
+ workspace: params.workspace,
814
965
  container: params.row.name,
815
966
  deps
816
967
  });
@@ -831,6 +982,11 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
831
982
  const warning = `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
832
983
  warnings.push(warning);
833
984
  (params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
985
+ const runVerboseFn = deps?.runVerbose ?? runVerboseEngine;
986
+ if (params.row.running) {
987
+ runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
988
+ }
989
+ runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
834
990
  await params.recreate(params.branch);
835
991
 
836
992
  const rows = (deps?.fetchRows ?? fetchSandboxRows)(
@@ -849,6 +1005,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
849
1005
  config: params.config,
850
1006
  engine: params.engine,
851
1007
  branch: params.branch,
1008
+ workspace: params.workspace,
852
1009
  container: replacement.name,
853
1010
  deps
854
1011
  });
@@ -0,0 +1,14 @@
1
+ const SENSITIVE_OUTPUT_PATTERNS = [
2
+ { pattern: /\{[^{}]*"claudeAiOauth"[\s\S]*?\}\s*\}/g, replacement: '[REDACTED credentials blob]' },
3
+ { pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g, replacement: '[REDACTED claude token]' },
4
+ { pattern: /gh[psoru]_[A-Za-z0-9]{30,}/g, replacement: '[REDACTED github token]' },
5
+ { pattern: /Bearer\s+[A-Za-z0-9._~+/=-]{20,}/gi, replacement: 'Bearer [REDACTED]' }
6
+ ];
7
+
8
+ export function redactCommandError(text: unknown): string {
9
+ if (!text || typeof text !== 'string') return '';
10
+ return SENSITIVE_OUTPUT_PATTERNS.reduce(
11
+ (result, { pattern, replacement }) => result.replace(pattern, replacement),
12
+ text
13
+ );
14
+ }