@fitlab-ai/agent-infra 0.8.3 → 0.8.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 (122) hide show
  1. package/README.zh-CN.md +10 -0
  2. package/bin/cli.ts +1 -1
  3. package/dist/bin/cli.js +1 -1
  4. package/dist/lib/cp.js +56 -15
  5. package/dist/lib/defaults.json +9 -0
  6. package/dist/lib/init.js +5 -0
  7. package/dist/lib/sandbox/capture.js +5 -3
  8. package/dist/lib/sandbox/clipboard/bridge.js +12 -9
  9. package/dist/lib/sandbox/clipboard/inbox.js +82 -0
  10. package/dist/lib/sandbox/commands/create.js +68 -26
  11. package/dist/lib/sandbox/engine.js +33 -3
  12. package/dist/lib/sandbox/engines/colima.js +2 -2
  13. package/dist/lib/sandbox/runtimes/base.dockerfile +7 -0
  14. package/dist/lib/sandbox/shell.js +9 -1
  15. package/dist/lib/sandbox/tools.js +2 -2
  16. package/dist/lib/server/daemon.js +9 -0
  17. package/dist/lib/server/process-control.js +66 -73
  18. package/dist/lib/server/process-state.js +168 -0
  19. package/dist/lib/update.js +2 -0
  20. package/lib/cp.ts +56 -15
  21. package/lib/defaults.json +9 -0
  22. package/lib/init.ts +9 -0
  23. package/lib/sandbox/capture.ts +5 -3
  24. package/lib/sandbox/clipboard/bridge.ts +14 -12
  25. package/lib/sandbox/clipboard/inbox.ts +83 -0
  26. package/lib/sandbox/commands/create.ts +80 -27
  27. package/lib/sandbox/engine.ts +37 -3
  28. package/lib/sandbox/engines/colima.ts +2 -2
  29. package/lib/sandbox/runtimes/base.dockerfile +7 -0
  30. package/lib/sandbox/shell.ts +11 -2
  31. package/lib/sandbox/tools.ts +5 -5
  32. package/lib/server/daemon.ts +9 -0
  33. package/lib/server/process-control.ts +70 -71
  34. package/lib/server/process-state.ts +183 -0
  35. package/lib/update.ts +6 -0
  36. package/package.json +2 -1
  37. package/runtime/platform-adapters/platform-sync.github.js +1501 -0
  38. package/templates/.agents/README.en.md +5 -0
  39. package/templates/.agents/README.zh-CN.md +5 -0
  40. package/templates/.agents/rules/create-issue.github.en.md +1 -1
  41. package/templates/.agents/rules/create-issue.github.zh-CN.md +1 -1
  42. package/templates/.agents/rules/review-handshake.en.md +5 -4
  43. package/templates/.agents/rules/review-handshake.zh-CN.md +5 -4
  44. package/templates/.agents/rules/testing-discipline.en.md +3 -41
  45. package/templates/.agents/rules/testing-discipline.zh-CN.md +3 -41
  46. package/templates/.agents/scripts/lib/agent-infra-package.js +149 -0
  47. package/templates/.agents/scripts/lib/post-review-commit.js +5 -0
  48. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +25 -1459
  49. package/templates/.agents/scripts/review-diff-fingerprint.js +95 -24
  50. package/templates/.agents/scripts/validate-artifact.js +144 -38
  51. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
  52. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +2 -2
  53. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +1 -1
  54. package/templates/.agents/skills/block-task/SKILL.en.md +1 -1
  55. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
  56. package/templates/.agents/skills/cancel-task/SKILL.en.md +1 -1
  57. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +2 -2
  58. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +9 -9
  59. package/templates/.agents/skills/close-codescan/SKILL.en.md +1 -1
  60. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +2 -2
  61. package/templates/.agents/skills/close-dependabot/SKILL.en.md +1 -1
  62. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +2 -2
  63. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  64. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
  65. package/templates/.agents/skills/code-task/reference/branch-management.zh-CN.md +2 -2
  66. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +2 -2
  67. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +2 -2
  68. package/templates/.agents/skills/code-task/reference/output-template.zh-CN.md +1 -1
  69. package/templates/.agents/skills/commit/SKILL.en.md +6 -4
  70. package/templates/.agents/skills/commit/SKILL.zh-CN.md +6 -4
  71. package/templates/.agents/skills/commit/reference/task-status-update.en.md +35 -8
  72. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +35 -8
  73. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +27 -3
  74. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +27 -3
  75. package/templates/.agents/skills/complete-task/SKILL.en.md +3 -3
  76. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +3 -3
  77. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
  78. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
  79. package/templates/.agents/skills/create-pr/SKILL.en.md +4 -4
  80. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +4 -4
  81. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  82. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  83. package/templates/.agents/skills/create-task/SKILL.en.md +2 -2
  84. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +5 -5
  85. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -2
  86. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +3 -3
  87. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -2
  88. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +3 -3
  89. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -2
  90. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +3 -3
  91. package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +1 -1
  92. package/templates/.agents/skills/init-milestones/SKILL.en.md +5 -4
  93. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +6 -5
  94. package/templates/.agents/skills/init-milestones/scripts/init-milestones.github.sh +170 -45
  95. package/templates/.agents/skills/plan-task/SKILL.en.md +1 -1
  96. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -2
  97. package/templates/.agents/skills/restore-task/SKILL.en.md +1 -1
  98. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +1 -1
  99. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  100. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  101. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +5 -5
  102. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +5 -5
  103. package/templates/.agents/skills/review-code/SKILL.en.md +8 -4
  104. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +6 -3
  105. package/templates/.agents/skills/review-code/config/verify.en.json +3 -1
  106. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +3 -1
  107. package/templates/.agents/skills/review-code/reference/output-templates.en.md +5 -5
  108. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +5 -5
  109. package/templates/.agents/skills/review-code/reference/report-template.en.md +3 -2
  110. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +3 -2
  111. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  112. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  113. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +5 -5
  114. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +5 -5
  115. package/templates/.agents/skills/test/SKILL.zh-CN.md +26 -8
  116. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +5 -1
  117. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +6 -2
  118. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +203 -142
  119. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -2
  120. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +2 -2
  121. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +8 -1
  122. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +8 -1
@@ -9,6 +9,7 @@ import {
9
9
  pruneClipboardDir,
10
10
  writeClipboardPngAtomic
11
11
  } from './paths.ts';
12
+ import { isClipboardInboxReadable, readPendingClipboardPath } from './inbox.ts';
12
13
  import { commandForEngine, restoreTerminal, runInteractiveEngine, runOkEngine } from '../shell.ts';
13
14
  import { loadNodePty, type NodePty, type PtyProcess } from './node-pty.ts';
14
15
 
@@ -85,13 +86,9 @@ export async function runInteractiveWithClipboardBridge(options: BridgeOptions):
85
86
  if (!stdin.isTTY || !stdout.isTTY) {
86
87
  return fallback('host stdin/stdout is not a TTY');
87
88
  }
88
- if (!adapter) {
89
- return fallback('no clipboard adapter available on this platform');
90
- }
91
- const available = adapter.available();
92
- if (!available.ok) {
93
- return fallback(available.reason);
94
- }
89
+ const available = adapter?.available() ?? { ok: false as const, reason: 'no clipboard adapter available on this platform' };
90
+ const inboxReady = isClipboardInboxReadable(home);
91
+ if (!available.ok && !inboxReady) return fallback(available.reason);
95
92
  if (!runOk(engine, 'docker', ['exec', container, 'sh', '-c', '[ -d /clipboard ] && [ -r /clipboard ]'])) {
96
93
  return fallback('container /clipboard mount is missing; rebuild the sandbox to enable image paste');
97
94
  }
@@ -119,7 +116,7 @@ export async function runInteractiveWithClipboardBridge(options: BridgeOptions):
119
116
  return runBridge({
120
117
  child,
121
118
  home,
122
- adapter,
119
+ adapter: available.ok ? adapter : null,
123
120
  writeStderr,
124
121
  stdin,
125
122
  stdout,
@@ -138,7 +135,7 @@ async function runBridge({
138
135
  }: {
139
136
  child: PtyProcess;
140
137
  home: string;
141
- adapter: ClipboardAdapter;
138
+ adapter: ClipboardAdapter | null;
142
139
  writeStderr: (chunk: string) => unknown;
143
140
  stdin: NodeJS.ReadStream;
144
141
  stdout: NodeJS.WriteStream;
@@ -182,9 +179,9 @@ async function runBridge({
182
179
  }
183
180
 
184
181
  function handleText(raw: string, target: PtyProcess): void {
185
- const textAdapter = adapter as TextImageClipboardAdapter;
182
+ const textAdapter = adapter as TextImageClipboardAdapter | null;
186
183
  const pastedText = extractSinglePathPaste(raw);
187
- if (!textAdapter.readImageFromText || pastedText === null) {
184
+ if (!textAdapter || !textAdapter.readImageFromText || pastedText === null) {
188
185
  target.write(raw);
189
186
  return;
190
187
  }
@@ -207,12 +204,17 @@ async function runBridge({
207
204
 
208
205
  function handleCtrlV(match: CtrlVMatch, target: PtyProcess): void {
209
206
  try {
207
+ const pendingPath = readPendingClipboardPath(home);
208
+ if (pendingPath) {
209
+ target.write(buildBracketedPaste(pendingPath));
210
+ return;
211
+ }
210
212
  // readImagePng returns null both for "no image on clipboard" and for
211
213
  // unexpected read failures; both cases forward the original Ctrl+V so
212
214
  // the container app handles it as a regular keystroke. The throw branch
213
215
  // below only fires on truly unexpected exceptions (e.g. fs write
214
216
  // errors writing to the host clipboard dir).
215
- const png = adapter.readImagePng();
217
+ const png = adapter?.readImagePng() ?? null;
216
218
  if (!png) {
217
219
  target.write(match.raw);
218
220
  return;
@@ -0,0 +1,83 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ clipboardHostDir,
5
+ containerClipboardPath,
6
+ pngClipboardFilename,
7
+ pruneClipboardDir,
8
+ writeClipboardPngAtomic
9
+ } from './paths.ts';
10
+
11
+ export const RECEIVER_CAPABILITY = 'AGENT_INFRA_CLIPBOARD_RECEIVER:v1';
12
+ const MARKER = '.pending.json';
13
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
14
+ const HASHED_PNG = /^[a-f0-9]{16}\.png$/u;
15
+ const REMOTE_TEMP = /^agent-infra-cp-[0-9a-f-]{36}\.png$/u;
16
+
17
+ export function isClipboardInboxReadable(home: string): boolean {
18
+ const dir = clipboardHostDir(home);
19
+ try {
20
+ fs.accessSync(dir, fs.constants.R_OK);
21
+ return fs.statSync(dir).isDirectory();
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ export function receiveRemoteClipboardPng(home: string, tempPath: string, platformName: NodeJS.Platform = process.platform): string {
28
+ if (platformName !== 'linux') throw new Error('remote clipboard receiver requires Linux');
29
+ if (path.dirname(tempPath) !== '/tmp' || !REMOTE_TEMP.test(path.basename(tempPath))) {
30
+ throw new Error('invalid clipboard upload path');
31
+ }
32
+ const signature = Buffer.alloc(PNG_SIGNATURE.length);
33
+ const fd = fs.openSync(tempPath, 'r');
34
+ let signatureBytes: number;
35
+ try {
36
+ signatureBytes = fs.readSync(fd, signature, 0, signature.length, 0);
37
+ } finally {
38
+ fs.closeSync(fd);
39
+ }
40
+ if (signatureBytes !== PNG_SIGNATURE.length || !signature.equals(PNG_SIGNATURE)) {
41
+ throw new Error('uploaded clipboard file is not a PNG');
42
+ }
43
+ const png = fs.readFileSync(tempPath);
44
+ const dir = clipboardHostDir(home);
45
+ if (!fs.existsSync(path.dirname(dir))) throw new Error('agent-infra is not installed on this host');
46
+ const filename = pngClipboardFilename(png);
47
+ writeClipboardPngAtomic(dir, filename, png);
48
+ pruneClipboardDir(dir);
49
+ const markerTmp = path.join(dir, `.${MARKER}.${process.pid}.tmp`);
50
+ fs.writeFileSync(markerTmp, JSON.stringify({ version: 1, filename, createdAt: new Date().toISOString() }) + '\n', { mode: 0o600 });
51
+ fs.renameSync(markerTmp, path.join(dir, MARKER));
52
+ return containerClipboardPath(filename);
53
+ }
54
+
55
+ export function readPendingClipboardPath(home: string): string | null {
56
+ const dir = clipboardHostDir(home);
57
+ const markerPath = path.join(dir, MARKER);
58
+ let markerText: string;
59
+ try {
60
+ markerText = fs.readFileSync(markerPath, 'utf8');
61
+ } catch (error) {
62
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
63
+ throw new Error('invalid clipboard pending marker', { cause: error });
64
+ }
65
+
66
+ let marker: { version?: unknown; filename?: unknown };
67
+ try {
68
+ marker = JSON.parse(markerText) as { version?: unknown; filename?: unknown };
69
+ } catch (error) {
70
+ throw new Error('invalid clipboard pending marker', { cause: error });
71
+ }
72
+ if (marker.version !== 1 || typeof marker.filename !== 'string' || !HASHED_PNG.test(marker.filename)) {
73
+ throw new Error('invalid clipboard pending marker');
74
+ }
75
+ const filePath = path.join(dir, marker.filename);
76
+ try {
77
+ const stat = fs.lstatSync(filePath);
78
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('pending clipboard image is not a regular file');
79
+ } catch (error) {
80
+ throw new Error('invalid clipboard pending marker', { cause: error });
81
+ }
82
+ return containerClipboardPath(marker.filename);
83
+ }
@@ -87,17 +87,36 @@ alias gy='gemini --yolo; tput ed'
87
87
  `;
88
88
  const CONTAINER_HOME = '/home/devuser';
89
89
  const CONTAINER_SHELL_CONFIG_MOUNT = `${CONTAINER_HOME}/.host-shell-config`;
90
- const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh]
90
+ const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh] [--inherit-proxy|-P]
91
91
 
92
92
  Host aliases:
93
93
  ${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and exposed
94
94
  as ${CONTAINER_HOME}/.bash_aliases inside the sandbox container (the host
95
95
  shell-config directory is bind-mounted at ${CONTAINER_SHELL_CONFIG_MOUNT} and
96
- symlinked into $HOME).`;
96
+ symlinked into $HOME).
97
+
98
+ Proxy:
99
+ --inherit-proxy, -P Copy non-empty standard host proxy variables into the
100
+ container environment through the private env file.`;
101
+ const HOST_PROXY_ENV_KEYS = [
102
+ 'http_proxy',
103
+ 'HTTP_PROXY',
104
+ 'https_proxy',
105
+ 'HTTPS_PROXY',
106
+ 'all_proxy',
107
+ 'ALL_PROXY',
108
+ 'no_proxy',
109
+ 'NO_PROXY'
110
+ ] as const;
97
111
 
98
112
  type SandboxCreateConfig = ReturnType<typeof loadConfig>;
99
113
  type PreparedDockerfile = ReturnType<typeof prepareDockerfile>;
100
114
  type ResolvedTool = { tool: SandboxTool; dir: string };
115
+ type TmpfsSeedPlanEntry = {
116
+ stagingPath: string;
117
+ targetPath: string;
118
+ volumeArgs: string[];
119
+ };
101
120
  type RuntimeCheck = { name: string; cmd: string[] };
102
121
  type JsonObject = Record<string, unknown>;
103
122
  type GpgCache = { pub: Buffer; sec: Buffer } | null;
@@ -322,12 +341,14 @@ export function writeSanitizedGitconfig({
322
341
  const SHELL_CONFIG_SYMLINKS = ['.gitconfig', '.gitignore_global', '.stCommitMsg', '.bash_aliases'];
323
342
 
324
343
  export function ensureShellConfigSymlinks(engine: string, container: string, execFn: EngineExecFn = execEngine): void {
325
- // Idempotent symlink setup. Runs against a started container so it also
326
- // covers custom Dockerfiles that don't bake the symlinks into the image.
327
- const script = SHELL_CONFIG_SYMLINKS
328
- .map((file) => `ln -sf .host-shell-config/${file} ${CONTAINER_HOME}/${file}`)
329
- .join(' && ');
330
- execFn(engine, 'docker', ['exec', container, 'bash', '-lc', script], { stdio: 'ignore' });
344
+ // Idempotent symlink setup. Avoid a shell command here because Windows .cmd
345
+ // engine shims would interpret metacharacters in a `bash -lc` script before
346
+ // forwarding it to Docker.
347
+ for (const file of SHELL_CONFIG_SYMLINKS) {
348
+ execFn(engine, 'docker', [
349
+ 'exec', container, 'ln', '-sf', `.host-shell-config/${file}`, `${CONTAINER_HOME}/${file}`
350
+ ], { stdio: 'ignore' });
351
+ }
331
352
  }
332
353
 
333
354
  export function prepareHostShellConfig({
@@ -619,28 +640,44 @@ function formatEnvFileEntry(key: string, value: string): string {
619
640
  return `${key}=${value}`;
620
641
  }
621
642
 
643
+ export function collectHostProxyEntries(env: NodeJS.ProcessEnv): Array<[string, string]> {
644
+ const entriesByExactKey = new Map(Object.entries(env));
645
+ return HOST_PROXY_ENV_KEYS.flatMap((key) => {
646
+ const value = entriesByExactKey.get(key);
647
+ return typeof value === 'string' && value !== '' ? [[key, value]] : [];
648
+ });
649
+ }
650
+
622
651
  export function buildContainerEnvFile(
623
652
  resolvedTools: ResolvedTool[],
624
653
  engine: string,
625
654
  runSafeEngineFn: EngineRunSafeFn = runSafeEngine,
626
655
  options: {
656
+ additionalEntries?: Array<[string, string]>;
627
657
  mkdtempFn?: typeof fs.mkdtempSync;
628
658
  writeFileFn?: typeof fs.writeFileSync;
629
659
  chmodFn?: typeof fs.chmodSync;
630
660
  rmFn?: typeof fs.rmSync;
631
661
  tmpDir?: string;
662
+ runSafeFn?: DirectRunSafeFn;
632
663
  } = {}
633
664
  ): { dockerArgs: string[]; cleanup: () => void } {
634
665
  const {
666
+ additionalEntries = [],
635
667
  mkdtempFn = fs.mkdtempSync,
636
668
  writeFileFn = fs.writeFileSync,
637
669
  chmodFn = fs.chmodSync,
638
670
  rmFn = fs.rmSync,
639
- tmpDir = os.tmpdir()
671
+ tmpDir = os.tmpdir(),
672
+ runSafeFn = runSafe
640
673
  } = options;
641
674
 
642
675
  const entries: Array<[string, string]> = resolvedTools.flatMap(({ tool }) => Object.entries(tool.envVars ?? {}));
643
- const ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
676
+ entries.push(...additionalEntries);
677
+ let ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
678
+ if (!ghToken && engine === 'wsl2') {
679
+ ghToken = runSafeFn('gh', ['auth', 'token']);
680
+ }
644
681
  if (ghToken) {
645
682
  entries.push(['GH_TOKEN', ghToken]);
646
683
  }
@@ -1234,6 +1271,7 @@ export async function create(args: string[]): Promise<void> {
1234
1271
  cpu: { type: 'string' },
1235
1272
  memory: { type: 'string' },
1236
1273
  'no-refresh': { type: 'boolean' },
1274
+ 'inherit-proxy': { type: 'boolean', short: 'P' },
1237
1275
  help: { type: 'boolean', short: 'h' }
1238
1276
  }
1239
1277
  });
@@ -1491,8 +1529,12 @@ export async function create(args: string[]): Promise<void> {
1491
1529
  signingKey
1492
1530
  )
1493
1531
  : null;
1494
- const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
1532
+ const proxyEntries = values['inherit-proxy'] ? collectHostProxyEntries(process.env) : [];
1533
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine, undefined, {
1534
+ additionalEntries: proxyEntries
1535
+ });
1495
1536
  let hostShellConfig: HostShellConfig;
1537
+ let tmpfsSeedPlan: TmpfsSeedPlanEntry[] = [];
1496
1538
  try {
1497
1539
  const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
1498
1540
  if (claudeCodeEntry) {
@@ -1533,22 +1575,24 @@ export async function create(args: string[]): Promise<void> {
1533
1575
  '-v',
1534
1576
  volumeArg(engine, hostPath, containerPath, ':ro')
1535
1577
  ]);
1536
- // A tmpfs containerMount starts empty, so the config seeded into the
1537
- // host dir before launch would be invisible in-container. Bind only
1538
- // the explicitly declared seed entries (config.toml, model-catalogs)
1539
- // back over the tmpfs as nested mounts — the same proven mechanism as
1540
- // hostLiveMounts/auth.json, established at `docker run` time (no
1541
- // post-start `docker cp`, which can land under a freshly-mounted
1542
- // tmpfs instead of inside it). The allowlist is deliberate: any
1543
- // runtime files left in the host dir (e.g. a stale logs_2.sqlite or
1544
- // sessions/ from a previous bind-mount era) must NOT be re-mounted,
1545
- // or the high-churn writes would land on the host SSD again.
1546
- const tmpfsSeedVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) =>
1547
- (tool.tmpfs?.seed ?? []).flatMap((entry) => {
1578
+ // Mount each declared seed read-only at an isolated staging path.
1579
+ // After docker run mounts the empty tmpfs, the container's default
1580
+ // user copies these entries into it so the runtime targets are normal,
1581
+ // writable files rather than nested mount points. The allowlist keeps
1582
+ // stale runtime files (logs_2.sqlite, sessions, etc.) on the host from
1583
+ // being exposed or written through.
1584
+ tmpfsSeedPlan = effectiveResolvedTools.flatMap(({ tool, dir }) =>
1585
+ (tool.tmpfs?.seed ?? []).flatMap((entry, index) => {
1548
1586
  const hostPath = path.join(dir, entry);
1549
- return fs.existsSync(hostPath)
1550
- ? ['-v', volumeArg(engine, hostPath, path.posix.join(tool.containerMount, entry))]
1551
- : [];
1587
+ if (!fs.existsSync(hostPath)) {
1588
+ return [];
1589
+ }
1590
+ const stagingPath = path.posix.join('/run/agent-infra/tmpfs-seeds', tool.id, String(index));
1591
+ return [{
1592
+ stagingPath,
1593
+ targetPath: path.posix.join(tool.containerMount, entry),
1594
+ volumeArgs: ['-v', volumeArg(engine, hostPath, stagingPath, ':ro')]
1595
+ }];
1552
1596
  })
1553
1597
  );
1554
1598
  const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) =>
@@ -1604,7 +1648,7 @@ export async function create(args: string[]): Promise<void> {
1604
1648
  ...dotfilesMount,
1605
1649
  ...toolVolumes,
1606
1650
  ...tmpfsArgs,
1607
- ...tmpfsSeedVolumes,
1651
+ ...tmpfsSeedPlan.flatMap(({ volumeArgs }) => volumeArgs),
1608
1652
  ...liveMountVolumes,
1609
1653
  ...shellConfigVolumes,
1610
1654
  ...envFile.dockerArgs,
@@ -1617,6 +1661,15 @@ export async function create(args: string[]): Promise<void> {
1617
1661
  envFile.cleanup();
1618
1662
  }
1619
1663
 
1664
+ for (const { stagingPath, targetPath } of tmpfsSeedPlan) {
1665
+ runEngineTaskCommand(engine, 'docker', [
1666
+ 'exec', container, 'mkdir', '-p', path.posix.dirname(targetPath)
1667
+ ]);
1668
+ runEngineTaskCommand(engine, 'docker', [
1669
+ 'exec', container, 'cp', '-R', '--', stagingPath, targetPath
1670
+ ]);
1671
+ }
1672
+
1620
1673
  // Belt-and-suspenders: re-create the four shell-config symlinks at
1621
1674
  // runtime so users with a custom `sandbox.dockerfile` (which won't
1622
1675
  // include the ai-tools.dockerfile symlink fragment) still get
@@ -2,7 +2,7 @@ import { platform } from 'node:os';
2
2
  import { detectHostResources } from './constants.ts';
3
3
  import { ADAPTERS, enginesForPlatform, getAdapter } from './engines/index.ts';
4
4
  import type { EffectiveSandboxConfig, OnMessage, RunFns, SandboxAdapter, SandboxVmConfig } from './engines/index.ts';
5
- import { run, runOk, runSafe, runVerbose } from './shell.ts';
5
+ import { run, runOk, runOkEngine, runSafe, runVerbose } from './shell.ts';
6
6
 
7
7
  export const ENGINES = Object.freeze({
8
8
  COLIMA: 'colima',
@@ -26,6 +26,7 @@ type EngineDependencies = {
26
26
  platformFn?: typeof platform;
27
27
  runFn?: RunFns['run'];
28
28
  runOkFn?: RunFns['runOk'];
29
+ runOkEngineFn?: typeof runOkEngine;
29
30
  runSafeFn?: RunFns['runSafe'];
30
31
  runVerboseFn?: RunFns['runVerbose'];
31
32
  };
@@ -50,6 +51,37 @@ function applyDockerContext(adapter: SandboxAdapter): void {
50
51
  }
51
52
  }
52
53
 
54
+ function buildKitError(engine: string): Error {
55
+ let repair: string;
56
+ switch (engine) {
57
+ case ENGINES.COLIMA:
58
+ repair = 'Install the Docker Buildx plugin with: brew install docker-buildx';
59
+ break;
60
+ case ENGINES.DOCKER_DESKTOP:
61
+ case ENGINES.WSL2:
62
+ repair = 'Upgrade or repair Docker Desktop so Docker Buildx and BuildKit are available.';
63
+ break;
64
+ case ENGINES.NATIVE:
65
+ repair = 'Install the Docker Buildx plugin for your Docker Engine installation.';
66
+ break;
67
+ default:
68
+ repair = `Upgrade or repair ${engineDisplayName(engine)} so Docker Buildx and BuildKit are available.`;
69
+ }
70
+
71
+ return new Error([
72
+ `Docker BuildKit is not available for ${engineDisplayName(engine)}.`,
73
+ repair,
74
+ 'Verify the builder with: docker buildx inspect --bootstrap',
75
+ 'Then retry the sandbox create or rebuild command.'
76
+ ].join('\n'));
77
+ }
78
+
79
+ function ensureBuildKit(engine: string, runOkEngineFn: typeof runOkEngine): void {
80
+ if (!runOkEngineFn(engine, 'docker', ['buildx', 'inspect', '--bootstrap'])) {
81
+ throw buildKitError(engine);
82
+ }
83
+ }
84
+
53
85
  export function validateSandboxEngine(
54
86
  engine: string | null | undefined,
55
87
  { platformFn = platform }: Pick<EngineDependencies, 'platformFn'> = {}
@@ -144,10 +176,12 @@ export async function ensureDocker(
144
176
  const engine = detectEngine(config, dependencies);
145
177
  const adapter = getAdapter(engine);
146
178
  const effectiveConfig = effectiveConfigFor(adapter, config);
179
+ const fns = runFns(dependencies);
147
180
 
148
181
  applyDockerContext(adapter);
149
- const vmJustStarted = await adapter.ensure(effectiveConfig, onMessage, runFns(dependencies));
150
- adapter.syncResources(effectiveConfig, onMessage, runFns(dependencies), { vmJustStarted });
182
+ const vmJustStarted = await adapter.ensure(effectiveConfig, onMessage, fns);
183
+ ensureBuildKit(engine, dependencies.runOkEngineFn ?? runOkEngine);
184
+ adapter.syncResources(effectiveConfig, onMessage, fns, { vmJustStarted });
151
185
  }
152
186
 
153
187
  export function isVmManaged(config: EngineConfig = {}, dependencies: EngineDependencies = {}): boolean {
@@ -35,8 +35,8 @@ export const colimaAdapter: SandboxAdapter = {
35
35
  let started = false;
36
36
 
37
37
  if (!runOk('which', ['colima'])) {
38
- onMessage?.('Installing colima + docker via Homebrew...');
39
- runVerbose('brew', ['install', 'colima', 'docker']);
38
+ onMessage?.('Installing colima + docker + docker-buildx via Homebrew...');
39
+ runVerbose('brew', ['install', 'colima', 'docker', 'docker-buildx']);
40
40
  }
41
41
 
42
42
  if (!runOk('colima', ['status'])) {
@@ -12,6 +12,9 @@ RUN if [ "${HOST_UID}" = "0" ]; then \
12
12
  (groupadd -o -g ${HOST_GID} devuser || true) && \
13
13
  useradd -o -u ${HOST_UID} -g ${HOST_GID} -m -s /bin/bash devuser; \
14
14
  else \
15
+ if ubuntu_uid="$(id -u ubuntu 2>/dev/null)" && [ "${ubuntu_uid}" = "${HOST_UID}" ]; then \
16
+ userdel -r ubuntu || exit 1; \
17
+ fi; \
15
18
  (groupadd -g ${HOST_GID} devuser || true) && \
16
19
  useradd -u ${HOST_UID} -g ${HOST_GID} -m -s /bin/bash devuser; \
17
20
  fi
@@ -188,6 +191,10 @@ fi
188
191
  exec tmux new-session -s "$SESSION"
189
192
  SCRIPT
190
193
 
194
+ RUN test -s /usr/local/bin/cc-token-status && test -x /usr/local/bin/cc-token-status && \
195
+ test -s /usr/local/bin/sandbox-dotfiles-link && test -x /usr/local/bin/sandbox-dotfiles-link && \
196
+ test -s /usr/local/bin/sandbox-tmux-entry && test -x /usr/local/bin/sandbox-tmux-entry
197
+
191
198
  ENV LANG=en_US.UTF-8
192
199
  ENV LC_ALL=en_US.UTF-8
193
200
  ENV TERM=xterm-256color
@@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import type { ExecFileSyncOptions, StdioOptions, SpawnSyncOptions, SpawnSyncReturns } from 'node:child_process';
5
+ import { getAdapter } from './engines/index.ts';
5
6
 
6
7
  const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
7
8
 
@@ -52,7 +53,7 @@ function resolveCommand(cmd: string): string {
52
53
  return cmd;
53
54
  }
54
55
 
55
- function commandOptions<T extends CommandOptions>(cmd: string, opts: T): T | (T & { shell: true }) {
56
+ function commandOptions<T extends object>(cmd: string, opts: T): T | (T & { shell: true }) {
56
57
  if (process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(cmd)) {
57
58
  return { ...opts, shell: true };
58
59
  }
@@ -143,6 +144,13 @@ export function commandForEngine(engine: string, cmd: string, args: string[] = [
143
144
  return { cmd: resolvedWrapper, args: ['--', cmd, ...args] };
144
145
  }
145
146
 
147
+ if (cmd === 'docker') {
148
+ const context = getAdapter(engine).dockerContext;
149
+ if (context) {
150
+ return { cmd, args: ['--context', context, ...args] };
151
+ }
152
+ }
153
+
146
154
  return { cmd, args };
147
155
  }
148
156
 
@@ -153,7 +161,8 @@ export function runEngine(engine: string, cmd: string, args: string[], opts: Com
153
161
 
154
162
  export function execEngine(engine: string, cmd: string, args: string[], opts: ExecFileSyncOptions = {}) {
155
163
  const command = commandForEngine(engine, cmd, args);
156
- return execFileSync(command.cmd, command.args, opts);
164
+ const resolved = resolveCommand(command.cmd);
165
+ return execFileSync(resolved, command.args, commandOptions(resolved, opts));
157
166
  }
158
167
 
159
168
  export function runOkEngine(engine: string, cmd: string, args: string[], opts: CommandOptions = {}): boolean {
@@ -22,9 +22,9 @@ export type SandboxTool = {
22
22
  // When set, containerMount is mounted as an in-container tmpfs (RAM) instead
23
23
  // of bind-mounting the host config dir, keeping high-churn tool logs off the
24
24
  // host disk. `seed` lists the host-dir entries (relative to the tool's config
25
- // dir) to bind back over the tmpfs so seeded config stays visible — it is an
26
- // explicit allowlist so runtime files (e.g. logs_2.sqlite, sessions) left in
27
- // the host dir are NOT re-mounted, which would defeat the tmpfs.
25
+ // dir) to copy into the tmpfs when the container starts. It is an explicit
26
+ // allowlist so runtime files (e.g. logs_2.sqlite, sessions) left in the host
27
+ // dir stay out of the container and seeded config cannot write back.
28
28
  tmpfs?: { size?: string; seed?: string[] };
29
29
  };
30
30
 
@@ -80,8 +80,8 @@ function createBuiltinTools(home: string, project: string): Record<string, Sandb
80
80
  // codex churns ~/.codex/logs_2.sqlite heavily (upstream openai/codex#24275);
81
81
  // a bind-mount would write-amplify onto the host SSD via virtiofs. Mount the
82
82
  // codex home as tmpfs so those logs stay in RAM and die with the container.
83
- // Only the seeded config (config.toml, model-catalogs) is bound back over
84
- // the tmpfs; runtime files like logs_2.sqlite must stay in RAM.
83
+ // Seeded config (config.toml, model-catalogs) is copied into the tmpfs at
84
+ // startup; runtime files like logs_2.sqlite stay in RAM.
85
85
  tmpfs: { size: '512m', seed: ['config.toml', 'model-catalogs'] },
86
86
  hostLiveMounts: [
87
87
  { hostPath: hostJoin(home, '.codex', 'auth.json'), containerSubpath: 'auth.json' }
@@ -12,6 +12,7 @@ import type { RunnerOptions, RunnerResult } from './runner.ts';
12
12
  import { streamCommand } from './streamer.ts';
13
13
  import { markdownMessage, replyOutbound, textMessage } from './display.ts';
14
14
  import { buildStatusModel, statusModelToDisplay, type StatusModel } from '../task/commands/status.ts';
15
+ import { getProcessStartTime, removePidRecordIfMatches } from './process-state.ts';
15
16
 
16
17
  function errorMessage(error: unknown): string {
17
18
  return error instanceof Error ? error.message : String(error);
@@ -110,6 +111,7 @@ export async function runDaemon(): Promise<void> {
110
111
 
111
112
  const logger = createLogger(config.log);
112
113
  logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
114
+ const ownStartTime = getProcessStartTime(process.pid);
113
115
 
114
116
  const abortController = new AbortController();
115
117
  let resolveShutdown: () => void = () => {};
@@ -135,6 +137,13 @@ export async function runDaemon(): Promise<void> {
135
137
  await unloadAdapters(adapters);
136
138
  clearInterval(heartbeat);
137
139
  logger.close();
140
+ if (ownStartTime !== null) {
141
+ removePidRecordIfMatches(config.pidFile, {
142
+ version: 1,
143
+ pid: process.pid,
144
+ startTime: ownStartTime
145
+ });
146
+ }
138
147
  resolveShutdown();
139
148
  })();
140
149
  };