@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
@@ -12,6 +12,7 @@ import {
12
12
  } from '../sandbox/tool-types.ts';
13
13
  import type {
14
14
  AgentClientSandboxRecoveryCheck,
15
+ AgentClientSandboxImageDescriptor,
15
16
  AgentClientSandboxDescriptor,
16
17
  SandboxAlias,
17
18
  SandboxTool,
@@ -50,12 +51,21 @@ type AgentClientSeedCommand = Readonly<{
50
51
  target: string;
51
52
  }>;
52
53
 
54
+ type AgentClientCustomCommandDescriptor = Readonly<{
55
+ target: string;
56
+ frontmatter: Readonly<Record<string, string | boolean>>;
57
+ argumentsToken?: string;
58
+ includeUsage?: boolean;
59
+ inheritDisableModelInvocation?: boolean;
60
+ }>;
61
+
53
62
  type AgentClientProjectDescriptor = Readonly<{
54
63
  ownedPathPrefixes: readonly string[];
55
64
  managed: readonly string[];
56
65
  merged: readonly string[];
57
66
  ejected: readonly string[];
58
67
  seedCommands: readonly AgentClientSeedCommand[];
68
+ customCommand?: AgentClientCustomCommandDescriptor;
59
69
  }>;
60
70
 
61
71
  type AgentClientAdapter = Readonly<{
@@ -81,12 +91,59 @@ type AgentClientManifestEntry = Readonly<{
81
91
  managed: readonly string[];
82
92
  merged: readonly string[];
83
93
  ejected: readonly string[];
94
+ customCommand?: AgentClientCustomCommandDescriptor;
84
95
  }>;
85
96
 
86
97
  const PROJECT_ASSET_CATEGORIES = ['managed', 'merged', 'ejected'] as const;
87
98
  const MAX_SANDBOX_HOOK_TIMEOUT_MS = 300_000;
88
99
  const TOOL_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
89
100
 
101
+ function freezeSandboxImageDescriptor(
102
+ id: AgentClientId,
103
+ value: AgentClientSandboxImageDescriptor | undefined
104
+ ): AgentClientSandboxImageDescriptor | undefined {
105
+ if (value === undefined) return undefined;
106
+ const keys = value && typeof value === 'object' && !Array.isArray(value)
107
+ ? Object.keys(value)
108
+ : [];
109
+ const fragment = value?.dockerfileFragment;
110
+ const exclusions = value?.dotfilesExclusions;
111
+ const validExclusion = (entry: unknown): entry is string => {
112
+ if (
113
+ typeof entry !== 'string'
114
+ || entry === ''
115
+ || entry.startsWith('/')
116
+ || entry.endsWith('/')
117
+ || entry.includes('\\')
118
+ || /[*?[\]]/.test(entry)
119
+ ) {
120
+ return false;
121
+ }
122
+ return entry.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
123
+ };
124
+ if (
125
+ !value
126
+ || typeof value !== 'object'
127
+ || Array.isArray(value)
128
+ || keys.length === 0
129
+ || keys.some((key) => !['dockerfileFragment', 'dotfilesExclusions'].includes(key))
130
+ || (fragment !== undefined && (typeof fragment !== 'string' || !TOOL_ID_PATTERN.test(fragment)))
131
+ || (exclusions !== undefined && (
132
+ !Array.isArray(exclusions)
133
+ || exclusions.some((entry) => !validExclusion(entry))
134
+ || new Set(exclusions).size !== exclusions.length
135
+ ))
136
+ ) {
137
+ throw new Error(`Agent Client '${id}' has an invalid sandbox image descriptor`);
138
+ }
139
+ return Object.freeze({
140
+ ...(fragment === undefined ? {} : { dockerfileFragment: fragment }),
141
+ ...(exclusions === undefined
142
+ ? {}
143
+ : { dotfilesExclusions: Object.freeze([...exclusions]) })
144
+ });
145
+ }
146
+
90
147
  function freezeRecoveryProbe(
91
148
  id: AgentClientId,
92
149
  checkId: string,
@@ -394,9 +451,68 @@ function defineAgentClientAdapter(
394
451
  });
395
452
  });
396
453
 
454
+ let customCommand: AgentClientCustomCommandDescriptor | undefined;
455
+ if (candidate.project.customCommand !== undefined) {
456
+ const descriptor = candidate.project.customCommand;
457
+ const placeholders = typeof descriptor?.target === 'string'
458
+ ? [...descriptor.target.matchAll(/\$\{([^}]+)\}/g)].map((match) => match[1])
459
+ : [];
460
+ const targetProbe = typeof descriptor?.target === 'string'
461
+ ? descriptor.target.replaceAll('${skillName}', 'skill')
462
+ : '';
463
+ const frontmatter = descriptor?.frontmatter;
464
+ if (
465
+ !descriptor
466
+ || typeof descriptor !== 'object'
467
+ || placeholders.length !== 1
468
+ || placeholders[0] !== 'skillName'
469
+ || targetProbe.includes('${')
470
+ || !isProjectRelativeLiteral(targetProbe)
471
+ || !paths.some((prefix) => targetProbe.startsWith(prefix))
472
+ || !frontmatter
473
+ || typeof frontmatter !== 'object'
474
+ || Array.isArray(frontmatter)
475
+ || Object.values(frontmatter).some((value) =>
476
+ typeof value !== 'string' && typeof value !== 'boolean'
477
+ )
478
+ || (
479
+ descriptor.argumentsToken !== undefined
480
+ && (
481
+ typeof descriptor.argumentsToken !== 'string'
482
+ || descriptor.argumentsToken === ''
483
+ || /[\r\n]/.test(descriptor.argumentsToken)
484
+ )
485
+ )
486
+ || (
487
+ descriptor.includeUsage !== undefined
488
+ && typeof descriptor.includeUsage !== 'boolean'
489
+ )
490
+ || (
491
+ descriptor.inheritDisableModelInvocation !== undefined
492
+ && typeof descriptor.inheritDisableModelInvocation !== 'boolean'
493
+ )
494
+ ) {
495
+ throw new Error(`Agent Client '${candidate.id}' has an invalid custom command`);
496
+ }
497
+ customCommand = Object.freeze({
498
+ target: descriptor.target,
499
+ frontmatter: Object.freeze({ ...frontmatter }),
500
+ ...(descriptor.argumentsToken === undefined
501
+ ? {}
502
+ : { argumentsToken: descriptor.argumentsToken }),
503
+ ...(descriptor.includeUsage === undefined
504
+ ? {}
505
+ : { includeUsage: descriptor.includeUsage }),
506
+ ...(descriptor.inheritDisableModelInvocation === undefined
507
+ ? {}
508
+ : { inheritDisableModelInvocation: descriptor.inheritDisableModelInvocation })
509
+ });
510
+ }
511
+
397
512
  if (!candidate.sandbox || typeof candidate.sandbox.createTool !== 'function') {
398
513
  throw new Error(`Agent Client '${candidate.id}' requires a sandbox descriptor`);
399
514
  }
515
+ const image = freezeSandboxImageDescriptor(candidate.id, candidate.sandbox.image);
400
516
  const hookIds = new Set<string>();
401
517
  const hooks = Object.freeze(candidate.sandbox.hooks.map((hook) => {
402
518
  if (
@@ -515,9 +631,16 @@ function defineAgentClientAdapter(
515
631
  project: Object.freeze({
516
632
  ownedPathPrefixes: Object.freeze(paths),
517
633
  ...projectAssets,
518
- seedCommands: Object.freeze(seedCommands)
634
+ seedCommands: Object.freeze(seedCommands),
635
+ ...(customCommand === undefined ? {} : { customCommand })
519
636
  }),
520
- sandbox: Object.freeze({ createTool, aliases, hooks, recoveryChecks })
637
+ sandbox: Object.freeze({
638
+ createTool,
639
+ aliases,
640
+ hooks,
641
+ recoveryChecks,
642
+ ...(image === undefined ? {} : { image })
643
+ })
521
644
  });
522
645
  }
523
646
 
@@ -525,6 +648,7 @@ export { defineAgentClientAdapter };
525
648
  export type {
526
649
  AgentClientAdapter,
527
650
  AgentClientCapabilities,
651
+ AgentClientCustomCommandDescriptor,
528
652
  AgentClientDelegationEvidence,
529
653
  AgentClientManifestEntry,
530
654
  AgentClientModelSelectionContext,
@@ -2,7 +2,8 @@ import { execFileSync } from 'node:child_process';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { hostJoin } from './engines/wsl2-paths.ts';
5
+ import { hostJoin } from '../../sandbox/engines/wsl2-paths.ts';
6
+ import { redactCommandError } from '../../sandbox/redaction.ts';
6
7
 
7
8
  type ExecFn = (file: string, args: string[], options?: Record<string, unknown>) => string | Buffer | void;
8
9
  type ReadFn = (targetPath: string) => string;
@@ -112,12 +113,6 @@ type CredentialPayload = {
112
113
 
113
114
  const LOCKED_PATTERN = /errSecInteractionNotAllowed|User interaction is not allowed/i;
114
115
  const NOT_FOUND_PATTERN = /errSecItemNotFound|specified item could not be found/i;
115
- const REDACTION_PATTERNS = [
116
- { pattern: /\{[^{}]*"claudeAiOauth"[\s\S]*?\}\s*\}/g, replacement: '[REDACTED credentials blob]' },
117
- { pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g, replacement: '[REDACTED claude token]' },
118
- { pattern: /gh[psoru]_[A-Za-z0-9]{30,}/g, replacement: '[REDACTED github token]' },
119
- { pattern: /Bearer\s+[A-Za-z0-9._~+/=-]{20,}/gi, replacement: 'Bearer [REDACTED]' }
120
- ];
121
116
 
122
117
  function isRecord(value: unknown): value is Record<string, unknown> {
123
118
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -131,18 +126,8 @@ function errorMessage(error: unknown): string {
131
126
  return error instanceof Error ? error.message : 'unknown error';
132
127
  }
133
128
 
134
- export function redactCommandError(text: unknown): string {
135
- if (!text || typeof text !== 'string') {
136
- return '';
137
- }
138
-
139
- return REDACTION_PATTERNS.reduce(
140
- (result, { pattern, replacement }) => result.replace(pattern, replacement),
141
- text
142
- );
143
- }
144
-
145
129
  export const redactSecurityOutput = redactCommandError;
130
+ export { redactCommandError };
146
131
 
147
132
  function extractStderrSafely(error: unknown): string {
148
133
  const stderr = typeof error === 'object' && error !== null && 'stderr' in error
@@ -0,0 +1,128 @@
1
+ import { homedir } from 'node:os';
2
+ import { parseArgs } from 'node:util';
3
+ import {
4
+ buildLockedGuidance,
5
+ discoverProjects,
6
+ formatCredentialWarnings,
7
+ formatRemaining,
8
+ reconcileClaudeCredentials,
9
+ redactCommandError,
10
+ validateClaudeCredentialsEnvOverride
11
+ } from './claude-code-credentials.ts';
12
+ import { runProbe } from '../../sandbox/shell.ts';
13
+
14
+ const USAGE = 'Usage: ai sandbox refresh';
15
+
16
+ type ReconcileOptions = NonNullable<Parameters<typeof reconcileClaudeCredentials>[1]>;
17
+
18
+ type RefreshDeps = Partial<ReconcileOptions> & {
19
+ spawnFn?: typeof runProbe;
20
+ discoverFn?: typeof discoverProjects;
21
+ writeStdout?: (chunk: string) => unknown;
22
+ writeStderr?: (chunk: string) => unknown;
23
+ };
24
+
25
+ export function probeClaudeStatus(spawnFn: typeof runProbe = runProbe): { ok: boolean; stderr: string; error: string | null } {
26
+ const result = spawnFn('claude', ['/status'], {
27
+ encoding: 'utf8',
28
+ stdio: ['ignore', 'pipe', 'pipe'],
29
+ timeout: 30_000
30
+ });
31
+ return {
32
+ ok: result.status === 0,
33
+ stderr: result.stderr?.toString() ?? '',
34
+ error: result.error?.message ?? null
35
+ };
36
+ }
37
+
38
+ export async function runClaudeCodeRefresh(args: string[], deps: RefreshDeps = {}): Promise<number> {
39
+ const {
40
+ spawnFn = runProbe,
41
+ execFn,
42
+ readFn,
43
+ existsFn,
44
+ writeFn,
45
+ writeHostFn,
46
+ discoverFn = discoverProjects,
47
+ writeStdout = (chunk: string) => process.stdout.write(chunk),
48
+ writeStderr = (chunk: string) => process.stderr.write(chunk)
49
+ } = deps;
50
+
51
+ if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
52
+ writeStdout(`${USAGE}\n`);
53
+ return 0;
54
+ }
55
+
56
+ const { positionals } = parseArgs({ args, allowPositionals: true, strict: true });
57
+ if (positionals.length > 0) {
58
+ throw new Error(USAGE);
59
+ }
60
+ validateClaudeCredentialsEnvOverride();
61
+
62
+ const home = homedir();
63
+ if (!home) {
64
+ throw new Error('sandbox: home directory is required');
65
+ }
66
+
67
+ const projects = discoverFn(home);
68
+ if (projects.length === 0) {
69
+ writeStdout('No project credentials to refresh.\n');
70
+ return 0;
71
+ }
72
+
73
+ const reconcileOptions = { execFn, readFn, existsFn, writeFn, writeHostFn, projects };
74
+ let result = reconcileClaudeCredentials(home, reconcileOptions);
75
+ if (result.status === 'STALE_ACCESS' && result.authoritative === null) {
76
+ writeStdout('Host credentials appear stale; probing claude /status to trigger refresh...\n');
77
+ const probe = probeClaudeStatus(spawnFn);
78
+ if (!probe.ok) {
79
+ writeStderr(`Probe failed: ${redactCommandError(probe.stderr || probe.error || 'unknown error')}\n`);
80
+ writeStderr('Run "claude /login" on the host to renew credentials.\n');
81
+ return 1;
82
+ }
83
+ writeStdout('Probe succeeded; re-inspecting host credentials.\n');
84
+ result = reconcileClaudeCredentials(home, reconcileOptions);
85
+ }
86
+
87
+ if (result.status === 'MISSING') {
88
+ writeStderr('No Claude Code credentials found on host.\n');
89
+ writeStderr('Run "claude /login" on the host to authenticate.\n');
90
+ return 1;
91
+ }
92
+
93
+ if (result.status === 'KEYCHAIN_LOCKED') {
94
+ writeStderr(`${buildLockedGuidance()}\n`);
95
+ return 1;
96
+ }
97
+
98
+ if (result.status === 'KEYCHAIN_ERROR') {
99
+ writeStderr(`Host keychain error: ${redactCommandError(result.detail || 'unknown error')}\n`);
100
+ writeStderr(`${buildLockedGuidance()}\n`);
101
+ return 1;
102
+ }
103
+
104
+ if (result.status === 'KEYCHAIN_WRITE_FAILED') {
105
+ writeStderr(`[host] keychain write failed: ${formatCredentialWarnings(result.warnings) || 'unknown error'}\n`);
106
+ return 1;
107
+ }
108
+
109
+ if (result.status !== 'OK') {
110
+ writeStderr('Host credentials still invalid after probe; run "claude /login".\n');
111
+ return 1;
112
+ }
113
+
114
+ if (result.authoritative && result.authoritative !== 'host' && result.hostWritten) {
115
+ writeStdout(`[host] reconciled from ${result.authoritative}\n`);
116
+ }
117
+
118
+ for (const project of projects) {
119
+ const action = result.filesWritten.includes(project) ? 'updated' : 'unchanged';
120
+ writeStdout(`[${project}] ${action}; expires in ${formatRemaining(result.expiresAt)}\n`);
121
+ }
122
+
123
+ for (const failure of result.fileErrors) {
124
+ writeStderr(`[${failure.project}] sync failed: ${failure.error}\n`);
125
+ }
126
+
127
+ return result.fileErrors.length > 0 ? 1 : 0;
128
+ }
@@ -0,0 +1,295 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type {
4
+ AgentClientSandboxHook,
5
+ SandboxHookCreateContext,
6
+ SandboxHookEnterContext
7
+ } from '../../sandbox/tool-types.ts';
8
+ import {
9
+ formatCredentialWarnings,
10
+ formatRemaining,
11
+ hasClaudeProviderAuth,
12
+ inspectClaudeKeychainStatus,
13
+ prepareClaudeCredentials,
14
+ reconcileClaudeCredentials,
15
+ redactCommandError,
16
+ validateClaudeCredentialsEnvOverride
17
+ } from './claude-code-credentials.ts';
18
+
19
+ type JsonObject = Record<string, unknown>;
20
+
21
+ function readHostJsonSafe(filePath: string): JsonObject | null {
22
+ if (!filePath || !fs.existsSync(filePath)) return null;
23
+ try {
24
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
25
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
26
+ ? parsed as JsonObject
27
+ : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ const CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS = [
34
+ 'model',
35
+ 'fallbackModel',
36
+ 'availableModels',
37
+ 'modelOverrides',
38
+ 'enforceAvailableModels',
39
+ 'advisorModel',
40
+ 'apiKeyHelper',
41
+ 'effortLevel'
42
+ ];
43
+
44
+ function isJsonObjectRecord(value: unknown): value is JsonObject {
45
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
46
+ }
47
+
48
+ function mergeMissingStringEnvFields(target: JsonObject, source: JsonObject): boolean {
49
+ if (!isJsonObjectRecord(source.env)) return false;
50
+ if (Object.hasOwn(target, 'env') && !isJsonObjectRecord(target.env)) return false;
51
+
52
+ let targetEnv = target.env as JsonObject | undefined;
53
+ let changed = false;
54
+ for (const [key, value] of Object.entries(source.env)) {
55
+ if (typeof value !== 'string' || value === '') continue;
56
+ if (!targetEnv) {
57
+ targetEnv = {};
58
+ target.env = targetEnv;
59
+ }
60
+ if (!Object.hasOwn(targetEnv, key)) {
61
+ targetEnv[key] = value;
62
+ changed = true;
63
+ }
64
+ }
65
+ return changed;
66
+ }
67
+
68
+ function mergeMissingTopLevelSettings(target: JsonObject, source: JsonObject): boolean {
69
+ let changed = false;
70
+ for (const key of CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS) {
71
+ if (!Object.hasOwn(source, key) || Object.hasOwn(target, key)) continue;
72
+ const value = source[key];
73
+ if (value === null || value === undefined || value === '') continue;
74
+ target[key] = value;
75
+ changed = true;
76
+ }
77
+ return changed;
78
+ }
79
+
80
+ function requireCreateContext(
81
+ context: SandboxHookCreateContext | undefined,
82
+ hookId: string
83
+ ): SandboxHookCreateContext {
84
+ if (!context) throw new Error(`${hookId} requires create context`);
85
+ return context;
86
+ }
87
+
88
+ function requireEnterContext(
89
+ context: SandboxHookEnterContext | undefined,
90
+ hookId: string
91
+ ): SandboxHookEnterContext {
92
+ if (!context) throw new Error(`${hookId} requires enter context`);
93
+ return context;
94
+ }
95
+
96
+ export function ensureClaudeOnboarding(toolDir: string, hostHomeDir?: string): void {
97
+ const claudeJsonPath = path.join(toolDir, '.claude.json');
98
+ let data: JsonObject & {
99
+ hasCompletedOnboarding?: boolean;
100
+ projects?: Record<string, { hasTrustDialogAccepted?: boolean }>;
101
+ model?: string;
102
+ } = {};
103
+ if (fs.existsSync(claudeJsonPath)) {
104
+ try {
105
+ data = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8')) as typeof data;
106
+ } catch {
107
+ // Malformed JSON is replaced with a valid managed sandbox baseline.
108
+ }
109
+ }
110
+ let changed = false;
111
+ if (!data.hasCompletedOnboarding) {
112
+ data.hasCompletedOnboarding = true;
113
+ changed = true;
114
+ }
115
+ if (!data.projects) {
116
+ data.projects = {};
117
+ changed = true;
118
+ }
119
+ if (!data.projects['/workspace']) {
120
+ data.projects['/workspace'] = {};
121
+ changed = true;
122
+ }
123
+ if (!data.projects['/workspace'].hasTrustDialogAccepted) {
124
+ data.projects['/workspace'].hasTrustDialogAccepted = true;
125
+ changed = true;
126
+ }
127
+ if (hostHomeDir) {
128
+ const hostClaudeJson = readHostJsonSafe(path.join(hostHomeDir, '.claude.json'));
129
+ if (
130
+ hostClaudeJson
131
+ && typeof hostClaudeJson.model === 'string'
132
+ && hostClaudeJson.model !== ''
133
+ && !Object.hasOwn(data, 'model')
134
+ ) {
135
+ data.model = hostClaudeJson.model;
136
+ changed = true;
137
+ }
138
+ if (hostClaudeJson) {
139
+ for (const key of Object.keys(hostClaudeJson)) {
140
+ if (
141
+ /^unpin.*LaunchEffort$/.test(key)
142
+ && hostClaudeJson[key] === true
143
+ && !Object.hasOwn(data, key)
144
+ ) {
145
+ data[key] = true;
146
+ changed = true;
147
+ }
148
+ }
149
+ }
150
+ }
151
+ if (changed) fs.writeFileSync(claudeJsonPath, JSON.stringify(data, null, 4), 'utf8');
152
+ }
153
+
154
+ export function ensureClaudeSettings(toolDir: string, hostHomeDir?: string): void {
155
+ const settingsPath = path.join(toolDir, 'settings.json');
156
+ let data: JsonObject & { skipDangerousModePermissionPrompt?: boolean } = {};
157
+ if (fs.existsSync(settingsPath)) {
158
+ try {
159
+ data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as typeof data;
160
+ } catch {
161
+ // Malformed JSON is replaced with a valid managed sandbox baseline.
162
+ }
163
+ }
164
+ let changed = false;
165
+ if (data.skipDangerousModePermissionPrompt !== true) {
166
+ data.skipDangerousModePermissionPrompt = true;
167
+ changed = true;
168
+ }
169
+ if (hostHomeDir) {
170
+ const hostSettings = readHostJsonSafe(path.join(hostHomeDir, '.claude', 'settings.json'));
171
+ if (hostSettings) {
172
+ changed = mergeMissingStringEnvFields(data, hostSettings) || changed;
173
+ changed = mergeMissingTopLevelSettings(data, hostSettings) || changed;
174
+ }
175
+ }
176
+ if (changed) fs.writeFileSync(settingsPath, JSON.stringify(data, null, 4), 'utf8');
177
+ }
178
+
179
+ export function formatCredentialSyncStatus(
180
+ result: ReturnType<typeof reconcileClaudeCredentials>,
181
+ isTTY = process.stderr.isTTY,
182
+ providerAuthAvailable = false
183
+ ): string | null {
184
+ if (providerAuthAvailable && (result.status === 'STALE_ACCESS' || result.status === 'MISSING')) return null;
185
+ if (result.status === 'STALE_ACCESS') {
186
+ return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
187
+ }
188
+ if (result.status === 'MISSING') {
189
+ return 'Warning: Claude Code credentials missing on host. Run "claude /login" to authenticate.\n';
190
+ }
191
+ if (result.status === 'KEYCHAIN_WRITE_FAILED') {
192
+ return `Warning: A sandbox refresh produced newer credentials but host Keychain write failed (${formatCredentialWarnings(result.warnings)}). Run "ai sandbox refresh" again or "claude /status" on the host to retry.\n`;
193
+ }
194
+ if (result.status === 'KEYCHAIN_LOCKED' || result.status === 'KEYCHAIN_ERROR') {
195
+ return 'Warning: Host keychain is unavailable; Claude credential sync skipped. Run "ai sandbox refresh" for details.\n';
196
+ }
197
+ if (result.status === 'OK' && result.authoritative !== 'host') {
198
+ const message = `Synced Claude Code credentials from sandbox refresh back to host (expires in ${formatRemaining(result.expiresAt)})`;
199
+ return isTTY ? `\x1b[2m${message}\x1b[0m\n` : `${message}\n`;
200
+ }
201
+ if (result.status === 'OK' && result.filesWritten.length > 0) {
202
+ const message = `Synced Claude Code credentials from host Keychain (expires in ${formatRemaining(result.expiresAt)})`;
203
+ return isTTY ? `\x1b[2m${message}\x1b[0m\n` : `${message}\n`;
204
+ }
205
+ return null;
206
+ }
207
+
208
+ const claudeCodeCredentialPreflightHook: AgentClientSandboxHook = {
209
+ id: 'claude-code-credential-preflight',
210
+ phase: 'prepare',
211
+ run: async ({ create }) => {
212
+ const { hostHome, hostEnv, project, resolvedTools } = requireCreateContext(
213
+ create,
214
+ 'claude-code-credential-preflight'
215
+ );
216
+ try {
217
+ validateClaudeCredentialsEnvOverride(hostEnv);
218
+ const outcome = prepareClaudeCredentials(
219
+ hostHome,
220
+ project,
221
+ [...resolvedTools],
222
+ undefined,
223
+ undefined,
224
+ (home) => inspectClaudeKeychainStatus(home, undefined, { envFn: () => ({ ...hostEnv }) })
225
+ );
226
+ if (outcome.status === 'SKIPPED') {
227
+ return {
228
+ status: 'warning',
229
+ message: 'Claude Code credentials not found on host - creating this sandbox WITHOUT Claude Code credentials.\n'
230
+ + ' Claude Code is still installed in the image but will not be authenticated.\n'
231
+ + ' To enable it: run "claude" once on the host to complete login, then re-run "ai sandbox create".'
232
+ };
233
+ }
234
+ return { status: 'ready' };
235
+ } catch (error) {
236
+ return {
237
+ status: 'fatal',
238
+ message: redactCommandError(error instanceof Error ? error.message : 'unknown credential error')
239
+ };
240
+ }
241
+ }
242
+ };
243
+
244
+ const claudeCodeBeforeContainerCreateHook: AgentClientSandboxHook = {
245
+ id: 'claude-code-before-container-create',
246
+ phase: 'before-container-create',
247
+ run: async ({ create }) => {
248
+ const { hostHome, resolvedTools } = requireCreateContext(
249
+ create,
250
+ 'claude-code-before-container-create'
251
+ );
252
+ const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
253
+ if (claudeCodeEntry) {
254
+ ensureClaudeOnboarding(claudeCodeEntry.dir, hostHome);
255
+ ensureClaudeSettings(claudeCodeEntry.dir, hostHome);
256
+ }
257
+ return { status: 'ready' };
258
+ }
259
+ };
260
+
261
+ const claudeCodeBeforeEnterHook: AgentClientSandboxHook = {
262
+ id: 'claude-code-before-enter',
263
+ phase: 'before-enter',
264
+ run: async ({ enter }) => {
265
+ const { hostHome, hostEnv } = requireEnterContext(enter, 'claude-code-before-enter');
266
+ try {
267
+ validateClaudeCredentialsEnvOverride(hostEnv);
268
+ } catch (error) {
269
+ return {
270
+ status: 'fatal',
271
+ message: redactCommandError(error instanceof Error ? error.message : 'invalid credentials override')
272
+ };
273
+ }
274
+ try {
275
+ const providerAuthAvailable = hasClaudeProviderAuth(hostHome);
276
+ const result = reconcileClaudeCredentials(hostHome, { envFn: () => ({ ...hostEnv }) });
277
+ const message = formatCredentialSyncStatus(result, process.stderr.isTTY, providerAuthAvailable);
278
+ return {
279
+ status: 'ready',
280
+ ...(message === null ? {} : { message })
281
+ };
282
+ } catch (error) {
283
+ return {
284
+ status: 'warning',
285
+ message: `Failed to sync Claude Code credentials: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}`
286
+ };
287
+ }
288
+ }
289
+ };
290
+
291
+ export {
292
+ claudeCodeBeforeContainerCreateHook,
293
+ claudeCodeBeforeEnterHook,
294
+ claudeCodeCredentialPreflightHook
295
+ };
@@ -1,5 +1,10 @@
1
1
  import { defineAgentClientAdapter } from '../adapter.ts';
2
2
  import { hostJoin } from '../../sandbox/engines/wsl2-paths.ts';
3
+ import {
4
+ claudeCodeBeforeContainerCreateHook,
5
+ claudeCodeBeforeEnterHook,
6
+ claudeCodeCredentialPreflightHook
7
+ } from './claude-code-sandbox.ts';
3
8
 
4
9
  const claudeCodeAdapter = defineAgentClientAdapter({
5
10
  id: 'claude-code',
@@ -26,7 +31,7 @@ const claudeCodeAdapter = defineAgentClientAdapter({
26
31
  },
27
32
  project: {
28
33
  ownedPathPrefixes: ['.claude/'],
29
- managed: ['.claude/commands/', '.claude/agents/'],
34
+ managed: ['.claude/commands/', '.claude/agents/', '.claude/rules/'],
30
35
  merged: ['.claude/settings.json'],
31
36
  ejected: [],
32
37
  seedCommands: [{
@@ -35,9 +40,18 @@ const claudeCodeAdapter = defineAgentClientAdapter({
35
40
  'zh-CN': '.claude/commands/update-agent-infra.zh-CN.md'
36
41
  },
37
42
  target: '.claude/commands/update-agent-infra.md'
38
- }]
43
+ }],
44
+ customCommand: {
45
+ target: '.claude/commands/${skillName}.md',
46
+ frontmatter: {},
47
+ includeUsage: true,
48
+ inheritDisableModelInvocation: true
49
+ }
39
50
  },
40
51
  sandbox: {
52
+ image: {
53
+ dockerfileFragment: 'claude-code'
54
+ },
41
55
  createTool: ({ home, project }) => ({
42
56
  id: 'claude-code',
43
57
  name: 'Claude Code',
@@ -72,7 +86,11 @@ const claudeCodeAdapter = defineAgentClientAdapter({
72
86
  { name: 'claude-yolo', command: 'claude --dangerously-skip-permissions; tput ed' },
73
87
  { name: 'cy', command: 'claude --dangerously-skip-permissions; tput ed' }
74
88
  ],
75
- hooks: []
89
+ hooks: [
90
+ claudeCodeCredentialPreflightHook,
91
+ claudeCodeBeforeContainerCreateHook,
92
+ claudeCodeBeforeEnterHook
93
+ ]
76
94
  }
77
95
  });
78
96