@fitlab-ai/agent-infra 0.8.0 → 0.8.1
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.
- package/bin/cli.ts +1 -1
- package/dist/bin/cli.js +1 -1
- package/dist/lib/run/index.js +96 -5
- package/dist/lib/sandbox/capture.js +113 -4
- package/dist/lib/sandbox/commands/create.js +3 -3
- package/dist/lib/sandbox/commands/enter.js +7 -3
- package/dist/lib/sandbox/credentials.js +42 -3
- package/dist/lib/sandbox/runtimes/base.dockerfile +13 -0
- package/dist/lib/task/commands/log.js +36 -14
- package/dist/lib/task/commands/status.js +144 -45
- package/lib/run/index.ts +119 -6
- package/lib/sandbox/capture.ts +136 -5
- package/lib/sandbox/commands/create.ts +3 -3
- package/lib/sandbox/commands/enter.ts +8 -2
- package/lib/sandbox/credentials.ts +57 -3
- package/lib/sandbox/runtimes/base.dockerfile +13 -0
- package/lib/task/commands/log.ts +36 -14
- package/lib/task/commands/status.ts +200 -58
- package/package.json +4 -3
- package/templates/.agents/rules/next-step-output.en.md +1 -1
- package/templates/.agents/rules/next-step-output.zh-CN.md +1 -1
- package/templates/.agents/rules/pr-sync.github.en.md +3 -3
- package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -3
- package/templates/.agents/rules/task-management.en.md +1 -1
- package/templates/.agents/rules/task-management.zh-CN.md +1 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
- package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -3
- package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +4 -4
- package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
- package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +11 -11
- package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +11 -11
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +5 -5
- package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-code/reference/output-templates.en.md +16 -16
- package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +12 -12
- package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-code/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +5 -5
- package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-plan/reference/output-templates.en.md +11 -11
- package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +11 -11
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +5 -5
package/bin/cli.ts
CHANGED
|
@@ -20,7 +20,7 @@ Commands:
|
|
|
20
20
|
help Show this help message
|
|
21
21
|
init Initialize a new project with update-agent-infra seed command
|
|
22
22
|
merge Merge tasks from another workspace directory (active/blocked/completed/archive)
|
|
23
|
-
run
|
|
23
|
+
run Schedule a lifecycle skill in the task sandbox tmux session
|
|
24
24
|
sandbox, s Manage Docker-based AI sandboxes
|
|
25
25
|
server Run the local AI collaboration daemon (start/stop/status/logs)
|
|
26
26
|
task, t Read-only views over .agents/workspace tasks (cat / files / grep / log / ls / show / status)
|
package/dist/bin/cli.js
CHANGED
|
@@ -24,7 +24,7 @@ Commands:
|
|
|
24
24
|
help Show this help message
|
|
25
25
|
init Initialize a new project with update-agent-infra seed command
|
|
26
26
|
merge Merge tasks from another workspace directory (active/blocked/completed/archive)
|
|
27
|
-
run
|
|
27
|
+
run Schedule a lifecycle skill in the task sandbox tmux session
|
|
28
28
|
sandbox, s Manage Docker-based AI sandboxes
|
|
29
29
|
server Run the local AI collaboration daemon (start/stop/status/logs)
|
|
30
30
|
task, t Read-only views over .agents/workspace tasks (cat / files / grep / log / ls / show / status)
|
package/dist/lib/run/index.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { loadServerConfig } from "../server/config.js";
|
|
2
4
|
import { resolveTaskBranch } from "../sandbox/task-resolver.js";
|
|
3
|
-
import { runInSandbox } from "../sandbox/capture.js";
|
|
5
|
+
import { createRunId, runInSandbox } from "../sandbox/capture.js";
|
|
6
|
+
import { loadShortIdByTaskId, normalizeShortIdInput } from "../task/short-id.js";
|
|
4
7
|
import { buildTuiCommand, renderPrompt, selectTui } from "./tui.js";
|
|
5
8
|
import { getSkillRunSpec } from "./skills.js";
|
|
6
9
|
import { runHostCommand } from "./host.js";
|
|
7
10
|
const USAGE = `Usage: ai run <skill> [task-ref] [args...] [--tui <name>]
|
|
8
11
|
|
|
12
|
+
Task skills are scheduled inside the sandbox tmux session; ai run returns once
|
|
13
|
+
the tmux window is created.
|
|
14
|
+
|
|
9
15
|
Examples:
|
|
10
16
|
ai run create-task "describe the task" --tui codex
|
|
11
17
|
ai run code-task #7 --tui codex`;
|
|
@@ -56,6 +62,83 @@ function assertAllowedByConfig(skill, commandConfig) {
|
|
|
56
62
|
throw new Error(`Skill '${skill}' is not allowed by command.allowedSkills`);
|
|
57
63
|
}
|
|
58
64
|
}
|
|
65
|
+
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
66
|
+
function readShortIdLength(repoRoot) {
|
|
67
|
+
try {
|
|
68
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
|
|
69
|
+
const value = cfg?.task?.shortIdLength;
|
|
70
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 1)
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Use the project default when config is absent in lightweight tests.
|
|
75
|
+
}
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
function readActiveShortIdRegistry(repoRoot) {
|
|
79
|
+
const registryPath = path.join(repoRoot, '.agents', 'workspace', 'active', '.short-ids.json');
|
|
80
|
+
try {
|
|
81
|
+
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
82
|
+
return data && typeof data === 'object' && data.ids && typeof data.ids === 'object' ? data.ids : {};
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function resolveActiveTaskIdentity(taskRef, repoRoot) {
|
|
89
|
+
let taskId = null;
|
|
90
|
+
let resolvedTaskRef = taskRef;
|
|
91
|
+
if (TASK_ID_RE.test(taskRef)) {
|
|
92
|
+
taskId = taskRef;
|
|
93
|
+
resolvedTaskRef = loadShortIdByTaskId(repoRoot).get(taskId) ?? taskRef;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const normalized = normalizeShortIdInput(taskRef, { shortIdLength: readShortIdLength(repoRoot) });
|
|
97
|
+
if (normalized.kind !== 'shortId')
|
|
98
|
+
return null;
|
|
99
|
+
resolvedTaskRef = normalized.value;
|
|
100
|
+
taskId = readActiveShortIdRegistry(repoRoot)[normalized.value.slice(1)] ?? null;
|
|
101
|
+
}
|
|
102
|
+
if (!taskId)
|
|
103
|
+
return null;
|
|
104
|
+
const taskDir = path.join(repoRoot, '.agents', 'workspace', 'active', taskId);
|
|
105
|
+
if (!fs.existsSync(path.join(taskDir, 'task.md')))
|
|
106
|
+
return null;
|
|
107
|
+
return { taskId, taskDir, taskRef: resolvedTaskRef };
|
|
108
|
+
}
|
|
109
|
+
function formatLocalTimestamp(date = new Date()) {
|
|
110
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
111
|
+
const year = date.getFullYear();
|
|
112
|
+
const month = pad(date.getMonth() + 1);
|
|
113
|
+
const day = pad(date.getDate());
|
|
114
|
+
const hour = pad(date.getHours());
|
|
115
|
+
const minute = pad(date.getMinutes());
|
|
116
|
+
const second = pad(date.getSeconds());
|
|
117
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
118
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
119
|
+
const offsetHour = pad(Math.floor(Math.abs(offsetMinutes) / 60));
|
|
120
|
+
const offsetMinute = pad(Math.abs(offsetMinutes) % 60);
|
|
121
|
+
return `${year}-${month}-${day} ${hour}:${minute}:${second}${sign}${offsetHour}:${offsetMinute}`;
|
|
122
|
+
}
|
|
123
|
+
function writeRunRecord(params) {
|
|
124
|
+
const runsDir = path.join(params.identity.taskDir, 'runs');
|
|
125
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
126
|
+
const record = {
|
|
127
|
+
version: 1,
|
|
128
|
+
run_id: params.run.runId,
|
|
129
|
+
task_id: params.identity.taskId,
|
|
130
|
+
task_ref: params.identity.taskRef,
|
|
131
|
+
branch: params.branch,
|
|
132
|
+
engine: params.run.engine,
|
|
133
|
+
container: params.run.container,
|
|
134
|
+
run_dir: params.run.runDir,
|
|
135
|
+
status_file: `${params.run.runDir}/status`,
|
|
136
|
+
log_file: `${params.run.runDir}/output.log`,
|
|
137
|
+
created_at: formatLocalTimestamp(),
|
|
138
|
+
command: params.command
|
|
139
|
+
};
|
|
140
|
+
fs.writeFileSync(path.join(runsDir, `${params.run.runId}.json`), `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
141
|
+
}
|
|
59
142
|
export async function runSkill(args, options = {}) {
|
|
60
143
|
const parsed = parseRunArgs(args);
|
|
61
144
|
const config = options.command ? null : loadServerConfig({ rootDir: options.repoRoot });
|
|
@@ -72,13 +155,21 @@ export async function runSkill(args, options = {}) {
|
|
|
72
155
|
}
|
|
73
156
|
const repoRoot = options.repoRoot ?? config?.repoRoot ?? process.cwd();
|
|
74
157
|
const branch = resolveTaskBranch(parsed.taskRef, repoRoot);
|
|
75
|
-
const
|
|
76
|
-
const
|
|
158
|
+
const identity = resolveActiveTaskIdentity(parsed.taskRef, repoRoot);
|
|
159
|
+
const runId = identity ? createRunId() : undefined;
|
|
160
|
+
const writeStdout = options.writeStdout ?? ((chunk) => process.stdout.write(chunk));
|
|
161
|
+
const writeStderr = options.writeStderr ?? ((chunk) => process.stderr.write(chunk));
|
|
162
|
+
const runSandbox = options.runSandbox ??
|
|
163
|
+
((request) => runInSandbox(request));
|
|
164
|
+
const result = await runSandbox({ taskRef: parsed.taskRef, branch, command, runId });
|
|
77
165
|
if (result.stdout) {
|
|
78
|
-
|
|
166
|
+
writeStdout(result.stdout);
|
|
79
167
|
}
|
|
80
168
|
if (result.stderr) {
|
|
81
|
-
|
|
169
|
+
writeStderr(result.stderr);
|
|
170
|
+
}
|
|
171
|
+
if ((result.exitCode ?? (result.signal ? 1 : 0)) === 0 && identity && result.run) {
|
|
172
|
+
writeRunRecord({ identity, run: result.run, branch, command });
|
|
82
173
|
}
|
|
83
174
|
return result.exitCode ?? (result.signal ? 1 : 0);
|
|
84
175
|
}
|
|
@@ -9,6 +9,19 @@ async function spawnCapture(file, args) {
|
|
|
9
9
|
const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
10
10
|
let stdout = '';
|
|
11
11
|
let stderr = '';
|
|
12
|
+
let settled = false;
|
|
13
|
+
const rejectOnce = (error) => {
|
|
14
|
+
if (settled)
|
|
15
|
+
return;
|
|
16
|
+
settled = true;
|
|
17
|
+
reject(error);
|
|
18
|
+
};
|
|
19
|
+
const resolveOnce = (result) => {
|
|
20
|
+
if (settled)
|
|
21
|
+
return;
|
|
22
|
+
settled = true;
|
|
23
|
+
resolve(result);
|
|
24
|
+
};
|
|
12
25
|
child.stdout.setEncoding('utf8');
|
|
13
26
|
child.stderr.setEncoding('utf8');
|
|
14
27
|
child.stdout.on('data', (chunk) => {
|
|
@@ -17,10 +30,93 @@ async function spawnCapture(file, args) {
|
|
|
17
30
|
child.stderr.on('data', (chunk) => {
|
|
18
31
|
stderr += chunk;
|
|
19
32
|
});
|
|
20
|
-
child.on('error',
|
|
21
|
-
child.on('close', (exitCode, signal) =>
|
|
33
|
+
child.on('error', rejectOnce);
|
|
34
|
+
child.on('close', (exitCode, signal) => {
|
|
35
|
+
resolveOnce({ exitCode, signal, stdout, stderr });
|
|
36
|
+
});
|
|
22
37
|
});
|
|
23
38
|
}
|
|
39
|
+
function shellQuote(value) {
|
|
40
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
41
|
+
}
|
|
42
|
+
export function createRunId(date = new Date()) {
|
|
43
|
+
return `run-${date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '').replace('T', '-').replace('Z', '')}-${process.pid}`;
|
|
44
|
+
}
|
|
45
|
+
function buildRunScript(params) {
|
|
46
|
+
const command = params.command.map(shellQuote).join(' ');
|
|
47
|
+
const runDir = shellQuote(params.runDir);
|
|
48
|
+
const runId = shellQuote(params.runId);
|
|
49
|
+
return `#!/bin/sh
|
|
50
|
+
set -u
|
|
51
|
+
cd /workspace
|
|
52
|
+
date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/started_at
|
|
53
|
+
printf '%s\\n' running > ${runDir}/status
|
|
54
|
+
${command}
|
|
55
|
+
code=$?
|
|
56
|
+
printf '%s\\n' "$code" > ${runDir}/exit_code
|
|
57
|
+
date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/finished_at
|
|
58
|
+
if [ "$code" -eq 0 ]; then
|
|
59
|
+
printf '%s\\n' completed > ${runDir}/status
|
|
60
|
+
else
|
|
61
|
+
printf '%s\\n' failed > ${runDir}/status
|
|
62
|
+
fi
|
|
63
|
+
printf '\\n[agent-infra] run %s finished with exit code %s\\n' ${runId} "$code"
|
|
64
|
+
exec bash -l
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
function buildTmuxLauncher(params) {
|
|
68
|
+
const session = 'work';
|
|
69
|
+
const window = `ai-${params.runId.replace(/^run-/, '').slice(0, 18)}`;
|
|
70
|
+
const runRoot = '/tmp/agent-infra-runs';
|
|
71
|
+
const runDir = `${runRoot}/${params.runId}`;
|
|
72
|
+
const runScript = buildRunScript({ command: params.request.command, runDir, runId: params.runId });
|
|
73
|
+
const runScriptBase64 = Buffer.from(runScript, 'utf8').toString('base64');
|
|
74
|
+
const paneCommand = `cd /workspace && ${shellQuote(`${runDir}/run.sh`)}`;
|
|
75
|
+
return `set -eu
|
|
76
|
+
session=${shellQuote(session)}
|
|
77
|
+
window=${shellQuote(window)}
|
|
78
|
+
run_id=${shellQuote(params.runId)}
|
|
79
|
+
run_dir=${shellQuote(runDir)}
|
|
80
|
+
task_ref=${shellQuote(params.request.taskRef)}
|
|
81
|
+
branch=${shellQuote(params.request.branch)}
|
|
82
|
+
|
|
83
|
+
sandbox-dotfiles-link >/dev/null 2>&1 || true
|
|
84
|
+
mkdir -p "$run_dir"
|
|
85
|
+
printf '%s\\n' pending > "$run_dir/status"
|
|
86
|
+
printf '%s\\n' "$task_ref" > "$run_dir/task_ref"
|
|
87
|
+
printf '%s\\n' "$branch" > "$run_dir/branch"
|
|
88
|
+
printf '%s\\n' ${shellQuote(params.request.command.join(' '))} > "$run_dir/command"
|
|
89
|
+
printf '%s' ${shellQuote(runScriptBase64)} | base64 -d > "$run_dir/run.sh"
|
|
90
|
+
chmod +x "$run_dir/run.sh"
|
|
91
|
+
|
|
92
|
+
if ! command -v tmux >/dev/null 2>&1; then
|
|
93
|
+
printf '%s\\n' "tmux is not installed in this sandbox" >&2
|
|
94
|
+
exit 127
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
if ! tmux has-session -t "$session" 2>/dev/null; then
|
|
98
|
+
tmux new-session -d -s "$session" -n shell
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
if [ -n "\${TZ:-}" ]; then
|
|
102
|
+
tmux set-environment -t "$session" TZ "$TZ" 2>/dev/null || true
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
pane=$(tmux new-window -d -P -F '#{pane_id}' -t "$session" -n "$window")
|
|
106
|
+
printf '%s\\n' "$session" > "$run_dir/session"
|
|
107
|
+
printf '%s\\n' "$window" > "$run_dir/window"
|
|
108
|
+
printf '%s\\n' "$pane" > "$run_dir/pane"
|
|
109
|
+
tmux pipe-pane -o -t "$pane" "cat > $run_dir/output.log"
|
|
110
|
+
tmux send-keys -t "$pane" ${shellQuote(paneCommand)} Enter
|
|
111
|
+
|
|
112
|
+
cat <<EOF
|
|
113
|
+
Started sandbox run $run_id in tmux session '$session', window '$window', pane '$pane'.
|
|
114
|
+
Attach with: ai sandbox enter $task_ref
|
|
115
|
+
Status file: $run_dir/status
|
|
116
|
+
Output log: $run_dir/output.log
|
|
117
|
+
EOF
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
24
120
|
export async function runInSandbox(request, options = {}) {
|
|
25
121
|
const config = options.engine ? null : loadConfig();
|
|
26
122
|
const engine = options.engine ?? detectEngine(config);
|
|
@@ -37,13 +133,26 @@ export async function runInSandbox(request, options = {}) {
|
|
|
37
133
|
if (!found.running) {
|
|
38
134
|
(options.startContainer ?? ((name) => startSandboxContainer(engine, name)))(found.name);
|
|
39
135
|
}
|
|
136
|
+
const runId = options.runId ?? createRunId();
|
|
137
|
+
const runDir = `/tmp/agent-infra-runs/${runId}`;
|
|
40
138
|
const dockerArgs = [
|
|
41
139
|
'exec',
|
|
42
140
|
...terminalEnvFlags(),
|
|
43
141
|
...hostTimezoneEnvFlags(),
|
|
44
142
|
found.name,
|
|
45
|
-
|
|
143
|
+
'bash',
|
|
144
|
+
'-lc',
|
|
145
|
+
buildTmuxLauncher({ request, runId })
|
|
46
146
|
];
|
|
47
|
-
|
|
147
|
+
const result = await (options.spawn ?? spawnCapture)('docker', dockerArgs);
|
|
148
|
+
return {
|
|
149
|
+
...result,
|
|
150
|
+
run: {
|
|
151
|
+
runId,
|
|
152
|
+
engine,
|
|
153
|
+
container: found.name,
|
|
154
|
+
runDir
|
|
155
|
+
}
|
|
156
|
+
};
|
|
48
157
|
}
|
|
49
158
|
//# sourceMappingURL=capture.js.map
|
|
@@ -1113,9 +1113,9 @@ export async function create(args) {
|
|
|
1113
1113
|
if (claudeCodeEntry) {
|
|
1114
1114
|
ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1115
1115
|
ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1116
|
-
// prepareClaudeCredentials wrote
|
|
1117
|
-
//
|
|
1118
|
-
// claude-code entry was removed
|
|
1116
|
+
// prepareClaudeCredentials wrote OAuth credentials or confirmed
|
|
1117
|
+
// provider/API settings are enough for Claude Code. If no usable
|
|
1118
|
+
// auth was present, the claude-code entry was removed above.
|
|
1119
1119
|
}
|
|
1120
1120
|
const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
|
|
1121
1121
|
if (codexEntry) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
2
|
import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from "../constants.js";
|
|
3
3
|
import { detectEngine } from "../engine.js";
|
|
4
|
-
import { formatCredentialWarnings, formatRemaining, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
|
|
4
|
+
import { formatCredentialWarnings, formatRemaining, hasClaudeProviderAuth, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
|
|
5
5
|
import { runInteractiveEngine } from "../shell.js";
|
|
6
6
|
import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
|
|
7
7
|
import { runInteractiveWithClipboardBridge } from "../clipboard/bridge.js";
|
|
@@ -50,7 +50,10 @@ export function runSandboxInteractive(params) {
|
|
|
50
50
|
}
|
|
51
51
|
return runBridge({ engine, dockerArgs, container, home });
|
|
52
52
|
}
|
|
53
|
-
export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY) {
|
|
53
|
+
export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY, providerAuthAvailable = false) {
|
|
54
|
+
if (providerAuthAvailable && (result.status === 'STALE_ACCESS' || result.status === 'MISSING')) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
54
57
|
if (result.status === 'STALE_ACCESS') {
|
|
55
58
|
return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
|
|
56
59
|
}
|
|
@@ -100,8 +103,9 @@ export async function enter(args) {
|
|
|
100
103
|
if (config.tools.includes('claude-code')) {
|
|
101
104
|
try {
|
|
102
105
|
// Scan all projects so a refresh from a neighbouring sandbox can still flow back to the host.
|
|
106
|
+
const providerAuthAvailable = hasClaudeProviderAuth(config.home);
|
|
103
107
|
const result = reconcileClaudeCredentials(config.home);
|
|
104
|
-
const message = formatCredentialSyncStatus(result);
|
|
108
|
+
const message = formatCredentialSyncStatus(result, process.stderr.isTTY, providerAuthAvailable);
|
|
105
109
|
if (message) {
|
|
106
110
|
process.stderr.write(message);
|
|
107
111
|
}
|
|
@@ -11,6 +11,12 @@ const REDACTION_PATTERNS = [
|
|
|
11
11
|
{ pattern: /gh[psoru]_[A-Za-z0-9]{30,}/g, replacement: '[REDACTED github token]' },
|
|
12
12
|
{ pattern: /Bearer\s+[A-Za-z0-9._~+/=-]{20,}/gi, replacement: 'Bearer [REDACTED]' }
|
|
13
13
|
];
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function hasNonEmptyString(value) {
|
|
18
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
19
|
+
}
|
|
14
20
|
function errorMessage(error) {
|
|
15
21
|
return error instanceof Error ? error.message : 'unknown error';
|
|
16
22
|
}
|
|
@@ -102,6 +108,28 @@ export function validateClaudeCredentialsEnvOverride(env = process.env) {
|
|
|
102
108
|
throw new Error('Invalid AGENT_INFRA_CLAUDE_CREDENTIALS_FILE value. Expected an absolute file path.');
|
|
103
109
|
}
|
|
104
110
|
}
|
|
111
|
+
export function hasClaudeProviderAuthInSettings(settings) {
|
|
112
|
+
if (!isRecord(settings)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const env = isRecord(settings.env) ? settings.env : {};
|
|
116
|
+
return hasNonEmptyString(env.ANTHROPIC_AUTH_TOKEN)
|
|
117
|
+
|| hasNonEmptyString(env.ANTHROPIC_API_KEY)
|
|
118
|
+
|| hasNonEmptyString(settings.apiKeyHelper);
|
|
119
|
+
}
|
|
120
|
+
export function hasClaudeProviderAuth(home, options = {}) {
|
|
121
|
+
const { readFn = (targetPath) => fs.readFileSync(targetPath, 'utf8'), existsFn = fs.existsSync } = options;
|
|
122
|
+
const settingsPath = path.join(home, '.claude', 'settings.json');
|
|
123
|
+
if (!existsFn(settingsPath)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
return hasClaudeProviderAuthInSettings(JSON.parse(readFn(settingsPath)));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
105
133
|
// Reconcile treats the freshest valid endpoint as authoritative so sandbox
|
|
106
134
|
// token rotations can flow back to the host credential store.
|
|
107
135
|
function validateClaudeCredentialsBlob(raw, blob = null) {
|
|
@@ -395,11 +423,19 @@ export function formatRemaining(expiresAt) {
|
|
|
395
423
|
const minutes = totalMinutes % 60;
|
|
396
424
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
397
425
|
}
|
|
398
|
-
export function prepareClaudeCredentials(home, project, resolvedTools, extractFn = extractClaudeCredentialsBlob, writeFn = writeClaudeCredentialsFile, inspectFn = inspectClaudeKeychainStatus) {
|
|
426
|
+
export function prepareClaudeCredentials(home, project, resolvedTools, extractFn = extractClaudeCredentialsBlob, writeFn = writeClaudeCredentialsFile, inspectFn = inspectClaudeKeychainStatus, providerAuthFn = hasClaudeProviderAuth) {
|
|
399
427
|
const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
400
428
|
if (!claudeCodeEntry) {
|
|
401
429
|
return { status: 'NOT_APPLICABLE' };
|
|
402
430
|
}
|
|
431
|
+
const providerAuthAvailable = () => {
|
|
432
|
+
try {
|
|
433
|
+
return providerAuthFn(home);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
};
|
|
403
439
|
let blob = null;
|
|
404
440
|
const hasCustomInspectFn = inspectFn !== inspectClaudeKeychainStatus;
|
|
405
441
|
const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
|
|
@@ -410,7 +446,7 @@ export function prepareClaudeCredentials(home, project, resolvedTools, extractFn
|
|
|
410
446
|
blob = inspection.blob;
|
|
411
447
|
break;
|
|
412
448
|
case 'MISSING':
|
|
413
|
-
return { status: 'SKIPPED' };
|
|
449
|
+
return providerAuthAvailable() ? { status: 'OK' } : { status: 'SKIPPED' };
|
|
414
450
|
case 'KEYCHAIN_LOCKED':
|
|
415
451
|
throw new Error([
|
|
416
452
|
'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
|
|
@@ -418,6 +454,9 @@ export function prepareClaudeCredentials(home, project, resolvedTools, extractFn
|
|
|
418
454
|
buildLockedGuidance()
|
|
419
455
|
].join('\n'));
|
|
420
456
|
case 'STALE_ACCESS':
|
|
457
|
+
if (providerAuthAvailable()) {
|
|
458
|
+
return { status: 'OK' };
|
|
459
|
+
}
|
|
421
460
|
throw new Error([
|
|
422
461
|
'Claude Code credentials on host are invalid or expired.',
|
|
423
462
|
'',
|
|
@@ -447,7 +486,7 @@ export function prepareClaudeCredentials(home, project, resolvedTools, extractFn
|
|
|
447
486
|
else {
|
|
448
487
|
blob = extractFn(home);
|
|
449
488
|
if (!blob) {
|
|
450
|
-
return { status: 'SKIPPED' };
|
|
489
|
+
return providerAuthAvailable() ? { status: 'OK' } : { status: 'SKIPPED' };
|
|
451
490
|
}
|
|
452
491
|
}
|
|
453
492
|
writeFn(home, project, blob);
|
|
@@ -61,6 +61,19 @@ RUN cat > /usr/local/bin/cc-token-status <<'SCRIPT' && chmod +x /usr/local/bin/c
|
|
|
61
61
|
#!/bin/sh
|
|
62
62
|
set -eu
|
|
63
63
|
|
|
64
|
+
SETTINGS_FILE="/home/devuser/.claude/settings.json"
|
|
65
|
+
if [ -r "$SETTINGS_FILE" ] && jq -e '
|
|
66
|
+
def has_nonempty($v):
|
|
67
|
+
if ($v | type) == "string" then ($v | gsub("^\\s+|\\s+$"; "") | length) > 0 else false end;
|
|
68
|
+
(if type == "object" then . else {} end) as $settings
|
|
69
|
+
| (if ($settings.env | type) == "object" then $settings.env else {} end) as $env
|
|
70
|
+
| has_nonempty($env.ANTHROPIC_AUTH_TOKEN)
|
|
71
|
+
or has_nonempty($env.ANTHROPIC_API_KEY)
|
|
72
|
+
or has_nonempty($settings.apiKeyHelper)
|
|
73
|
+
' "$SETTINGS_FILE" >/dev/null 2>&1; then
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
76
|
+
|
|
64
77
|
CRED_FILE="/home/devuser/.claude/.credentials.json"
|
|
65
78
|
[ -r "$CRED_FILE" ] || exit 0
|
|
66
79
|
|
|
@@ -12,7 +12,7 @@ completion time (or '(in progress)' while still running).
|
|
|
12
12
|
Columns: # (row) / STEP / AGENT / STARTED / DONE / NOTE
|
|
13
13
|
A human-executed step shows AGENT as 'human' and, when it has no start marker,
|
|
14
14
|
a '-' STARTED placeholder. Review-step NOTE also carries two human counts in
|
|
15
|
-
the verdict list, right after blockers/major/minor: manual-
|
|
15
|
+
the verdict list, right after blockers/major/minor: manual-validation
|
|
16
16
|
and human-decision (current ledger stage total).
|
|
17
17
|
`;
|
|
18
18
|
const TABLE_HEADERS = ['#', 'STEP', 'AGENT', 'STARTED', 'DONE', 'NOTE'];
|
|
@@ -33,10 +33,6 @@ const STARTED_SUFFIX_RE = /\s*\[started\]\s*$/;
|
|
|
33
33
|
// note these differ from the `.airc.json` long names claude-code/gemini-cli).
|
|
34
34
|
// Any other executor token (a human name, possibly CJK) is treated as human.
|
|
35
35
|
const KNOWN_AI_AGENTS = new Set(['claude', 'codex', 'gemini', 'opencode', 'cursor']);
|
|
36
|
-
const ENV_BLOCKED_RE = /\(\+\s*(\d+)\s+env-blocked\)/i;
|
|
37
|
-
// Same match plus any leading whitespace, so folding the count into the verdict
|
|
38
|
-
// text drops the redundant `(+ n env-blocked)` fragment without leaving a gap.
|
|
39
|
-
const ENV_BLOCKED_STRIP_RE = /\s*\(\+\s*\d+\s+env-blocked\)/i;
|
|
40
36
|
const REVIEW_STAGE_PREFIXES = [
|
|
41
37
|
{ prefix: 'Review Analysis', stage: 'analysis' },
|
|
42
38
|
{ prefix: 'Review Plan', stage: 'plan' },
|
|
@@ -116,9 +112,31 @@ function isReviewStage(stage) {
|
|
|
116
112
|
function reviewStageForStep(step) {
|
|
117
113
|
return REVIEW_STAGE_PREFIXES.find(({ prefix }) => step.startsWith(prefix))?.stage;
|
|
118
114
|
}
|
|
115
|
+
function splitArtifactSuffix(note) {
|
|
116
|
+
const arrow = note.indexOf(' → ');
|
|
117
|
+
return arrow === -1 ? { verdict: note, suffix: '' } : { verdict: note.slice(0, arrow), suffix: note.slice(arrow) };
|
|
118
|
+
}
|
|
119
|
+
function fieldNumber(field, label) {
|
|
120
|
+
const trimmed = field.trim();
|
|
121
|
+
const colon = trimmed.indexOf(':');
|
|
122
|
+
if (colon === -1)
|
|
123
|
+
return undefined;
|
|
124
|
+
if (trimmed.slice(0, colon).trim().toLowerCase() !== label)
|
|
125
|
+
return undefined;
|
|
126
|
+
const value = Number(trimmed.slice(colon + 1).trim());
|
|
127
|
+
return Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
128
|
+
}
|
|
119
129
|
function humanValidationCount(note) {
|
|
120
|
-
const
|
|
121
|
-
|
|
130
|
+
const { verdict } = splitArtifactSuffix(note);
|
|
131
|
+
for (const field of verdict.split(',')) {
|
|
132
|
+
const value = fieldNumber(field, 'manual-validation');
|
|
133
|
+
if (value !== undefined)
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
function isHumanCountField(field) {
|
|
139
|
+
return fieldNumber(field, 'manual-validation') !== undefined || fieldNumber(field, 'human-decision') !== undefined;
|
|
122
140
|
}
|
|
123
141
|
// A step is human-executed when its agent token is not a known AI token. Take
|
|
124
142
|
// the first whitespace-delimited token and drop any trailing parenthetical
|
|
@@ -129,13 +147,17 @@ function isHumanAgent(agent) {
|
|
|
129
147
|
}
|
|
130
148
|
// Fold the two human counts into a review row's verdict NOTE: comma-joined, right
|
|
131
149
|
// after the blockers/major/minor list and before the ` → artifact` link, mirroring
|
|
132
|
-
// the review count line.
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
150
|
+
// the review count line. Review done notes already carry `Manual-validation` as a
|
|
151
|
+
// source field, so build the final verdict field list once instead of cleaning a
|
|
152
|
+
// previously rendered string.
|
|
153
|
+
function foldHumanCounts(note, decisions, manualValidation) {
|
|
154
|
+
const { verdict, suffix } = splitArtifactSuffix(note);
|
|
155
|
+
const fields = verdict
|
|
156
|
+
.split(',')
|
|
157
|
+
.map((field) => field.trim())
|
|
158
|
+
.filter((field) => field !== '' && !isHumanCountField(field));
|
|
159
|
+
const group = `Manual-validation: ${manualValidation}, Human-decision: ${decisions}`;
|
|
160
|
+
return `${[...fields, group].join(', ')}${suffix}`;
|
|
139
161
|
}
|
|
140
162
|
function log(args = []) {
|
|
141
163
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|