@fitlab-ai/agent-infra 0.7.7 → 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/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/bin/cli.ts +42 -15
- package/dist/bin/cli.js +46 -16
- package/dist/lib/decide.js +110 -0
- package/dist/lib/run/host.js +39 -0
- package/dist/lib/run/index.js +193 -0
- package/dist/lib/run/prompt.js +2 -0
- package/dist/lib/run/skills.js +29 -0
- package/dist/lib/run/tui.js +35 -0
- package/dist/lib/sandbox/capture.js +158 -0
- package/dist/lib/sandbox/commands/create.js +58 -9
- 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/server/adapters/_contract.js +9 -0
- package/dist/lib/server/adapters/feishu/index.js +40 -0
- package/dist/lib/server/adapters/feishu/transport.js +95 -0
- package/dist/lib/server/auth.js +19 -0
- package/dist/lib/server/config.js +183 -0
- package/dist/lib/server/daemon.js +96 -0
- package/dist/lib/server/index.js +47 -0
- package/dist/lib/server/logger.js +34 -0
- package/dist/lib/server/plugin-loader.js +73 -0
- package/dist/lib/server/process-control.js +200 -0
- package/dist/lib/server/protocol.js +69 -0
- package/dist/lib/server/redact.js +12 -0
- package/dist/lib/server/runner.js +75 -0
- package/dist/lib/server/streamer.js +44 -0
- package/dist/lib/task/commands/decisions.js +258 -0
- package/dist/lib/task/commands/log.js +38 -41
- package/dist/lib/task/commands/status.js +144 -45
- package/dist/lib/task/index.js +9 -0
- package/dist/lib/task/ledger.js +66 -0
- package/dist/lib/task/sections.js +30 -1
- package/lib/decide.ts +124 -0
- package/lib/run/host.ts +47 -0
- package/lib/run/index.ts +237 -0
- package/lib/run/prompt.ts +1 -0
- package/lib/run/skills.ts +34 -0
- package/lib/run/tui.ts +40 -0
- package/lib/sandbox/capture.ts +215 -0
- package/lib/sandbox/commands/create.ts +64 -12
- 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/server/adapters/_contract.ts +42 -0
- package/lib/server/adapters/feishu/index.ts +47 -0
- package/lib/server/adapters/feishu/transport.ts +128 -0
- package/lib/server/auth.ts +28 -0
- package/lib/server/config.ts +228 -0
- package/lib/server/daemon.ts +110 -0
- package/lib/server/index.ts +50 -0
- package/lib/server/logger.ts +48 -0
- package/lib/server/plugin-loader.ts +84 -0
- package/lib/server/process-control.ts +225 -0
- package/lib/server/protocol.ts +79 -0
- package/lib/server/redact.ts +12 -0
- package/lib/server/runner.ts +95 -0
- package/lib/server/server.schema.json +127 -0
- package/lib/server/streamer.ts +57 -0
- package/lib/task/commands/decisions.ts +272 -0
- package/lib/task/commands/log.ts +38 -39
- package/lib/task/commands/status.ts +200 -58
- package/lib/task/index.ts +9 -0
- package/lib/task/ledger.ts +73 -0
- package/lib/task/sections.ts +30 -1
- package/package.json +8 -3
- package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
- package/templates/.agents/rules/issue-fields.github.en.md +1 -1
- package/templates/.agents/rules/issue-fields.github.zh-CN.md +1 -1
- package/templates/.agents/rules/next-step-output.en.md +5 -1
- package/templates/.agents/rules/next-step-output.zh-CN.md +5 -1
- package/templates/.agents/rules/no-mid-flow-questions.en.md +7 -2
- package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +7 -2
- 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/review-handshake.en.md +5 -1
- package/templates/.agents/rules/review-handshake.zh-CN.md +5 -1
- package/templates/.agents/rules/task-management.en.md +2 -2
- package/templates/.agents/rules/task-management.zh-CN.md +2 -2
- package/templates/.agents/scripts/lib/post-review-commit.js +15 -18
- package/templates/.agents/scripts/validate-artifact.js +8 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +15 -3
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +15 -3
- package/templates/.agents/skills/analyze-task/config/verify.en.json +2 -1
- package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +2 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +3 -3
- 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/complete-task/SKILL.en.md +1 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/complete-task/config/verify.en.json +2 -1
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -1
- 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/create-task/SKILL.en.md +1 -3
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -3
- package/templates/.agents/skills/import-codescan/SKILL.en.md +0 -3
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +0 -3
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +0 -1
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +0 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +1 -2
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -2
- package/templates/.agents/skills/plan-task/SKILL.en.md +1 -2
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -2
- 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/templates/.agents/templates/task.en.md +3 -3
- package/templates/.agents/templates/task.zh-CN.md +3 -3
- package/templates/.github/workflows/metadata-sync.yml +0 -18
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { containerNameCandidates, sandboxBranchLabel, sandboxLabel } from "./constants.js";
|
|
4
|
+
import { detectEngine } from "./engine.js";
|
|
5
|
+
import { hostTimezoneEnvFlags, terminalEnvFlags } from "./commands/enter.js";
|
|
6
|
+
import { fetchSandboxRows, selectSandboxContainer, startSandboxContainer } from "./commands/list-running.js";
|
|
7
|
+
async function spawnCapture(file, args) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
10
|
+
let stdout = '';
|
|
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
|
+
};
|
|
25
|
+
child.stdout.setEncoding('utf8');
|
|
26
|
+
child.stderr.setEncoding('utf8');
|
|
27
|
+
child.stdout.on('data', (chunk) => {
|
|
28
|
+
stdout += chunk;
|
|
29
|
+
});
|
|
30
|
+
child.stderr.on('data', (chunk) => {
|
|
31
|
+
stderr += chunk;
|
|
32
|
+
});
|
|
33
|
+
child.on('error', rejectOnce);
|
|
34
|
+
child.on('close', (exitCode, signal) => {
|
|
35
|
+
resolveOnce({ exitCode, signal, stdout, stderr });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
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
|
+
}
|
|
120
|
+
export async function runInSandbox(request, options = {}) {
|
|
121
|
+
const config = options.engine ? null : loadConfig();
|
|
122
|
+
const engine = options.engine ?? detectEngine(config);
|
|
123
|
+
const rows = options.rows ??
|
|
124
|
+
(() => {
|
|
125
|
+
const fetched = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
|
|
126
|
+
return [...fetched.running, ...fetched.nonRunning];
|
|
127
|
+
})();
|
|
128
|
+
const candidates = options.containerCandidates ?? containerNameCandidates(config, request.branch);
|
|
129
|
+
const found = selectSandboxContainer(rows, candidates);
|
|
130
|
+
if (!found) {
|
|
131
|
+
throw new Error(`Sandbox for ${request.branch} not found. Create it first with ai sandbox create ${request.taskRef}.`);
|
|
132
|
+
}
|
|
133
|
+
if (!found.running) {
|
|
134
|
+
(options.startContainer ?? ((name) => startSandboxContainer(engine, name)))(found.name);
|
|
135
|
+
}
|
|
136
|
+
const runId = options.runId ?? createRunId();
|
|
137
|
+
const runDir = `/tmp/agent-infra-runs/${runId}`;
|
|
138
|
+
const dockerArgs = [
|
|
139
|
+
'exec',
|
|
140
|
+
...terminalEnvFlags(),
|
|
141
|
+
...hostTimezoneEnvFlags(),
|
|
142
|
+
found.name,
|
|
143
|
+
'bash',
|
|
144
|
+
'-lc',
|
|
145
|
+
buildTmuxLauncher({ request, runId })
|
|
146
|
+
];
|
|
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
|
+
};
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=capture.js.map
|
|
@@ -514,6 +514,58 @@ function readHostJsonSafe(filePath) {
|
|
|
514
514
|
return null;
|
|
515
515
|
}
|
|
516
516
|
}
|
|
517
|
+
const CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS = [
|
|
518
|
+
'model',
|
|
519
|
+
'fallbackModel',
|
|
520
|
+
'availableModels',
|
|
521
|
+
'modelOverrides',
|
|
522
|
+
'enforceAvailableModels',
|
|
523
|
+
'advisorModel',
|
|
524
|
+
'apiKeyHelper',
|
|
525
|
+
'effortLevel'
|
|
526
|
+
];
|
|
527
|
+
function isJsonObjectRecord(value) {
|
|
528
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
529
|
+
}
|
|
530
|
+
function mergeMissingStringEnvFields(target, source) {
|
|
531
|
+
if (!isJsonObjectRecord(source.env)) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
if (Object.hasOwn(target, 'env') && !isJsonObjectRecord(target.env)) {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
let targetEnv = target.env;
|
|
538
|
+
let changed = false;
|
|
539
|
+
for (const [key, value] of Object.entries(source.env)) {
|
|
540
|
+
if (typeof value !== 'string' || value === '') {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (!targetEnv) {
|
|
544
|
+
targetEnv = {};
|
|
545
|
+
target.env = targetEnv;
|
|
546
|
+
}
|
|
547
|
+
if (!Object.hasOwn(targetEnv, key)) {
|
|
548
|
+
targetEnv[key] = value;
|
|
549
|
+
changed = true;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return changed;
|
|
553
|
+
}
|
|
554
|
+
function mergeMissingTopLevelSettings(target, source) {
|
|
555
|
+
let changed = false;
|
|
556
|
+
for (const key of CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS) {
|
|
557
|
+
if (!Object.hasOwn(source, key) || Object.hasOwn(target, key)) {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const value = source[key];
|
|
561
|
+
if (value === null || value === undefined || value === '') {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
target[key] = value;
|
|
565
|
+
changed = true;
|
|
566
|
+
}
|
|
567
|
+
return changed;
|
|
568
|
+
}
|
|
517
569
|
export function ensureClaudeOnboarding(toolDir, hostHomeDir) {
|
|
518
570
|
const claudeJsonPath = path.join(toolDir, '.claude.json');
|
|
519
571
|
let data = {};
|
|
@@ -592,12 +644,9 @@ export function ensureClaudeSettings(toolDir, hostHomeDir) {
|
|
|
592
644
|
}
|
|
593
645
|
if (hostHomeDir) {
|
|
594
646
|
const hostSettings = readHostJsonSafe(path.join(hostHomeDir, '.claude', 'settings.json'));
|
|
595
|
-
if (hostSettings
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
&& !Object.hasOwn(data, 'effortLevel')) {
|
|
599
|
-
data.effortLevel = hostSettings.effortLevel;
|
|
600
|
-
changed = true;
|
|
647
|
+
if (hostSettings) {
|
|
648
|
+
changed = mergeMissingStringEnvFields(data, hostSettings) || changed;
|
|
649
|
+
changed = mergeMissingTopLevelSettings(data, hostSettings) || changed;
|
|
601
650
|
}
|
|
602
651
|
}
|
|
603
652
|
if (changed) {
|
|
@@ -1064,9 +1113,9 @@ export async function create(args) {
|
|
|
1064
1113
|
if (claudeCodeEntry) {
|
|
1065
1114
|
ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1066
1115
|
ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1067
|
-
// prepareClaudeCredentials wrote
|
|
1068
|
-
//
|
|
1069
|
-
// 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.
|
|
1070
1119
|
}
|
|
1071
1120
|
const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
|
|
1072
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
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Adapter contract for agent-infra-server.
|
|
2
|
+
//
|
|
3
|
+
// This is a pure type + constant module: it carries no runtime logic and no
|
|
4
|
+
// third-party imports. Subtask B (the feishu adapter) and subtask C (the
|
|
5
|
+
// command protocol / runner) build against these shapes. Bump
|
|
6
|
+
// ADAPTER_CONTRACT_VERSION on a breaking change so plugin-loader can reject
|
|
7
|
+
// adapters compiled against an older contract.
|
|
8
|
+
export const ADAPTER_CONTRACT_VERSION = 1;
|
|
9
|
+
//# sourceMappingURL=_contract.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createFeishuTransport, normalizeMessage } from "./transport.js";
|
|
2
|
+
// Assemble the feishu adapter. The transport is injectable so unit tests can
|
|
3
|
+
// drive start/dispatch/reply/sendMessage against a fake transport without the
|
|
4
|
+
// real SDK; production uses the default SDK-backed transport.
|
|
5
|
+
export function createFeishuAdapter(config, transport = createFeishuTransport(config)) {
|
|
6
|
+
let ctx = null;
|
|
7
|
+
return {
|
|
8
|
+
name: 'feishu',
|
|
9
|
+
async start(adapterCtx) {
|
|
10
|
+
ctx = adapterCtx;
|
|
11
|
+
await transport.start(async (raw) => {
|
|
12
|
+
try {
|
|
13
|
+
const normalized = normalizeMessage(raw);
|
|
14
|
+
const message = {
|
|
15
|
+
adapter: 'feishu',
|
|
16
|
+
userId: normalized.userId,
|
|
17
|
+
chatId: normalized.chatId,
|
|
18
|
+
text: normalized.text,
|
|
19
|
+
messageId: normalized.messageId,
|
|
20
|
+
raw: normalized.raw,
|
|
21
|
+
reply: (text) => transport.send(normalized.chatId, text)
|
|
22
|
+
};
|
|
23
|
+
await adapterCtx.dispatch(message);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
ctx?.logger.err(`feishu: dropped message: ${error instanceof Error ? error.message : String(error)}`);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
async stop() {
|
|
31
|
+
await transport.stop();
|
|
32
|
+
},
|
|
33
|
+
async sendMessage(target, text) {
|
|
34
|
+
await transport.send(target.chatId, text);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const factory = (adapterConfig) => createFeishuAdapter(adapterConfig);
|
|
39
|
+
export default factory;
|
|
40
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as lark from '@larksuiteoapi/node-sdk';
|
|
2
|
+
function asRecord(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null ? value : {};
|
|
4
|
+
}
|
|
5
|
+
// Strip feishu mention placeholders (e.g. "@_user_1") that the platform injects
|
|
6
|
+
// for @-mentions, then collapse surrounding whitespace. A group message like
|
|
7
|
+
// "@_user_1 /ping" normalizes to "/ping" so the daemon dispatcher matches it.
|
|
8
|
+
function stripMentions(text) {
|
|
9
|
+
return text.replace(/@_user_\d+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
10
|
+
}
|
|
11
|
+
// Parse an im.message.receive_v1 event into the daemon's shape. Pure: no SDK,
|
|
12
|
+
// no IO. Throws on missing/invalid fields so the adapter can drop a single bad
|
|
13
|
+
// message without crashing the long connection.
|
|
14
|
+
export function normalizeMessage(event) {
|
|
15
|
+
const message = asRecord(asRecord(event).message);
|
|
16
|
+
const messageId = message.message_id;
|
|
17
|
+
const chatId = message.chat_id;
|
|
18
|
+
const content = message.content;
|
|
19
|
+
if (typeof messageId !== 'string' || typeof chatId !== 'string' || typeof content !== 'string') {
|
|
20
|
+
throw new Error('feishu: malformed im.message.receive_v1 event (missing message_id/chat_id/content)');
|
|
21
|
+
}
|
|
22
|
+
const senderId = asRecord(asRecord(asRecord(event).sender).sender_id);
|
|
23
|
+
const userId = (typeof senderId.open_id === 'string' && senderId.open_id) ||
|
|
24
|
+
(typeof senderId.union_id === 'string' && senderId.union_id) ||
|
|
25
|
+
(typeof senderId.user_id === 'string' && senderId.user_id) ||
|
|
26
|
+
'';
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(content);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error('feishu: message.content is not valid JSON');
|
|
33
|
+
}
|
|
34
|
+
const rawText = asRecord(parsed).text;
|
|
35
|
+
if (typeof rawText !== 'string') {
|
|
36
|
+
throw new Error('feishu: unsupported message content (no text field)');
|
|
37
|
+
}
|
|
38
|
+
return { userId, chatId, text: stripMentions(rawText), messageId, raw: event };
|
|
39
|
+
}
|
|
40
|
+
function resolveDomain(value) {
|
|
41
|
+
return value === 'lark' || value === 'Lark' ? lark.Domain.Lark : lark.Domain.Feishu;
|
|
42
|
+
}
|
|
43
|
+
// Build the real SDK-backed transport from adapter config. appId/appSecret come
|
|
44
|
+
// from server config (appSecret only via server.local.json / env). The WSClient
|
|
45
|
+
// long connection delivers events; the Client REST call sends replies.
|
|
46
|
+
// Feishu self-built app IDs have this shape; the SDK's WSClient.start() silently
|
|
47
|
+
// rejects anything else, so we validate up front to fail loudly instead.
|
|
48
|
+
const APP_ID_PATTERN = /^cli_[0-9a-fA-F]{16}$/;
|
|
49
|
+
export function createFeishuTransport(config) {
|
|
50
|
+
const appId = String(config.appId ?? '').trim();
|
|
51
|
+
const appSecret = String(config.appSecret ?? '').trim();
|
|
52
|
+
const domain = resolveDomain(config.domain);
|
|
53
|
+
// Fail fast on missing or malformed credentials. The SDK only logs to its own
|
|
54
|
+
// logger and resolves WSClient.start() for an empty or malformed appId, so
|
|
55
|
+
// without these checks an enabled-but-misconfigured feishu adapter would be
|
|
56
|
+
// counted as loaded while the long connection never opens. Throwing here lets
|
|
57
|
+
// loadAdapters log `failed to load adapter "feishu": ...` and skip it.
|
|
58
|
+
if (appId === '' || appSecret === '') {
|
|
59
|
+
throw new Error('feishu: appId and appSecret are required; put appSecret in .agents/server.local.json or AGENT_INFRA_SERVER_adapters__feishu__appSecret');
|
|
60
|
+
}
|
|
61
|
+
if (!APP_ID_PATTERN.test(appId)) {
|
|
62
|
+
throw new Error('feishu: appId must match cli_[0-9a-fA-F]{16}');
|
|
63
|
+
}
|
|
64
|
+
const client = new lark.Client({ appId, appSecret, domain });
|
|
65
|
+
const wsClient = new lark.WSClient({ appId, appSecret, domain, loggerLevel: lark.LoggerLevel.warn });
|
|
66
|
+
return {
|
|
67
|
+
async start(onMessage) {
|
|
68
|
+
const eventDispatcher = new lark.EventDispatcher({}).register({
|
|
69
|
+
'im.message.receive_v1': async (data) => {
|
|
70
|
+
await onMessage(data);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
wsClient.start({ eventDispatcher });
|
|
74
|
+
},
|
|
75
|
+
async stop() {
|
|
76
|
+
// Best-effort: close the long connection if the SDK exposes it. Never throw
|
|
77
|
+
// from shutdown — unloadAdapters isolates stop failures, but staying quiet
|
|
78
|
+
// keeps the daemon shutdown path clean.
|
|
79
|
+
const closable = wsClient;
|
|
80
|
+
try {
|
|
81
|
+
closable.close?.();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Ignore: the daemon is shutting down regardless.
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
async send(chatId, text) {
|
|
88
|
+
await client.im.message.create({
|
|
89
|
+
params: { receive_id_type: 'chat_id' },
|
|
90
|
+
data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) }
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=transport.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const ROLE_RANK = { read: 1, write: 2, exec: 3 };
|
|
2
|
+
function userRole(auth, key) {
|
|
3
|
+
const users = auth.users;
|
|
4
|
+
if (!users || typeof users !== 'object' || Array.isArray(users))
|
|
5
|
+
return null;
|
|
6
|
+
const record = users[key];
|
|
7
|
+
if (!record || typeof record !== 'object' || Array.isArray(record))
|
|
8
|
+
return null;
|
|
9
|
+
const role = record.role;
|
|
10
|
+
return role === 'read' || role === 'write' || role === 'exec' ? role : null;
|
|
11
|
+
}
|
|
12
|
+
export function authorize(user, required, auth) {
|
|
13
|
+
const role = userRole(auth ?? {}, `${user.adapter}:${user.userId}`);
|
|
14
|
+
if (!role || ROLE_RANK[role] < ROLE_RANK[required]) {
|
|
15
|
+
return { ok: false, message: `requires ${required}` };
|
|
16
|
+
}
|
|
17
|
+
return { ok: true };
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=auth.js.map
|