@fitlab-ai/agent-infra 0.9.3 → 0.9.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 (175) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/bin/cli.ts +12 -0
  4. package/dist/bin/cli.js +14 -0
  5. package/dist/bin/internal-cli.js +134 -123
  6. package/dist/lib/agent-clients/adapter.js +92 -2
  7. package/dist/lib/{sandbox/credentials.js → agent-clients/adapters/claude-code-credentials.js} +4 -14
  8. package/dist/lib/agent-clients/adapters/claude-code-refresh.js +85 -0
  9. package/dist/lib/agent-clients/adapters/claude-code-sandbox.js +247 -0
  10. package/dist/lib/agent-clients/adapters/claude-code.js +17 -3
  11. package/dist/lib/agent-clients/adapters/opencode-sandbox.js +108 -0
  12. package/dist/lib/agent-clients/adapters/opencode.js +15 -3
  13. package/dist/lib/agent-clients/adapters/runtimes/claude-code.dockerfile +65 -0
  14. package/dist/lib/agent-clients/registry.js +9 -1
  15. package/dist/lib/internal/sandbox-control.js +24 -0
  16. package/dist/lib/internal/task-activity.js +45 -13
  17. package/dist/lib/internal/task-ledger.js +12 -3
  18. package/dist/lib/internal/task-lifecycle.js +19 -1
  19. package/dist/lib/internal/task-orchestration.js +122 -18
  20. package/dist/lib/platform/github-client.js +19 -2
  21. package/dist/lib/platform/pr-checks.js +1 -1
  22. package/dist/lib/platform/pull-requests.js +47 -1
  23. package/dist/lib/platform/releases.js +13 -5
  24. package/dist/lib/process-data/index.js +239 -0
  25. package/dist/lib/process-data/normalize.js +198 -0
  26. package/dist/lib/process-data/privacy.js +33 -0
  27. package/dist/lib/process-data/reconcile.js +95 -0
  28. package/dist/lib/process-data/repair.js +58 -0
  29. package/dist/lib/process-data/sources.js +345 -0
  30. package/dist/lib/process-data/store.js +282 -0
  31. package/dist/lib/process-data/types.js +2 -0
  32. package/dist/lib/run/index.js +4 -3
  33. package/dist/lib/run/tui.js +7 -8
  34. package/dist/lib/sandbox/agent-client-reconciler.js +26 -2
  35. package/dist/lib/sandbox/capture.js +7 -2
  36. package/dist/lib/sandbox/commands/create.js +109 -228
  37. package/dist/lib/sandbox/commands/enter.js +15 -47
  38. package/dist/lib/sandbox/commands/list-running.js +16 -6
  39. package/dist/lib/sandbox/commands/ls.js +10 -6
  40. package/dist/lib/sandbox/commands/rebuild.js +4 -3
  41. package/dist/lib/sandbox/commands/refresh.js +4 -84
  42. package/dist/lib/sandbox/commands/rm.js +24 -3
  43. package/dist/lib/sandbox/commands/show.js +12 -3
  44. package/dist/lib/sandbox/commands/start.js +7 -4
  45. package/dist/lib/sandbox/config.js +2 -0
  46. package/dist/lib/sandbox/constants.js +6 -0
  47. package/dist/lib/sandbox/control/client.js +44 -0
  48. package/dist/lib/sandbox/control/protocol.js +35 -0
  49. package/dist/lib/sandbox/control/server.js +109 -0
  50. package/dist/lib/sandbox/dockerfile.js +36 -13
  51. package/dist/lib/sandbox/mounts.js +17 -4
  52. package/dist/lib/sandbox/recovery.js +128 -5
  53. package/dist/lib/sandbox/redaction.js +12 -0
  54. package/dist/lib/sandbox/runtimes/base.dockerfile +24 -74
  55. package/dist/lib/sandbox/workspace-identity.js +95 -0
  56. package/dist/lib/sandbox/workspace-view.js +103 -0
  57. package/dist/lib/task/activity-intent.js +257 -51
  58. package/dist/lib/task/commit-finalization.js +267 -0
  59. package/dist/lib/task/commit-intent.js +165 -0
  60. package/dist/lib/task/decision-intents.js +31 -17
  61. package/dist/lib/task/delegation-receipts.js +2 -2
  62. package/dist/lib/task/implementation-inputs.js +54 -7
  63. package/dist/lib/task/ledger-intents.js +35 -1
  64. package/dist/lib/task/orchestration.js +363 -28
  65. package/dist/lib/task/resolve-ref.js +41 -1
  66. package/lib/agent-clients/adapter.ts +126 -2
  67. package/lib/{sandbox/credentials.ts → agent-clients/adapters/claude-code-credentials.ts} +3 -18
  68. package/lib/agent-clients/adapters/claude-code-refresh.ts +128 -0
  69. package/lib/agent-clients/adapters/claude-code-sandbox.ts +295 -0
  70. package/lib/agent-clients/adapters/claude-code.ts +21 -3
  71. package/lib/agent-clients/adapters/opencode-sandbox.ts +136 -0
  72. package/lib/agent-clients/adapters/opencode.ts +18 -3
  73. package/lib/agent-clients/adapters/runtimes/claude-code.dockerfile +65 -0
  74. package/lib/agent-clients/registry.ts +9 -1
  75. package/lib/internal/sandbox-control.ts +24 -0
  76. package/lib/internal/task-activity.ts +50 -14
  77. package/lib/internal/task-ledger.ts +12 -3
  78. package/lib/internal/task-lifecycle.ts +22 -1
  79. package/lib/internal/task-orchestration.ts +81 -11
  80. package/lib/platform/github-client.ts +20 -2
  81. package/lib/platform/pr-checks.ts +1 -1
  82. package/lib/platform/pull-requests.ts +49 -1
  83. package/lib/platform/releases.ts +16 -4
  84. package/lib/process-data/index.ts +224 -0
  85. package/lib/process-data/normalize.ts +202 -0
  86. package/lib/process-data/privacy.ts +41 -0
  87. package/lib/process-data/reconcile.ts +110 -0
  88. package/lib/process-data/repair.ts +64 -0
  89. package/lib/process-data/sources.ts +354 -0
  90. package/lib/process-data/store.ts +315 -0
  91. package/lib/process-data/types.ts +135 -0
  92. package/lib/run/index.ts +5 -3
  93. package/lib/run/tui.ts +11 -8
  94. package/lib/sandbox/agent-client-reconciler.ts +44 -2
  95. package/lib/sandbox/capture.ts +9 -2
  96. package/lib/sandbox/commands/create.ts +126 -260
  97. package/lib/sandbox/commands/enter.ts +13 -59
  98. package/lib/sandbox/commands/list-running.ts +23 -6
  99. package/lib/sandbox/commands/ls.ts +22 -6
  100. package/lib/sandbox/commands/rebuild.ts +4 -3
  101. package/lib/sandbox/commands/refresh.ts +4 -126
  102. package/lib/sandbox/commands/rm.ts +24 -3
  103. package/lib/sandbox/commands/show.ts +29 -3
  104. package/lib/sandbox/commands/start.ts +6 -4
  105. package/lib/sandbox/config.ts +4 -0
  106. package/lib/sandbox/constants.ts +8 -0
  107. package/lib/sandbox/control/client.ts +55 -0
  108. package/lib/sandbox/control/protocol.ts +75 -0
  109. package/lib/sandbox/control/server.ts +117 -0
  110. package/lib/sandbox/dockerfile.ts +66 -13
  111. package/lib/sandbox/mounts.ts +24 -4
  112. package/lib/sandbox/recovery.ts +163 -6
  113. package/lib/sandbox/redaction.ts +14 -0
  114. package/lib/sandbox/runtimes/base.dockerfile +24 -74
  115. package/lib/sandbox/tool-types.ts +16 -0
  116. package/lib/sandbox/workspace-identity.ts +123 -0
  117. package/lib/sandbox/workspace-view.ts +143 -0
  118. package/lib/task/activity-intent.ts +350 -61
  119. package/lib/task/commit-finalization.ts +356 -0
  120. package/lib/task/commit-intent.ts +230 -0
  121. package/lib/task/decision-intents.ts +32 -12
  122. package/lib/task/delegation-receipts.ts +2 -2
  123. package/lib/task/implementation-inputs.ts +62 -8
  124. package/lib/task/ledger-intents.ts +43 -3
  125. package/lib/task/orchestration.ts +501 -33
  126. package/lib/task/resolve-ref.ts +46 -1
  127. package/package.json +1 -1
  128. package/templates/.agents/rules/README.en.md +1 -1
  129. package/templates/.agents/rules/README.zh-CN.md +1 -1
  130. package/templates/.agents/rules/human-decision-context.en.md +11 -2
  131. package/templates/.agents/rules/human-decision-context.zh-CN.md +11 -2
  132. package/templates/.agents/rules/pr-checks-commands.en.md +1 -1
  133. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  134. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +2 -2
  135. package/templates/.agents/rules/pr-checks-commands.zh-CN.md +1 -1
  136. package/templates/.agents/rules/review-handshake.zh-CN.md +1 -1
  137. package/templates/.agents/rules/task-management.en.md +2 -0
  138. package/templates/.agents/rules/task-management.zh-CN.md +2 -0
  139. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  140. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
  141. package/templates/.agents/skills/commit/SKILL.en.md +21 -11
  142. package/templates/.agents/skills/commit/SKILL.zh-CN.md +27 -11
  143. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +49 -0
  144. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +55 -0
  145. package/templates/.agents/skills/commit/reference/task-status-update.en.md +4 -4
  146. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +4 -4
  147. package/templates/.agents/skills/complete-task/SKILL.en.md +1 -1
  148. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -1
  149. package/templates/.agents/skills/create-pr/SKILL.en.md +4 -4
  150. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +4 -4
  151. package/templates/.agents/skills/create-task/reference/context-capture.en.md +34 -0
  152. package/templates/.agents/skills/create-task/reference/context-capture.zh-CN.md +34 -0
  153. package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
  154. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
  155. package/templates/.agents/skills/review-code/reference/output-templates.en.md +1 -1
  156. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +1 -1
  157. package/templates/.agents/skills/review-pr/SKILL.en.md +36 -8
  158. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +36 -8
  159. package/templates/.agents/skills/review-pr/reference/report-template.en.md +1 -1
  160. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +1 -1
  161. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +67 -50
  162. package/templates/.agents/skills/watch-pr/SKILL.en.md +4 -4
  163. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +4 -4
  164. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +2 -2
  165. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +2 -2
  166. package/templates/.agents/templates/task.en.md +1 -1
  167. package/templates/.agents/templates/task.zh-CN.md +1 -1
  168. package/templates/.claude/commands/watch-pr.en.md +1 -1
  169. package/templates/.claude/commands/watch-pr.zh-CN.md +1 -1
  170. package/templates/.claude/rules/tool-preferences.en.md +20 -0
  171. package/templates/.claude/rules/tool-preferences.zh-CN.md +20 -0
  172. package/templates/.opencode/commands/watch-pr.en.md +1 -1
  173. package/templates/.opencode/commands/watch-pr.zh-CN.md +1 -1
  174. package/templates/.gemini/commands/_project_/review-pr.en.toml +0 -10
  175. package/templates/.gemini/commands/_project_/review-pr.zh-CN.toml +0 -10
@@ -64,69 +64,9 @@ RUN printf '%s\n' \
64
64
  'set -g mouse on' \
65
65
  'set -g status-interval 1' \
66
66
  'set -g status-right-length 80' \
67
- "set -g status-right '#(/usr/local/bin/cc-token-status) | %H:%M'" \
67
+ "set -g status-right '%H:%M'" \
68
68
  > /etc/tmux.conf
69
69
 
70
- RUN cat > /usr/local/bin/cc-token-status <<'SCRIPT' && chmod +x /usr/local/bin/cc-token-status
71
- #!/bin/sh
72
- set -eu
73
-
74
- SETTINGS_FILE="/home/devuser/.claude/settings.json"
75
- if [ -r "$SETTINGS_FILE" ] && jq -e '
76
- def has_nonempty($v):
77
- if ($v | type) == "string" then ($v | gsub("^\\s+|\\s+$"; "") | length) > 0 else false end;
78
- (if type == "object" then . else {} end) as $settings
79
- | (if ($settings.env | type) == "object" then $settings.env else {} end) as $env
80
- | has_nonempty($env.ANTHROPIC_AUTH_TOKEN)
81
- or has_nonempty($env.ANTHROPIC_API_KEY)
82
- or has_nonempty($settings.apiKeyHelper)
83
- ' "$SETTINGS_FILE" >/dev/null 2>&1; then
84
- exit 0
85
- fi
86
-
87
- CRED_FILE="/home/devuser/.claude/.credentials.json"
88
- [ -r "$CRED_FILE" ] || exit 0
89
-
90
- EXPIRES_MS=$(jq -r '(.claudeAiOauth.expiresAt // .expiresAt) // empty' "$CRED_FILE" 2>/dev/null || true)
91
- case "$EXPIRES_MS" in
92
- ''|*[!0-9]*) exit 0 ;;
93
- esac
94
-
95
- NOW_MS=$(($(date +%s) * 1000))
96
- DIFF_MS=$((EXPIRES_MS - NOW_MS))
97
- DIFF_S=$((DIFF_MS / 1000))
98
-
99
- DIM='#[fg=colour245]'
100
- YELLOW='#[fg=yellow]'
101
- YELLOW_BOLD='#[fg=yellow,bold]'
102
- RED_BOLD='#[fg=red,bold]'
103
- RED_REV='#[fg=red,reverse]'
104
- RESET='#[default]'
105
-
106
- if [ "$DIFF_S" -le 0 ]; then
107
- ELAPSED=$(( -DIFF_S ))
108
- M=$((ELAPSED / 60))
109
- printf '%sClaude Code auth EXPIRED %dm ago%s' "$RED_REV" "$M" "$RESET"
110
- elif [ "$DIFF_S" -lt 60 ]; then
111
- printf '%sClaude Code auth expires in %ds%s' "$RED_BOLD" "$DIFF_S" "$RESET"
112
- elif [ "$DIFF_S" -lt 300 ]; then
113
- M=$((DIFF_S / 60))
114
- S=$((DIFF_S % 60))
115
- printf '%sClaude Code auth expires in %dm %ds%s' "$RED_BOLD" "$M" "$S" "$RESET"
116
- elif [ "$DIFF_S" -lt 1800 ]; then
117
- M=$((DIFF_S / 60))
118
- printf '%sClaude Code auth expires in %dm%s' "$YELLOW_BOLD" "$M" "$RESET"
119
- elif [ "$DIFF_S" -lt 3600 ]; then
120
- M=$((DIFF_S / 60))
121
- printf '%sClaude Code auth expires in %dm%s' "$YELLOW" "$M" "$RESET"
122
- else
123
- TOTAL_M=$((DIFF_S / 60))
124
- H=$((TOTAL_M / 60))
125
- M=$((TOTAL_M % 60))
126
- printf '%sClaude Code auth expires in %dh %dm%s' "$DIM" "$H" "$M" "$RESET"
127
- fi
128
- SCRIPT
129
-
130
70
  RUN cat > /usr/local/bin/sandbox-dotfiles-link <<'SCRIPT' && chmod +x /usr/local/bin/sandbox-dotfiles-link
131
71
  #!/bin/sh
132
72
  # Mirror /dotfiles/ tree as symlinks under $HOME/, overwriting any image-baked
@@ -136,23 +76,34 @@ set -eu
136
76
  DOTFILES_SRC=/dotfiles
137
77
  [ -d "$DOTFILES_SRC" ] || exit 0
138
78
 
139
- cd "$DOTFILES_SRC"
140
- find . -type f -print | while IFS= read -r rel; do
141
- rel=${rel#./}
142
- target="$HOME/$rel"
143
- case "$rel" in
79
+ is_dotfiles_excluded() {
80
+ candidate=$1
81
+ case "$candidate" in
144
82
  .ssh|.ssh/*|\
145
83
  .gnupg|.gnupg/*|\
146
- .claude|.claude/*|\
147
- .codex|.codex/*|\
148
- .gemini|.gemini/*|\
149
- .config/opencode|.config/opencode/*|\
150
- .local/share/opencode|.local/share/opencode/*|\
151
84
  .host-shell-config|.host-shell-config/*|\
152
85
  .gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases|README.md)
153
- continue ;;
86
+ return 0 ;;
154
87
  esac
155
88
 
89
+ [ -r /etc/agent-infra/dotfiles-exclusions ] || return 1
90
+ while IFS= read -r prefix; do
91
+ [ -n "$prefix" ] || continue
92
+ case "$candidate" in
93
+ "$prefix"|"$prefix"/*) return 0 ;;
94
+ esac
95
+ done < /etc/agent-infra/dotfiles-exclusions
96
+ return 1
97
+ }
98
+
99
+ cd "$DOTFILES_SRC"
100
+ find . -type f -print | while IFS= read -r rel; do
101
+ rel=${rel#./}
102
+ target="$HOME/$rel"
103
+ if is_dotfiles_excluded "$rel"; then
104
+ continue
105
+ fi
106
+
156
107
  mkdir -p "$(dirname "$target")"
157
108
  if [ -d "$target" ] && [ ! -L "$target" ]; then
158
109
  printf 'sandbox-dotfiles-link: skipping %s (existing directory; use nested path like %s/<file> instead)\n' "$target" "$rel" >&2
@@ -198,8 +149,7 @@ fi
198
149
  exec tmux new-session -s "$SESSION"
199
150
  SCRIPT
200
151
 
201
- RUN test -s /usr/local/bin/cc-token-status && test -x /usr/local/bin/cc-token-status && \
202
- test -s /usr/local/bin/sandbox-dotfiles-link && test -x /usr/local/bin/sandbox-dotfiles-link && \
152
+ RUN test -s /usr/local/bin/sandbox-dotfiles-link && test -x /usr/local/bin/sandbox-dotfiles-link && \
203
153
  test -s /usr/local/bin/sandbox-tmux-entry && test -x /usr/local/bin/sandbox-tmux-entry
204
154
 
205
155
  ENV LANG=en_US.UTF-8
@@ -64,15 +64,23 @@ type SandboxResolvedToolState = Readonly<{
64
64
 
65
65
  type SandboxHookCreateContext = Readonly<{
66
66
  hostHome: string;
67
+ hostEnv?: Readonly<NodeJS.ProcessEnv>;
68
+ project: string;
67
69
  resolvedTools: readonly SandboxResolvedToolState[];
68
70
  }>;
69
71
 
72
+ type SandboxHookEnterContext = Readonly<{
73
+ hostHome: string;
74
+ hostEnv?: Readonly<NodeJS.ProcessEnv>;
75
+ }>;
76
+
70
77
  type AgentClientSandboxHookContext = Readonly<{
71
78
  signal: AbortSignal;
72
79
  runCommand(command: SandboxHookCommand): Promise<SandboxHookCommandResult>;
73
80
  config?: Readonly<Record<string, unknown>>;
74
81
  plan?: Readonly<Record<string, unknown>>;
75
82
  create?: SandboxHookCreateContext;
83
+ enter?: SandboxHookEnterContext;
76
84
  }>;
77
85
 
78
86
  type AgentClientSandboxHook = Readonly<{
@@ -113,11 +121,17 @@ type AgentClientSandboxRecoveryCheck = Readonly<{
113
121
  repair?: SandboxRecoveryRepair;
114
122
  }>;
115
123
 
124
+ type AgentClientSandboxImageDescriptor = Readonly<{
125
+ dockerfileFragment?: string;
126
+ dotfilesExclusions?: readonly string[];
127
+ }>;
128
+
116
129
  type AgentClientSandboxDescriptor = Readonly<{
117
130
  createTool(context: SandboxToolContext): SandboxTool;
118
131
  aliases: readonly SandboxAlias[];
119
132
  hooks: readonly AgentClientSandboxHook[];
120
133
  recoveryChecks?: readonly AgentClientSandboxRecoveryCheck[];
134
+ image?: AgentClientSandboxImageDescriptor;
121
135
  }>;
122
136
 
123
137
  export { SANDBOX_HOOK_PHASES };
@@ -126,6 +140,7 @@ export type {
126
140
  AgentClientSandboxHook,
127
141
  AgentClientSandboxHookContext,
128
142
  AgentClientSandboxHookResult,
143
+ AgentClientSandboxImageDescriptor,
129
144
  AgentClientSandboxRecoveryCheck,
130
145
  SandboxAlias,
131
146
  SandboxHookCommand,
@@ -133,6 +148,7 @@ export type {
133
148
  SandboxHookPhase,
134
149
  SandboxHookStatus,
135
150
  SandboxHookCreateContext,
151
+ SandboxHookEnterContext,
136
152
  SandboxRecoveryFindingDescriptor,
137
153
  SandboxRecoveryProbe,
138
154
  SandboxRecoveryRepair,
@@ -0,0 +1,123 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isRemovedHashShortIdInput } from '../task/short-id.ts';
4
+
5
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
+ const SHORT_ID_RE = /^\d+$/;
7
+
8
+ export type SandboxWorkspaceIdentity =
9
+ | Readonly<{ mode: 'task-bound'; taskId: string; shortId: string }>
10
+ | Readonly<{ mode: 'branch-only' }>;
11
+
12
+ export type SandboxContainerWorkspaceIdentity =
13
+ | Readonly<{ mode: 'task-bound'; taskId: string }>
14
+ | Readonly<{ mode: 'branch-only' }>
15
+ | Readonly<{ mode: 'legacy-invalid' }>;
16
+
17
+ export type SandboxTarget = Readonly<{
18
+ requestedRef: string;
19
+ branch: string;
20
+ workspace: SandboxWorkspaceIdentity;
21
+ }>;
22
+
23
+ function stripQuotes(value: string): string {
24
+ return value.replace(/^(?:"([^"]*)"|'([^']*)')$/, '$1$2');
25
+ }
26
+
27
+ function taskBranch(taskMd: string, taskId: string): string {
28
+ const content = fs.readFileSync(taskMd, 'utf8');
29
+ const value = content.match(/^branch:\s*(.+)$/m)?.[1]?.trim()
30
+ ?? content.match(/^- \*\*(?:分支|Branch)\*\*:[ \t]*`?([^`\n]+)`?$/m)?.[1]?.trim();
31
+ if (!value) throw new Error(`Task ${taskId} has no branch field in task.md`);
32
+ return stripQuotes(value);
33
+ }
34
+
35
+ function activeRegistry(repoRoot: string): Record<string, string> {
36
+ const registryPath = path.join(repoRoot, '.agents', 'workspace', 'active', '.short-ids.json');
37
+ if (!fs.existsSync(registryPath)) return {};
38
+ const value = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown;
39
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
40
+ throw new Error('Active task short-id registry is invalid');
41
+ }
42
+ const ids = 'ids' in value && value.ids && typeof value.ids === 'object' && !Array.isArray(value.ids)
43
+ ? value.ids as Record<string, unknown>
44
+ : value as Record<string, unknown>;
45
+ return Object.fromEntries(
46
+ Object.entries(ids).filter((entry): entry is [string, string] =>
47
+ SHORT_ID_RE.test(entry[0]) && typeof entry[1] === 'string' && TASK_ID_RE.test(entry[1])
48
+ )
49
+ );
50
+ }
51
+
52
+ function taskIdentity(repoRoot: string, taskId: string, registry: Record<string, string>): SandboxWorkspaceIdentity {
53
+ const taskMd = path.join(repoRoot, '.agents', 'workspace', 'active', taskId, 'task.md');
54
+ if (!fs.existsSync(taskMd)) throw new Error(`Active task not found: ${taskId}`);
55
+ const shortIds = Object.entries(registry).filter(([, id]) => id === taskId).map(([shortId]) => shortId);
56
+ if (shortIds.length !== 1) {
57
+ throw new Error(`Task ${taskId} must have exactly one active short id`);
58
+ }
59
+ return { mode: 'task-bound', taskId, shortId: shortIds[0]! };
60
+ }
61
+
62
+ export function resolveSandboxTarget(requestedRef: string, repoRoot: string): SandboxTarget {
63
+ if (isRemovedHashShortIdInput(requestedRef)) {
64
+ throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
65
+ }
66
+ const registry = activeRegistry(repoRoot);
67
+ if (SHORT_ID_RE.test(requestedRef)) {
68
+ const matchingShortIds = Object.keys(registry).filter((shortId) =>
69
+ shortId === requestedRef || Number(shortId) === Number(requestedRef)
70
+ );
71
+ if (matchingShortIds.length > 1) {
72
+ throw new Error(`Short id '${requestedRef}' is ambiguous in active task registry`);
73
+ }
74
+ const canonicalShortId = matchingShortIds[0];
75
+ const taskId = canonicalShortId ? registry[canonicalShortId] : undefined;
76
+ if (!taskId) throw new Error(`Short id '${requestedRef}' not found in active task registry`);
77
+ const workspace = taskIdentity(repoRoot, taskId, registry);
78
+ return { requestedRef, branch: taskBranch(path.join(repoRoot, '.agents', 'workspace', 'active', taskId, 'task.md'), taskId), workspace };
79
+ }
80
+ if (TASK_ID_RE.test(requestedRef)) {
81
+ const workspace = taskIdentity(repoRoot, requestedRef, registry);
82
+ return { requestedRef, branch: taskBranch(path.join(repoRoot, '.agents', 'workspace', 'active', requestedRef, 'task.md'), requestedRef), workspace };
83
+ }
84
+
85
+ const activeRoot = path.join(repoRoot, '.agents', 'workspace', 'active');
86
+ const matches = fs.existsSync(activeRoot)
87
+ ? fs.readdirSync(activeRoot, { withFileTypes: true }).flatMap((entry) => {
88
+ if (!entry.isDirectory() || !TASK_ID_RE.test(entry.name)) return [];
89
+ const taskMd = path.join(activeRoot, entry.name, 'task.md');
90
+ if (!fs.existsSync(taskMd)) return [];
91
+ return taskBranch(taskMd, entry.name) === requestedRef ? [entry.name] : [];
92
+ })
93
+ : [];
94
+ if (matches.length > 1) {
95
+ throw new Error(`SANDBOX_TASK_IDENTITY_AMBIGUOUS: branch '${requestedRef}' is bound to multiple active tasks`);
96
+ }
97
+ if (matches.length === 1) {
98
+ const taskId = matches[0]!;
99
+ return { requestedRef, branch: requestedRef, workspace: taskIdentity(repoRoot, taskId, registry) };
100
+ }
101
+ return { requestedRef, branch: requestedRef, workspace: { mode: 'branch-only' } };
102
+ }
103
+
104
+ export function parseSandboxWorkspaceIdentity(
105
+ labels: Readonly<Record<string, string>>,
106
+ keys: Readonly<{ mode: string; taskId: string }>
107
+ ): SandboxContainerWorkspaceIdentity {
108
+ const mode = labels[keys.mode];
109
+ const taskId = labels[keys.taskId];
110
+ if (mode === 'branch-only' && taskId === undefined) return { mode: 'branch-only' };
111
+ if (mode === 'task-bound' && typeof taskId === 'string' && TASK_ID_RE.test(taskId)) {
112
+ return { mode: 'task-bound', taskId };
113
+ }
114
+ return { mode: 'legacy-invalid' };
115
+ }
116
+
117
+ export function sameSandboxWorkspaceIdentity(
118
+ left: SandboxWorkspaceIdentity | SandboxContainerWorkspaceIdentity,
119
+ right: SandboxWorkspaceIdentity | SandboxContainerWorkspaceIdentity
120
+ ): boolean {
121
+ return left.mode === right.mode
122
+ && (left.mode !== 'task-bound' || (right.mode === 'task-bound' && left.taskId === right.taskId));
123
+ }
@@ -0,0 +1,143 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import type { SandboxWorkspaceIdentity } from './workspace-identity.ts';
5
+ import type { SandboxControlManifest } from './control/protocol.ts';
6
+
7
+ export type SandboxWorkspaceView = Readonly<{
8
+ root: string;
9
+ taskMountPath: string | null;
10
+ }>;
11
+
12
+ export function sandboxWorkspaceViewPaths(params: Readonly<{
13
+ base: string;
14
+ project: string;
15
+ container: string;
16
+ identity: SandboxWorkspaceIdentity;
17
+ }>): SandboxWorkspaceView {
18
+ const identityKey = params.identity.mode === 'task-bound'
19
+ ? `task-bound:${params.identity.taskId}`
20
+ : 'branch-only';
21
+ const digest = createHash('sha256').update(identityKey).digest('hex').slice(0, 16);
22
+ const projectRoot = path.resolve(params.base, params.project);
23
+ const root = path.resolve(projectRoot, params.container, digest);
24
+ return {
25
+ root,
26
+ taskMountPath: params.identity.mode === 'task-bound'
27
+ ? path.join(root, 'active', params.identity.taskId)
28
+ : null
29
+ };
30
+ }
31
+
32
+ export type SandboxControlSetup = Readonly<{
33
+ channelDir: string;
34
+ manifestPath: string;
35
+ token: string;
36
+ }>;
37
+
38
+ export function sandboxControlPaths(params: Readonly<{
39
+ base: string;
40
+ project: string;
41
+ container: string;
42
+ identity: SandboxWorkspaceIdentity;
43
+ }>): Readonly<{ root: string; channelDir: string; manifestPath: string }> {
44
+ const identityKey = params.identity.mode === 'task-bound'
45
+ ? `task-bound:${params.identity.taskId}`
46
+ : 'branch-only';
47
+ const digest = createHash('sha256').update(identityKey).digest('hex').slice(0, 16);
48
+ const root = path.resolve(params.base, params.project, params.container, digest);
49
+ return { root, channelDir: path.join(root, 'channel'), manifestPath: path.join(root, 'manifest.json') };
50
+ }
51
+
52
+ function assertSafeDirectory(directory: string, expectedBase: string): void {
53
+ const relative = path.relative(expectedBase, directory);
54
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
55
+ throw new Error(`Sandbox workspace view escapes its configured root: ${directory}`);
56
+ }
57
+ if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) {
58
+ throw new Error(`Sandbox workspace view must not be a symbolic link: ${directory}`);
59
+ }
60
+ }
61
+
62
+ export function assertSandboxTaskSource(repoRoot: string, taskId: string): string {
63
+ if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
64
+ const activeRoot = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace', 'active'));
65
+ const source = path.join(activeRoot, taskId);
66
+ const stat = fs.lstatSync(source);
67
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
68
+ throw new Error(`SANDBOX_TASK_SOURCE_INVALID: ${source} must be a real directory`);
69
+ }
70
+ const canonical = fs.realpathSync.native(source);
71
+ if (path.dirname(canonical) !== activeRoot) {
72
+ throw new Error(`SANDBOX_TASK_SOURCE_INVALID: ${source} escapes the active workspace`);
73
+ }
74
+ return canonical;
75
+ }
76
+
77
+ export function materializeSandboxWorkspaceView(params: Readonly<{
78
+ base: string;
79
+ project: string;
80
+ container: string;
81
+ identity: SandboxWorkspaceIdentity;
82
+ }>): SandboxWorkspaceView {
83
+ const projectRoot = path.resolve(params.base, params.project);
84
+ const { root, taskMountPath } = sandboxWorkspaceViewPaths(params);
85
+ assertSafeDirectory(projectRoot, path.resolve(params.base));
86
+ assertSafeDirectory(root, projectRoot);
87
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
88
+ for (const state of ['active', 'completed', 'blocked', 'archive']) {
89
+ const stateDir = path.join(root, state);
90
+ assertSafeDirectory(stateDir, root);
91
+ fs.rmSync(stateDir, { recursive: true, force: true });
92
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
93
+ }
94
+
95
+ const active = path.join(root, 'active');
96
+ const registry = {
97
+ version: 1,
98
+ ids: params.identity.mode === 'task-bound'
99
+ ? { [params.identity.shortId]: params.identity.taskId }
100
+ : {}
101
+ };
102
+ fs.writeFileSync(path.join(active, '.short-ids.json'), `${JSON.stringify(registry)}\n`, {
103
+ encoding: 'utf8',
104
+ mode: 0o600
105
+ });
106
+ if (taskMountPath) {
107
+ assertSafeDirectory(taskMountPath, active);
108
+ fs.mkdirSync(taskMountPath, { recursive: true, mode: 0o700 });
109
+ }
110
+ return { root, taskMountPath };
111
+ }
112
+
113
+ export function materializeSandboxControl(params: Readonly<{
114
+ base: string;
115
+ repoRoot: string;
116
+ project: string;
117
+ container: string;
118
+ branch: string;
119
+ identity: SandboxWorkspaceIdentity;
120
+ }>): SandboxControlSetup {
121
+ const { root, channelDir, manifestPath } = sandboxControlPaths(params);
122
+ assertSafeDirectory(root, path.resolve(params.base));
123
+ for (const queue of ['requests', 'responses']) {
124
+ const directory = path.join(channelDir, queue);
125
+ assertSafeDirectory(directory, root);
126
+ fs.rmSync(directory, { recursive: true, force: true });
127
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
128
+ }
129
+ const token = randomBytes(32).toString('hex');
130
+ const manifest: SandboxControlManifest = {
131
+ version: 1,
132
+ repoRoot: fs.realpathSync.native(params.repoRoot),
133
+ project: params.project,
134
+ container: params.container,
135
+ branch: params.branch,
136
+ mode: params.identity.mode,
137
+ taskId: params.identity.mode === 'task-bound' ? params.identity.taskId : null,
138
+ token,
139
+ channelDir
140
+ };
141
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
142
+ return { channelDir, manifestPath, token };
143
+ }