@fitlab-ai/agent-infra 0.8.3 → 0.8.4

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 (100) 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 +1 -0
  6. package/dist/lib/sandbox/capture.js +5 -3
  7. package/dist/lib/sandbox/clipboard/bridge.js +12 -9
  8. package/dist/lib/sandbox/clipboard/inbox.js +82 -0
  9. package/dist/lib/sandbox/commands/create.js +39 -23
  10. package/dist/lib/sandbox/runtimes/base.dockerfile +3 -0
  11. package/dist/lib/sandbox/shell.js +9 -1
  12. package/dist/lib/sandbox/tools.js +2 -2
  13. package/lib/cp.ts +56 -15
  14. package/lib/defaults.json +1 -0
  15. package/lib/sandbox/capture.ts +5 -3
  16. package/lib/sandbox/clipboard/bridge.ts +14 -12
  17. package/lib/sandbox/clipboard/inbox.ts +83 -0
  18. package/lib/sandbox/commands/create.ts +48 -24
  19. package/lib/sandbox/runtimes/base.dockerfile +3 -0
  20. package/lib/sandbox/shell.ts +11 -2
  21. package/lib/sandbox/tools.ts +5 -5
  22. package/package.json +1 -1
  23. package/templates/.agents/README.en.md +3 -0
  24. package/templates/.agents/README.zh-CN.md +3 -0
  25. package/templates/.agents/rules/create-issue.github.en.md +1 -1
  26. package/templates/.agents/rules/create-issue.github.zh-CN.md +1 -1
  27. package/templates/.agents/rules/review-handshake.en.md +2 -2
  28. package/templates/.agents/rules/review-handshake.zh-CN.md +2 -2
  29. package/templates/.agents/rules/testing-discipline.en.md +3 -41
  30. package/templates/.agents/rules/testing-discipline.zh-CN.md +3 -41
  31. package/templates/.agents/scripts/validate-artifact.js +93 -0
  32. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
  33. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +2 -2
  34. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +1 -1
  35. package/templates/.agents/skills/block-task/SKILL.en.md +1 -1
  36. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
  37. package/templates/.agents/skills/cancel-task/SKILL.en.md +1 -1
  38. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +2 -2
  39. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +9 -9
  40. package/templates/.agents/skills/close-codescan/SKILL.en.md +1 -1
  41. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +2 -2
  42. package/templates/.agents/skills/close-dependabot/SKILL.en.md +1 -1
  43. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +2 -2
  44. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  45. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
  46. package/templates/.agents/skills/code-task/reference/branch-management.zh-CN.md +2 -2
  47. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +2 -2
  48. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +2 -2
  49. package/templates/.agents/skills/code-task/reference/output-template.zh-CN.md +1 -1
  50. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  51. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  52. package/templates/.agents/skills/commit/reference/task-status-update.en.md +6 -6
  53. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +6 -6
  54. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +27 -3
  55. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +27 -3
  56. package/templates/.agents/skills/complete-task/SKILL.en.md +1 -1
  57. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -1
  58. package/templates/.agents/skills/create-pr/SKILL.en.md +4 -4
  59. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +4 -4
  60. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  61. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  62. package/templates/.agents/skills/create-task/SKILL.en.md +2 -2
  63. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +5 -5
  64. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -2
  65. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +3 -3
  66. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -2
  67. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +3 -3
  68. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -2
  69. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +3 -3
  70. package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +1 -1
  71. package/templates/.agents/skills/init-milestones/SKILL.en.md +3 -2
  72. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +4 -3
  73. package/templates/.agents/skills/init-milestones/scripts/init-milestones.github.sh +165 -41
  74. package/templates/.agents/skills/plan-task/SKILL.en.md +1 -1
  75. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -2
  76. package/templates/.agents/skills/restore-task/SKILL.en.md +1 -1
  77. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +1 -1
  78. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  79. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  80. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +5 -5
  81. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +5 -5
  82. package/templates/.agents/skills/review-code/SKILL.en.md +8 -4
  83. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +6 -3
  84. package/templates/.agents/skills/review-code/config/verify.en.json +1 -0
  85. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -0
  86. package/templates/.agents/skills/review-code/reference/output-templates.en.md +5 -5
  87. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +5 -5
  88. package/templates/.agents/skills/review-code/reference/report-template.en.md +2 -2
  89. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +2 -2
  90. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  91. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  92. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +5 -5
  93. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +5 -5
  94. package/templates/.agents/skills/test/SKILL.zh-CN.md +26 -8
  95. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -1
  96. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +29 -6
  97. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -2
  98. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +2 -2
  99. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +8 -1
  100. 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 {
@@ -52,6 +52,7 @@
52
52
  "**/test-integration.*",
53
53
  "**/test.*",
54
54
  "**/upgrade-dependency.*",
55
+ ".agents/rules/testing-discipline.*",
55
56
  ".agents/skills/post-release/SKILL.*",
56
57
  ".agents/skills/release/SKILL.*",
57
58
  ".agents/skills/test-integration/SKILL.*",
@@ -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
@@ -209,12 +209,14 @@ export function writeSanitizedGitconfig({ home, hostConfigDir, stripGpg, repoRoo
209
209
  // Keep in sync with the symlink block in lib/sandbox/runtimes/ai-tools.dockerfile.
210
210
  const SHELL_CONFIG_SYMLINKS = ['.gitconfig', '.gitignore_global', '.stCommitMsg', '.bash_aliases'];
211
211
  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' });
212
+ // Idempotent symlink setup. Avoid a shell command here because Windows .cmd
213
+ // engine shims would interpret metacharacters in a `bash -lc` script before
214
+ // forwarding it to Docker.
215
+ for (const file of SHELL_CONFIG_SYMLINKS) {
216
+ execFn(engine, 'docker', [
217
+ 'exec', container, 'ln', '-sf', `.host-shell-config/${file}`, `${CONTAINER_HOME}/${file}`
218
+ ], { stdio: 'ignore' });
219
+ }
218
220
  }
219
221
  export function prepareHostShellConfig({ home, project, branch, repoRoot }) {
220
222
  const hostDir = hostShellConfigDir(home, project, branch);
@@ -424,9 +426,12 @@ function formatEnvFileEntry(key, value) {
424
426
  return `${key}=${value}`;
425
427
  }
426
428
  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;
429
+ const { mkdtempFn = fs.mkdtempSync, writeFileFn = fs.writeFileSync, chmodFn = fs.chmodSync, rmFn = fs.rmSync, tmpDir = os.tmpdir(), runSafeFn = runSafe } = options;
428
430
  const entries = resolvedTools.flatMap(({ tool }) => Object.entries(tool.envVars ?? {}));
429
- const ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
431
+ let ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
432
+ if (!ghToken && engine === 'wsl2') {
433
+ ghToken = runSafeFn('gh', ['auth', 'token']);
434
+ }
430
435
  if (ghToken) {
431
436
  entries.push(['GH_TOKEN', ghToken]);
432
437
  }
@@ -1145,6 +1150,7 @@ export async function create(args) {
1145
1150
  : null;
1146
1151
  const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
1147
1152
  let hostShellConfig;
1153
+ let tmpfsSeedPlan = [];
1148
1154
  try {
1149
1155
  const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
1150
1156
  if (claudeCodeEntry) {
@@ -1181,21 +1187,23 @@ export async function create(args) {
1181
1187
  '-v',
1182
1188
  volumeArg(engine, hostPath, containerPath, ':ro')
1183
1189
  ]);
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) => {
1190
+ // Mount each declared seed read-only at an isolated staging path.
1191
+ // After docker run mounts the empty tmpfs, the container's default
1192
+ // user copies these entries into it so the runtime targets are normal,
1193
+ // writable files rather than nested mount points. The allowlist keeps
1194
+ // stale runtime files (logs_2.sqlite, sessions, etc.) on the host from
1195
+ // being exposed or written through.
1196
+ tmpfsSeedPlan = effectiveResolvedTools.flatMap(({ tool, dir }) => (tool.tmpfs?.seed ?? []).flatMap((entry, index) => {
1195
1197
  const hostPath = path.join(dir, entry);
1196
- return fs.existsSync(hostPath)
1197
- ? ['-v', volumeArg(engine, hostPath, path.posix.join(tool.containerMount, entry))]
1198
- : [];
1198
+ if (!fs.existsSync(hostPath)) {
1199
+ return [];
1200
+ }
1201
+ const stagingPath = path.posix.join('/run/agent-infra/tmpfs-seeds', tool.id, String(index));
1202
+ return [{
1203
+ stagingPath,
1204
+ targetPath: path.posix.join(tool.containerMount, entry),
1205
+ volumeArgs: ['-v', volumeArg(engine, hostPath, stagingPath, ':ro')]
1206
+ }];
1199
1207
  }));
1200
1208
  const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
1201
1209
  .filter(({ hostPath }) => fs.existsSync(hostPath))
@@ -1238,7 +1246,7 @@ export async function create(args) {
1238
1246
  ...dotfilesMount,
1239
1247
  ...toolVolumes,
1240
1248
  ...tmpfsArgs,
1241
- ...tmpfsSeedVolumes,
1249
+ ...tmpfsSeedPlan.flatMap(({ volumeArgs }) => volumeArgs),
1242
1250
  ...liveMountVolumes,
1243
1251
  ...shellConfigVolumes,
1244
1252
  ...envFile.dockerArgs,
@@ -1251,6 +1259,14 @@ export async function create(args) {
1251
1259
  finally {
1252
1260
  envFile.cleanup();
1253
1261
  }
1262
+ for (const { stagingPath, targetPath } of tmpfsSeedPlan) {
1263
+ runEngineTaskCommand(engine, 'docker', [
1264
+ 'exec', container, 'mkdir', '-p', path.posix.dirname(targetPath)
1265
+ ]);
1266
+ runEngineTaskCommand(engine, 'docker', [
1267
+ 'exec', container, 'cp', '-R', '--', stagingPath, targetPath
1268
+ ]);
1269
+ }
1254
1270
  // Belt-and-suspenders: re-create the four shell-config symlinks at
1255
1271
  // runtime so users with a custom `sandbox.dockerfile` (which won't
1256
1272
  // include the ai-tools.dockerfile symlink fragment) still get
@@ -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
@@ -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);
@@ -44,8 +44,8 @@ function createBuiltinTools(home, project) {
44
44
  // codex churns ~/.codex/logs_2.sqlite heavily (upstream openai/codex#24275);
45
45
  // a bind-mount would write-amplify onto the host SSD via virtiofs. Mount the
46
46
  // codex home as tmpfs so those logs stay in RAM and die with the container.
47
- // Only the seeded config (config.toml, model-catalogs) is bound back over
48
- // the tmpfs; runtime files like logs_2.sqlite must stay in RAM.
47
+ // Seeded config (config.toml, model-catalogs) is copied into the tmpfs at
48
+ // startup; runtime files like logs_2.sqlite stay in RAM.
49
49
  tmpfs: { size: '512m', seed: ['config.toml', 'model-catalogs'] },
50
50
  hostLiveMounts: [
51
51
  { hostPath: hostJoin(home, '.codex', 'auth.json'), containerSubpath: 'auth.json' }
package/lib/cp.ts CHANGED
@@ -5,8 +5,9 @@ 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, type ClipboardAdapter } from './sandbox/clipboard/index.ts';
8
+ import { RECEIVER_CAPABILITY, receiveRemoteClipboardPng } from './sandbox/clipboard/inbox.ts';
8
9
 
9
- const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS NSPasteboard over ssh/scp.\n';
10
+ 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';
10
11
  const COMMAND_TIMEOUT_MS = 30_000;
11
12
 
12
13
  export type SpawnResult = {
@@ -65,6 +66,21 @@ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number>
65
66
  writeStderr = (chunk: string) => process.stderr.write(chunk)
66
67
  } = deps;
67
68
 
69
+ if (args.length === 1 && args[0] === '--remote-receive-capability') {
70
+ if (platform === 'linux') writeStdout(`${RECEIVER_CAPABILITY}\n`);
71
+ return platform === 'linux' ? 0 : 1;
72
+ }
73
+ if (args.length === 2 && args[0] === '--remote-receive-v1') {
74
+ try {
75
+ const result = receiveRemoteClipboardPng(process.env.HOME || process.env.USERPROFILE || '', args[1]!, platform);
76
+ writeStdout(`${result}\n`);
77
+ return 0;
78
+ } catch (error) {
79
+ writeStderr(`${error instanceof Error ? error.message : 'clipboard receive failed'}\n`);
80
+ return 1;
81
+ }
82
+ }
83
+
68
84
  if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
69
85
  writeStdout(USAGE);
70
86
  return 0;
@@ -110,6 +126,35 @@ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number>
110
126
  writeFileFn(localPng, png);
111
127
 
112
128
  remotePath = `/tmp/agent-infra-cp-${randomId()}.png`;
129
+ const remoteSystem = spawnFn('ssh', [
130
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'uname', '-s'
131
+ ]);
132
+ if (remoteSystem.status !== 0) {
133
+ writeStderr(`failed to detect remote platform on ${alias}:\n${commandDetail(remoteSystem)}\n`);
134
+ return 1;
135
+ }
136
+ const remotePlatform = remoteSystem.stdout.trim();
137
+ if (remotePlatform !== 'Darwin' && remotePlatform !== 'Linux') {
138
+ writeStderr(`unsupported remote platform on ${alias}: ${remotePlatform || 'unknown'}\n`);
139
+ return 1;
140
+ }
141
+ if (remotePlatform === 'Linux') {
142
+ const homeCheck = spawnFn('ssh', [
143
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'test', '-d', '$HOME/.agent-infra'
144
+ ]);
145
+ if (homeCheck.status !== 0) {
146
+ writeStderr(`remote agent-infra installation is missing on ${alias}\n`);
147
+ return 1;
148
+ }
149
+ const capability = spawnFn('ssh', [
150
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'ai', 'cp', '--remote-receive-capability'
151
+ ]);
152
+ if (capability.status !== 0 || capability.stderr !== '' || capability.stdout.replace(/\n$/u, '') !== RECEIVER_CAPABILITY) {
153
+ writeStderr(`remote agent-infra clipboard receiver is unavailable or incompatible on ${alias}; update agent-infra\n`);
154
+ return 1;
155
+ }
156
+ }
157
+
113
158
  const upload = spawnFn('scp', [
114
159
  '-o',
115
160
  'BatchMode=yes',
@@ -124,25 +169,21 @@ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number>
124
169
  }
125
170
  uploaded = true;
126
171
 
127
- // Remote write currently targets macOS only: it pipes an AppleScript to the
128
- // remote `osascript` to set its NSPasteboard. This is the extension point for
129
- // other remote platforms later (e.g. dispatch on remote OS to wl-copy/xclip
130
- // on Linux); a non-macOS remote fails here with a clear non-zero error today.
131
- const setRemote = spawnFn('ssh', [
132
- '-o',
133
- 'BatchMode=yes',
134
- '-o',
135
- 'ConnectTimeout=10',
136
- alias,
137
- 'osascript',
138
- '-'
139
- ], remoteSetScript(remotePath));
172
+ const setRemote = remotePlatform === 'Darwin'
173
+ ? spawnFn('ssh', [
174
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'osascript', '-'
175
+ ], remoteSetScript(remotePath))
176
+ : spawnFn('ssh', [
177
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'ai', 'cp', '--remote-receive-v1', remotePath
178
+ ]);
140
179
  if (setRemote.status !== 0) {
141
180
  writeStderr(`failed to set remote clipboard on ${alias}:\n${commandDetail(setRemote)}\n`);
142
181
  return 1;
143
182
  }
144
183
 
145
- writeStdout(`copied clipboard image to ${alias}\n`);
184
+ writeStdout(remotePlatform === 'Linux'
185
+ ? `uploaded clipboard image to ${alias}; return to the SSH session and press Ctrl+V\n`
186
+ : `copied clipboard image to ${alias}\n`);
146
187
  return 0;
147
188
  } finally {
148
189
  if (uploaded && remotePath) {
package/lib/defaults.json CHANGED
@@ -52,6 +52,7 @@
52
52
  "**/test-integration.*",
53
53
  "**/test.*",
54
54
  "**/upgrade-dependency.*",
55
+ ".agents/rules/testing-discipline.*",
55
56
  ".agents/skills/post-release/SKILL.*",
56
57
  ".agents/skills/release/SKILL.*",
57
58
  ".agents/skills/test-integration/SKILL.*",
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { loadConfig } from './config.ts';
3
3
  import { containerNameCandidates, sandboxBranchLabel, sandboxLabel } from './constants.ts';
4
4
  import { detectEngine } from './engine.ts';
5
+ import { commandForEngine } from './shell.ts';
5
6
  import { hostTimezoneEnvFlags, terminalEnvFlags } from './commands/enter.ts';
6
7
  import {
7
8
  fetchSandboxRows,
@@ -95,12 +96,12 @@ function buildRunScript(params: {
95
96
  return `#!/bin/sh
96
97
  set -u
97
98
  cd /workspace
98
- date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/started_at
99
+ date "+%Y-%m-%d %H:%M:%S%z" | sed 's/\\([+-][0-9][0-9]\\)\\([0-9][0-9]\\)$/\\1:\\2/' > ${runDir}/started_at
99
100
  printf '%s\\n' running > ${runDir}/status
100
101
  ${command}
101
102
  code=$?
102
103
  printf '%s\\n' "$code" > ${runDir}/exit_code
103
- date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/finished_at
104
+ date "+%Y-%m-%d %H:%M:%S%z" | sed 's/\\([+-][0-9][0-9]\\)\\([0-9][0-9]\\)$/\\1:\\2/' > ${runDir}/finished_at
104
105
  if [ "$code" -eq 0 ]; then
105
106
  printf '%s\\n' completed > ${runDir}/status
106
107
  else
@@ -202,7 +203,8 @@ export async function runInSandbox(
202
203
  '-lc',
203
204
  buildTmuxLauncher({ request, runId })
204
205
  ];
205
- const result = await (options.spawn ?? spawnCapture)('docker', dockerArgs);
206
+ const command = commandForEngine(engine, 'docker', dockerArgs);
207
+ const result = await (options.spawn ?? spawnCapture)(command.cmd, command.args);
206
208
  return {
207
209
  ...result,
208
210
  run: {