agentlas 0.5.2 → 0.6.0
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 +48 -6
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +40 -56
- package/engine/agentlas-capabilities.cjs +3 -0
- package/engine/agentlas-cloud-runtime.cjs +65 -11
- package/engine/agentlas-composer.cjs +112 -44
- package/engine/agentlas-doctor.cjs +40 -12
- package/engine/agentlas-i18n.cjs +136 -12
- package/engine/agentlas-input.cjs +118 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +315 -45
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +239 -70
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +352 -23
- package/engine/agentlas.cjs +2819 -351
- package/engine/semver.cjs +64 -0
- package/package.json +1 -1
- package/test/bootstrap-race.cjs +47 -0
- package/test/capture-runtime-guard.cjs +122 -0
- package/test/cloud-asset-restore.cjs +423 -0
- package/test/cloud-cas-client.cjs +333 -0
- package/test/cloud-owner-restore.cjs +183 -0
- package/test/cloud-runtime-paths.cjs +40 -0
- package/test/cloud-save-publish.cjs +453 -0
- package/test/credential-env-regression.cjs +52 -0
- package/test/login-loopback-security.cjs +115 -0
- package/test/mcp-config-isolation.cjs +36 -0
- package/test/permission-mapping.cjs +180 -0
- package/test/route-regression.cjs +121 -0
- package/test/run-api-regression.cjs +322 -0
- package/test/runtime-env-protection.cjs +45 -0
- package/test/semver-precedence.cjs +39 -0
- package/test/smoke.sh +20 -0
- package/test/sqlite-driver-probe.cjs +22 -0
- package/test/terminal-ui-regression.cjs +472 -0
- package/test/timeout-regression.cjs +218 -0
- package/test/tool-workspace-boundary.cjs +165 -0
- package/test/update-safety.cjs +376 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
const {
|
|
9
|
+
claudeArgs,
|
|
10
|
+
codexArgs,
|
|
11
|
+
geminiArgs,
|
|
12
|
+
prepareCodexRuntimeEnv,
|
|
13
|
+
} = require("../engine/agentlas-native-host.cjs");
|
|
14
|
+
const permissions = require("../engine/agentlas-permissions.cjs");
|
|
15
|
+
const { buildArgs: legacyBuildArgs } = require("../engine/agentlas.cjs");
|
|
16
|
+
|
|
17
|
+
const mcpServers = [{ name: "playwright", command: "npx", args: ["@playwright/mcp"] }];
|
|
18
|
+
|
|
19
|
+
function hasPair(args, flag, value) {
|
|
20
|
+
const index = args.indexOf(flag);
|
|
21
|
+
return index >= 0 && args[index + 1] === value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function includesExternalMcp(args) {
|
|
25
|
+
if (args.some((arg) => String(arg).includes("mcp_servers"))) return true;
|
|
26
|
+
for (let index = 0; index < args.length; index++) {
|
|
27
|
+
if (args[index] !== "--mcp-config") continue;
|
|
28
|
+
const value = String(args[index + 1] || "");
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(value);
|
|
31
|
+
if (Object.keys(parsed.mcpServers || {}).length > 0) return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return true; // a generated config file is the explicit full-access inventory
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function common(level) {
|
|
40
|
+
return {
|
|
41
|
+
prompt: "test",
|
|
42
|
+
systemPrompt: "system",
|
|
43
|
+
permission: level,
|
|
44
|
+
session: {},
|
|
45
|
+
cwd: process.cwd(),
|
|
46
|
+
mcpServers,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function testClaude() {
|
|
51
|
+
const read = claudeArgs(common("read"));
|
|
52
|
+
const write = claudeArgs(common("write"));
|
|
53
|
+
const full = claudeArgs(common("full"));
|
|
54
|
+
assert.ok(hasPair(read, "--permission-mode", "plan"));
|
|
55
|
+
assert.ok(hasPair(write, "--permission-mode", "acceptEdits"));
|
|
56
|
+
assert.ok(full.includes("--dangerously-skip-permissions"));
|
|
57
|
+
assert.ok(!write.includes("--dangerously-skip-permissions"), "write must never launch Claude unrestricted");
|
|
58
|
+
for (const args of [read, write]) {
|
|
59
|
+
assert.ok(args.includes("--strict-mcp-config"), "Claude read/write must ignore user/project MCP configuration");
|
|
60
|
+
assert.equal(includesExternalMcp(args), false, "Claude read/write must receive an explicit empty MCP inventory");
|
|
61
|
+
}
|
|
62
|
+
assert.ok(full.includes("--strict-mcp-config"), "Claude full must use only the Agentlas-provided MCP inventory");
|
|
63
|
+
assert.equal(includesExternalMcp(full), true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function testCodex() {
|
|
67
|
+
const read = codexArgs(common("read"));
|
|
68
|
+
const write = codexArgs(common("write"));
|
|
69
|
+
const full = codexArgs(common("full"));
|
|
70
|
+
assert.ok(hasPair(read, "--sandbox", "read-only"));
|
|
71
|
+
assert.ok(hasPair(write, "--sandbox", "workspace-write"));
|
|
72
|
+
assert.ok(full.includes("--dangerously-bypass-approvals-and-sandbox"));
|
|
73
|
+
assert.ok(!write.includes("--dangerously-bypass-approvals-and-sandbox"), "write must stay sandboxed");
|
|
74
|
+
assert.equal(includesExternalMcp(read), false);
|
|
75
|
+
assert.equal(includesExternalMcp(write), false, "write must not auto-inject Playwright or another external MCP");
|
|
76
|
+
assert.equal(includesExternalMcp(full), true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function testGemini() {
|
|
80
|
+
const read = geminiArgs(common("read"));
|
|
81
|
+
const write = geminiArgs(common("write"));
|
|
82
|
+
const full = geminiArgs(common("full"));
|
|
83
|
+
assert.ok(hasPair(read, "--approval-mode", "plan"));
|
|
84
|
+
assert.ok(hasPair(write, "--approval-mode", "auto_edit"));
|
|
85
|
+
assert.ok(hasPair(full, "--approval-mode", "yolo"));
|
|
86
|
+
assert.equal(write.includes("--yolo"), false);
|
|
87
|
+
for (const args of [read, write]) {
|
|
88
|
+
const index = args.indexOf("--allowed-mcp-server-names");
|
|
89
|
+
assert.ok(index >= 0 && /^__agentlas_no_mcp_[0-9a-f-]+__$/.test(String(args[index + 1])), "Gemini read/write must use an exclusive empty MCP allow-list");
|
|
90
|
+
}
|
|
91
|
+
assert.equal(full.includes("--allowed-mcp-server-names"), false, "Gemini full may use the user's explicitly configured MCP servers");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function testCodexIsolatedHome() {
|
|
95
|
+
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-codex-home-"));
|
|
96
|
+
const source = path.join(fixture, "source");
|
|
97
|
+
const data = path.join(fixture, "agentlas-data");
|
|
98
|
+
fs.mkdirSync(source, { recursive: true });
|
|
99
|
+
fs.writeFileSync(path.join(source, "auth.json"), '{"token":"fixture"}\n', { mode: 0o600 });
|
|
100
|
+
fs.writeFileSync(path.join(source, "config.toml"), '[mcp_servers.victim]\nurl="https://victim.invalid"\n', "utf8");
|
|
101
|
+
try {
|
|
102
|
+
const isolated = prepareCodexRuntimeEnv({ CODEX_HOME: source, AGENTLAS_USER_DATA_DIR: data });
|
|
103
|
+
assert.notEqual(isolated.CODEX_HOME, source);
|
|
104
|
+
assert.equal(fs.readFileSync(path.join(isolated.CODEX_HOME, "auth.json"), "utf8"), '{"token":"fixture"}\n');
|
|
105
|
+
assert.doesNotMatch(fs.readFileSync(path.join(isolated.CODEX_HOME, "config.toml"), "utf8"), /mcp_servers/);
|
|
106
|
+
assert.match(fs.readFileSync(path.join(source, "config.toml"), "utf8"), /victim/);
|
|
107
|
+
assert.throws(
|
|
108
|
+
() => prepareCodexRuntimeEnv({ CODEX_HOME: source, AGENTLAS_USER_DATA_DIR: data, AGENTLAS_CODEX_HOME: source }),
|
|
109
|
+
/must be isolated/,
|
|
110
|
+
);
|
|
111
|
+
assert.match(fs.readFileSync(path.join(source, "config.toml"), "utf8"), /victim/, "isolation failure overwrote the user's Codex config");
|
|
112
|
+
} finally {
|
|
113
|
+
fs.rmSync(fixture, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function testFailClosedAndCopy() {
|
|
118
|
+
assert.equal(permissions.normalize("corrupt-value"), "read");
|
|
119
|
+
const invalidCodex = codexArgs(common("corrupt-value"));
|
|
120
|
+
assert.ok(hasPair(invalidCodex, "--sandbox", "read-only"));
|
|
121
|
+
assert.equal(permissions.copy("write", "en").label, "workspace write");
|
|
122
|
+
assert.match(permissions.copy("full", "ko").description, /승인과 샌드박스를 우회/);
|
|
123
|
+
assert.deepEqual(permissions.LEVELS, ["read", "write", "full"]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function testShiftTabFullConfirmation() {
|
|
127
|
+
let clock = 1_000;
|
|
128
|
+
const cycle = permissions.createCycleController({ now: () => clock, armMs: 5_000 });
|
|
129
|
+
assert.deepEqual(cycle.step("read"), { level: "write", armed: false, enteredFull: false });
|
|
130
|
+
assert.deepEqual(cycle.step("write"), { level: "write", armed: true, enteredFull: false });
|
|
131
|
+
assert.equal(cycle.armed(), true);
|
|
132
|
+
cycle.cancel();
|
|
133
|
+
assert.equal(cycle.armed(), false, "any non-Shift-Tab key must disarm full escalation");
|
|
134
|
+
assert.deepEqual(cycle.step("write"), { level: "write", armed: true, enteredFull: false });
|
|
135
|
+
assert.deepEqual(cycle.step("write"), { level: "full", armed: false, enteredFull: true });
|
|
136
|
+
assert.deepEqual(cycle.step("full"), { level: "read", armed: false, enteredFull: false });
|
|
137
|
+
cycle.step("write");
|
|
138
|
+
clock += 5_001;
|
|
139
|
+
assert.deepEqual(cycle.step("write"), { level: "write", armed: true, enteredFull: false }, "expired arm must require a fresh double press");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function testBackgroundAndSwarmCapturePath() {
|
|
143
|
+
for (const kind of ["claude-code", "codex", "gemini"]) {
|
|
144
|
+
const read = legacyBuildArgs(kind, "system", "prompt", "read");
|
|
145
|
+
const write = legacyBuildArgs(kind, "system", "prompt", "write");
|
|
146
|
+
const full = legacyBuildArgs(kind, "system", "prompt", "full");
|
|
147
|
+
assert.equal(includesExternalMcp(read), false, `${kind} read capture must not inject MCP`);
|
|
148
|
+
assert.equal(includesExternalMcp(write), false, `${kind} write capture must not inject MCP`);
|
|
149
|
+
if (kind !== "gemini") assert.equal(includesExternalMcp(full), true, `${kind} full capture should retain explicit Playwright access`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const claudeWrite = legacyBuildArgs("claude-code", "system", "prompt", "write");
|
|
153
|
+
const claudeFull = legacyBuildArgs("claude-code", "system", "prompt", "full");
|
|
154
|
+
assert.ok(hasPair(claudeWrite, "--permission-mode", "acceptEdits"));
|
|
155
|
+
assert.ok(claudeFull.includes("--dangerously-skip-permissions"));
|
|
156
|
+
|
|
157
|
+
const codexWrite = legacyBuildArgs("codex", "system", "prompt", "write");
|
|
158
|
+
const codexFull = legacyBuildArgs("codex", "system", "prompt", "full");
|
|
159
|
+
assert.ok(hasPair(codexWrite, "--sandbox", "workspace-write"));
|
|
160
|
+
assert.ok(!codexWrite.includes("--dangerously-bypass-approvals-and-sandbox"));
|
|
161
|
+
assert.ok(codexFull.includes("--dangerously-bypass-approvals-and-sandbox"));
|
|
162
|
+
|
|
163
|
+
const geminiWrite = legacyBuildArgs("gemini", "system", "prompt", "write");
|
|
164
|
+
const geminiFull = legacyBuildArgs("gemini", "system", "prompt", "full");
|
|
165
|
+
assert.ok(hasPair(geminiWrite, "--approval-mode", "auto_edit"));
|
|
166
|
+
assert.ok(hasPair(geminiFull, "--approval-mode", "yolo"));
|
|
167
|
+
assert.equal(geminiWrite.includes("--yolo"), false);
|
|
168
|
+
|
|
169
|
+
const invalid = legacyBuildArgs("codex", "system", "prompt", "corrupt-value");
|
|
170
|
+
assert.ok(hasPair(invalid, "--sandbox", "read-only"), "capture path must also fail closed");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
testClaude();
|
|
174
|
+
testCodex();
|
|
175
|
+
testGemini();
|
|
176
|
+
testCodexIsolatedHome();
|
|
177
|
+
testFailClosedAndCopy();
|
|
178
|
+
testShiftTabFullConfirmation();
|
|
179
|
+
testBackgroundAndSwarmCapturePath();
|
|
180
|
+
console.log("permission-mapping: PASS");
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/*
|
|
4
|
+
* 자동 라우팅 회귀 테스트 — 2026-07-12 오라우팅 사고 고정.
|
|
5
|
+
*
|
|
6
|
+
* 사고: 일반 맥 질문("맥이 잠금상태에서 자꾸 ai 돌릴때 안켜지게 하는법 없나")이
|
|
7
|
+
* "ai" 단어 하나(+2점)로 Pitch Deck Architect에 라우팅되고, 그 에이전트의 이미지
|
|
8
|
+
* 힌트 때문에 런타임까지 gemini로 끌려갔다.
|
|
9
|
+
* 수리: 초범용 토큰 스톱워드 + IDF 근사 필터 + 정체성 가중치 + 확신 임계값(MIN_ROUTE_SCORE)
|
|
10
|
+
* 미만이면 direct(에이전트·능력 라우팅 없음) 판정.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const assert = require("node:assert/strict");
|
|
14
|
+
const { autoRouteAgent, autoRouteNote, autoRoutePreamble, directSystemPrompt } = require("../engine/agentlas.cjs");
|
|
15
|
+
|
|
16
|
+
// 실제 설치 상태를 흉내 낸 스텁 DB — 모든 에이전트 프롬프트에 "AI"가 들어 있다(현실과 동일).
|
|
17
|
+
const AGENTS = [
|
|
18
|
+
{
|
|
19
|
+
id: "a1",
|
|
20
|
+
slug: "pitch-deck-architect",
|
|
21
|
+
name: "Pitch Deck Architect",
|
|
22
|
+
name_en: "Pitch Deck Architect",
|
|
23
|
+
tagline: "투자 유치용 피치덱 설계",
|
|
24
|
+
tagline_en: "Investor pitch deck design",
|
|
25
|
+
system_prompt: "You are an AI pitch deck architect. Design slides, visuals, images, 디자인, storytelling for investors.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "a2",
|
|
29
|
+
slug: "thumbnail-studio",
|
|
30
|
+
name: "썸네일 스튜디오",
|
|
31
|
+
name_en: "Thumbnail Studio",
|
|
32
|
+
tagline: "유튜브 썸네일 디자인",
|
|
33
|
+
tagline_en: "YouTube thumbnail design",
|
|
34
|
+
system_prompt: "You are an AI thumbnail designer. Generate 썸네일 images with bold typography.",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "a3",
|
|
38
|
+
slug: "agentlas-pm-soul",
|
|
39
|
+
name: "PM Soul",
|
|
40
|
+
name_en: "PM Soul",
|
|
41
|
+
tagline: "프로젝트 연속성 관리",
|
|
42
|
+
tagline_en: "Project continuity",
|
|
43
|
+
system_prompt: "You are an AI project manager. Track decisions, plans, handoffs.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "a4",
|
|
47
|
+
slug: "agentlas-memory-curator",
|
|
48
|
+
name: "Memory Curator",
|
|
49
|
+
name_en: "Memory Curator",
|
|
50
|
+
tagline: "기억 저장/회상 관리",
|
|
51
|
+
tagline_en: "Memory curation",
|
|
52
|
+
system_prompt: "You are an AI memory curator. Store and recall durable memory entries.",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
const META = [
|
|
56
|
+
{
|
|
57
|
+
id: "m1",
|
|
58
|
+
slug: "agentlas-meta-agent",
|
|
59
|
+
name: "메타에이전트",
|
|
60
|
+
name_en: "Meta Agent",
|
|
61
|
+
tagline: "에이전트/팀 빌더",
|
|
62
|
+
tagline_en: "Agent/team builder",
|
|
63
|
+
system_prompt: "You build new agents and teams.",
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
const db = {
|
|
67
|
+
prepare(sql) {
|
|
68
|
+
if (/WHERE slug IN/.test(sql)) return { all: () => META };
|
|
69
|
+
return { all: () => AGENTS };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// 1) 사고 재현 프롬프트 → direct (어떤 에이전트도, 특히 Pitch Deck Architect도 선택 금지)
|
|
74
|
+
{
|
|
75
|
+
const choice = autoRouteAgent(db, "맥이 잠금상태에서 자꾸 ai 돌릴때 안켜지게 하는법 없나", "ko");
|
|
76
|
+
assert.equal(choice.direct, true, `일반 질문은 direct여야 함 — 실제: ${JSON.stringify(choice.agent && choice.agent.slug)}`);
|
|
77
|
+
assert.equal(choice.agent, null);
|
|
78
|
+
assert.match(autoRouteNote(choice, "ko"), /사용 에이전트: 없음/);
|
|
79
|
+
assert.match(autoRouteNote(choice, "en"), /Selected agent: none/);
|
|
80
|
+
assert.match(autoRoutePreamble(choice, "ko"), /direct answer/i);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 2) "ai"/"llm" 단독 언급의 영어 일반 질문도 direct
|
|
84
|
+
{
|
|
85
|
+
const choice = autoRouteAgent(db, "how do I keep my mac from sleeping while ai jobs run", "en");
|
|
86
|
+
assert.equal(choice.direct, true, "영어 일반 질문도 direct여야 함");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3) 정체성(이름/태그라인) 적중은 여전히 전문 라우트 — 썸네일 요청은 썸네일 에이전트로
|
|
90
|
+
{
|
|
91
|
+
const choice = autoRouteAgent(db, "유튜브 썸네일 하나 뽑아줘", "ko");
|
|
92
|
+
assert.equal(choice.direct, undefined, "썸네일 요청이 direct로 새면 안 됨");
|
|
93
|
+
assert.equal(choice.agent.slug, "thumbnail-studio");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 4) 에이전트 이름을 직접 부르면 그 에이전트로
|
|
97
|
+
{
|
|
98
|
+
const choice = autoRouteAgent(db, "pitch deck 초안 잡아줘", "ko");
|
|
99
|
+
assert.equal(choice.direct, undefined);
|
|
100
|
+
assert.equal(choice.agent.slug, "pitch-deck-architect");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 5) 빌드 의도는 메타빌더 직행 (기존 동작 유지)
|
|
104
|
+
{
|
|
105
|
+
const choice = autoRouteAgent(db, "인스타 카드뉴스 에이전트 하나 만들어줘", "ko");
|
|
106
|
+
assert.equal(choice.agent.slug, "agentlas-meta-agent");
|
|
107
|
+
assert.equal(choice.score, 1000);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 6) 설치 에이전트가 없어도 direct로 답한다 (픽커 오류 대신)
|
|
111
|
+
{
|
|
112
|
+
const empty = { prepare: (sql) => ({ all: () => (/WHERE slug IN/.test(sql) ? [] : []) }) };
|
|
113
|
+
const choice = autoRouteAgent(empty, "안녕 오늘 날씨 어때", "ko");
|
|
114
|
+
assert.equal(choice.direct, true);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 7) 직답 시스템 프롬프트 — 페르소나 없음, 양 언어 모두 존재
|
|
118
|
+
assert.match(directSystemPrompt("ko"), /기본 어시스턴트/);
|
|
119
|
+
assert.match(directSystemPrompt("en"), /default assistant/);
|
|
120
|
+
|
|
121
|
+
console.log("route-regression: PASS");
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
runApi,
|
|
11
|
+
DEFAULT_API_MODEL,
|
|
12
|
+
ANTHROPIC_COMPAT_API,
|
|
13
|
+
} = require("../engine/agentlas.cjs");
|
|
14
|
+
const { create: createParity } = require("../engine/agentlas-parity.cjs");
|
|
15
|
+
|
|
16
|
+
function response(status, payload, errorText) {
|
|
17
|
+
return {
|
|
18
|
+
ok: status >= 200 && status < 300,
|
|
19
|
+
status,
|
|
20
|
+
json: async () => payload,
|
|
21
|
+
text: async () => errorText || "",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function openFixtureDb(file) {
|
|
26
|
+
try {
|
|
27
|
+
const Database = require("better-sqlite3");
|
|
28
|
+
return new Database(file);
|
|
29
|
+
} catch {
|
|
30
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
31
|
+
return new DatabaseSync(file);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function quietUi() {
|
|
36
|
+
const same = (value) => String(value ?? "");
|
|
37
|
+
return {
|
|
38
|
+
lang: "en",
|
|
39
|
+
c: { paw: same, bold: same, text: same, dim: same },
|
|
40
|
+
line() {},
|
|
41
|
+
info() {},
|
|
42
|
+
warn() {},
|
|
43
|
+
error() {},
|
|
44
|
+
tool() {},
|
|
45
|
+
toolResult() {},
|
|
46
|
+
startSpinner() {},
|
|
47
|
+
stopSpinner() {},
|
|
48
|
+
markdown() {},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function testAnthropicCompatibleProviders() {
|
|
53
|
+
for (const backend of ["glm", "kimi", "deepseek"]) {
|
|
54
|
+
const calls = [];
|
|
55
|
+
const text = await runApi(backend, null, "system", "prompt", {
|
|
56
|
+
apiKey: `${backend}-secret`,
|
|
57
|
+
fetch: async (url, init) => {
|
|
58
|
+
calls.push({ url, init });
|
|
59
|
+
return response(200, { content: [{ type: "text", text: `${backend}-ok` }] });
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.equal(text, `${backend}-ok`);
|
|
64
|
+
assert.equal(calls.length, 1);
|
|
65
|
+
assert.equal(calls[0].url, `${ANTHROPIC_COMPAT_API[backend].baseUrl}/v1/messages`);
|
|
66
|
+
assert.equal(calls[0].init.headers["x-api-key"], `${backend}-secret`);
|
|
67
|
+
assert.equal(calls[0].init.headers.authorization, `Bearer ${backend}-secret`);
|
|
68
|
+
assert.equal(calls[0].init.headers["anthropic-version"], "2023-06-01");
|
|
69
|
+
assert.equal(JSON.parse(calls[0].init.body).model, DEFAULT_API_MODEL[backend]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function testCustomBaseUrlComesFromSharedDb() {
|
|
74
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-run-api-"));
|
|
75
|
+
const previous = process.env.AGENTLAS_USER_DATA_DIR;
|
|
76
|
+
process.env.AGENTLAS_USER_DATA_DIR = dir;
|
|
77
|
+
const db = openFixtureDb(path.join(dir, "agentlas.sqlite"));
|
|
78
|
+
try {
|
|
79
|
+
db.exec("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
80
|
+
db.prepare("INSERT INTO meta(key, value) VALUES (?, ?)").run(
|
|
81
|
+
"custom_base_url",
|
|
82
|
+
"https://gateway.example.test/openai/v1/",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const calls = [];
|
|
86
|
+
const text = await runApi("custom", "company-model", "system", "prompt", {
|
|
87
|
+
apiKey: "custom-secret",
|
|
88
|
+
fetch: async (url, init) => {
|
|
89
|
+
calls.push({ url, init });
|
|
90
|
+
return response(200, { choices: [{ message: { content: "custom-ok" } }] });
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
assert.equal(text, "custom-ok");
|
|
95
|
+
assert.equal(calls.length, 1);
|
|
96
|
+
assert.equal(calls[0].url, "https://gateway.example.test/openai/v1/chat/completions");
|
|
97
|
+
assert.equal(calls[0].init.headers.authorization, "Bearer custom-secret");
|
|
98
|
+
assert.equal(JSON.parse(calls[0].init.body).model, "company-model");
|
|
99
|
+
|
|
100
|
+
db.prepare("UPDATE meta SET value = ? WHERE key = ?").run(
|
|
101
|
+
"http://public.example.test/v1",
|
|
102
|
+
"custom_base_url",
|
|
103
|
+
);
|
|
104
|
+
let unsafeFetchCalled = false;
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
runApi("custom", "company-model", "system", "prompt", {
|
|
107
|
+
apiKey: "custom-secret",
|
|
108
|
+
fetch: async () => {
|
|
109
|
+
unsafeFetchCalled = true;
|
|
110
|
+
return response(200, {});
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
/HTTPS.*localhost\/LAN/,
|
|
114
|
+
);
|
|
115
|
+
assert.equal(unsafeFetchCalled, false, "invalid public HTTP base URL must not receive the API key");
|
|
116
|
+
} finally {
|
|
117
|
+
try { db.close(); } catch { /* ignore */ }
|
|
118
|
+
if (previous === undefined) delete process.env.AGENTLAS_USER_DATA_DIR;
|
|
119
|
+
else process.env.AGENTLAS_USER_DATA_DIR = previous;
|
|
120
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function testProviderErrorsNeverExitTheHostProcess() {
|
|
125
|
+
const originalExit = process.exit;
|
|
126
|
+
let exitCalls = 0;
|
|
127
|
+
process.exit = (code) => {
|
|
128
|
+
exitCalls += 1;
|
|
129
|
+
throw new Error(`unexpected process.exit(${code})`);
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
await assert.rejects(
|
|
133
|
+
runApi("openai", "gpt-test", "system", "prompt", {
|
|
134
|
+
apiKey: "openai-secret",
|
|
135
|
+
fetch: async () => response(429, null, "rate limited"),
|
|
136
|
+
}),
|
|
137
|
+
/OpenAI 429: rate limited/,
|
|
138
|
+
);
|
|
139
|
+
await assert.rejects(
|
|
140
|
+
runApi("deepseek", "deepseek-chat", "system", "prompt", {
|
|
141
|
+
apiKey: "",
|
|
142
|
+
fetch: async () => response(200, {}),
|
|
143
|
+
}),
|
|
144
|
+
/deepseek API \ud0a4/,
|
|
145
|
+
);
|
|
146
|
+
await assert.rejects(
|
|
147
|
+
runApi("unknown", null, "system", "prompt", {
|
|
148
|
+
apiKey: "secret",
|
|
149
|
+
fetch: async () => response(200, {}),
|
|
150
|
+
}),
|
|
151
|
+
/\uc9c0\uc6d0\ud558\uc9c0 \uc54a\ub294 backend/,
|
|
152
|
+
);
|
|
153
|
+
assert.equal(exitCalls, 0, "runApi must throw to swarm/automation instead of exiting");
|
|
154
|
+
} finally {
|
|
155
|
+
process.exit = originalExit;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function testSwarmAndAutomationContainProviderFailure() {
|
|
160
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-run-api-host-"));
|
|
161
|
+
const db = openFixtureDb(path.join(dir, "agentlas.sqlite"));
|
|
162
|
+
const originalExit = process.exit;
|
|
163
|
+
const originalExitCode = process.exitCode;
|
|
164
|
+
let exitCalls = 0;
|
|
165
|
+
process.exit = (code) => {
|
|
166
|
+
exitCalls += 1;
|
|
167
|
+
throw new Error(`unexpected process.exit(${code})`);
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
db.exec(`
|
|
171
|
+
CREATE TABLE installed_agents (
|
|
172
|
+
id TEXT PRIMARY KEY,
|
|
173
|
+
name TEXT NOT NULL,
|
|
174
|
+
system_prompt TEXT
|
|
175
|
+
);
|
|
176
|
+
CREATE TABLE automations (
|
|
177
|
+
id TEXT PRIMARY KEY,
|
|
178
|
+
name TEXT NOT NULL,
|
|
179
|
+
schedule TEXT,
|
|
180
|
+
target_type TEXT NOT NULL,
|
|
181
|
+
target_id TEXT NOT NULL,
|
|
182
|
+
prompt_template TEXT NOT NULL,
|
|
183
|
+
enabled INTEGER NOT NULL,
|
|
184
|
+
claimed_at TEXT,
|
|
185
|
+
lease_owner TEXT,
|
|
186
|
+
last_run_at TEXT,
|
|
187
|
+
next_run_at TEXT,
|
|
188
|
+
schedule_json TEXT,
|
|
189
|
+
timezone TEXT,
|
|
190
|
+
trigger_type TEXT,
|
|
191
|
+
run_count INTEGER NOT NULL DEFAULT 0,
|
|
192
|
+
max_runs INTEGER
|
|
193
|
+
);
|
|
194
|
+
CREATE TABLE run_history (
|
|
195
|
+
id TEXT PRIMARY KEY,
|
|
196
|
+
automation_id TEXT,
|
|
197
|
+
scheduled_for TEXT,
|
|
198
|
+
ran_at TEXT,
|
|
199
|
+
status TEXT,
|
|
200
|
+
skipped_count INTEGER,
|
|
201
|
+
error TEXT
|
|
202
|
+
);
|
|
203
|
+
INSERT INTO installed_agents(id, name, system_prompt)
|
|
204
|
+
VALUES ('agent-1', 'Regression Agent', 'System');
|
|
205
|
+
INSERT INTO automations(
|
|
206
|
+
id, name, schedule, target_type, target_id, prompt_template, enabled,
|
|
207
|
+
next_run_at, timezone, trigger_type, run_count
|
|
208
|
+
) VALUES (
|
|
209
|
+
'automation-1', 'Regression Automation', 'daily-09:00', 'agent', 'agent-1', 'Run', 1,
|
|
210
|
+
'2026-01-01T00:00:00.000Z', 'Asia/Seoul', 'schedule', 0
|
|
211
|
+
);
|
|
212
|
+
`);
|
|
213
|
+
|
|
214
|
+
const failingRunApi = (backend, model, system, prompt) => runApi(backend, model, system, prompt, {
|
|
215
|
+
apiKey: "openai-secret",
|
|
216
|
+
fetch: async () => response(429, null, "rate limited"),
|
|
217
|
+
});
|
|
218
|
+
const parity = createParity({
|
|
219
|
+
prefsLang: () => "en",
|
|
220
|
+
resolveRuntime: () => ({ mode: "api", backend: "openai", model: "gpt-test" }),
|
|
221
|
+
buildChildEnvCli: async () => ({}),
|
|
222
|
+
runApi: failingRunApi,
|
|
223
|
+
captureRuntime: async () => "",
|
|
224
|
+
runCwd: () => dir,
|
|
225
|
+
out() {},
|
|
226
|
+
fail(message) { throw new Error(message); },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const swarm = await parity.swarmRun(db, "provider failure containment", { ui: quietUi(), concurrency: 2 });
|
|
230
|
+
assert.equal(swarm.ok, false, "all-failed swarm should finish normally with ok=false");
|
|
231
|
+
|
|
232
|
+
await parity.cmdAutomation(db, ["run", "automation-1"]);
|
|
233
|
+
const automation = db.prepare(
|
|
234
|
+
"SELECT claimed_at, lease_owner, last_run_at, run_count FROM automations WHERE id = ?",
|
|
235
|
+
).get("automation-1");
|
|
236
|
+
assert.equal(automation.claimed_at, null, "automation lease must be released after provider error");
|
|
237
|
+
assert.equal(automation.lease_owner, null, "automation lease owner must be cleared after provider error");
|
|
238
|
+
assert.ok(automation.last_run_at, "automation failure must still be recorded");
|
|
239
|
+
assert.equal(automation.run_count, 0, "failed automation must not count as success");
|
|
240
|
+
const history = db.prepare(
|
|
241
|
+
"SELECT status, error FROM run_history WHERE automation_id = ? ORDER BY ran_at DESC LIMIT 1",
|
|
242
|
+
).get("automation-1");
|
|
243
|
+
assert.equal(history.status, "error");
|
|
244
|
+
assert.match(history.error, /OpenAI 429: rate limited/);
|
|
245
|
+
assert.equal(exitCalls, 0, "swarm/automation must contain provider errors without exiting the host");
|
|
246
|
+
|
|
247
|
+
const fixedFrom = new Date("2026-07-10T00:30:00.000Z"); // 09:30 Asia/Seoul
|
|
248
|
+
assert.equal(
|
|
249
|
+
parity.nextAutomationRun(
|
|
250
|
+
{ schedule: "daily-09:00", schedule_json: null, timezone: "Asia/Seoul" },
|
|
251
|
+
fixedFrom,
|
|
252
|
+
).toISOString(),
|
|
253
|
+
"2026-07-11T00:00:00.000Z",
|
|
254
|
+
"legacy desktop token must advance in its stored timezone",
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const scheduledFailureRow = db.prepare("SELECT * FROM automations WHERE id = ?").get("automation-1");
|
|
258
|
+
const failedScheduled = await parity.runAutomationOnce(db, scheduledFailureRow, {
|
|
259
|
+
ui: quietUi(),
|
|
260
|
+
advanceSchedule: true,
|
|
261
|
+
scheduledFor: scheduledFailureRow.next_run_at,
|
|
262
|
+
});
|
|
263
|
+
assert.equal(failedScheduled.ok, false);
|
|
264
|
+
const afterScheduledFailure = db.prepare(
|
|
265
|
+
"SELECT next_run_at, run_count, enabled FROM automations WHERE id = ?",
|
|
266
|
+
).get("automation-1");
|
|
267
|
+
assert.ok(
|
|
268
|
+
Date.parse(afterScheduledFailure.next_run_at) > Date.now(),
|
|
269
|
+
"failed scheduled automation must advance beyond now instead of retrying every poll",
|
|
270
|
+
);
|
|
271
|
+
assert.equal(afterScheduledFailure.run_count, 0, "failed run remains excluded from success count");
|
|
272
|
+
|
|
273
|
+
db.prepare(
|
|
274
|
+
`INSERT INTO automations(
|
|
275
|
+
id, name, schedule, target_type, target_id, prompt_template, enabled,
|
|
276
|
+
next_run_at, timezone, trigger_type, run_count
|
|
277
|
+
) VALUES ('automation-2', 'Success Automation', 'cron:*/5 * * * *', 'agent', 'agent-1', 'Run', 1,
|
|
278
|
+
'2026-01-01T00:00:00.000Z', 'UTC', 'schedule', 0)`,
|
|
279
|
+
).run();
|
|
280
|
+
const successParity = createParity({
|
|
281
|
+
prefsLang: () => "en",
|
|
282
|
+
resolveRuntime: () => ({ mode: "api", backend: "openai", model: "gpt-test" }),
|
|
283
|
+
buildChildEnvCli: async () => ({}),
|
|
284
|
+
runApi: async () => "ok",
|
|
285
|
+
captureRuntime: async () => "",
|
|
286
|
+
runCwd: () => dir,
|
|
287
|
+
out() {},
|
|
288
|
+
fail(message) { throw new Error(message); },
|
|
289
|
+
});
|
|
290
|
+
const successRow = db.prepare("SELECT * FROM automations WHERE id = ?").get("automation-2");
|
|
291
|
+
const scheduledSuccess = await successParity.runAutomationOnce(db, successRow, {
|
|
292
|
+
ui: quietUi(),
|
|
293
|
+
advanceSchedule: true,
|
|
294
|
+
scheduledFor: successRow.next_run_at,
|
|
295
|
+
});
|
|
296
|
+
assert.equal(scheduledSuccess.ok, true);
|
|
297
|
+
const afterScheduledSuccess = db.prepare(
|
|
298
|
+
"SELECT next_run_at, run_count, enabled FROM automations WHERE id = ?",
|
|
299
|
+
).get("automation-2");
|
|
300
|
+
assert.ok(Date.parse(afterScheduledSuccess.next_run_at) > Date.now());
|
|
301
|
+
assert.equal(afterScheduledSuccess.run_count, 1);
|
|
302
|
+
assert.equal(afterScheduledSuccess.enabled, 1);
|
|
303
|
+
} finally {
|
|
304
|
+
process.exit = originalExit;
|
|
305
|
+
process.exitCode = originalExitCode;
|
|
306
|
+
try { db.close(); } catch { /* ignore */ }
|
|
307
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function main() {
|
|
312
|
+
await testAnthropicCompatibleProviders();
|
|
313
|
+
await testCustomBaseUrlComesFromSharedDb();
|
|
314
|
+
await testProviderErrorsNeverExitTheHostProcess();
|
|
315
|
+
await testSwarmAndAutomationContainProviderFailure();
|
|
316
|
+
process.stdout.write("run-api regression: PASS (BYOK parity + swarm/automation failure containment)\n");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
main().catch((error) => {
|
|
320
|
+
process.stderr.write(`${error && error.stack ? error.stack : error}\n`);
|
|
321
|
+
process.exitCode = 1;
|
|
322
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const runtime = require("../engine/agentlas.cjs");
|
|
4
|
+
|
|
5
|
+
const base = {
|
|
6
|
+
HOME: "/trusted/home",
|
|
7
|
+
PATH: "/trusted/bin",
|
|
8
|
+
CODEX_HOME: "/trusted/codex",
|
|
9
|
+
AGENTLAS_CODEX_HOME: "/trusted/agentlas-codex",
|
|
10
|
+
AGENTLAS_USER_DATA_DIR: "/trusted/agentlas-data",
|
|
11
|
+
CLAUDE_CONFIG_DIR: "/trusted/claude",
|
|
12
|
+
GEMINI_CLI_HOME: "/trusted/gemini",
|
|
13
|
+
API_TOKEN: "old",
|
|
14
|
+
};
|
|
15
|
+
const maliciousDotenv = runtime.parseDotEnvCli([
|
|
16
|
+
"HOME=/tmp/attacker",
|
|
17
|
+
"PATH=/tmp/attacker/bin",
|
|
18
|
+
"CODEX_HOME=/tmp/attacker/codex",
|
|
19
|
+
"AGENTLAS_CODEX_HOME=/tmp/attacker/victim",
|
|
20
|
+
"AGENTLAS_USER_DATA_DIR=/tmp/attacker/data",
|
|
21
|
+
"CLAUDE_CONFIG_DIR=/tmp/attacker/claude",
|
|
22
|
+
"GEMINI_CLI_HOME=/tmp/attacker/gemini",
|
|
23
|
+
"GEMINI_CLI_EXTENSION_REGISTRY_URI=https://attacker.invalid/extensions",
|
|
24
|
+
"CLAUDE_CODE_SAFE_MODE=1",
|
|
25
|
+
"NODE_OPTIONS=--require=/tmp/attacker.js",
|
|
26
|
+
"API_TOKEN=new",
|
|
27
|
+
].join("\n"));
|
|
28
|
+
maliciousDotenv.Path = "/tmp/attacker/windows-bin";
|
|
29
|
+
|
|
30
|
+
runtime.mergeChildEnvValuesCli(base, maliciousDotenv, true);
|
|
31
|
+
|
|
32
|
+
assert.equal(base.HOME, "/trusted/home");
|
|
33
|
+
assert.equal(base.PATH, "/trusted/bin");
|
|
34
|
+
assert.equal(base.Path, undefined);
|
|
35
|
+
assert.equal(base.CODEX_HOME, "/trusted/codex");
|
|
36
|
+
assert.equal(base.AGENTLAS_CODEX_HOME, "/trusted/agentlas-codex");
|
|
37
|
+
assert.equal(base.AGENTLAS_USER_DATA_DIR, "/trusted/agentlas-data");
|
|
38
|
+
assert.equal(base.CLAUDE_CONFIG_DIR, "/trusted/claude");
|
|
39
|
+
assert.equal(base.GEMINI_CLI_HOME, "/trusted/gemini");
|
|
40
|
+
assert.equal(base.GEMINI_CLI_EXTENSION_REGISTRY_URI, undefined);
|
|
41
|
+
assert.equal(base.CLAUDE_CODE_SAFE_MODE, undefined);
|
|
42
|
+
assert.equal(base.NODE_OPTIONS, undefined);
|
|
43
|
+
assert.equal(base.API_TOKEN, "new");
|
|
44
|
+
|
|
45
|
+
console.log(JSON.stringify({ ok: true, checks: 12 }, null, 2));
|