@openparachute/agent 0.2.3-rc.2 → 0.2.3-rc.4

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.
Files changed (58) hide show
  1. package/package.json +4 -1
  2. package/src/transports/vault.ts +19 -1
  3. package/web/ui/dist/assets/{index-5KEwEhfi.js → index-0e7eQymr.js} +1 -1
  4. package/web/ui/dist/assets/index-tvKbxee4.css +1 -0
  5. package/web/ui/dist/index.html +2 -2
  6. package/src/_parked/interactive-spawn.test.ts +0 -324
  7. package/src/_parked/interactive-spawn.ts +0 -701
  8. package/src/agent-defs.test.ts +0 -1504
  9. package/src/agent-mcp-config.test.ts +0 -115
  10. package/src/agents.test.ts +0 -360
  11. package/src/auth.test.ts +0 -46
  12. package/src/backends/attached-queue.test.ts +0 -376
  13. package/src/backends/programmatic.test.ts +0 -1715
  14. package/src/backends/registry.test.ts +0 -1494
  15. package/src/backends/stream-json.test.ts +0 -570
  16. package/src/channel-backend-wiring.test.ts +0 -237
  17. package/src/credentials.test.ts +0 -274
  18. package/src/cron.test.ts +0 -342
  19. package/src/daemon-agent-def-api.test.ts +0 -166
  20. package/src/daemon-agent-defs-api.test.ts +0 -953
  21. package/src/daemon-agent-env-api.test.ts +0 -338
  22. package/src/daemon-attached-queue-store.test.ts +0 -65
  23. package/src/daemon-config-api.test.ts +0 -962
  24. package/src/daemon-jobs-api.test.ts +0 -271
  25. package/src/daemon-vault-chat.test.ts +0 -250
  26. package/src/daemon.test.ts +0 -746
  27. package/src/def-vaults.test.ts +0 -136
  28. package/src/delivery-state.test.ts +0 -110
  29. package/src/effective-env.test.ts +0 -114
  30. package/src/grants.test.ts +0 -638
  31. package/src/hub-jwt.test.ts +0 -161
  32. package/src/jobs.test.ts +0 -245
  33. package/src/mcp-http.test.ts +0 -265
  34. package/src/mint-token.test.ts +0 -152
  35. package/src/module-manifest.test.ts +0 -158
  36. package/src/programmatic-wiring.test.ts +0 -838
  37. package/src/registry.test.ts +0 -227
  38. package/src/resolve-port.test.ts +0 -64
  39. package/src/routing.test.ts +0 -184
  40. package/src/runner.test.ts +0 -506
  41. package/src/sandbox/config.test.ts +0 -150
  42. package/src/sandbox/egress.test.ts +0 -113
  43. package/src/sandbox/live-seatbelt.test.ts +0 -277
  44. package/src/sandbox/mounts.test.ts +0 -154
  45. package/src/sandbox/sandbox.test.ts +0 -168
  46. package/src/services-manifest.test.ts +0 -106
  47. package/src/spa-serve.test.ts +0 -116
  48. package/src/spawn-agent-cli.test.ts +0 -172
  49. package/src/spawn-agent.test.ts +0 -1218
  50. package/src/spawn-deps.test.ts +0 -54
  51. package/src/terminal-assets.test.ts +0 -50
  52. package/src/terminal.test.ts +0 -530
  53. package/src/transports/http-ui.test.ts +0 -455
  54. package/src/transports/telegram.test.ts +0 -174
  55. package/src/transports/vault.test.ts +0 -2012
  56. package/src/ui-kit.test.ts +0 -178
  57. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  58. 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
- });
@@ -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
- });