@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
package/README.zh-CN.md CHANGED
@@ -207,3 +207,13 @@ my-project/
207
207
  ## 许可协议
208
208
 
209
209
  [MIT](License.txt)
210
+
211
+ ## 社区
212
+
213
+ - 微信公众号:`FitFramework`
214
+ - QQ 技术交流群:`1029802553`
215
+
216
+ <p align="center">
217
+ <img src="./assets/qrcode_for_wechat_gh.png" alt="FitFramework 微信公众号二维码" width="250">
218
+ <img src="./assets/qrcode_for_qq_01.png" alt="FitFramework QQ 技术交流群二维码" width="250">
219
+ </p>
package/bin/cli.ts CHANGED
@@ -15,7 +15,7 @@ const USAGE = `agent-infra ${VERSION} - bootstrap AI collaboration infrastructur
15
15
  Usage: ai <command> [options]
16
16
 
17
17
  Commands:
18
- cp <ssh-alias> Copy local clipboard image to a remote macOS NSPasteboard
18
+ cp <ssh-alias> Copy local clipboard image to a remote macOS clipboard or Linux sandbox over SSH
19
19
  decide Record a human decision for a task HD item
20
20
  help Show this help message
21
21
  init Initialize a new project with update-agent-infra seed command
package/dist/bin/cli.js CHANGED
@@ -19,7 +19,7 @@ const USAGE = `agent-infra ${VERSION} - bootstrap AI collaboration infrastructur
19
19
  Usage: ai <command> [options]
20
20
 
21
21
  Commands:
22
- cp <ssh-alias> Copy local clipboard image to a remote macOS NSPasteboard
22
+ cp <ssh-alias> Copy local clipboard image to a remote macOS clipboard or Linux sandbox over SSH
23
23
  decide Record a human decision for a task HD item
24
24
  help Show this help message
25
25
  init Initialize a new project with update-agent-infra seed command
package/dist/lib/cp.js CHANGED
@@ -5,7 +5,8 @@ import { platform as currentPlatform, tmpdir as defaultTmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { createClipboardAdapter } from './sandbox/clipboard/index.js';
8
- const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS NSPasteboard over ssh/scp.\n';
8
+ import { RECEIVER_CAPABILITY, receiveRemoteClipboardPng } from './sandbox/clipboard/inbox.js';
9
+ const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS clipboard or Linux sandbox over SSH.\n';
9
10
  const COMMAND_TIMEOUT_MS = 30_000;
10
11
  export function runCommand(cmd, args, input) {
11
12
  const result = spawnSync(cmd, args, {
@@ -23,6 +24,22 @@ export function runCommand(cmd, args, input) {
23
24
  }
24
25
  export async function cmdCp(args, deps = {}) {
25
26
  const { platform = currentPlatform(), createAdapter = createClipboardAdapter, spawnFn = runCommand, randomId = randomUUID, mkdtempFn = fs.mkdtempSync, writeFileFn = fs.writeFileSync, rmFn = fs.rmSync, tmpdir = defaultTmpdir, writeStdout = (chunk) => process.stdout.write(chunk), writeStderr = (chunk) => process.stderr.write(chunk) } = deps;
27
+ if (args.length === 1 && args[0] === '--remote-receive-capability') {
28
+ if (platform === 'linux')
29
+ writeStdout(`${RECEIVER_CAPABILITY}\n`);
30
+ return platform === 'linux' ? 0 : 1;
31
+ }
32
+ if (args.length === 2 && args[0] === '--remote-receive-v1') {
33
+ try {
34
+ const result = receiveRemoteClipboardPng(process.env.HOME || process.env.USERPROFILE || '', args[1], platform);
35
+ writeStdout(`${result}\n`);
36
+ return 0;
37
+ }
38
+ catch (error) {
39
+ writeStderr(`${error instanceof Error ? error.message : 'clipboard receive failed'}\n`);
40
+ return 1;
41
+ }
42
+ }
26
43
  if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
27
44
  writeStdout(USAGE);
28
45
  return 0;
@@ -62,6 +79,34 @@ export async function cmdCp(args, deps = {}) {
62
79
  const localPng = path.join(localTmpDir, 'clipboard.png');
63
80
  writeFileFn(localPng, png);
64
81
  remotePath = `/tmp/agent-infra-cp-${randomId()}.png`;
82
+ const remoteSystem = spawnFn('ssh', [
83
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'uname', '-s'
84
+ ]);
85
+ if (remoteSystem.status !== 0) {
86
+ writeStderr(`failed to detect remote platform on ${alias}:\n${commandDetail(remoteSystem)}\n`);
87
+ return 1;
88
+ }
89
+ const remotePlatform = remoteSystem.stdout.trim();
90
+ if (remotePlatform !== 'Darwin' && remotePlatform !== 'Linux') {
91
+ writeStderr(`unsupported remote platform on ${alias}: ${remotePlatform || 'unknown'}\n`);
92
+ return 1;
93
+ }
94
+ if (remotePlatform === 'Linux') {
95
+ const homeCheck = spawnFn('ssh', [
96
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'test', '-d', '$HOME/.agent-infra'
97
+ ]);
98
+ if (homeCheck.status !== 0) {
99
+ writeStderr(`remote agent-infra installation is missing on ${alias}\n`);
100
+ return 1;
101
+ }
102
+ const capability = spawnFn('ssh', [
103
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'ai', 'cp', '--remote-receive-capability'
104
+ ]);
105
+ if (capability.status !== 0 || capability.stderr !== '' || capability.stdout.replace(/\n$/u, '') !== RECEIVER_CAPABILITY) {
106
+ writeStderr(`remote agent-infra clipboard receiver is unavailable or incompatible on ${alias}; update agent-infra\n`);
107
+ return 1;
108
+ }
109
+ }
65
110
  const upload = spawnFn('scp', [
66
111
  '-o',
67
112
  'BatchMode=yes',
@@ -75,24 +120,20 @@ export async function cmdCp(args, deps = {}) {
75
120
  return 1;
76
121
  }
77
122
  uploaded = true;
78
- // Remote write currently targets macOS only: it pipes an AppleScript to the
79
- // remote `osascript` to set its NSPasteboard. This is the extension point for
80
- // other remote platforms later (e.g. dispatch on remote OS to wl-copy/xclip
81
- // on Linux); a non-macOS remote fails here with a clear non-zero error today.
82
- const setRemote = spawnFn('ssh', [
83
- '-o',
84
- 'BatchMode=yes',
85
- '-o',
86
- 'ConnectTimeout=10',
87
- alias,
88
- 'osascript',
89
- '-'
90
- ], remoteSetScript(remotePath));
123
+ const setRemote = remotePlatform === 'Darwin'
124
+ ? spawnFn('ssh', [
125
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'osascript', '-'
126
+ ], remoteSetScript(remotePath))
127
+ : spawnFn('ssh', [
128
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'ai', 'cp', '--remote-receive-v1', remotePath
129
+ ]);
91
130
  if (setRemote.status !== 0) {
92
131
  writeStderr(`failed to set remote clipboard on ${alias}:\n${commandDetail(setRemote)}\n`);
93
132
  return 1;
94
133
  }
95
- writeStdout(`copied clipboard image to ${alias}\n`);
134
+ writeStdout(remotePlatform === 'Linux'
135
+ ? `uploaded clipboard image to ${alias}; return to the SSH session and press Ctrl+V\n`
136
+ : `copied clipboard image to ${alias}\n`);
96
137
  return 0;
97
138
  }
98
139
  finally {
@@ -44,14 +44,23 @@
44
44
  ".gemini/commands/",
45
45
  ".git-hooks/check-version-format.sh",
46
46
  ".github/scripts/",
47
+ ".github/workflows/metadata-sync.yml",
48
+ ".github/workflows/pr-label.yml",
49
+ ".github/workflows/status-label.yml",
47
50
  ".opencode/commands/"
48
51
  ],
52
+ "guardedManaged": [
53
+ ".github/workflows/metadata-sync.yml",
54
+ ".github/workflows/pr-label.yml",
55
+ ".github/workflows/status-label.yml"
56
+ ],
49
57
  "merged": [
50
58
  "**/post-release.*",
51
59
  "**/release.*",
52
60
  "**/test-integration.*",
53
61
  "**/test.*",
54
62
  "**/upgrade-dependency.*",
63
+ ".agents/rules/testing-discipline.*",
55
64
  ".agents/skills/post-release/SKILL.*",
56
65
  ".agents/skills/release/SKILL.*",
57
66
  ".agents/skills/test-integration/SKILL.*",
package/dist/lib/init.js CHANGED
@@ -197,6 +197,8 @@ async function cmdInit() {
197
197
  // install skill
198
198
  copySkillDir(path.join(templateDir, '.agents', 'skills', 'update-agent-infra'), path.join('.agents', 'skills', 'update-agent-infra'), replacements, language, platformType);
199
199
  ok('Installed .agents/skills/update-agent-infra/');
200
+ renderFile(path.join(templateDir, '.agents', 'scripts', 'lib', 'agent-infra-package.js'), path.join('.agents', 'scripts', 'lib', 'agent-infra-package.js'), replacements);
201
+ ok('Installed .agents/scripts/lib/agent-infra-package.js');
200
202
  // install Claude command (only if enabled)
201
203
  if (enabledTUISet.has('claude-code')) {
202
204
  renderFile(path.join(templateDir, '.claude', 'commands', claudeSrc), path.join('.claude', 'commands', 'update-agent-infra.md'), replacements);
@@ -245,6 +247,9 @@ async function cmdInit() {
245
247
  console.log('');
246
248
  ok('Project initialized successfully!');
247
249
  console.log('');
250
+ console.log(' If this init used npx, install agent-infra persistently before update or validation:');
251
+ console.log(' npm install -g @fitlab-ai/agent-infra');
252
+ console.log('');
248
253
  if (enabledTUISet.size === 0) {
249
254
  console.log(' No built-in TUI selected.');
250
255
  console.log(` Configure "customTUIs" in ${configPath} before running update-agent-infra.`);
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { loadConfig } from './config.js';
3
3
  import { containerNameCandidates, sandboxBranchLabel, sandboxLabel } from './constants.js';
4
4
  import { detectEngine } from './engine.js';
5
+ import { commandForEngine } from './shell.js';
5
6
  import { hostTimezoneEnvFlags, terminalEnvFlags } from './commands/enter.js';
6
7
  import { fetchSandboxRows, selectSandboxContainer, startSandboxContainer } from './commands/list-running.js';
7
8
  async function spawnCapture(file, args) {
@@ -49,12 +50,12 @@ function buildRunScript(params) {
49
50
  return `#!/bin/sh
50
51
  set -u
51
52
  cd /workspace
52
- date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/started_at
53
+ date "+%Y-%m-%d %H:%M:%S%z" | sed 's/\\([+-][0-9][0-9]\\)\\([0-9][0-9]\\)$/\\1:\\2/' > ${runDir}/started_at
53
54
  printf '%s\\n' running > ${runDir}/status
54
55
  ${command}
55
56
  code=$?
56
57
  printf '%s\\n' "$code" > ${runDir}/exit_code
57
- date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/finished_at
58
+ date "+%Y-%m-%d %H:%M:%S%z" | sed 's/\\([+-][0-9][0-9]\\)\\([0-9][0-9]\\)$/\\1:\\2/' > ${runDir}/finished_at
58
59
  if [ "$code" -eq 0 ]; then
59
60
  printf '%s\\n' completed > ${runDir}/status
60
61
  else
@@ -144,7 +145,8 @@ export async function runInSandbox(request, options = {}) {
144
145
  '-lc',
145
146
  buildTmuxLauncher({ request, runId })
146
147
  ];
147
- const result = await (options.spawn ?? spawnCapture)('docker', dockerArgs);
148
+ const command = commandForEngine(engine, 'docker', dockerArgs);
149
+ const result = await (options.spawn ?? spawnCapture)(command.cmd, command.args);
148
150
  return {
149
151
  ...result,
150
152
  run: {
@@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process';
3
3
  import { createClipboardAdapter } from './index.js';
4
4
  import { buildBracketedPaste, CtrlVDetector } from './keys.js';
5
5
  import { clipboardHostDir, containerClipboardPath, pngClipboardFilename, pruneClipboardDir, writeClipboardPngAtomic } from './paths.js';
6
+ import { isClipboardInboxReadable, readPendingClipboardPath } from './inbox.js';
6
7
  import { commandForEngine, restoreTerminal, runInteractiveEngine, runOkEngine } from '../shell.js';
7
8
  import { loadNodePty } from './node-pty.js';
8
9
  const FALLBACK_PREFIX = 'Warning: clipboard image paste bridge disabled';
@@ -37,13 +38,10 @@ export async function runInteractiveWithClipboardBridge(options) {
37
38
  if (!stdin.isTTY || !stdout.isTTY) {
38
39
  return fallback('host stdin/stdout is not a TTY');
39
40
  }
40
- if (!adapter) {
41
- return fallback('no clipboard adapter available on this platform');
42
- }
43
- const available = adapter.available();
44
- if (!available.ok) {
41
+ const available = adapter?.available() ?? { ok: false, reason: 'no clipboard adapter available on this platform' };
42
+ const inboxReady = isClipboardInboxReadable(home);
43
+ if (!available.ok && !inboxReady)
45
44
  return fallback(available.reason);
46
- }
47
45
  if (!runOk(engine, 'docker', ['exec', container, 'sh', '-c', '[ -d /clipboard ] && [ -r /clipboard ]'])) {
48
46
  return fallback('container /clipboard mount is missing; rebuild the sandbox to enable image paste');
49
47
  }
@@ -69,7 +67,7 @@ export async function runInteractiveWithClipboardBridge(options) {
69
67
  return runBridge({
70
68
  child,
71
69
  home,
72
- adapter,
70
+ adapter: available.ok ? adapter : null,
73
71
  writeStderr,
74
72
  stdin,
75
73
  stdout,
@@ -116,7 +114,7 @@ async function runBridge({ child, home, adapter, writeStderr, stdin, stdout, det
116
114
  function handleText(raw, target) {
117
115
  const textAdapter = adapter;
118
116
  const pastedText = extractSinglePathPaste(raw);
119
- if (!textAdapter.readImageFromText || pastedText === null) {
117
+ if (!textAdapter || !textAdapter.readImageFromText || pastedText === null) {
120
118
  target.write(raw);
121
119
  return;
122
120
  }
@@ -138,12 +136,17 @@ async function runBridge({ child, home, adapter, writeStderr, stdin, stdout, det
138
136
  }
139
137
  function handleCtrlV(match, target) {
140
138
  try {
139
+ const pendingPath = readPendingClipboardPath(home);
140
+ if (pendingPath) {
141
+ target.write(buildBracketedPaste(pendingPath));
142
+ return;
143
+ }
141
144
  // readImagePng returns null both for "no image on clipboard" and for
142
145
  // unexpected read failures; both cases forward the original Ctrl+V so
143
146
  // the container app handles it as a regular keystroke. The throw branch
144
147
  // below only fires on truly unexpected exceptions (e.g. fs write
145
148
  // errors writing to the host clipboard dir).
146
- const png = adapter.readImagePng();
149
+ const png = adapter?.readImagePng() ?? null;
147
150
  if (!png) {
148
151
  target.write(match.raw);
149
152
  return;
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { clipboardHostDir, containerClipboardPath, pngClipboardFilename, pruneClipboardDir, writeClipboardPngAtomic } from './paths.js';
4
+ export const RECEIVER_CAPABILITY = 'AGENT_INFRA_CLIPBOARD_RECEIVER:v1';
5
+ const MARKER = '.pending.json';
6
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
7
+ const HASHED_PNG = /^[a-f0-9]{16}\.png$/u;
8
+ const REMOTE_TEMP = /^agent-infra-cp-[0-9a-f-]{36}\.png$/u;
9
+ export function isClipboardInboxReadable(home) {
10
+ const dir = clipboardHostDir(home);
11
+ try {
12
+ fs.accessSync(dir, fs.constants.R_OK);
13
+ return fs.statSync(dir).isDirectory();
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ export function receiveRemoteClipboardPng(home, tempPath, platformName = process.platform) {
20
+ if (platformName !== 'linux')
21
+ throw new Error('remote clipboard receiver requires Linux');
22
+ if (path.dirname(tempPath) !== '/tmp' || !REMOTE_TEMP.test(path.basename(tempPath))) {
23
+ throw new Error('invalid clipboard upload path');
24
+ }
25
+ const signature = Buffer.alloc(PNG_SIGNATURE.length);
26
+ const fd = fs.openSync(tempPath, 'r');
27
+ let signatureBytes;
28
+ try {
29
+ signatureBytes = fs.readSync(fd, signature, 0, signature.length, 0);
30
+ }
31
+ finally {
32
+ fs.closeSync(fd);
33
+ }
34
+ if (signatureBytes !== PNG_SIGNATURE.length || !signature.equals(PNG_SIGNATURE)) {
35
+ throw new Error('uploaded clipboard file is not a PNG');
36
+ }
37
+ const png = fs.readFileSync(tempPath);
38
+ const dir = clipboardHostDir(home);
39
+ if (!fs.existsSync(path.dirname(dir)))
40
+ throw new Error('agent-infra is not installed on this host');
41
+ const filename = pngClipboardFilename(png);
42
+ writeClipboardPngAtomic(dir, filename, png);
43
+ pruneClipboardDir(dir);
44
+ const markerTmp = path.join(dir, `.${MARKER}.${process.pid}.tmp`);
45
+ fs.writeFileSync(markerTmp, JSON.stringify({ version: 1, filename, createdAt: new Date().toISOString() }) + '\n', { mode: 0o600 });
46
+ fs.renameSync(markerTmp, path.join(dir, MARKER));
47
+ return containerClipboardPath(filename);
48
+ }
49
+ export function readPendingClipboardPath(home) {
50
+ const dir = clipboardHostDir(home);
51
+ const markerPath = path.join(dir, MARKER);
52
+ let markerText;
53
+ try {
54
+ markerText = fs.readFileSync(markerPath, 'utf8');
55
+ }
56
+ catch (error) {
57
+ if (error.code === 'ENOENT')
58
+ return null;
59
+ throw new Error('invalid clipboard pending marker', { cause: error });
60
+ }
61
+ let marker;
62
+ try {
63
+ marker = JSON.parse(markerText);
64
+ }
65
+ catch (error) {
66
+ throw new Error('invalid clipboard pending marker', { cause: error });
67
+ }
68
+ if (marker.version !== 1 || typeof marker.filename !== 'string' || !HASHED_PNG.test(marker.filename)) {
69
+ throw new Error('invalid clipboard pending marker');
70
+ }
71
+ const filePath = path.join(dir, marker.filename);
72
+ try {
73
+ const stat = fs.lstatSync(filePath);
74
+ if (!stat.isFile() || stat.isSymbolicLink())
75
+ throw new Error('pending clipboard image is not a regular file');
76
+ }
77
+ catch (error) {
78
+ throw new Error('invalid clipboard pending marker', { cause: error });
79
+ }
80
+ return containerClipboardPath(marker.filename);
81
+ }
82
+ //# sourceMappingURL=inbox.js.map
@@ -47,13 +47,27 @@ alias gy='gemini --yolo; tput ed'
47
47
  `;
48
48
  const CONTAINER_HOME = '/home/devuser';
49
49
  const CONTAINER_SHELL_CONFIG_MOUNT = `${CONTAINER_HOME}/.host-shell-config`;
50
- const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh]
50
+ const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh] [--inherit-proxy|-P]
51
51
 
52
52
  Host aliases:
53
53
  ${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and exposed
54
54
  as ${CONTAINER_HOME}/.bash_aliases inside the sandbox container (the host
55
55
  shell-config directory is bind-mounted at ${CONTAINER_SHELL_CONFIG_MOUNT} and
56
- symlinked into $HOME).`;
56
+ symlinked into $HOME).
57
+
58
+ Proxy:
59
+ --inherit-proxy, -P Copy non-empty standard host proxy variables into the
60
+ container environment through the private env file.`;
61
+ const HOST_PROXY_ENV_KEYS = [
62
+ 'http_proxy',
63
+ 'HTTP_PROXY',
64
+ 'https_proxy',
65
+ 'HTTPS_PROXY',
66
+ 'all_proxy',
67
+ 'ALL_PROXY',
68
+ 'no_proxy',
69
+ 'NO_PROXY'
70
+ ];
57
71
  function resolveToolDirs(config, tools, branch) {
58
72
  return tools.map((tool) => {
59
73
  const candidates = toolConfigDirCandidates(tool, config.project, branch);
@@ -209,12 +223,14 @@ export function writeSanitizedGitconfig({ home, hostConfigDir, stripGpg, repoRoo
209
223
  // Keep in sync with the symlink block in lib/sandbox/runtimes/ai-tools.dockerfile.
210
224
  const SHELL_CONFIG_SYMLINKS = ['.gitconfig', '.gitignore_global', '.stCommitMsg', '.bash_aliases'];
211
225
  export function ensureShellConfigSymlinks(engine, container, execFn = execEngine) {
212
- // Idempotent symlink setup. Runs against a started container so it also
213
- // covers custom Dockerfiles that don't bake the symlinks into the image.
214
- const script = SHELL_CONFIG_SYMLINKS
215
- .map((file) => `ln -sf .host-shell-config/${file} ${CONTAINER_HOME}/${file}`)
216
- .join(' && ');
217
- execFn(engine, 'docker', ['exec', container, 'bash', '-lc', script], { stdio: 'ignore' });
226
+ // Idempotent symlink setup. Avoid a shell command here because Windows .cmd
227
+ // engine shims would interpret metacharacters in a `bash -lc` script before
228
+ // forwarding it to Docker.
229
+ for (const file of SHELL_CONFIG_SYMLINKS) {
230
+ execFn(engine, 'docker', [
231
+ 'exec', container, 'ln', '-sf', `.host-shell-config/${file}`, `${CONTAINER_HOME}/${file}`
232
+ ], { stdio: 'ignore' });
233
+ }
218
234
  }
219
235
  export function prepareHostShellConfig({ home, project, branch, repoRoot }) {
220
236
  const hostDir = hostShellConfigDir(home, project, branch);
@@ -423,10 +439,21 @@ function formatEnvFileEntry(key, value) {
423
439
  }
424
440
  return `${key}=${value}`;
425
441
  }
442
+ export function collectHostProxyEntries(env) {
443
+ const entriesByExactKey = new Map(Object.entries(env));
444
+ return HOST_PROXY_ENV_KEYS.flatMap((key) => {
445
+ const value = entriesByExactKey.get(key);
446
+ return typeof value === 'string' && value !== '' ? [[key, value]] : [];
447
+ });
448
+ }
426
449
  export function buildContainerEnvFile(resolvedTools, engine, runSafeEngineFn = runSafeEngine, options = {}) {
427
- const { mkdtempFn = fs.mkdtempSync, writeFileFn = fs.writeFileSync, chmodFn = fs.chmodSync, rmFn = fs.rmSync, tmpDir = os.tmpdir() } = options;
450
+ const { additionalEntries = [], mkdtempFn = fs.mkdtempSync, writeFileFn = fs.writeFileSync, chmodFn = fs.chmodSync, rmFn = fs.rmSync, tmpDir = os.tmpdir(), runSafeFn = runSafe } = options;
428
451
  const entries = resolvedTools.flatMap(({ tool }) => Object.entries(tool.envVars ?? {}));
429
- const ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
452
+ entries.push(...additionalEntries);
453
+ let ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
454
+ if (!ghToken && engine === 'wsl2') {
455
+ ghToken = runSafeFn('gh', ['auth', 'token']);
456
+ }
430
457
  if (ghToken) {
431
458
  entries.push(['GH_TOKEN', ghToken]);
432
459
  }
@@ -928,6 +955,7 @@ export async function create(args) {
928
955
  cpu: { type: 'string' },
929
956
  memory: { type: 'string' },
930
957
  'no-refresh': { type: 'boolean' },
958
+ 'inherit-proxy': { type: 'boolean', short: 'P' },
931
959
  help: { type: 'boolean', short: 'h' }
932
960
  }
933
961
  });
@@ -1143,8 +1171,12 @@ export async function create(args) {
1143
1171
  const cachedGpg = needsGpg
1144
1172
  ? readGpgCache(effectiveConfig.home, effectiveConfig.project, undefined, signingKey)
1145
1173
  : null;
1146
- const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
1174
+ const proxyEntries = values['inherit-proxy'] ? collectHostProxyEntries(process.env) : [];
1175
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine, undefined, {
1176
+ additionalEntries: proxyEntries
1177
+ });
1147
1178
  let hostShellConfig;
1179
+ let tmpfsSeedPlan = [];
1148
1180
  try {
1149
1181
  const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
1150
1182
  if (claudeCodeEntry) {
@@ -1181,21 +1213,23 @@ export async function create(args) {
1181
1213
  '-v',
1182
1214
  volumeArg(engine, hostPath, containerPath, ':ro')
1183
1215
  ]);
1184
- // A tmpfs containerMount starts empty, so the config seeded into the
1185
- // host dir before launch would be invisible in-container. Bind only
1186
- // the explicitly declared seed entries (config.toml, model-catalogs)
1187
- // back over the tmpfs as nested mounts — the same proven mechanism as
1188
- // hostLiveMounts/auth.json, established at `docker run` time (no
1189
- // post-start `docker cp`, which can land under a freshly-mounted
1190
- // tmpfs instead of inside it). The allowlist is deliberate: any
1191
- // runtime files left in the host dir (e.g. a stale logs_2.sqlite or
1192
- // sessions/ from a previous bind-mount era) must NOT be re-mounted,
1193
- // or the high-churn writes would land on the host SSD again.
1194
- const tmpfsSeedVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => (tool.tmpfs?.seed ?? []).flatMap((entry) => {
1216
+ // Mount each declared seed read-only at an isolated staging path.
1217
+ // After docker run mounts the empty tmpfs, the container's default
1218
+ // user copies these entries into it so the runtime targets are normal,
1219
+ // writable files rather than nested mount points. The allowlist keeps
1220
+ // stale runtime files (logs_2.sqlite, sessions, etc.) on the host from
1221
+ // being exposed or written through.
1222
+ tmpfsSeedPlan = effectiveResolvedTools.flatMap(({ tool, dir }) => (tool.tmpfs?.seed ?? []).flatMap((entry, index) => {
1195
1223
  const hostPath = path.join(dir, entry);
1196
- return fs.existsSync(hostPath)
1197
- ? ['-v', volumeArg(engine, hostPath, path.posix.join(tool.containerMount, entry))]
1198
- : [];
1224
+ if (!fs.existsSync(hostPath)) {
1225
+ return [];
1226
+ }
1227
+ const stagingPath = path.posix.join('/run/agent-infra/tmpfs-seeds', tool.id, String(index));
1228
+ return [{
1229
+ stagingPath,
1230
+ targetPath: path.posix.join(tool.containerMount, entry),
1231
+ volumeArgs: ['-v', volumeArg(engine, hostPath, stagingPath, ':ro')]
1232
+ }];
1199
1233
  }));
1200
1234
  const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
1201
1235
  .filter(({ hostPath }) => fs.existsSync(hostPath))
@@ -1238,7 +1272,7 @@ export async function create(args) {
1238
1272
  ...dotfilesMount,
1239
1273
  ...toolVolumes,
1240
1274
  ...tmpfsArgs,
1241
- ...tmpfsSeedVolumes,
1275
+ ...tmpfsSeedPlan.flatMap(({ volumeArgs }) => volumeArgs),
1242
1276
  ...liveMountVolumes,
1243
1277
  ...shellConfigVolumes,
1244
1278
  ...envFile.dockerArgs,
@@ -1251,6 +1285,14 @@ export async function create(args) {
1251
1285
  finally {
1252
1286
  envFile.cleanup();
1253
1287
  }
1288
+ for (const { stagingPath, targetPath } of tmpfsSeedPlan) {
1289
+ runEngineTaskCommand(engine, 'docker', [
1290
+ 'exec', container, 'mkdir', '-p', path.posix.dirname(targetPath)
1291
+ ]);
1292
+ runEngineTaskCommand(engine, 'docker', [
1293
+ 'exec', container, 'cp', '-R', '--', stagingPath, targetPath
1294
+ ]);
1295
+ }
1254
1296
  // Belt-and-suspenders: re-create the four shell-config symlinks at
1255
1297
  // runtime so users with a custom `sandbox.dockerfile` (which won't
1256
1298
  // include the ai-tools.dockerfile symlink fragment) still get
@@ -1,7 +1,7 @@
1
1
  import { platform } from 'node:os';
2
2
  import { detectHostResources } from './constants.js';
3
3
  import { ADAPTERS, enginesForPlatform, getAdapter } from './engines/index.js';
4
- import { run, runOk, runSafe, runVerbose } from './shell.js';
4
+ import { run, runOk, runOkEngine, runSafe, runVerbose } from './shell.js';
5
5
  export const ENGINES = Object.freeze({
6
6
  COLIMA: 'colima',
7
7
  ORBSTACK: 'orbstack',
@@ -27,6 +27,34 @@ function applyDockerContext(adapter) {
27
27
  process.env.DOCKER_CONTEXT = adapter.dockerContext;
28
28
  }
29
29
  }
30
+ function buildKitError(engine) {
31
+ let repair;
32
+ switch (engine) {
33
+ case ENGINES.COLIMA:
34
+ repair = 'Install the Docker Buildx plugin with: brew install docker-buildx';
35
+ break;
36
+ case ENGINES.DOCKER_DESKTOP:
37
+ case ENGINES.WSL2:
38
+ repair = 'Upgrade or repair Docker Desktop so Docker Buildx and BuildKit are available.';
39
+ break;
40
+ case ENGINES.NATIVE:
41
+ repair = 'Install the Docker Buildx plugin for your Docker Engine installation.';
42
+ break;
43
+ default:
44
+ repair = `Upgrade or repair ${engineDisplayName(engine)} so Docker Buildx and BuildKit are available.`;
45
+ }
46
+ return new Error([
47
+ `Docker BuildKit is not available for ${engineDisplayName(engine)}.`,
48
+ repair,
49
+ 'Verify the builder with: docker buildx inspect --bootstrap',
50
+ 'Then retry the sandbox create or rebuild command.'
51
+ ].join('\n'));
52
+ }
53
+ function ensureBuildKit(engine, runOkEngineFn) {
54
+ if (!runOkEngineFn(engine, 'docker', ['buildx', 'inspect', '--bootstrap'])) {
55
+ throw buildKitError(engine);
56
+ }
57
+ }
30
58
  export function validateSandboxEngine(engine, { platformFn = platform } = {}) {
31
59
  if (engine === null || engine === undefined) {
32
60
  return null;
@@ -90,9 +118,11 @@ export async function ensureDocker(config, onMessage, dependencies = {}) {
90
118
  const engine = detectEngine(config, dependencies);
91
119
  const adapter = getAdapter(engine);
92
120
  const effectiveConfig = effectiveConfigFor(adapter, config);
121
+ const fns = runFns(dependencies);
93
122
  applyDockerContext(adapter);
94
- const vmJustStarted = await adapter.ensure(effectiveConfig, onMessage, runFns(dependencies));
95
- adapter.syncResources(effectiveConfig, onMessage, runFns(dependencies), { vmJustStarted });
123
+ const vmJustStarted = await adapter.ensure(effectiveConfig, onMessage, fns);
124
+ ensureBuildKit(engine, dependencies.runOkEngineFn ?? runOkEngine);
125
+ adapter.syncResources(effectiveConfig, onMessage, fns, { vmJustStarted });
96
126
  }
97
127
  export function isVmManaged(config = {}, dependencies = {}) {
98
128
  try {
@@ -28,8 +28,8 @@ export const colimaAdapter = {
28
28
  async ensure(config, onMessage, { runOk, runSafe, runVerbose }) {
29
29
  let started = false;
30
30
  if (!runOk('which', ['colima'])) {
31
- onMessage?.('Installing colima + docker via Homebrew...');
32
- runVerbose('brew', ['install', 'colima', 'docker']);
31
+ onMessage?.('Installing colima + docker + docker-buildx via Homebrew...');
32
+ runVerbose('brew', ['install', 'colima', 'docker', 'docker-buildx']);
33
33
  }
34
34
  if (!runOk('colima', ['status'])) {
35
35
  onMessage?.('Starting Colima VM...');
@@ -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
@@ -1,6 +1,7 @@
1
1
  import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { getAdapter } from './engines/index.js';
4
5
  const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
5
6
  function normalizeOptions(opts = {}, stdio) {
6
7
  return {
@@ -114,6 +115,12 @@ export function commandForEngine(engine, cmd, args = []) {
114
115
  const resolvedWrapper = resolveCommand('wsl.exe');
115
116
  return { cmd: resolvedWrapper, args: ['--', cmd, ...args] };
116
117
  }
118
+ if (cmd === 'docker') {
119
+ const context = getAdapter(engine).dockerContext;
120
+ if (context) {
121
+ return { cmd, args: ['--context', context, ...args] };
122
+ }
123
+ }
117
124
  return { cmd, args };
118
125
  }
119
126
  export function runEngine(engine, cmd, args, opts = {}) {
@@ -122,7 +129,8 @@ export function runEngine(engine, cmd, args, opts = {}) {
122
129
  }
123
130
  export function execEngine(engine, cmd, args, opts = {}) {
124
131
  const command = commandForEngine(engine, cmd, args);
125
- return execFileSync(command.cmd, command.args, opts);
132
+ const resolved = resolveCommand(command.cmd);
133
+ return execFileSync(resolved, command.args, commandOptions(resolved, opts));
126
134
  }
127
135
  export function runOkEngine(engine, cmd, args, opts = {}) {
128
136
  const command = commandForEngine(engine, cmd, args);