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,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const SEMVER_RE = /^[vV]?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
4
|
+
|
|
5
|
+
function parseSemVer(value) {
|
|
6
|
+
if (typeof value !== "string") return null;
|
|
7
|
+
const match = value.trim().match(SEMVER_RE);
|
|
8
|
+
if (!match) return null;
|
|
9
|
+
const prerelease = match[4] ? match[4].split(".") : [];
|
|
10
|
+
if (prerelease.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0"))) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
major: match[1],
|
|
15
|
+
minor: match[2],
|
|
16
|
+
patch: match[3],
|
|
17
|
+
prerelease,
|
|
18
|
+
build: match[5] ? match[5].split(".") : [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeSemVer(value) {
|
|
23
|
+
const parsed = parseSemVer(value);
|
|
24
|
+
if (!parsed) return null;
|
|
25
|
+
return `${parsed.major}.${parsed.minor}.${parsed.patch}` +
|
|
26
|
+
`${parsed.prerelease.length ? `-${parsed.prerelease.join(".")}` : ""}` +
|
|
27
|
+
`${parsed.build.length ? `+${parsed.build.join(".")}` : ""}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function compareNumericIdentifier(left, right) {
|
|
31
|
+
if (left.length !== right.length) return left.length < right.length ? -1 : 1;
|
|
32
|
+
if (left === right) return 0;
|
|
33
|
+
return left < right ? -1 : 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** SemVer 2.0.0 precedence. Build metadata is intentionally ignored. */
|
|
37
|
+
function compareSemVer(left, right) {
|
|
38
|
+
const a = parseSemVer(left);
|
|
39
|
+
const b = parseSemVer(right);
|
|
40
|
+
if (!a || !b) return null;
|
|
41
|
+
for (const key of ["major", "minor", "patch"]) {
|
|
42
|
+
const compared = compareNumericIdentifier(a[key], b[key]);
|
|
43
|
+
if (compared !== 0) return compared;
|
|
44
|
+
}
|
|
45
|
+
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
|
|
46
|
+
if (a.prerelease.length === 0) return 1;
|
|
47
|
+
if (b.prerelease.length === 0) return -1;
|
|
48
|
+
const length = Math.max(a.prerelease.length, b.prerelease.length);
|
|
49
|
+
for (let index = 0; index < length; index += 1) {
|
|
50
|
+
const leftIdentifier = a.prerelease[index];
|
|
51
|
+
const rightIdentifier = b.prerelease[index];
|
|
52
|
+
if (leftIdentifier === undefined) return -1;
|
|
53
|
+
if (rightIdentifier === undefined) return 1;
|
|
54
|
+
if (leftIdentifier === rightIdentifier) continue;
|
|
55
|
+
const leftNumeric = /^\d+$/.test(leftIdentifier);
|
|
56
|
+
const rightNumeric = /^\d+$/.test(rightIdentifier);
|
|
57
|
+
if (leftNumeric && rightNumeric) return compareNumericIdentifier(leftIdentifier, rightIdentifier);
|
|
58
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
|
59
|
+
return leftIdentifier < rightIdentifier ? -1 : 1;
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { parseSemVer, normalizeSemVer, compareSemVer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlas",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "Agentlas agent terminal — chat with your installed AI agents and teams from the terminal, Claude Code style. Standalone: no desktop app required.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agentlas": "bin/agentlas.cjs"
|
|
@@ -0,0 +1,47 @@
|
|
|
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 { spawn } = require("node:child_process");
|
|
9
|
+
|
|
10
|
+
const root = path.resolve(__dirname, "..");
|
|
11
|
+
const launcher = path.join(root, "bin", "agentlas.cjs");
|
|
12
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-bootstrap-race-"));
|
|
13
|
+
const probe = `const x=require(${JSON.stringify(launcher)}); process.stdout.write(JSON.stringify(x.bootstrapDbIfMissing()))`;
|
|
14
|
+
|
|
15
|
+
function runOne() {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const child = spawn(process.execPath, ["-e", probe], {
|
|
18
|
+
cwd: root,
|
|
19
|
+
env: { ...process.env, AGENTLAS_USER_DATA_DIR: temp },
|
|
20
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
21
|
+
});
|
|
22
|
+
let stdout = "";
|
|
23
|
+
let stderr = "";
|
|
24
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
25
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
26
|
+
child.on("error", reject);
|
|
27
|
+
child.on("close", (code) => {
|
|
28
|
+
if (code !== 0) return reject(new Error(`bootstrap child ${code}: ${stderr}`));
|
|
29
|
+
resolve(JSON.parse(stdout));
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Promise.all(Array.from({ length: 8 }, () => runOne()))
|
|
35
|
+
.then((results) => {
|
|
36
|
+
assert.equal(results.filter((result) => result.created).length, 1, "exactly one process owns first creation");
|
|
37
|
+
const db = path.join(temp, "agentlas.sqlite");
|
|
38
|
+
assert.equal(fs.existsSync(db), true);
|
|
39
|
+
assert.ok(fs.statSync(db).size > 0);
|
|
40
|
+
assert.deepEqual(fs.readdirSync(temp).filter((name) => name.includes(".bootstrap-")), []);
|
|
41
|
+
console.log(JSON.stringify({ ok: true, workers: results.length, created: 1 }, null, 2));
|
|
42
|
+
})
|
|
43
|
+
.catch((error) => {
|
|
44
|
+
console.error(error);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
})
|
|
47
|
+
.finally(() => fs.rmSync(temp, { recursive: true, force: true }));
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const { EventEmitter } = require("node:events");
|
|
6
|
+
const { PassThrough } = require("node:stream");
|
|
7
|
+
const { captureRuntime, captureOutputLimit, isProtectedChildEnvKeyCli } = require("../engine/agentlas.cjs");
|
|
8
|
+
|
|
9
|
+
class FakeChild extends EventEmitter {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this.stdout = new PassThrough();
|
|
13
|
+
this.stderr = new PassThrough();
|
|
14
|
+
this.signals = [];
|
|
15
|
+
this.ignoreTerm = !!options.ignoreTerm;
|
|
16
|
+
this.ignoreKill = !!options.ignoreKill;
|
|
17
|
+
this.closed = false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
kill(signal) {
|
|
21
|
+
this.signals.push(signal);
|
|
22
|
+
if (signal === "SIGTERM" && this.ignoreTerm) return true;
|
|
23
|
+
if (signal === "SIGKILL" && this.ignoreKill) return true;
|
|
24
|
+
this.finish(null, signal);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
finish(code = 0, signal = null) {
|
|
29
|
+
if (this.closed) return;
|
|
30
|
+
this.closed = true;
|
|
31
|
+
this.stdout.end();
|
|
32
|
+
this.stderr.end();
|
|
33
|
+
queueMicrotask(() => this.emit("close", code, signal));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function capture(child, options = {}) {
|
|
38
|
+
return captureRuntime("gemini", "system", "prompt", {
|
|
39
|
+
cwd: process.cwd(),
|
|
40
|
+
permission: "read",
|
|
41
|
+
env: {},
|
|
42
|
+
timeoutConfig: options.timeoutConfig || { idleMs: 40, totalMs: 300, killGraceMs: 15 },
|
|
43
|
+
outputLimitBytes: options.outputLimitBytes || 1_024,
|
|
44
|
+
signal: options.signal,
|
|
45
|
+
spawn: () => child,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function main() {
|
|
50
|
+
assert.equal(captureOutputLimit({ AGENTLAS_CAPTURE_MAX_OUTPUT_BYTES: "NaN" }), 4 * 1024 * 1024);
|
|
51
|
+
assert.equal(captureOutputLimit({ AGENTLAS_CAPTURE_MAX_OUTPUT_BYTES: "-1" }), 64 * 1024);
|
|
52
|
+
assert.equal(captureOutputLimit({ AGENTLAS_CAPTURE_MAX_OUTPUT_BYTES: "999999999999" }), 32 * 1024 * 1024);
|
|
53
|
+
for (const key of [
|
|
54
|
+
"AGENTLAS_NATIVE_IDLE_TIMEOUT_MS",
|
|
55
|
+
"AGENTLAS_NATIVE_TOTAL_TIMEOUT_MS",
|
|
56
|
+
"AGENTLAS_NATIVE_KILL_GRACE_MS",
|
|
57
|
+
"AGENTLAS_CAPTURE_MAX_OUTPUT_BYTES",
|
|
58
|
+
]) {
|
|
59
|
+
assert.equal(isProtectedChildEnvKeyCli(key), true, `${key} must not be overridden by a project .env`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const streaming = new FakeChild();
|
|
63
|
+
const streamingRun = capture(streaming, { timeoutConfig: { idleMs: 35, totalMs: 250, killGraceMs: 15 } });
|
|
64
|
+
["one", "two", "three", "four"].forEach((value, index) => {
|
|
65
|
+
setTimeout(() => streaming.stdout.write(value), 15 + index * 20);
|
|
66
|
+
});
|
|
67
|
+
setTimeout(() => streaming.finish(0), 110);
|
|
68
|
+
assert.equal(await streamingRun, "onetwothreefour", "regular output must keep the capture idle timer alive");
|
|
69
|
+
assert.deepEqual(streaming.signals, []);
|
|
70
|
+
|
|
71
|
+
const silent = new FakeChild({ ignoreTerm: true });
|
|
72
|
+
await assert.rejects(
|
|
73
|
+
capture(silent, { timeoutConfig: { idleMs: 30, totalMs: 250, killGraceMs: 15 } }),
|
|
74
|
+
(error) => error && error.code === "AGENTLAS_CAPTURE_IDLE_TIMEOUT",
|
|
75
|
+
);
|
|
76
|
+
assert.deepEqual(silent.signals, ["SIGTERM", "SIGKILL"]);
|
|
77
|
+
|
|
78
|
+
const active = new FakeChild({ ignoreTerm: true });
|
|
79
|
+
const activeRun = capture(active, { timeoutConfig: { idleMs: 35, totalMs: 85, killGraceMs: 15 } });
|
|
80
|
+
const activity = setInterval(() => active.stderr.write("progress\n"), 12);
|
|
81
|
+
await assert.rejects(activeRun, (error) => error && error.code === "AGENTLAS_CAPTURE_TOTAL_TIMEOUT");
|
|
82
|
+
clearInterval(activity);
|
|
83
|
+
assert.deepEqual(active.signals, ["SIGTERM", "SIGKILL"], "total timeout caps an otherwise active capture");
|
|
84
|
+
|
|
85
|
+
const noisy = new FakeChild();
|
|
86
|
+
const noisyRun = capture(noisy, {
|
|
87
|
+
outputLimitBytes: 128,
|
|
88
|
+
timeoutConfig: { idleMs: 200, totalMs: 300, killGraceMs: 15 },
|
|
89
|
+
});
|
|
90
|
+
noisy.stdout.write(Buffer.alloc(512, 0x61));
|
|
91
|
+
await assert.rejects(noisyRun, (error) => error && error.code === "AGENTLAS_CAPTURE_OUTPUT_LIMIT");
|
|
92
|
+
assert.deepEqual(noisy.signals, ["SIGTERM"], "output overflow should stop immediately without retaining unbounded data");
|
|
93
|
+
|
|
94
|
+
const cancellable = new FakeChild();
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const cancelRun = capture(cancellable, {
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
timeoutConfig: { idleMs: 200, totalMs: 300, killGraceMs: 15 },
|
|
99
|
+
});
|
|
100
|
+
setTimeout(() => controller.abort(new Error("operator cancelled")), 15);
|
|
101
|
+
await assert.rejects(cancelRun, /operator cancelled/);
|
|
102
|
+
assert.deepEqual(cancellable.signals, ["SIGTERM"]);
|
|
103
|
+
|
|
104
|
+
const stubborn = new FakeChild({ ignoreTerm: true, ignoreKill: true });
|
|
105
|
+
const startedAt = Date.now();
|
|
106
|
+
await assert.rejects(
|
|
107
|
+
capture(stubborn, { timeoutConfig: { idleMs: 20, totalMs: 300, killGraceMs: 10 } }),
|
|
108
|
+
(error) => error && error.code === "AGENTLAS_CAPTURE_IDLE_TIMEOUT",
|
|
109
|
+
);
|
|
110
|
+
assert.ok(Date.now() - startedAt < 1_000, "capture slot must be released even if the child never emits close");
|
|
111
|
+
assert.deepEqual(stubborn.signals, ["SIGTERM", "SIGKILL"]);
|
|
112
|
+
assert.equal(stubborn.stdout.listenerCount("data"), 0, "force resolution must detach stdout capture");
|
|
113
|
+
assert.equal(stubborn.stderr.listenerCount("data"), 0, "force resolution must detach stderr capture");
|
|
114
|
+
assert.equal(stubborn.listenerCount("close"), 0, "force resolution must detach process listeners");
|
|
115
|
+
|
|
116
|
+
console.log("capture-runtime-guard: PASS");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch((error) => {
|
|
120
|
+
console.error(error);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
});
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
const fs = require("node:fs");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
const {
|
|
10
|
+
agentSystemPromptCli,
|
|
11
|
+
cloudSystemPromptFromPackageCli,
|
|
12
|
+
materializeCloudListingCli,
|
|
13
|
+
persistCloudListingCli,
|
|
14
|
+
recoverCloudInstallJournalCli,
|
|
15
|
+
recoverCloudInstallJournalsCli,
|
|
16
|
+
} = require("../engine/agentlas.cjs");
|
|
17
|
+
|
|
18
|
+
function cloudFile(filePath, content, overrides = {}) {
|
|
19
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
|
|
20
|
+
return {
|
|
21
|
+
path: filePath,
|
|
22
|
+
contentBase64: bytes.toString("base64"),
|
|
23
|
+
bytes: bytes.length,
|
|
24
|
+
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function packageHash(files, version = "path-sha256-v1") {
|
|
30
|
+
const hash = crypto.createHash("sha256");
|
|
31
|
+
for (const file of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
|
|
32
|
+
hash.update(file.path);
|
|
33
|
+
hash.update("\0");
|
|
34
|
+
hash.update(file.sha256);
|
|
35
|
+
hash.update("\0");
|
|
36
|
+
if (version === "path-sha256-executable-v2") {
|
|
37
|
+
hash.update(file.executable ? "x" : "-");
|
|
38
|
+
hash.update("\0");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return hash.digest("hex");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function packageRecord(files, options = {}) {
|
|
45
|
+
const version = options.packageHashVersion || "path-sha256-v1";
|
|
46
|
+
return {
|
|
47
|
+
packageHash: options.packageHash || packageHash(files, version),
|
|
48
|
+
...(options.packageHashVersion ? { packageHashVersion: options.packageHashVersion } : {}),
|
|
49
|
+
fileCount: files.length,
|
|
50
|
+
totalBytes: files.reduce((sum, file) => sum + file.bytes, 0),
|
|
51
|
+
files,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const userData = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-cloud-asset-"));
|
|
56
|
+
const previousUserData = process.env.AGENTLAS_USER_DATA_DIR;
|
|
57
|
+
process.env.AGENTLAS_USER_DATA_DIR = userData;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const slug = "portable-agent";
|
|
61
|
+
const root = path.join(userData, "cloud-agent-installs", slug);
|
|
62
|
+
fs.mkdirSync(root, { recursive: true });
|
|
63
|
+
fs.writeFileSync(path.join(root, "AGENTS.md"), "old agent\n");
|
|
64
|
+
fs.writeFileSync(path.join(root, "removed-in-v2.md"), "stale\n");
|
|
65
|
+
fs.writeFileSync(
|
|
66
|
+
path.join(root, ".agentlas-cloud-package.json"),
|
|
67
|
+
JSON.stringify({ agentId: "agent-1", packageHash: "sha256:v1" }),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const v2Files = [
|
|
71
|
+
cloudFile("AGENTS.md", "new agent\n", { executable: false }),
|
|
72
|
+
cloudFile("assets/model.bin", Buffer.from([0x00, 0xff, 0x80, 0x41]), { executable: false }),
|
|
73
|
+
cloudFile("run.sh", "#!/bin/sh\nexit 0\n", { executable: true }),
|
|
74
|
+
cloudFile("skills/core/SKILL.md", "portable skill\n", { executable: false }),
|
|
75
|
+
];
|
|
76
|
+
const v2 = {
|
|
77
|
+
cloudPackage: packageRecord(v2Files, { packageHashVersion: "path-sha256-executable-v2" }),
|
|
78
|
+
};
|
|
79
|
+
assert.equal(materializeCloudListingCli("agent-1", slug, v2), root);
|
|
80
|
+
assert.equal(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), "new agent\n");
|
|
81
|
+
|
|
82
|
+
const overlongComponentFiles = [cloudFile("a".repeat(256), "too long\n")];
|
|
83
|
+
assert.throws(
|
|
84
|
+
() => materializeCloudListingCli("agent-1", slug, {
|
|
85
|
+
cloudPackage: packageRecord(overlongComponentFiles),
|
|
86
|
+
}),
|
|
87
|
+
/unsafe cloud package path/,
|
|
88
|
+
);
|
|
89
|
+
const overlongUtf8Files = [cloudFile("한".repeat(86), "too many UTF-8 bytes\n")];
|
|
90
|
+
assert.throws(
|
|
91
|
+
() => materializeCloudListingCli("agent-1", slug, { cloudPackage: packageRecord(overlongUtf8Files) }),
|
|
92
|
+
/unsafe cloud package path/,
|
|
93
|
+
);
|
|
94
|
+
const surrogateFiles = [cloudFile("\ud800.txt", "ill-formed path\n")];
|
|
95
|
+
assert.throws(
|
|
96
|
+
() => materializeCloudListingCli("agent-1", slug, { cloudPackage: packageRecord(surrogateFiles) }),
|
|
97
|
+
/unsafe cloud package path/,
|
|
98
|
+
);
|
|
99
|
+
assert.equal(fs.existsSync(path.join(root, "removed-in-v2.md")), false, "removed files must not survive restore");
|
|
100
|
+
assert.deepEqual(fs.readFileSync(path.join(root, "assets/model.bin")), Buffer.from([0x00, 0xff, 0x80, 0x41]));
|
|
101
|
+
if (process.platform !== "win32") {
|
|
102
|
+
assert.equal(fs.statSync(path.join(root, "AGENTS.md")).mode & 0o777, 0o600);
|
|
103
|
+
assert.equal(fs.statSync(path.join(root, "run.sh")).mode & 0o777, 0o700);
|
|
104
|
+
}
|
|
105
|
+
assert.deepEqual(
|
|
106
|
+
JSON.parse(fs.readFileSync(path.join(root, ".agentlas-cloud-package.json"), "utf8")).executablePaths,
|
|
107
|
+
["run.sh"],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
fs.writeFileSync(path.join(root, "AGENTS.md"), "locally mutated\n");
|
|
111
|
+
materializeCloudListingCli("agent-1", slug, v2);
|
|
112
|
+
assert.equal(
|
|
113
|
+
fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"),
|
|
114
|
+
"new agent\n",
|
|
115
|
+
"same-hash restore must reproduce the immutable asset",
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const broken = {
|
|
119
|
+
cloudPackage: packageRecord(
|
|
120
|
+
[cloudFile("AGENTS.md", "broken update\n", { sha256: "0".repeat(64), executable: false })],
|
|
121
|
+
{ packageHash: "0".repeat(64), packageHashVersion: "path-sha256-executable-v2" },
|
|
122
|
+
),
|
|
123
|
+
};
|
|
124
|
+
assert.throws(() => materializeCloudListingCli("agent-1", slug, broken), /integrity failed/);
|
|
125
|
+
assert.equal(
|
|
126
|
+
fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"),
|
|
127
|
+
"new agent\n",
|
|
128
|
+
"failed restore must preserve the last valid asset",
|
|
129
|
+
);
|
|
130
|
+
assert.equal(
|
|
131
|
+
JSON.parse(fs.readFileSync(path.join(root, ".agentlas-cloud-package.json"), "utf8")).packageHash,
|
|
132
|
+
packageHash(v2Files, "path-sha256-executable-v2"),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const aggregateMismatchFiles = [cloudFile("AGENTS.md", "aggregate mismatch\n", { executable: false })];
|
|
136
|
+
const aggregateMismatch = {
|
|
137
|
+
cloudPackage: packageRecord(aggregateMismatchFiles, { packageHash: "f".repeat(64), packageHashVersion: "path-sha256-executable-v2" }),
|
|
138
|
+
};
|
|
139
|
+
assert.throws(() => materializeCloudListingCli("agent-1", slug, aggregateMismatch), /aggregate integrity failed/);
|
|
140
|
+
assert.equal(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), "new agent\n");
|
|
141
|
+
|
|
142
|
+
const writes = [];
|
|
143
|
+
const fakeDb = {
|
|
144
|
+
prepare(sql) {
|
|
145
|
+
return {
|
|
146
|
+
get() {
|
|
147
|
+
if (sql.startsWith("SELECT * FROM installed_agents")) return { id: "agent-1", slug };
|
|
148
|
+
return null;
|
|
149
|
+
},
|
|
150
|
+
run() {
|
|
151
|
+
writes.push(sql);
|
|
152
|
+
},
|
|
153
|
+
all() {
|
|
154
|
+
return [];
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
assert.throws(() => persistCloudListingCli(fakeDb, { slug, name: slug, cloudPackage: aggregateMismatch.cloudPackage }), /aggregate integrity failed/);
|
|
160
|
+
assert.equal(writes.length, 0, "failed asset restore must not commit newer DB metadata");
|
|
161
|
+
|
|
162
|
+
const nestedEntrySlug = "nested-entry-agent";
|
|
163
|
+
const nestedMarker = "CLOUD_PACKAGE_INVOKE_MARKER_7f3c";
|
|
164
|
+
const nestedEntryFiles = [
|
|
165
|
+
cloudFile("agentlas.json", JSON.stringify({ schemaVersion: "1.0", entry: "agents/ceo/AGENT.md" }) + "\n", { executable: false }),
|
|
166
|
+
cloudFile("AGENTS.md", "ROOT_DECOY_MUST_NOT_WIN\n", { executable: false }),
|
|
167
|
+
cloudFile("agents/ceo/AGENT.md", `# CEO\n\n${nestedMarker}\n`, { executable: false }),
|
|
168
|
+
];
|
|
169
|
+
const nestedListing = {
|
|
170
|
+
slug: nestedEntrySlug,
|
|
171
|
+
name: "Nested Entry Agent",
|
|
172
|
+
tagline: "Restored package invocation fixture",
|
|
173
|
+
mcpServers: [{ id: "fixture" }],
|
|
174
|
+
envRequirements: [{ key: "FIXTURE_KEY" }],
|
|
175
|
+
cloudPackage: packageRecord(nestedEntryFiles, { packageHashVersion: "path-sha256-executable-v2" }),
|
|
176
|
+
};
|
|
177
|
+
assert.match(cloudSystemPromptFromPackageCli(nestedListing, nestedEntrySlug), new RegExp(nestedMarker));
|
|
178
|
+
assert.doesNotMatch(cloudSystemPromptFromPackageCli(nestedListing, nestedEntrySlug), /ROOT_DECOY_MUST_NOT_WIN/);
|
|
179
|
+
let legacyRow = {
|
|
180
|
+
id: "legacy-db-agent",
|
|
181
|
+
slug: nestedEntrySlug,
|
|
182
|
+
name: "Old Agent",
|
|
183
|
+
name_en: "Old Agent",
|
|
184
|
+
tagline: "old",
|
|
185
|
+
tagline_en: "old",
|
|
186
|
+
system_prompt: "old prompt",
|
|
187
|
+
mcp_servers_json: "[]",
|
|
188
|
+
env_requirements_json: "[]",
|
|
189
|
+
trust_grade: "unknown",
|
|
190
|
+
installed_at: "2000-01-01T00:00:00.000Z",
|
|
191
|
+
tone: "gray",
|
|
192
|
+
};
|
|
193
|
+
const legacySql = [];
|
|
194
|
+
const legacyDb = {
|
|
195
|
+
prepare(sql) {
|
|
196
|
+
legacySql.push(sql);
|
|
197
|
+
return {
|
|
198
|
+
get() {
|
|
199
|
+
if (sql.startsWith("SELECT * FROM installed_agents")) return legacyRow;
|
|
200
|
+
return null;
|
|
201
|
+
},
|
|
202
|
+
all() {
|
|
203
|
+
if (sql.startsWith("PRAGMA table_info(installed_agents)")) {
|
|
204
|
+
return Object.keys(legacyRow).map((name) => ({ name }));
|
|
205
|
+
}
|
|
206
|
+
return [];
|
|
207
|
+
},
|
|
208
|
+
run(...args) {
|
|
209
|
+
if (sql.startsWith("UPDATE installed_agents")) {
|
|
210
|
+
const [name, nameEn, tagline, taglineEn, systemPrompt, mcpJson, envJson, trustGrade, installedAt, tone, updateSlug] = args;
|
|
211
|
+
assert.equal(updateSlug, nestedEntrySlug);
|
|
212
|
+
legacyRow = {
|
|
213
|
+
...legacyRow,
|
|
214
|
+
name,
|
|
215
|
+
name_en: nameEn,
|
|
216
|
+
tagline,
|
|
217
|
+
tagline_en: taglineEn,
|
|
218
|
+
system_prompt: systemPrompt,
|
|
219
|
+
mcp_servers_json: mcpJson,
|
|
220
|
+
env_requirements_json: envJson,
|
|
221
|
+
trust_grade: trustGrade,
|
|
222
|
+
installed_at: installedAt,
|
|
223
|
+
tone,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
transaction(fn) { return () => fn(); },
|
|
230
|
+
};
|
|
231
|
+
persistCloudListingCli(legacyDb, nestedListing);
|
|
232
|
+
const legacyUpdateSql = legacySql.find((sql) => sql.startsWith("UPDATE installed_agents"));
|
|
233
|
+
assert.ok(legacyUpdateSql);
|
|
234
|
+
assert.equal(/visibility/.test(legacyUpdateSql), false, "legacy DB update must not reference a missing visibility column");
|
|
235
|
+
assert.match(legacyRow.system_prompt, new RegExp(nestedMarker));
|
|
236
|
+
assert.doesNotMatch(legacyRow.system_prompt, /ROOT_DECOY_MUST_NOT_WIN/);
|
|
237
|
+
assert.match(agentSystemPromptCli(legacyRow), new RegExp(nestedMarker), "normal invoke must use the restored canonical package entry");
|
|
238
|
+
assert.equal(
|
|
239
|
+
fs.readFileSync(path.join(userData, "cloud-agent-installs", nestedEntrySlug, "agents/ceo/AGENT.md"), "utf8").includes(nestedMarker),
|
|
240
|
+
true,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const dbFailureFiles = [cloudFile("AGENTS.md", "db failure must roll back disk\n", { executable: false })];
|
|
244
|
+
const dbFailureListing = {
|
|
245
|
+
slug,
|
|
246
|
+
name: "DB Failure Candidate",
|
|
247
|
+
cloudPackage: packageRecord(dbFailureFiles, { packageHashVersion: "path-sha256-executable-v2" }),
|
|
248
|
+
};
|
|
249
|
+
const dbFailure = {
|
|
250
|
+
prepare(sql) {
|
|
251
|
+
return {
|
|
252
|
+
get() { return sql.startsWith("SELECT * FROM installed_agents") ? { id: "agent-1", slug, name: "Old" } : null; },
|
|
253
|
+
all() { return []; },
|
|
254
|
+
run() { throw new Error("simulated sqlite update failure"); },
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
assert.throws(() => persistCloudListingCli(dbFailure, dbFailureListing), /simulated sqlite update failure/);
|
|
259
|
+
assert.equal(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), "new agent\n", "DB failure must restore the prior disk snapshot");
|
|
260
|
+
assert.equal(
|
|
261
|
+
fs.readdirSync(path.dirname(root)).some((name) => name.includes("install-journal") || name.includes(".backup-")),
|
|
262
|
+
false,
|
|
263
|
+
"compensated DB failure must not leave journal or backup debris",
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const crashFiles = [cloudFile("AGENTS.md", "pending crash snapshot\n", { executable: false })];
|
|
267
|
+
materializeCloudListingCli("agent-1", slug, {
|
|
268
|
+
cloudPackage: packageRecord(crashFiles, { packageHashVersion: "path-sha256-executable-v2" }),
|
|
269
|
+
}, { deferCommit: true, dbExpected: { name: "Expected New Row" } });
|
|
270
|
+
assert.equal(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), "pending crash snapshot\n");
|
|
271
|
+
const oldRowDb = {
|
|
272
|
+
prepare() { return { get() { return { id: "agent-1", slug, name: "Old Row" }; } }; },
|
|
273
|
+
};
|
|
274
|
+
recoverCloudInstallJournalCli(oldRowDb, slug);
|
|
275
|
+
assert.equal(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), "new agent\n", "restart recovery must roll back disk when DB never committed");
|
|
276
|
+
|
|
277
|
+
const committedCrashFiles = [cloudFile("AGENTS.md", "committed crash snapshot\n", { executable: false })];
|
|
278
|
+
materializeCloudListingCli("agent-1", slug, {
|
|
279
|
+
cloudPackage: packageRecord(committedCrashFiles, { packageHashVersion: "path-sha256-executable-v2" }),
|
|
280
|
+
}, { deferCommit: true, dbExpected: { name: "Committed Row" } });
|
|
281
|
+
const committedRowDb = {
|
|
282
|
+
prepare() { return { get() { return { id: "agent-1", slug, name: "Committed Row" }; } }; },
|
|
283
|
+
};
|
|
284
|
+
recoverCloudInstallJournalCli(committedRowDb, slug);
|
|
285
|
+
assert.equal(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), "committed crash snapshot\n", "restart recovery must keep disk when DB committed");
|
|
286
|
+
|
|
287
|
+
const metadataOnlyCrashFiles = [cloudFile("AGENTS.md", "metadata-only crash snapshot\n", { executable: false })];
|
|
288
|
+
materializeCloudListingCli("agent-1", slug, {
|
|
289
|
+
cloudPackage: packageRecord(metadataOnlyCrashFiles, { packageHashVersion: "path-sha256-executable-v2" }),
|
|
290
|
+
}, {
|
|
291
|
+
deferCommit: true,
|
|
292
|
+
dbExpected: { name: "Same Display", mcp_servers_json: '["new"]', installed_at: "2099-01-01T00:00:00.000Z" },
|
|
293
|
+
});
|
|
294
|
+
const staleMetadataDb = {
|
|
295
|
+
prepare() {
|
|
296
|
+
return { get() { return { id: "agent-1", slug, name: "Same Display", mcp_servers_json: '["old"]', installed_at: "2000-01-01T00:00:00.000Z" }; } };
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
recoverCloudInstallJournalCli(staleMetadataDb, slug);
|
|
300
|
+
assert.equal(
|
|
301
|
+
fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"),
|
|
302
|
+
"committed crash snapshot\n",
|
|
303
|
+
"WAL recovery must compare MCP/env/revision metadata rather than display text alone",
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const recoveryDb = { prepare() { return { get() { return null; } }; } };
|
|
307
|
+
const installParent = path.join(userData, "cloud-agent-installs");
|
|
308
|
+
const writePreparedJournal = (journalSlug, hadExisting, state) => {
|
|
309
|
+
const destination = path.join(installParent, journalSlug);
|
|
310
|
+
const staging = path.join(installParent, `.${journalSlug}.installing-fixture`);
|
|
311
|
+
const backup = path.join(installParent, `.${journalSlug}.backup-fixture`);
|
|
312
|
+
fs.rmSync(destination, { recursive: true, force: true });
|
|
313
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
314
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
315
|
+
if (state.destination) {
|
|
316
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
317
|
+
fs.writeFileSync(path.join(destination, "state.txt"), state.destination);
|
|
318
|
+
}
|
|
319
|
+
if (state.staging) {
|
|
320
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
321
|
+
fs.writeFileSync(path.join(staging, "state.txt"), state.staging);
|
|
322
|
+
}
|
|
323
|
+
if (state.backup) {
|
|
324
|
+
fs.mkdirSync(backup, { recursive: true });
|
|
325
|
+
fs.writeFileSync(path.join(backup, "state.txt"), state.backup);
|
|
326
|
+
}
|
|
327
|
+
fs.writeFileSync(
|
|
328
|
+
path.join(installParent, `.${journalSlug}.install-journal.json`),
|
|
329
|
+
JSON.stringify({
|
|
330
|
+
schemaVersion: 1,
|
|
331
|
+
slug: journalSlug,
|
|
332
|
+
phase: "prepared",
|
|
333
|
+
destination,
|
|
334
|
+
staging,
|
|
335
|
+
backup,
|
|
336
|
+
hadExisting,
|
|
337
|
+
dbExpected: { installed_at: "never-committed" },
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
return { destination, staging, backup };
|
|
341
|
+
};
|
|
342
|
+
const afterOldRename = writePreparedJournal("crash-after-old-rename", true, { staging: "new", backup: "old" });
|
|
343
|
+
const afterNewRename = writePreparedJournal("crash-after-new-rename", true, { destination: "new", backup: "old" });
|
|
344
|
+
const firstInstallRename = writePreparedJournal("crash-first-install", false, { destination: "new" });
|
|
345
|
+
assert.equal(recoverCloudInstallJournalsCli(recoveryDb), 3, "startup sweep must recover every interrupted slug before normal resolution");
|
|
346
|
+
assert.equal(fs.readFileSync(path.join(afterOldRename.destination, "state.txt"), "utf8"), "old");
|
|
347
|
+
assert.equal(fs.existsSync(afterOldRename.staging), false);
|
|
348
|
+
assert.equal(fs.readFileSync(path.join(afterNewRename.destination, "state.txt"), "utf8"), "old");
|
|
349
|
+
assert.equal(fs.existsSync(firstInstallRename.destination), false, "uncommitted first install must not survive the prepared crash window");
|
|
350
|
+
|
|
351
|
+
// Restore the main v2 fixture for the remaining path-safety assertions.
|
|
352
|
+
materializeCloudListingCli("agent-1", slug, v2);
|
|
353
|
+
|
|
354
|
+
const duplicateFiles = [cloudFile("AGENTS.md", "first\n"), cloudFile("AGENTS.md", "second\n")];
|
|
355
|
+
const duplicate = { cloudPackage: packageRecord(duplicateFiles) };
|
|
356
|
+
assert.throws(() => materializeCloudListingCli("agent-1", slug, duplicate), /repeats file path/);
|
|
357
|
+
assert.equal(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), "new agent\n");
|
|
358
|
+
|
|
359
|
+
const ancestorAliasFiles = [
|
|
360
|
+
cloudFile("Skills/writer/SKILL.md", "first\n"),
|
|
361
|
+
cloudFile("skills/reviewer/SKILL.md", "second\n"),
|
|
362
|
+
];
|
|
363
|
+
assert.throws(
|
|
364
|
+
() => materializeCloudListingCli("agent-1", slug, { cloudPackage: packageRecord(ancestorAliasFiles) }),
|
|
365
|
+
/Ancestor directories.*alias/,
|
|
366
|
+
);
|
|
367
|
+
const unicodeAliasFiles = [
|
|
368
|
+
cloudFile("Caf\u00e9/a.md", "first\n"),
|
|
369
|
+
cloudFile("Cafe\u0301/b.md", "second\n"),
|
|
370
|
+
];
|
|
371
|
+
assert.throws(
|
|
372
|
+
() => materializeCloudListingCli("agent-1", slug, { cloudPackage: packageRecord(unicodeAliasFiles) }),
|
|
373
|
+
/Unicode NFC/,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const missingExecutable = [cloudFile("AGENTS.md", "missing v2 bit\n")];
|
|
377
|
+
assert.throws(
|
|
378
|
+
() => materializeCloudListingCli("agent-1", slug, {
|
|
379
|
+
cloudPackage: packageRecord(missingExecutable, { packageHashVersion: "path-sha256-executable-v2" }),
|
|
380
|
+
}),
|
|
381
|
+
/requires executable boolean/,
|
|
382
|
+
);
|
|
383
|
+
const executableTamperFiles = [cloudFile("run.sh", "#!/bin/sh\n", { executable: true })];
|
|
384
|
+
const executableTamperHash = packageHash(executableTamperFiles, "path-sha256-executable-v2");
|
|
385
|
+
executableTamperFiles[0].executable = false;
|
|
386
|
+
assert.throws(
|
|
387
|
+
() => materializeCloudListingCli("agent-1", slug, {
|
|
388
|
+
cloudPackage: packageRecord(executableTamperFiles, { packageHashVersion: "path-sha256-executable-v2", packageHash: executableTamperHash }),
|
|
389
|
+
}),
|
|
390
|
+
/aggregate integrity failed/,
|
|
391
|
+
);
|
|
392
|
+
const unauthenticatedLegacyMode = [cloudFile("run.sh", "#!/bin/sh\n", { executable: true })];
|
|
393
|
+
assert.throws(
|
|
394
|
+
() => materializeCloudListingCli("agent-1", slug, {
|
|
395
|
+
cloudPackage: packageRecord(unauthenticatedLegacyMode),
|
|
396
|
+
}),
|
|
397
|
+
/legacy cloud package hash v1 cannot authenticate executable flag/,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const legacySlug = "legacy-v1-agent";
|
|
401
|
+
const legacyRoot = path.join(userData, "cloud-agent-installs", legacySlug);
|
|
402
|
+
const legacyFiles = [cloudFile("AGENTS.md", "legacy v1 exact bytes\n")];
|
|
403
|
+
assert.equal(materializeCloudListingCli("legacy-agent", legacySlug, {
|
|
404
|
+
cloudPackage: packageRecord(legacyFiles),
|
|
405
|
+
}), legacyRoot);
|
|
406
|
+
assert.equal(fs.readFileSync(path.join(legacyRoot, "AGENTS.md"), "utf8"), "legacy v1 exact bytes\n");
|
|
407
|
+
assert.equal(
|
|
408
|
+
JSON.parse(fs.readFileSync(path.join(legacyRoot, ".agentlas-cloud-package.json"), "utf8")).packageHashVersion,
|
|
409
|
+
"path-sha256-v1",
|
|
410
|
+
);
|
|
411
|
+
if (process.platform !== "win32") assert.equal(fs.statSync(path.join(legacyRoot, "AGENTS.md")).mode & 0o777, 0o600);
|
|
412
|
+
|
|
413
|
+
const escapingFiles = [cloudFile("../outside.md", "escape\n")];
|
|
414
|
+
const escaping = { cloudPackage: packageRecord(escapingFiles) };
|
|
415
|
+
assert.throws(() => materializeCloudListingCli("agent-1", slug, escaping), /unsafe cloud package path/);
|
|
416
|
+
assert.equal(fs.existsSync(path.join(userData, "cloud-agent-installs", "outside.md")), false);
|
|
417
|
+
assert.equal(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), "new agent\n");
|
|
418
|
+
console.log("cloud asset restore: PASS");
|
|
419
|
+
} finally {
|
|
420
|
+
if (previousUserData === undefined) delete process.env.AGENTLAS_USER_DATA_DIR;
|
|
421
|
+
else process.env.AGENTLAS_USER_DATA_DIR = previousUserData;
|
|
422
|
+
fs.rmSync(userData, { recursive: true, force: true });
|
|
423
|
+
}
|