agentlas 0.4.0 → 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 +112 -23
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +66 -51
- 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 +65 -14
- package/engine/agentlas-i18n.cjs +132 -12
- package/engine/agentlas-input.cjs +123 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +373 -53
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +149 -47
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +349 -24
- package/engine/agentlas.cjs +3074 -379
- package/engine/architecture.data.json +5 -1
- 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 +33 -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,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const assert = require("node:assert/strict");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { runTool } = require("../engine/agentlas-tools.cjs");
|
|
8
|
+
|
|
9
|
+
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-tool-boundary-"));
|
|
10
|
+
const workspace = path.join(fixture, "workspace");
|
|
11
|
+
const outside = path.join(fixture, "outside");
|
|
12
|
+
fs.mkdirSync(path.join(workspace, "docs"), { recursive: true });
|
|
13
|
+
fs.mkdirSync(outside, { recursive: true });
|
|
14
|
+
fs.writeFileSync(path.join(workspace, "docs", "guide.md"), "alpha\n", "utf8");
|
|
15
|
+
fs.writeFileSync(path.join(workspace, "docs", "file..md"), "valid dots\n", "utf8");
|
|
16
|
+
fs.writeFileSync(path.join(outside, "secret.txt"), "outside-secret\n", "utf8");
|
|
17
|
+
|
|
18
|
+
const readCtx = { cwd: workspace, permission: "read" };
|
|
19
|
+
const writeCtx = { cwd: workspace, permission: "write" };
|
|
20
|
+
|
|
21
|
+
function expectAllowed(name, args, ctx = readCtx) {
|
|
22
|
+
const result = runTool(name, args, ctx);
|
|
23
|
+
assert.equal(result.ok, true, `${name} unexpectedly failed: ${result.content}`);
|
|
24
|
+
return result.content;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function expectDenied(name, args, ctx = readCtx) {
|
|
28
|
+
const result = runTool(name, args, ctx);
|
|
29
|
+
assert.equal(result.ok, false, `${name} unexpectedly escaped the workspace`);
|
|
30
|
+
assert.match(result.content, /workspace path denied:/, `${name} did not use the workspace boundary`);
|
|
31
|
+
return result.content;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// Normal workspace-relative reads, creates, and edits must keep working.
|
|
36
|
+
assert.equal(expectAllowed("read_file", { path: "docs/guide.md" }), "alpha\n");
|
|
37
|
+
assert.equal(expectAllowed("read_file", { path: "docs/file..md" }), "valid dots\n");
|
|
38
|
+
assert.match(expectAllowed("list_dir", { path: "docs" }), /guide\.md/);
|
|
39
|
+
expectAllowed("write_file", { path: "notes/new.md", content: "draft\n" }, writeCtx);
|
|
40
|
+
expectAllowed(
|
|
41
|
+
"edit_file",
|
|
42
|
+
{ path: "notes/new.md", old_string: "draft", new_string: "ready" },
|
|
43
|
+
writeCtx,
|
|
44
|
+
);
|
|
45
|
+
assert.equal(fs.readFileSync(path.join(workspace, "notes", "new.md"), "utf8"), "ready\n");
|
|
46
|
+
|
|
47
|
+
const outsideSecret = path.join(outside, "secret.txt");
|
|
48
|
+
const originalSecret = fs.readFileSync(outsideSecret, "utf8");
|
|
49
|
+
|
|
50
|
+
// All absolute path dialects are denied, including an absolute path that
|
|
51
|
+
// happens to point back into the workspace.
|
|
52
|
+
for (const [name, args, ctx] of [
|
|
53
|
+
["list_dir", { path: outside }, readCtx],
|
|
54
|
+
["read_file", { path: outsideSecret }, readCtx],
|
|
55
|
+
["read_file", { path: path.join(workspace, "docs", "guide.md") }, readCtx],
|
|
56
|
+
["write_file", { path: outsideSecret, content: "changed\n" }, writeCtx],
|
|
57
|
+
["edit_file", { path: outsideSecret, old_string: "outside", new_string: "changed" }, writeCtx],
|
|
58
|
+
["read_file", { path: "C:\\Windows\\System32\\drivers\\etc\\hosts" }, readCtx],
|
|
59
|
+
["read_file", { path: "C:relative-drive-path.txt" }, readCtx],
|
|
60
|
+
["read_file", { path: "\\\\server\\share\\secret.txt" }, readCtx],
|
|
61
|
+
]) {
|
|
62
|
+
expectDenied(name, args, ctx);
|
|
63
|
+
}
|
|
64
|
+
assert.equal(fs.readFileSync(outsideSecret, "utf8"), originalSecret, "absolute path changed outside data");
|
|
65
|
+
|
|
66
|
+
// A parent segment is denied before normalization, even if it would land
|
|
67
|
+
// back inside the workspace or uses the other platform's separator.
|
|
68
|
+
for (const [name, args, ctx] of [
|
|
69
|
+
["list_dir", { path: "../outside" }, readCtx],
|
|
70
|
+
["read_file", { path: "../outside/secret.txt" }, readCtx],
|
|
71
|
+
["read_file", { path: "docs/../docs/guide.md" }, readCtx],
|
|
72
|
+
["read_file", { path: "..\\outside\\secret.txt" }, readCtx],
|
|
73
|
+
["write_file", { path: "../outside/created.txt", content: "escape\n" }, writeCtx],
|
|
74
|
+
["edit_file", { path: "../outside/secret.txt", old_string: "outside", new_string: "changed" }, writeCtx],
|
|
75
|
+
]) {
|
|
76
|
+
expectDenied(name, args, ctx);
|
|
77
|
+
}
|
|
78
|
+
assert.equal(fs.existsSync(path.join(outside, "created.txt")), false, "traversal created an outside file");
|
|
79
|
+
assert.equal(fs.readFileSync(outsideSecret, "utf8"), originalSecret, "traversal changed outside data");
|
|
80
|
+
|
|
81
|
+
const outsideLink = path.join(workspace, "outside-link");
|
|
82
|
+
const insideLink = path.join(workspace, "inside-link");
|
|
83
|
+
fs.symlinkSync(outside, outsideLink, process.platform === "win32" ? "junction" : "dir");
|
|
84
|
+
fs.symlinkSync(path.join(workspace, "docs"), insideLink, process.platform === "win32" ? "junction" : "dir");
|
|
85
|
+
|
|
86
|
+
// Existing targets and not-yet-created descendants cannot escape through a
|
|
87
|
+
// directory symlink. The denied create must have no mkdir side effect.
|
|
88
|
+
expectDenied("list_dir", { path: "outside-link" });
|
|
89
|
+
expectDenied("read_file", { path: "outside-link/secret.txt" });
|
|
90
|
+
expectDenied("write_file", { path: "outside-link/new/deep.txt", content: "escape\n" }, writeCtx);
|
|
91
|
+
expectDenied(
|
|
92
|
+
"edit_file",
|
|
93
|
+
{ path: "outside-link/secret.txt", old_string: "outside", new_string: "changed" },
|
|
94
|
+
writeCtx,
|
|
95
|
+
);
|
|
96
|
+
assert.equal(fs.existsSync(path.join(outside, "new")), false, "symlink escape created outside directories");
|
|
97
|
+
assert.equal(fs.readFileSync(outsideSecret, "utf8"), originalSecret, "symlink escape changed outside data");
|
|
98
|
+
|
|
99
|
+
// In-workspace symlinks remain valid and resolve to their canonical target.
|
|
100
|
+
assert.equal(expectAllowed("read_file", { path: "inside-link/guide.md" }), "alpha\n");
|
|
101
|
+
expectAllowed("write_file", { path: "inside-link/linked-write.md", content: "inside\n" }, writeCtx);
|
|
102
|
+
expectAllowed(
|
|
103
|
+
"edit_file",
|
|
104
|
+
{ path: "inside-link/linked-write.md", old_string: "inside", new_string: "safe" },
|
|
105
|
+
writeCtx,
|
|
106
|
+
);
|
|
107
|
+
assert.equal(fs.readFileSync(path.join(workspace, "docs", "linked-write.md"), "utf8"), "safe\n");
|
|
108
|
+
|
|
109
|
+
// A hard link shares an inode even though both paths are lexically valid.
|
|
110
|
+
// Writes and edits must replace the workspace entry, not mutate the outside inode.
|
|
111
|
+
if (process.platform !== "win32") {
|
|
112
|
+
const hardWrite = path.join(workspace, "hard-write.txt");
|
|
113
|
+
fs.linkSync(outsideSecret, hardWrite);
|
|
114
|
+
expectAllowed("write_file", { path: "hard-write.txt", content: "workspace-only\n" }, writeCtx);
|
|
115
|
+
assert.equal(fs.readFileSync(hardWrite, "utf8"), "workspace-only\n");
|
|
116
|
+
assert.equal(fs.readFileSync(outsideSecret, "utf8"), originalSecret, "hard-link write changed outside data");
|
|
117
|
+
|
|
118
|
+
const hardEdit = path.join(workspace, "hard-edit.txt");
|
|
119
|
+
fs.linkSync(outsideSecret, hardEdit);
|
|
120
|
+
expectAllowed(
|
|
121
|
+
"edit_file",
|
|
122
|
+
{ path: "hard-edit.txt", old_string: "outside", new_string: "workspace" },
|
|
123
|
+
writeCtx,
|
|
124
|
+
);
|
|
125
|
+
assert.match(fs.readFileSync(hardEdit, "utf8"), /workspace-secret/);
|
|
126
|
+
assert.equal(fs.readFileSync(outsideSecret, "utf8"), originalSecret, "hard-link edit changed outside data");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const executable = path.join(workspace, "script.sh");
|
|
130
|
+
fs.writeFileSync(executable, "#!/bin/sh\necho old\n", { encoding: "utf8", mode: 0o755 });
|
|
131
|
+
fs.chmodSync(executable, 0o755);
|
|
132
|
+
expectAllowed(
|
|
133
|
+
"edit_file",
|
|
134
|
+
{ path: "script.sh", old_string: "old", new_string: "new" },
|
|
135
|
+
writeCtx,
|
|
136
|
+
);
|
|
137
|
+
assert.equal(fs.statSync(executable).mode & 0o777, 0o755, "atomic edit stripped executable mode bits");
|
|
138
|
+
expectAllowed("write_file", { path: "script.sh", content: "#!/bin/sh\necho overwritten\n" }, writeCtx);
|
|
139
|
+
assert.equal(fs.statSync(executable).mode & 0o777, 0o755, "atomic overwrite stripped executable mode bits");
|
|
140
|
+
|
|
141
|
+
if (process.platform !== "win32") {
|
|
142
|
+
const outsideFileLink = path.join(workspace, "outside-file-link");
|
|
143
|
+
fs.symlinkSync(outsideSecret, outsideFileLink, "file");
|
|
144
|
+
expectDenied("read_file", { path: "outside-file-link" });
|
|
145
|
+
expectDenied("write_file", { path: "outside-file-link", content: "changed\n" }, writeCtx);
|
|
146
|
+
expectDenied(
|
|
147
|
+
"edit_file",
|
|
148
|
+
{ path: "outside-file-link", old_string: "outside", new_string: "changed" },
|
|
149
|
+
writeCtx,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const brokenLink = path.join(workspace, "broken-link");
|
|
153
|
+
fs.symlinkSync(path.join(outside, "missing-target"), brokenLink, "dir");
|
|
154
|
+
expectDenied("write_file", { path: "broken-link/file.txt", content: "no\n" }, writeCtx);
|
|
155
|
+
assert.equal(fs.existsSync(path.join(outside, "missing-target")), false, "broken symlink created an outside target");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const invalidPath of ["", 42, "bad\0path"]) {
|
|
159
|
+
expectDenied("read_file", { path: invalidPath });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log("tool workspace boundary: PASS");
|
|
163
|
+
} finally {
|
|
164
|
+
fs.rmSync(fixture, { recursive: true, force: true });
|
|
165
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
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
|
+
updateTimeoutConfig,
|
|
11
|
+
fetchUpdateMetadata,
|
|
12
|
+
validateDesktopUpdateArtifact,
|
|
13
|
+
downloadUpdateFile,
|
|
14
|
+
verifyMacAppBundle,
|
|
15
|
+
replaceMacAppBundle,
|
|
16
|
+
} = require("../engine/agentlas.cjs");
|
|
17
|
+
|
|
18
|
+
function streamedResponse(parts, delayMs = 0, options = {}) {
|
|
19
|
+
let timer = null;
|
|
20
|
+
let index = 0;
|
|
21
|
+
const body = new ReadableStream({
|
|
22
|
+
start(controller) {
|
|
23
|
+
const push = () => {
|
|
24
|
+
if (index >= parts.length) {
|
|
25
|
+
if (!options.stall) controller.close();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
controller.enqueue(Buffer.from(parts[index++]));
|
|
29
|
+
timer = setTimeout(push, delayMs);
|
|
30
|
+
};
|
|
31
|
+
timer = setTimeout(push, options.immediate ? 0 : delayMs);
|
|
32
|
+
},
|
|
33
|
+
cancel() {
|
|
34
|
+
if (timer) clearTimeout(timer);
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const headers = new Headers(options.headers || {});
|
|
38
|
+
return {
|
|
39
|
+
ok: options.status == null || (options.status >= 200 && options.status < 300),
|
|
40
|
+
status: options.status || 200,
|
|
41
|
+
headers,
|
|
42
|
+
body,
|
|
43
|
+
arrayBuffer() {
|
|
44
|
+
throw new Error("arrayBuffer must never be used for updater transfers");
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sha256(value) {
|
|
50
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function artifactFor(value, overrides = {}) {
|
|
54
|
+
return {
|
|
55
|
+
url: "https://downloads.example.test/Agentlas.dmg",
|
|
56
|
+
fileName: "Agentlas.dmg",
|
|
57
|
+
sizeBytes: value.length,
|
|
58
|
+
sha256: sha256(value),
|
|
59
|
+
...overrides,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function assertNoPartials(dir) {
|
|
64
|
+
assert.deepEqual(fs.readdirSync(dir).filter((name) => name.includes(".partial.")), []);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function testMetadataPolicy() {
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
updateTimeoutConfig({
|
|
70
|
+
AGENTLAS_UPDATE_METADATA_CONNECT_TIMEOUT_MS: "NaN",
|
|
71
|
+
AGENTLAS_UPDATE_METADATA_IDLE_TIMEOUT_MS: "Infinity",
|
|
72
|
+
AGENTLAS_UPDATE_METADATA_TOTAL_TIMEOUT_MS: "bad",
|
|
73
|
+
}, "metadata"),
|
|
74
|
+
{ connectMs: 15_000, idleMs: 15_000, totalMs: 30_000 },
|
|
75
|
+
);
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
updateTimeoutConfig({
|
|
78
|
+
AGENTLAS_UPDATE_DOWNLOAD_CONNECT_TIMEOUT_MS: "-1",
|
|
79
|
+
AGENTLAS_UPDATE_DOWNLOAD_IDLE_TIMEOUT_MS: "0",
|
|
80
|
+
AGENTLAS_UPDATE_DOWNLOAD_TOTAL_TIMEOUT_MS: "999999999999",
|
|
81
|
+
}, "download"),
|
|
82
|
+
{ connectMs: 1_000, idleMs: 1_000, totalMs: 3_600_000 },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const metadata = JSON.stringify({ version: "1.2.3-rc.1", artifacts: [] });
|
|
86
|
+
const parsed = await fetchUpdateMetadata("https://agentlas.example.test/latest", {
|
|
87
|
+
fetch: async () => streamedResponse([metadata.slice(0, 8), metadata.slice(8)], 12),
|
|
88
|
+
timeoutConfig: { connectMs: 40, idleMs: 30, totalMs: 150 },
|
|
89
|
+
});
|
|
90
|
+
assert.equal(parsed.version, "1.2.3-rc.1");
|
|
91
|
+
|
|
92
|
+
await assert.rejects(
|
|
93
|
+
fetchUpdateMetadata("https://agentlas.example.test/latest", {
|
|
94
|
+
fetch: () => new Promise(() => {}),
|
|
95
|
+
timeoutConfig: { connectMs: 20, idleMs: 40, totalMs: 80 },
|
|
96
|
+
}),
|
|
97
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_CONNECT_TIMEOUT",
|
|
98
|
+
);
|
|
99
|
+
await assert.rejects(
|
|
100
|
+
fetchUpdateMetadata("https://agentlas.example.test/latest", {
|
|
101
|
+
fetch: async () => streamedResponse(["{"], 0, { immediate: true, stall: true }),
|
|
102
|
+
timeoutConfig: { connectMs: 30, idleMs: 20, totalMs: 100 },
|
|
103
|
+
}),
|
|
104
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_IDLE_TIMEOUT",
|
|
105
|
+
);
|
|
106
|
+
await assert.rejects(
|
|
107
|
+
fetchUpdateMetadata("https://agentlas.example.test/latest", {
|
|
108
|
+
fetch: async () => streamedResponse(Array(20).fill(" "), 8, { immediate: true }),
|
|
109
|
+
timeoutConfig: { connectMs: 20, idleMs: 20, totalMs: 35 },
|
|
110
|
+
}),
|
|
111
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_TOTAL_TIMEOUT",
|
|
112
|
+
);
|
|
113
|
+
await assert.rejects(
|
|
114
|
+
fetchUpdateMetadata("https://agentlas.example.test/latest", {
|
|
115
|
+
fetch: async () => streamedResponse(["x".repeat(33)], 0, { immediate: true }),
|
|
116
|
+
maxBytes: 32,
|
|
117
|
+
timeoutConfig: { connectMs: 30, idleMs: 30, totalMs: 100 },
|
|
118
|
+
}),
|
|
119
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_TOO_LARGE",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function testArtifactAndDownloadPolicy() {
|
|
124
|
+
const payload = Buffer.from("streamed-dmg-payload");
|
|
125
|
+
assert.throws(
|
|
126
|
+
() => validateDesktopUpdateArtifact(artifactFor(payload, { sha256: undefined })),
|
|
127
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_MISSING_DIGEST",
|
|
128
|
+
);
|
|
129
|
+
assert.throws(
|
|
130
|
+
() => validateDesktopUpdateArtifact(artifactFor(payload, { sizeBytes: undefined })),
|
|
131
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_MISSING_SIZE",
|
|
132
|
+
);
|
|
133
|
+
assert.throws(
|
|
134
|
+
() => validateDesktopUpdateArtifact(artifactFor(payload, { fileName: "../Agentlas.dmg" })),
|
|
135
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_INVALID_FILENAME",
|
|
136
|
+
);
|
|
137
|
+
assert.throws(
|
|
138
|
+
() => validateDesktopUpdateArtifact(artifactFor(payload, { url: "http://downloads.example.test/Agentlas.dmg" })),
|
|
139
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_INSECURE_URL",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-update-test."));
|
|
143
|
+
try {
|
|
144
|
+
let unsafeFetchCalled = false;
|
|
145
|
+
const unsafeDestination = path.join(root, "missing-integrity.dmg");
|
|
146
|
+
await assert.rejects(
|
|
147
|
+
downloadUpdateFile("https://downloads.example.test/Agentlas.dmg", unsafeDestination, artifactFor(payload, { sha256: undefined }), {
|
|
148
|
+
fetch: async () => {
|
|
149
|
+
unsafeFetchCalled = true;
|
|
150
|
+
return streamedResponse([payload], 0, { immediate: true });
|
|
151
|
+
},
|
|
152
|
+
maxBytes: 1024,
|
|
153
|
+
}),
|
|
154
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_MISSING_DIGEST",
|
|
155
|
+
);
|
|
156
|
+
assert.equal(unsafeFetchCalled, false, "missing integrity metadata must fail before any network request");
|
|
157
|
+
assert.equal(fs.existsSync(unsafeDestination), false);
|
|
158
|
+
|
|
159
|
+
const destination = path.join(root, "ok.dmg");
|
|
160
|
+
const result = await downloadUpdateFile("https://downloads.example.test/Agentlas.dmg", destination, artifactFor(payload), {
|
|
161
|
+
fetch: async () => streamedResponse([payload.subarray(0, 5), payload.subarray(5, 11), payload.subarray(11)], 12, {
|
|
162
|
+
headers: { "content-length": String(payload.length) },
|
|
163
|
+
}),
|
|
164
|
+
maxBytes: 1024,
|
|
165
|
+
timeoutConfig: { connectMs: 40, idleMs: 30, totalMs: 200 },
|
|
166
|
+
});
|
|
167
|
+
assert.equal(result.bytes, payload.length);
|
|
168
|
+
assert.deepEqual(fs.readFileSync(destination), payload);
|
|
169
|
+
assertNoPartials(root);
|
|
170
|
+
|
|
171
|
+
const connectionDestination = path.join(root, "connect-timeout.dmg");
|
|
172
|
+
await assert.rejects(
|
|
173
|
+
downloadUpdateFile("https://downloads.example.test/Agentlas.dmg", connectionDestination, artifactFor(payload), {
|
|
174
|
+
fetch: () => new Promise(() => {}),
|
|
175
|
+
maxBytes: 1024,
|
|
176
|
+
timeoutConfig: { connectMs: 20, idleMs: 40, totalMs: 90 },
|
|
177
|
+
}),
|
|
178
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_CONNECT_TIMEOUT",
|
|
179
|
+
);
|
|
180
|
+
assert.equal(fs.existsSync(connectionDestination), false);
|
|
181
|
+
assertNoPartials(root);
|
|
182
|
+
|
|
183
|
+
const idleDestination = path.join(root, "idle-timeout.dmg");
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
downloadUpdateFile("https://downloads.example.test/Agentlas.dmg", idleDestination, artifactFor(payload), {
|
|
186
|
+
fetch: async () => streamedResponse([payload.subarray(0, 2)], 0, { immediate: true, stall: true }),
|
|
187
|
+
maxBytes: 1024,
|
|
188
|
+
timeoutConfig: { connectMs: 30, idleMs: 20, totalMs: 100 },
|
|
189
|
+
}),
|
|
190
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_IDLE_TIMEOUT",
|
|
191
|
+
);
|
|
192
|
+
assert.equal(fs.existsSync(idleDestination), false);
|
|
193
|
+
assertNoPartials(root);
|
|
194
|
+
|
|
195
|
+
const digestDestination = path.join(root, "bad-digest.dmg");
|
|
196
|
+
await assert.rejects(
|
|
197
|
+
downloadUpdateFile("https://downloads.example.test/Agentlas.dmg", digestDestination, artifactFor(payload, { sha256: "0".repeat(64) }), {
|
|
198
|
+
fetch: async () => streamedResponse([payload], 0, { immediate: true }),
|
|
199
|
+
maxBytes: 1024,
|
|
200
|
+
timeoutConfig: { connectMs: 30, idleMs: 30, totalMs: 100 },
|
|
201
|
+
}),
|
|
202
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_DIGEST_MISMATCH",
|
|
203
|
+
);
|
|
204
|
+
assert.equal(fs.existsSync(digestDestination), false);
|
|
205
|
+
assertNoPartials(root);
|
|
206
|
+
|
|
207
|
+
const sizeDestination = path.join(root, "bad-size.dmg");
|
|
208
|
+
await assert.rejects(
|
|
209
|
+
downloadUpdateFile("https://downloads.example.test/Agentlas.dmg", sizeDestination, artifactFor(payload, { sizeBytes: payload.length - 1 }), {
|
|
210
|
+
fetch: async () => streamedResponse([payload], 0, { immediate: true }),
|
|
211
|
+
maxBytes: 1024,
|
|
212
|
+
timeoutConfig: { connectMs: 30, idleMs: 30, totalMs: 100 },
|
|
213
|
+
}),
|
|
214
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_SIZE_MISMATCH",
|
|
215
|
+
);
|
|
216
|
+
assert.equal(fs.existsSync(sizeDestination), false);
|
|
217
|
+
assertNoPartials(root);
|
|
218
|
+
|
|
219
|
+
const maxDestination = path.join(root, "too-large.dmg");
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
downloadUpdateFile("https://downloads.example.test/Agentlas.dmg", maxDestination, artifactFor(payload), {
|
|
222
|
+
fetch: async () => streamedResponse([payload], 0, { immediate: true }),
|
|
223
|
+
maxBytes: payload.length - 1,
|
|
224
|
+
timeoutConfig: { connectMs: 30, idleMs: 30, totalMs: 100 },
|
|
225
|
+
}),
|
|
226
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_TOO_LARGE",
|
|
227
|
+
);
|
|
228
|
+
assert.equal(fs.existsSync(maxDestination), false);
|
|
229
|
+
assertNoPartials(root);
|
|
230
|
+
} finally {
|
|
231
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function writeApp(appPath, marker) {
|
|
236
|
+
fs.mkdirSync(appPath, { recursive: true });
|
|
237
|
+
fs.writeFileSync(path.join(appPath, "marker.txt"), marker);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function readMarker(appPath) {
|
|
241
|
+
return fs.readFileSync(path.join(appPath, "marker.txt"), "utf8");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function appFixture() {
|
|
245
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-app-transaction."));
|
|
246
|
+
const sourceApp = path.join(root, "Source.app");
|
|
247
|
+
const targetApp = path.join(root, "Agentlas.app");
|
|
248
|
+
const backupPath = path.join(root, ".Agentlas.backup.test.app");
|
|
249
|
+
const stagingPath = path.join(root, ".Agentlas.installing.test.app");
|
|
250
|
+
writeApp(sourceApp, "new");
|
|
251
|
+
writeApp(targetApp, "original");
|
|
252
|
+
return { root, sourceApp, targetApp, backupPath, stagingPath };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function mockCommands(fixture, options = {}) {
|
|
256
|
+
const commands = { mv: "mock-mv", rm: "mock-rm", ditto: "mock-ditto", codesign: "mock-codesign", spctl: "mock-spctl" };
|
|
257
|
+
const calls = [];
|
|
258
|
+
const runCommand = async (command, args) => {
|
|
259
|
+
calls.push({ command, args: [...args] });
|
|
260
|
+
const pathArgs = args.filter((value) => !String(value).startsWith("-"));
|
|
261
|
+
if (!pathArgs.every((value) => path.resolve(value).startsWith(fixture.root)) && command !== commands.codesign && command !== commands.spctl) {
|
|
262
|
+
throw new Error(`test command escaped fixture: ${args.join(" ")}`);
|
|
263
|
+
}
|
|
264
|
+
if (command === commands.ditto) {
|
|
265
|
+
if (options.failDitto && args[0] === fixture.sourceApp) throw new Error("mock ditto failure");
|
|
266
|
+
fs.cpSync(args[0], args[1], { recursive: true, errorOnExist: true });
|
|
267
|
+
} else if (command === commands.mv) {
|
|
268
|
+
if (options.failRestore && args[0] === fixture.backupPath && args[1] === fixture.targetApp) throw new Error("mock restore failure");
|
|
269
|
+
fs.renameSync(args[0], args[1]);
|
|
270
|
+
} else if (command === commands.rm) {
|
|
271
|
+
fs.rmSync(args[1], { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
274
|
+
};
|
|
275
|
+
return { commands, calls, runCommand };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function runReplacementCase(options = {}) {
|
|
279
|
+
const fixture = appFixture();
|
|
280
|
+
const mock = mockCommands(fixture, options);
|
|
281
|
+
const phases = [];
|
|
282
|
+
const verifyApp = async (appPath, context) => {
|
|
283
|
+
phases.push(context.phase);
|
|
284
|
+
assert.equal(fs.existsSync(path.join(appPath, "marker.txt")), true, `missing app marker during ${context.phase}`);
|
|
285
|
+
if (options.failPhase === context.phase) throw new Error(`mock ${context.phase} verification failure`);
|
|
286
|
+
return { identifier: "com.agentlas.desktop", teamIdentifier: options.teamByPhase?.[context.phase] || "AGENTLAS123" };
|
|
287
|
+
};
|
|
288
|
+
const promise = replaceMacAppBundle({ ...fixture, ...mock, verifyApp });
|
|
289
|
+
return { fixture, mock, phases, promise };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function testReplacementTransaction() {
|
|
293
|
+
{
|
|
294
|
+
const run = await runReplacementCase();
|
|
295
|
+
try {
|
|
296
|
+
const result = await run.promise;
|
|
297
|
+
assert.equal(readMarker(run.fixture.targetApp), "new");
|
|
298
|
+
assert.equal(fs.existsSync(run.fixture.backupPath), false);
|
|
299
|
+
assert.equal(fs.existsSync(run.fixture.stagingPath), false);
|
|
300
|
+
assert.equal(result.backupRetained, false);
|
|
301
|
+
assert.deepEqual(run.phases, ["source", "original", "backup", "staging", "installed"]);
|
|
302
|
+
} finally {
|
|
303
|
+
fs.rmSync(run.fixture.root, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const failure of [{ failDitto: true }, { failPhase: "staging" }, { failPhase: "installed" }, { teamByPhase: { staging: "EVIL123" } }]) {
|
|
308
|
+
const run = await runReplacementCase(failure);
|
|
309
|
+
try {
|
|
310
|
+
await assert.rejects(run.promise, (error) => error && error.code === "AGENTLAS_UPDATE_REPLACEMENT_FAILED_ROLLED_BACK");
|
|
311
|
+
assert.equal(readMarker(run.fixture.targetApp), "original", "the exact original app must be restored");
|
|
312
|
+
assert.equal(fs.existsSync(run.fixture.backupPath), false);
|
|
313
|
+
assert.equal(fs.existsSync(run.fixture.stagingPath), false);
|
|
314
|
+
assert.equal(run.phases.at(-1), "restored", "rollback must verify the restored app");
|
|
315
|
+
} finally {
|
|
316
|
+
fs.rmSync(run.fixture.root, { recursive: true, force: true });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
{
|
|
321
|
+
const run = await runReplacementCase({ failDitto: true, failRestore: true });
|
|
322
|
+
try {
|
|
323
|
+
await assert.rejects(run.promise, (error) => {
|
|
324
|
+
assert.equal(error.code, "AGENTLAS_UPDATE_ROLLBACK_FAILED");
|
|
325
|
+
assert.equal(error.backupPath, run.fixture.backupPath);
|
|
326
|
+
return true;
|
|
327
|
+
});
|
|
328
|
+
assert.equal(readMarker(run.fixture.backupPath), "original", "failed rollback must retain the original backup");
|
|
329
|
+
} finally {
|
|
330
|
+
fs.rmSync(run.fixture.root, { recursive: true, force: true });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function testCodeSigningChecks() {
|
|
336
|
+
const calls = [];
|
|
337
|
+
const identity = await verifyMacAppBundle("/tmp/fixture/Agentlas.app", {
|
|
338
|
+
commands: { codesign: "codesign", spctl: "spctl" },
|
|
339
|
+
runCommand: async (command, args, options) => {
|
|
340
|
+
calls.push({ command, args, options });
|
|
341
|
+
if (command === "codesign" && args[0] === "-d") {
|
|
342
|
+
return { code: 0, stdout: "", stderr: "Identifier=com.agentlas.desktop\nTeamIdentifier=AGENTLAS123\n" };
|
|
343
|
+
}
|
|
344
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
assert.deepEqual(identity, { identifier: "com.agentlas.desktop", teamIdentifier: "AGENTLAS123" });
|
|
348
|
+
assert.deepEqual(calls, [
|
|
349
|
+
{ command: "codesign", args: ["--verify", "--deep", "--strict", "--verbose=2", "/tmp/fixture/Agentlas.app"], options: undefined },
|
|
350
|
+
{ command: "codesign", args: ["-d", "--verbose=4", "/tmp/fixture/Agentlas.app"], options: { capture: true } },
|
|
351
|
+
{ command: "spctl", args: ["-a", "-t", "exec", "-vv", "/tmp/fixture/Agentlas.app"], options: undefined },
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
await assert.rejects(
|
|
355
|
+
verifyMacAppBundle("/tmp/fixture/Evil.app", {
|
|
356
|
+
commands: { codesign: "codesign", spctl: "spctl" },
|
|
357
|
+
runCommand: async (command, args) => command === "codesign" && args[0] === "-d"
|
|
358
|
+
? { code: 0, stdout: "", stderr: "Identifier=com.example.evil\nTeamIdentifier=EVIL123\n" }
|
|
359
|
+
: { code: 0, stdout: "", stderr: "" },
|
|
360
|
+
}),
|
|
361
|
+
(error) => error && error.code === "AGENTLAS_UPDATE_SIGNER_MISMATCH",
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function main() {
|
|
366
|
+
await testMetadataPolicy();
|
|
367
|
+
await testArtifactAndDownloadPolicy();
|
|
368
|
+
await testReplacementTransaction();
|
|
369
|
+
await testCodeSigningChecks();
|
|
370
|
+
console.log("update-safety: PASS");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
main().catch((error) => {
|
|
374
|
+
console.error(error);
|
|
375
|
+
process.exitCode = 1;
|
|
376
|
+
});
|