agentlas 0.5.2 → 0.5.5
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 +109 -44
- package/engine/agentlas-doctor.cjs +40 -12
- package/engine/agentlas-i18n.cjs +120 -12
- package/engine/agentlas-input.cjs +116 -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 +99 -45
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +348 -23
- package/engine/agentlas.cjs +2742 -338
- 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/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 +19 -0
- package/test/sqlite-driver-probe.cjs +22 -0
- package/test/terminal-ui-regression.cjs +454 -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,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));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { compareSemVer, normalizeSemVer, parseSemVer } = require("../engine/semver.cjs");
|
|
8
|
+
|
|
9
|
+
const precedence = [
|
|
10
|
+
"1.0.0-alpha",
|
|
11
|
+
"1.0.0-alpha.1",
|
|
12
|
+
"1.0.0-alpha.beta",
|
|
13
|
+
"1.0.0-beta",
|
|
14
|
+
"1.0.0-beta.2",
|
|
15
|
+
"1.0.0-beta.11",
|
|
16
|
+
"1.0.0-rc.1",
|
|
17
|
+
"1.0.0",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
for (let index = 0; index < precedence.length - 1; index += 1) {
|
|
21
|
+
assert.equal(compareSemVer(precedence[index], precedence[index + 1]), -1);
|
|
22
|
+
assert.equal(compareSemVer(precedence[index + 1], precedence[index]), 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
assert.equal(compareSemVer("v2.3.4", "2.3.4"), 0);
|
|
26
|
+
assert.equal(normalizeSemVer("v2.3.4-rc.1+build.7"), "2.3.4-rc.1+build.7");
|
|
27
|
+
assert.equal(compareSemVer("1.0.0+build.1", "1.0.0+build.99"), 0);
|
|
28
|
+
assert.equal(compareSemVer("1.0.0-1", "1.0.0-alpha"), -1);
|
|
29
|
+
assert.equal(compareSemVer("999999999999999999999.0.0", "2.0.0"), 1);
|
|
30
|
+
assert.equal(compareSemVer("1.0.0", "1.0.0-rc.99"), 1);
|
|
31
|
+
assert.equal(parseSemVer("1.0.0-01"), null);
|
|
32
|
+
assert.equal(parseSemVer("01.0.0"), null);
|
|
33
|
+
assert.equal(compareSemVer("not-a-version", "1.0.0"), null);
|
|
34
|
+
|
|
35
|
+
const updater = fs.readFileSync(path.join(__dirname, "../engine/agentlas.cjs"), "utf8");
|
|
36
|
+
assert.match(updater, /compareSemVer\(currentVersion, latestVersion\)/);
|
|
37
|
+
assert.doesNotMatch(updater, /function versionParts/);
|
|
38
|
+
|
|
39
|
+
console.log("semver-precedence: PASS");
|
package/test/smoke.sh
CHANGED
|
@@ -29,6 +29,25 @@ check "help" node "$BIN" help
|
|
|
29
29
|
check "usage" node "$BIN" usage
|
|
30
30
|
check "mcp" node "$BIN" mcp
|
|
31
31
|
check "chats" node "$BIN" chats
|
|
32
|
+
check "run-api-regression" node "$SCRIPT_DIR/run-api-regression.cjs"
|
|
33
|
+
check "cloud-runtime-paths" node "$SCRIPT_DIR/cloud-runtime-paths.cjs"
|
|
34
|
+
check "cloud-save-publish" node "$SCRIPT_DIR/cloud-save-publish.cjs"
|
|
35
|
+
check "cloud-asset-restore" node "$SCRIPT_DIR/cloud-asset-restore.cjs"
|
|
36
|
+
check "cloud-owner-restore" node "$SCRIPT_DIR/cloud-owner-restore.cjs"
|
|
37
|
+
check "cloud-cas-client" node "$SCRIPT_DIR/cloud-cas-client.cjs"
|
|
38
|
+
check "runtime-env-protection" node "$SCRIPT_DIR/runtime-env-protection.cjs"
|
|
39
|
+
check "credential-env-regression" node "$SCRIPT_DIR/credential-env-regression.cjs"
|
|
40
|
+
check "tool-workspace-boundary" node "$SCRIPT_DIR/tool-workspace-boundary.cjs"
|
|
41
|
+
check "mcp-config-isolation" node "$SCRIPT_DIR/mcp-config-isolation.cjs"
|
|
42
|
+
check "bootstrap-race" node "$SCRIPT_DIR/bootstrap-race.cjs"
|
|
43
|
+
check "login-loopback-security" node "$SCRIPT_DIR/login-loopback-security.cjs"
|
|
44
|
+
check "timeout-regression" node "$SCRIPT_DIR/timeout-regression.cjs"
|
|
45
|
+
check "terminal-ui-regression" node "$SCRIPT_DIR/terminal-ui-regression.cjs"
|
|
46
|
+
check "permission-mapping" node "$SCRIPT_DIR/permission-mapping.cjs"
|
|
47
|
+
check "sqlite-driver-probe" node "$SCRIPT_DIR/sqlite-driver-probe.cjs"
|
|
48
|
+
check "capture-runtime-guard" node "$SCRIPT_DIR/capture-runtime-guard.cjs"
|
|
49
|
+
check "update-safety" node "$SCRIPT_DIR/update-safety.cjs"
|
|
50
|
+
check "semver-precedence" node "$SCRIPT_DIR/semver-precedence.cjs"
|
|
32
51
|
|
|
33
52
|
# Agentlas OS 표면: 무인자 호출은 usage를 내고 exit 1 (프롬프트 오라우팅 방지 확인)
|
|
34
53
|
guard() {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const { spawnSync } = require("node:child_process");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { probeSqliteDriver } = require("../bin/agentlas.cjs");
|
|
8
|
+
|
|
9
|
+
const driver = probeSqliteDriver();
|
|
10
|
+
assert.ok(driver === "better-sqlite3" || driver === "node:sqlite", `unexpected SQLite driver: ${driver}`);
|
|
11
|
+
|
|
12
|
+
const launcher = path.join(__dirname, "..", "bin", "agentlas.cjs");
|
|
13
|
+
const result = spawnSync(process.execPath, [launcher, "--where"], {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
env: { ...process.env, NODE_NO_WARNINGS: "" },
|
|
16
|
+
});
|
|
17
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
18
|
+
assert.doesNotMatch(result.stderr, /ExperimentalWarning|SQLite is an experimental feature/i);
|
|
19
|
+
const where = JSON.parse(result.stdout);
|
|
20
|
+
assert.equal(where.sqliteDriver, driver, "--where must report the driver that can actually open a database");
|
|
21
|
+
|
|
22
|
+
console.log(`sqlite-driver-probe: PASS (${driver})`);
|