@openparachute/agent 0.2.2 → 0.2.3-rc.11
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/.parachute/module.json +3 -3
- package/package.json +4 -1
- package/src/agent-defs.ts +9 -0
- package/src/auth.ts +182 -14
- package/src/backends/programmatic.ts +35 -2
- package/src/backends/registry.ts +159 -40
- package/src/backends/types.ts +44 -0
- package/src/daemon.ts +317 -12
- package/src/def-vault-triggers.ts +317 -0
- package/src/preflight.ts +139 -0
- package/src/spawn-agent.ts +16 -0
- package/src/step-up.ts +316 -0
- package/src/terminal-ui.ts +73 -0
- package/src/transports/http-ui.ts +10 -8
- package/src/transports/vault.ts +48 -27
- package/src/ui-kit.ts +6 -3
- package/src/ui-ticket.ts +121 -0
- package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
- package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2011
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
- package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
- package/web/ui/tsconfig.json +0 -21
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { Sandbox, configForSpec, type SandboxEngine } from "./index.ts";
|
|
3
|
-
import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
|
4
|
-
import type { AgentSpec, BaseBinds } from "./types.ts";
|
|
5
|
-
import type { EgressBaseInput } from "./egress.ts";
|
|
6
|
-
|
|
7
|
-
const BASE_BINDS: BaseBinds = { workspace: "/state/sessions/arm", runtimeReadOnly: ["/cfg"] };
|
|
8
|
-
const EGRESS_BASE: EgressBaseInput = { hubOrigin: "https://hub.example.com" };
|
|
9
|
-
|
|
10
|
-
/** A fake engine that records what it was initialized with + what it wrapped. */
|
|
11
|
-
function fakeEngine(): SandboxEngine & {
|
|
12
|
-
initializedWith: SandboxRuntimeConfig | null;
|
|
13
|
-
wrappedCommands: string[];
|
|
14
|
-
resets: number;
|
|
15
|
-
calls: string[];
|
|
16
|
-
} {
|
|
17
|
-
const rec = {
|
|
18
|
-
initializedWith: null as SandboxRuntimeConfig | null,
|
|
19
|
-
wrappedCommands: [] as string[],
|
|
20
|
-
resets: 0,
|
|
21
|
-
calls: [] as string[],
|
|
22
|
-
isSupportedPlatform: () => true,
|
|
23
|
-
isSandboxingEnabled: () => true,
|
|
24
|
-
async initialize(cfg: SandboxRuntimeConfig) {
|
|
25
|
-
rec.initializedWith = cfg;
|
|
26
|
-
rec.calls.push("initialize");
|
|
27
|
-
},
|
|
28
|
-
async wrapWithSandboxArgv(command: string) {
|
|
29
|
-
rec.wrappedCommands.push(command);
|
|
30
|
-
rec.calls.push("wrap");
|
|
31
|
-
return {
|
|
32
|
-
argv: ["/bin/bash", "-c", `SANDBOXED ${command}`],
|
|
33
|
-
env: { SANDBOX_RUNTIME: "1", HTTP_PROXY: "http://localhost:9999" },
|
|
34
|
-
};
|
|
35
|
-
},
|
|
36
|
-
async reset() {
|
|
37
|
-
rec.resets += 1;
|
|
38
|
-
rec.calls.push("reset");
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
return rec;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// network "restricted" so the allowedDomains-floor assertion applies; the /Users
|
|
45
|
-
// read-deny applies via the DEFAULT filesystem "workspace" (scoped reads). The
|
|
46
|
-
// open-network default is covered in config.test.ts.
|
|
47
|
-
const SPEC: AgentSpec = {
|
|
48
|
-
name: "arm",
|
|
49
|
-
channels: ["ch"],
|
|
50
|
-
network: "restricted",
|
|
51
|
-
egress: ["registry.npmjs.org"],
|
|
52
|
-
mounts: [{ hostPath: "/proj", mountPath: "/work", mode: "rw" }],
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
describe("Sandbox adapter", () => {
|
|
56
|
-
test("initializes the engine with the spec-derived config, then wraps the command", async () => {
|
|
57
|
-
const engine = fakeEngine();
|
|
58
|
-
const sandbox = new Sandbox(engine);
|
|
59
|
-
const wrapped = await sandbox.wrap({
|
|
60
|
-
spec: SPEC,
|
|
61
|
-
baseBinds: BASE_BINDS,
|
|
62
|
-
egressBase: EGRESS_BASE,
|
|
63
|
-
command: "claude --strict-mcp-config",
|
|
64
|
-
platform: "darwin",
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
// It initialized with the right config (egress floor + scoped reads).
|
|
68
|
-
expect(engine.initializedWith).not.toBeNull();
|
|
69
|
-
expect(engine.initializedWith!.network.allowedDomains).toContain("api.anthropic.com");
|
|
70
|
-
expect(engine.initializedWith!.network.allowedDomains).toContain("registry.npmjs.org");
|
|
71
|
-
expect(engine.initializedWith!.filesystem.denyRead).toContain("/Users");
|
|
72
|
-
expect(engine.initializedWith!.filesystem.allowWrite).toContain("/proj");
|
|
73
|
-
|
|
74
|
-
// It wrapped exactly the command we passed.
|
|
75
|
-
expect(engine.wrappedCommands).toEqual(["claude --strict-mcp-config"]);
|
|
76
|
-
|
|
77
|
-
// It returns argv + env + the config used.
|
|
78
|
-
expect(wrapped.argv[0]).toBe("/bin/bash");
|
|
79
|
-
expect(wrapped.argv[2]).toContain("SANDBOXED claude");
|
|
80
|
-
expect(wrapped.env.SANDBOX_RUNTIME).toBe("1");
|
|
81
|
-
expect(wrapped.config).toBe(engine.initializedWith!);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("wrap() RESETS the singleton before initializing (no stale proxy/config leak across spawns)", async () => {
|
|
85
|
-
const engine = fakeEngine();
|
|
86
|
-
const sandbox = new Sandbox(engine);
|
|
87
|
-
await sandbox.wrap({
|
|
88
|
-
spec: SPEC,
|
|
89
|
-
baseBinds: BASE_BINDS,
|
|
90
|
-
egressBase: EGRESS_BASE,
|
|
91
|
-
command: "claude",
|
|
92
|
-
});
|
|
93
|
-
// The order matters: reset → initialize → wrap. Without the leading reset, a
|
|
94
|
-
// prior spawn's network config (e.g. HTTP_PROXY from a restricted session)
|
|
95
|
-
// leaks into this wrap and an open session routes to a dead proxy → dies.
|
|
96
|
-
expect(engine.calls).toEqual(["reset", "initialize", "wrap"]);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("a RESTRICTED-network spawn's proxy env does NOT leak into a later OPEN spawn (the real-bug scenario)", async () => {
|
|
100
|
-
// A leak-MODELING engine, mirroring the real singleton: `initialize` turns the
|
|
101
|
-
// proxy on iff the config has an allowlist (restricted network); `wrap` emits
|
|
102
|
-
// HTTP_PROXY iff the proxy is on; `reset` turns it off. Without the
|
|
103
|
-
// reset-before-init in Sandbox.wrap, the proxy would still be on from the
|
|
104
|
-
// restricted spawn and leak into the open spawn's env — exactly the live failure.
|
|
105
|
-
let proxyOn = false;
|
|
106
|
-
const engine: SandboxEngine = {
|
|
107
|
-
isSupportedPlatform: () => true,
|
|
108
|
-
isSandboxingEnabled: () => true,
|
|
109
|
-
async initialize(cfg: SandboxRuntimeConfig) {
|
|
110
|
-
if ((cfg.network as { allowedDomains?: string[] }).allowedDomains) proxyOn = true;
|
|
111
|
-
},
|
|
112
|
-
async wrapWithSandboxArgv(command: string) {
|
|
113
|
-
return { argv: ["/bin/bash", "-c", command], env: proxyOn ? { HTTP_PROXY: "http://localhost:1" } : {} };
|
|
114
|
-
},
|
|
115
|
-
async reset() {
|
|
116
|
-
proxyOn = false;
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
const sandbox = new Sandbox(engine);
|
|
120
|
-
// 1) restricted-network spawn → allowlist present → proxy on, HTTP_PROXY present.
|
|
121
|
-
const restricted = await sandbox.wrap({ spec: { name: "r", channels: ["c"], network: "restricted" }, baseBinds: BASE_BINDS, egressBase: EGRESS_BASE, command: "claude" });
|
|
122
|
-
expect(restricted.env.HTTP_PROXY).toBeDefined();
|
|
123
|
-
// 2) open-network spawn right after (the default) → the per-wrap reset clears the
|
|
124
|
-
// proxy, so NO stale HTTP_PROXY leaks in (the bug: claude would route to the
|
|
125
|
-
// dead proxy + die).
|
|
126
|
-
const open = await sandbox.wrap({ spec: { name: "o", channels: ["c"] }, baseBinds: BASE_BINDS, egressBase: EGRESS_BASE, command: "claude" });
|
|
127
|
-
expect(open.env.HTTP_PROXY).toBeUndefined();
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("reset() tears the engine down", async () => {
|
|
131
|
-
const engine = fakeEngine();
|
|
132
|
-
const sandbox = new Sandbox(engine);
|
|
133
|
-
await sandbox.reset();
|
|
134
|
-
expect(engine.resets).toBe(1);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("isSupportedPlatform delegates to the engine", () => {
|
|
138
|
-
const engine = fakeEngine();
|
|
139
|
-
expect(new Sandbox(engine).isSupportedPlatform()).toBe(true);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("SECURITY: a restricted-network spec omitting egress still gets the base floor in the engine config", async () => {
|
|
143
|
-
const engine = fakeEngine();
|
|
144
|
-
const sandbox = new Sandbox(engine);
|
|
145
|
-
await sandbox.wrap({
|
|
146
|
-
spec: { name: "x", channels: ["c"], network: "restricted" }, // restricted, no egress declared
|
|
147
|
-
baseBinds: BASE_BINDS,
|
|
148
|
-
egressBase: EGRESS_BASE,
|
|
149
|
-
command: "claude",
|
|
150
|
-
platform: "darwin",
|
|
151
|
-
});
|
|
152
|
-
expect(engine.initializedWith!.network.allowedDomains).toContain("api.anthropic.com");
|
|
153
|
-
expect(engine.initializedWith!.network.allowedDomains).toContain("hub.example.com");
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
describe("configForSpec helper", () => {
|
|
158
|
-
test("builds the config without touching an engine", () => {
|
|
159
|
-
const cfg = configForSpec({
|
|
160
|
-
spec: SPEC,
|
|
161
|
-
baseBinds: BASE_BINDS,
|
|
162
|
-
egressBase: EGRESS_BASE,
|
|
163
|
-
platform: "darwin",
|
|
164
|
-
});
|
|
165
|
-
expect(cfg.network.allowedDomains).toContain("registry.npmjs.org");
|
|
166
|
-
expect(cfg.filesystem.allowWrite).toContain("/proj");
|
|
167
|
-
});
|
|
168
|
-
});
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "fs";
|
|
3
|
-
import { tmpdir } from "os";
|
|
4
|
-
import { join } from "path";
|
|
5
|
-
import { resolveManifestPath, upsertService, listVaultNames, type ServiceEntry } from "./services-manifest.ts";
|
|
6
|
-
|
|
7
|
-
function tmp(): string {
|
|
8
|
-
return join(mkdtempSync(join(tmpdir(), "pc-manifest-")), "services.json");
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const AGENT: ServiceEntry = {
|
|
12
|
-
name: "parachute-agent",
|
|
13
|
-
port: 1941,
|
|
14
|
-
paths: ["/agent"],
|
|
15
|
-
health: "/health",
|
|
16
|
-
version: "0.1.0",
|
|
17
|
-
displayName: "Agent",
|
|
18
|
-
stripPrefix: true,
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
describe("resolveManifestPath", () => {
|
|
22
|
-
test("honors PARACHUTE_HOME (sandbox for tests/e2e)", () => {
|
|
23
|
-
expect(resolveManifestPath({ PARACHUTE_HOME: "/tmp/sandbox" })).toBe("/tmp/sandbox/services.json");
|
|
24
|
-
});
|
|
25
|
-
test("falls back to HOME/.parachute", () => {
|
|
26
|
-
expect(resolveManifestPath({ HOME: "/home/x" })).toBe("/home/x/.parachute/services.json");
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("upsertService", () => {
|
|
31
|
-
test("creates the manifest with the entry on first write", () => {
|
|
32
|
-
const path = tmp();
|
|
33
|
-
upsertService(AGENT, path);
|
|
34
|
-
const m = JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
-
expect(m.services).toHaveLength(1);
|
|
36
|
-
expect(m.services[0].name).toBe("parachute-agent");
|
|
37
|
-
expect(m.services[0].paths).toEqual(["/agent"]);
|
|
38
|
-
expect(m.services[0].stripPrefix).toBe(true);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("carries startCmd so the hub supervisor can start/restart/adopt the module (agent#34)", () => {
|
|
42
|
-
const path = tmp();
|
|
43
|
-
upsertService({ ...AGENT, startCmd: ["parachute-agent"] }, path);
|
|
44
|
-
const m = JSON.parse(readFileSync(path, "utf8"));
|
|
45
|
-
expect(m.services[0].startCmd).toEqual(["parachute-agent"]);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("is idempotent — re-registering the same name does not duplicate", () => {
|
|
49
|
-
const path = tmp();
|
|
50
|
-
upsertService(AGENT, path);
|
|
51
|
-
upsertService({ ...AGENT, version: "0.1.1" }, path);
|
|
52
|
-
const m = JSON.parse(readFileSync(path, "utf8"));
|
|
53
|
-
expect(m.services).toHaveLength(1);
|
|
54
|
-
expect(m.services[0].version).toBe("0.1.1"); // module wins for fields it owns
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("merges — preserves hub-stamped fields the module doesn't author", () => {
|
|
58
|
-
const path = tmp();
|
|
59
|
-
// hub stamped installDir onto the row; module re-registers without it.
|
|
60
|
-
writeFileSync(path, JSON.stringify({ services: [{ ...AGENT, installDir: "/hub/stamped" }] }));
|
|
61
|
-
upsertService(AGENT, path);
|
|
62
|
-
const m = JSON.parse(readFileSync(path, "utf8"));
|
|
63
|
-
expect(m.services[0].installDir).toBe("/hub/stamped");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("preserves other modules' entries", () => {
|
|
67
|
-
const path = tmp();
|
|
68
|
-
writeFileSync(path, JSON.stringify({ services: [{ name: "parachute-vault", port: 1940, paths: ["/vault/default"], health: "/vault/default/health", version: "0.5.2" }] }));
|
|
69
|
-
upsertService(AGENT, path);
|
|
70
|
-
const m = JSON.parse(readFileSync(path, "utf8"));
|
|
71
|
-
expect(m.services).toHaveLength(2);
|
|
72
|
-
expect(m.services.map((s: ServiceEntry) => s.name).sort()).toEqual(["parachute-agent", "parachute-vault"]);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("throws on a malformed manifest rather than clobbering it", () => {
|
|
76
|
-
const path = tmp();
|
|
77
|
-
writeFileSync(path, JSON.stringify({ not_services: true }));
|
|
78
|
-
expect(() => upsertService(AGENT, path)).toThrow(/malformed/);
|
|
79
|
-
expect(existsSync(path)).toBe(true); // original left intact
|
|
80
|
-
expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ not_services: true }); // content untouched
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe("listVaultNames", () => {
|
|
85
|
-
test("extracts vault names from the vault module's /vault/<name> paths, default first", () => {
|
|
86
|
-
const path = tmp();
|
|
87
|
-
writeFileSync(path, JSON.stringify({ services: [
|
|
88
|
-
{ name: "parachute-vault", port: 1940, paths: ["/vault/boulder", "/vault/default", "/vault/techne"], health: "x", version: "1" },
|
|
89
|
-
{ name: "parachute-agent", port: 1941, paths: ["/agent"], health: "x", version: "1" },
|
|
90
|
-
] }));
|
|
91
|
-
expect(listVaultNames(path)).toEqual(["default", "boulder", "techne"]);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("dedupes across services and ignores non-vault paths", () => {
|
|
95
|
-
const path = tmp();
|
|
96
|
-
writeFileSync(path, JSON.stringify({ services: [
|
|
97
|
-
{ name: "a", port: 1, paths: ["/vault/x", "/other"], health: "x", version: "1" },
|
|
98
|
-
{ name: "b", port: 2, paths: ["/vault/x", "/vault/y"], health: "x", version: "1" },
|
|
99
|
-
] }));
|
|
100
|
-
expect(listVaultNames(path).sort()).toEqual(["x", "y"]);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("returns [] when the manifest is absent or unreadable", () => {
|
|
104
|
-
expect(listVaultNames(join(tmpdir(), "does-not-exist-" + Math.floor(performance.now()), "services.json"))).toEqual([]);
|
|
105
|
-
});
|
|
106
|
-
});
|
package/src/spa-serve.test.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the v2 agent SPA bundle serving (`src/spa-serve.ts`) — the daemon's
|
|
3
|
-
* `/app` mount. Proves: a missing dist 503s with a build hint; the SPA shell is
|
|
4
|
-
* served for the mount root + client-routed paths; real assets serve with the
|
|
5
|
-
* right content-type; path traversal is blocked; the mount-path predicate is
|
|
6
|
-
* exact.
|
|
7
|
-
*/
|
|
8
|
-
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
9
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
10
|
-
import { join } from "path";
|
|
11
|
-
import { tmpdir } from "os";
|
|
12
|
-
import {
|
|
13
|
-
SPA_MOUNT,
|
|
14
|
-
isSpaPath,
|
|
15
|
-
serveSpa,
|
|
16
|
-
spaContentType,
|
|
17
|
-
spaDistDir,
|
|
18
|
-
} from "./spa-serve.ts";
|
|
19
|
-
|
|
20
|
-
describe("isSpaPath", () => {
|
|
21
|
-
test("matches the mount root + sub-paths only", () => {
|
|
22
|
-
expect(isSpaPath("/app")).toBe(true);
|
|
23
|
-
expect(isSpaPath("/app/")).toBe(true);
|
|
24
|
-
expect(isSpaPath("/app/agents")).toBe(true);
|
|
25
|
-
expect(isSpaPath("/app/assets/index-abc.js")).toBe(true);
|
|
26
|
-
// NOT the daemon-rendered HTML pages or other routes.
|
|
27
|
-
expect(isSpaPath("/agents")).toBe(false);
|
|
28
|
-
expect(isSpaPath("/apple")).toBe(false);
|
|
29
|
-
expect(isSpaPath("/api/agents")).toBe(false);
|
|
30
|
-
expect(isSpaPath("/")).toBe(false);
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
describe("spaContentType", () => {
|
|
35
|
-
test("maps the realistic Vite output extensions", () => {
|
|
36
|
-
expect(spaContentType("/x/index.html")).toContain("text/html");
|
|
37
|
-
expect(spaContentType("/x/index-abc.js")).toContain("javascript");
|
|
38
|
-
expect(spaContentType("/x/index-abc.css")).toContain("css");
|
|
39
|
-
expect(spaContentType("/x/logo.svg")).toBe("image/svg+xml");
|
|
40
|
-
expect(spaContentType("/x/unknown.bin")).toBe("application/octet-stream");
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe("spaDistDir", () => {
|
|
45
|
-
test("anchors to <installDir>/web/ui/dist", () => {
|
|
46
|
-
expect(spaDistDir("/opt/agent")).toBe("/opt/agent/web/ui/dist");
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("serveSpa — missing bundle", () => {
|
|
51
|
-
test("503s with a build hint when dist/ is absent", async () => {
|
|
52
|
-
const res = serveSpa("/nonexistent/dist", "/app/");
|
|
53
|
-
expect(res.status).toBe(503);
|
|
54
|
-
expect(await res.text()).toContain("run `bun run build`");
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe("serveSpa — with a fixture bundle", () => {
|
|
59
|
-
let dist: string;
|
|
60
|
-
|
|
61
|
-
beforeAll(() => {
|
|
62
|
-
dist = mkdtempSync(join(tmpdir(), "agent-spa-test-"));
|
|
63
|
-
mkdirSync(join(dist, "assets"), { recursive: true });
|
|
64
|
-
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
65
|
-
writeFileSync(join(dist, "assets", "index-abc.js"), "console.log('spa')");
|
|
66
|
-
writeFileSync(join(dist, "assets", "index-abc.css"), ".x{color:red}");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
afterAll(() => {
|
|
70
|
-
rmSync(dist, { recursive: true, force: true });
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("serves the SPA shell at the mount root", async () => {
|
|
74
|
-
const res = serveSpa(dist, SPA_MOUNT);
|
|
75
|
-
expect(res.status).toBe(200);
|
|
76
|
-
expect(res.headers.get("content-type")).toContain("text/html");
|
|
77
|
-
expect(await res.text()).toContain("id=root");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("serves the SPA shell for a client-routed path (no extension)", async () => {
|
|
81
|
-
const res = serveSpa(dist, "/app/agents");
|
|
82
|
-
expect(res.status).toBe(200);
|
|
83
|
-
expect(res.headers.get("content-type")).toContain("text/html");
|
|
84
|
-
expect(await res.text()).toContain("id=root");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("serves a real JS asset with the right content-type", async () => {
|
|
88
|
-
const res = serveSpa(dist, "/app/assets/index-abc.js");
|
|
89
|
-
expect(res.status).toBe(200);
|
|
90
|
-
expect(res.headers.get("content-type")).toContain("javascript");
|
|
91
|
-
expect(await res.text()).toContain("spa");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("serves a real CSS asset", async () => {
|
|
95
|
-
const res = serveSpa(dist, "/app/assets/index-abc.css");
|
|
96
|
-
expect(res.status).toBe(200);
|
|
97
|
-
expect(res.headers.get("content-type")).toContain("css");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("an asset-shaped path that doesn't exist falls back to the SPA shell", async () => {
|
|
101
|
-
const res = serveSpa(dist, "/app/assets/missing.js");
|
|
102
|
-
expect(res.status).toBe(200);
|
|
103
|
-
expect(res.headers.get("content-type")).toContain("text/html");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("blocks path traversal out of dist/", async () => {
|
|
107
|
-
// A `..`-containing path never enters the asset branch → SPA shell (not the
|
|
108
|
-
// escaped file). The 404 belt-and-braces guards the loosened case.
|
|
109
|
-
const res = serveSpa(dist, "/app/../../etc/passwd");
|
|
110
|
-
// Either the shell (traversal filtered) or a 404 — never the escaped file.
|
|
111
|
-
expect([200, 404]).toContain(res.status);
|
|
112
|
-
if (res.status === 200) {
|
|
113
|
-
expect(res.headers.get("content-type")).toContain("text/html");
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
});
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { parseArgs, ArgError } from "../scripts/spawn-agent.ts";
|
|
3
|
-
import type { AgentChannelSpec } from "./sandbox/types.ts";
|
|
4
|
-
|
|
5
|
-
// These tests exercise ONLY the pure arg-parser (`parseArgs`) — no fs/tmux/mint
|
|
6
|
-
// side effect. The real-dep wiring (operator token, hub mint, sandbox, tmux) is
|
|
7
|
-
// covered by spawn-agent.test.ts against stubs; here we assert flags → the right
|
|
8
|
-
// AgentSpec, the --help short-circuit, and that bad args reject.
|
|
9
|
-
|
|
10
|
-
describe("parseArgs — name + channels + vault + egress + mounts → the right AgentSpec", () => {
|
|
11
|
-
test("a full invocation builds the complete spec", () => {
|
|
12
|
-
const { help, spec } = parseArgs([
|
|
13
|
-
"aaron-dev",
|
|
14
|
-
"--channel",
|
|
15
|
-
"aaron-dev",
|
|
16
|
-
"--channel",
|
|
17
|
-
"ops:read",
|
|
18
|
-
"--vault",
|
|
19
|
-
"default:read:#agent/message,#decision",
|
|
20
|
-
"--egress",
|
|
21
|
-
"registry.npmjs.org,github.com",
|
|
22
|
-
"--mount",
|
|
23
|
-
"/host/code:/work/code:ro",
|
|
24
|
-
"--mount",
|
|
25
|
-
"/host/cache:/work/cache:rw:shared-cache",
|
|
26
|
-
]);
|
|
27
|
-
expect(help).toBe(false);
|
|
28
|
-
expect(spec).toBeDefined();
|
|
29
|
-
expect(spec!.name).toBe("aaron-dev");
|
|
30
|
-
|
|
31
|
-
// Channels: first is the wake channel (write by default); second scoped read.
|
|
32
|
-
expect(spec!.channels).toEqual([
|
|
33
|
-
{ name: "aaron-dev" },
|
|
34
|
-
{ name: "ops", access: "read" },
|
|
35
|
-
] as AgentChannelSpec[]);
|
|
36
|
-
|
|
37
|
-
// Vault: name + access + tag-scope.
|
|
38
|
-
expect(spec!.vault).toEqual({
|
|
39
|
-
name: "default",
|
|
40
|
-
access: "read",
|
|
41
|
-
tags: ["#agent/message", "#decision"],
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
// Egress: additive host list, comma-split.
|
|
45
|
-
expect(spec!.egress).toEqual(["registry.npmjs.org", "github.com"]);
|
|
46
|
-
|
|
47
|
-
// Mounts: ro + rw-with-shared.
|
|
48
|
-
expect(spec!.mounts).toEqual([
|
|
49
|
-
{ hostPath: "/host/code", mountPath: "/work/code", mode: "ro" },
|
|
50
|
-
{ hostPath: "/host/cache", mountPath: "/work/cache", mode: "rw", shared: "shared-cache" },
|
|
51
|
-
]);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("a bare --channel defaults to write (back-compat single-threaded session)", () => {
|
|
55
|
-
const { spec } = parseArgs(["a", "--channel", "c"]);
|
|
56
|
-
expect(spec!.channels).toEqual([{ name: "c" }]);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("--channel <name>:write is explicit write", () => {
|
|
60
|
-
const { spec } = parseArgs(["a", "--channel", "c:write"]);
|
|
61
|
-
expect(spec!.channels).toEqual([{ name: "c", access: "write" }]);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("a minimal invocation (name + one channel) omits optional fields", () => {
|
|
65
|
-
const { spec } = parseArgs(["solo", "--channel", "solo"]);
|
|
66
|
-
expect(spec).toEqual({ name: "solo", channels: [{ name: "solo" }] });
|
|
67
|
-
expect(spec!.vault).toBeUndefined();
|
|
68
|
-
expect(spec!.egress).toBeUndefined();
|
|
69
|
-
expect(spec!.mounts).toBeUndefined();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("--vault without a tag list omits tags", () => {
|
|
73
|
-
const { spec } = parseArgs(["a", "--channel", "c", "--vault", "default:write"]);
|
|
74
|
-
expect(spec!.vault).toEqual({ name: "default", access: "write" });
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("the FIRST channel is the wake channel (order preserved)", () => {
|
|
78
|
-
const { spec } = parseArgs(["a", "--channel", "first", "--channel", "second"]);
|
|
79
|
-
expect(spec!.channels[0]).toEqual({ name: "first" });
|
|
80
|
-
expect(spec!.channels[1]).toEqual({ name: "second" });
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("defaults omit filesystem/network; --full-read + --restricted set them + keep --egress", () => {
|
|
84
|
-
const def = parseArgs(["a", "--channel", "c"]).spec!;
|
|
85
|
-
expect(def.filesystem).toBeUndefined(); // default = workspace (scoped reads)
|
|
86
|
-
expect(def.network).toBeUndefined(); // default = open
|
|
87
|
-
const { spec } = parseArgs(["a", "--channel", "c", "--egress", "x.com", "--full-read", "--restricted"]);
|
|
88
|
-
expect(spec!.filesystem).toBe("full");
|
|
89
|
-
expect(spec!.network).toBe("restricted");
|
|
90
|
-
expect(spec!.egress).toEqual(["x.com"]); // additive hosts kept (used under restricted)
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe("parseArgs — --help short-circuits", () => {
|
|
95
|
-
test("--help returns help:true and builds no spec", () => {
|
|
96
|
-
const r = parseArgs(["--help"]);
|
|
97
|
-
expect(r.help).toBe(true);
|
|
98
|
-
expect(r.spec).toBeUndefined();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("-h is the short alias", () => {
|
|
102
|
-
expect(parseArgs(["-h"]).help).toBe(true);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("--help wins even with other args present", () => {
|
|
106
|
-
const r = parseArgs(["name", "--channel", "c", "--help"]);
|
|
107
|
-
expect(r.help).toBe(true);
|
|
108
|
-
expect(r.spec).toBeUndefined();
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
describe("parseArgs — bad args reject", () => {
|
|
113
|
-
test("missing name", () => {
|
|
114
|
-
expect(() => parseArgs(["--channel", "c"])).toThrow(ArgError);
|
|
115
|
-
expect(() => parseArgs(["--channel", "c"])).toThrow(/missing required <name>/);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("no channels", () => {
|
|
119
|
-
expect(() => parseArgs(["just-a-name"])).toThrow(/at least one --channel/);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("a second positional is rejected", () => {
|
|
123
|
-
expect(() => parseArgs(["a", "b", "--channel", "c"])).toThrow(/only one <name>/);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("an unknown flag is rejected", () => {
|
|
127
|
-
expect(() => parseArgs(["a", "--channel", "c", "--bogus"])).toThrow(/unknown flag "--bogus"/);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("a flag with a missing value is rejected", () => {
|
|
131
|
-
expect(() => parseArgs(["a", "--channel"])).toThrow(/--channel: expected a value/);
|
|
132
|
-
// A following flag is NOT consumed as the value.
|
|
133
|
-
expect(() => parseArgs(["a", "--channel", "--vault"])).toThrow(/--channel: expected a value/);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("a bad channel access verb is rejected", () => {
|
|
137
|
-
expect(() => parseArgs(["a", "--channel", "c:admin"])).toThrow(/access must be "read" or "write"/);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("an over-segmented channel is rejected", () => {
|
|
141
|
-
expect(() => parseArgs(["a", "--channel", "c:read:extra"])).toThrow(/extra ":"/);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("a bad vault shape is rejected", () => {
|
|
145
|
-
expect(() => parseArgs(["a", "--channel", "c", "--vault", "default"])).toThrow(
|
|
146
|
-
/expected <name>:<read\|write>/,
|
|
147
|
-
);
|
|
148
|
-
expect(() => parseArgs(["a", "--channel", "c", "--vault", "default:admin"])).toThrow(
|
|
149
|
-
/access must be "read" or "write"/,
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test("--vault given twice is rejected", () => {
|
|
154
|
-
expect(() =>
|
|
155
|
-
parseArgs(["a", "--channel", "c", "--vault", "default:read", "--vault", "other:write"]),
|
|
156
|
-
).toThrow(/--vault may only be given once/);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("a bad mount shape is rejected", () => {
|
|
160
|
-
expect(() => parseArgs(["a", "--channel", "c", "--mount", "/h:/m"])).toThrow(
|
|
161
|
-
/expected <hostPath:mountPath:ro\|rw/,
|
|
162
|
-
);
|
|
163
|
-
expect(() => parseArgs(["a", "--channel", "c", "--mount", "/h:/m:rx"])).toThrow(
|
|
164
|
-
/mode must be "ro" or "rw"/,
|
|
165
|
-
);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test("an empty egress list parses to no egress (not an error)", () => {
|
|
169
|
-
const { spec } = parseArgs(["a", "--channel", "c", "--egress", ""]);
|
|
170
|
-
expect(spec!.egress).toBeUndefined();
|
|
171
|
-
});
|
|
172
|
-
});
|