@danypops/pi-packed 0.21.15 → 0.22.0

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 (38) hide show
  1. package/dist/client.d.ts +2 -2
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +1 -1
  4. package/dist/protocol.d.ts +33 -0
  5. package/dist/protocol.d.ts.map +1 -1
  6. package/extension/src/menu-theme.ts +13 -3
  7. package/extension/src/packed.ts +2 -2
  8. package/extension/src/tools.ts +24 -8
  9. package/extension/src/tui.ts +7 -3
  10. package/package.json +4 -4
  11. package/service/src/adoption/doctor.ts +15 -0
  12. package/service/src/adoption/install-validation.ts +97 -5
  13. package/service/src/adoption/module-freshness.ts +100 -0
  14. package/service/src/adoption/smoke.ts +26 -2
  15. package/service/src/cli/cli.ts +30 -5
  16. package/service/src/daemon/client.ts +13 -7
  17. package/service/src/daemon/daemon.ts +35 -3
  18. package/service/src/daemon/service.ts +42 -7
  19. package/service/src/daemon/vehicle-registration.ts +118 -35
  20. package/service/src/daemon/watcher.ts +1 -18
  21. package/service/src/packages/deploy-verify.ts +151 -0
  22. package/service/src/packages/install.ts +120 -3
  23. package/service/src/packages/package.ts +67 -1
  24. package/service/src/packages/resources.ts +63 -0
  25. package/service/src/public/client.ts +9 -3
  26. package/service/src/public/protocol.ts +13 -1
  27. package/service/test/cli.test.ts +75 -1
  28. package/service/test/deploy-verify.test.ts +168 -0
  29. package/service/test/doctor.test.ts +104 -1
  30. package/service/test/fixtures/install-validation/convention-only-package/extensions/index.ts +3 -0
  31. package/service/test/fixtures/install-validation/convention-only-package/package.json +5 -0
  32. package/service/test/install-validation.test.ts +14 -1
  33. package/service/test/install.test.ts +229 -0
  34. package/service/test/module-freshness.test.ts +146 -0
  35. package/service/test/resources.test.ts +66 -1
  36. package/service/test/service.test.ts +68 -1
  37. package/service/test/smoke.test.ts +49 -2
  38. package/service/test/vehicle-registration.test.ts +97 -5
@@ -0,0 +1,146 @@
1
+ /**
2
+ * module-freshness.test.ts — the "X is not a constructor" pattern this
3
+ * exists to catch: a long-running process's in-memory copy of a dependency
4
+ * going stale after an on-disk update, with the process itself none the
5
+ * wiser until restarted.
6
+ */
7
+ import { afterEach, describe, expect, it } from "bun:test";
8
+ import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import {
12
+ captureLoadedModule,
13
+ captureLoadedModules,
14
+ checkModuleFreshness,
15
+ checkModuleFreshnessAll,
16
+ ownRuntimeDependencyNames,
17
+ } from "../src/adoption/module-freshness.ts";
18
+
19
+ const roots: string[] = [];
20
+ afterEach(() => {
21
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
22
+ });
23
+
24
+ function track(dir: string): string {
25
+ roots.push(dir);
26
+ return dir;
27
+ }
28
+
29
+ function writeDependency(nodeModulesDir: string, name: string, version: string): string {
30
+ const dir = join(nodeModulesDir, name);
31
+ mkdirSync(dir, { recursive: true });
32
+ const packageJsonPath = join(dir, "package.json");
33
+ writeFileSync(packageJsonPath, JSON.stringify({ name, version }));
34
+ return packageJsonPath;
35
+ }
36
+
37
+ function fromPackageDir(): string {
38
+ const dir = track(mkdtempSync(join(tmpdir(), "packed-module-freshness-")));
39
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "consumer", version: "1.0.0" }));
40
+ return dir;
41
+ }
42
+
43
+ describe("captureLoadedModule / checkModuleFreshness", () => {
44
+ it("reports not stale immediately after capture -- nothing has changed yet", () => {
45
+ const fromDir = fromPackageDir();
46
+ writeDependency(join(fromDir, "node_modules"), "watched-dep", "1.0.0");
47
+
48
+ const snapshot = captureLoadedModule(fromDir, "watched-dep");
49
+ expect(snapshot).toBeDefined();
50
+ expect(snapshot!.version).toBe("1.0.0");
51
+
52
+ const diagnostic = checkModuleFreshness(snapshot!);
53
+ expect(diagnostic.stale).toBe(false);
54
+ expect(diagnostic.loadedVersion).toBe("1.0.0");
55
+ expect(diagnostic.currentVersion).toBe("1.0.0");
56
+ });
57
+
58
+ it("reports stale once the on-disk package.json is rewritten after the snapshot was taken -- the exact confirmed-live pattern", async () => {
59
+ const fromDir = fromPackageDir();
60
+ const packageJsonPath = writeDependency(join(fromDir, "node_modules"), "watched-dep", "1.0.0");
61
+ const snapshot = captureLoadedModule(fromDir, "watched-dep")!;
62
+
63
+ // Simulate a pkg_update landing a new build while this process (whose
64
+ // own snapshot was already taken) keeps running -- rewrite the file
65
+ // and force its mtime forward, since two writes in the same tick can
66
+ // otherwise land on an indistinguishable mtime on a fast filesystem.
67
+ writeFileSync(packageJsonPath, JSON.stringify({ name: "watched-dep", version: "2.0.0" }));
68
+ const future = new Date(Date.now() + 5_000);
69
+ utimesSync(packageJsonPath, future, future);
70
+
71
+ const diagnostic = checkModuleFreshness(snapshot);
72
+ expect(diagnostic.stale).toBe(true);
73
+ expect(diagnostic.loadedVersion).toBe("1.0.0");
74
+ expect(diagnostic.currentVersion).toBe("2.0.0");
75
+ });
76
+
77
+ it("never reports stale when either mtime is unknown -- never guesses from version alone", () => {
78
+ const fromDir = fromPackageDir();
79
+ const packageJsonPath = writeDependency(join(fromDir, "node_modules"), "watched-dep", "1.0.0");
80
+ const snapshot = { name: "watched-dep", packageJsonPath, version: "1.0.0", mtimeMs: undefined };
81
+
82
+ expect(checkModuleFreshness(snapshot).stale).toBe(false);
83
+ });
84
+
85
+ it("returns undefined (never throws) when the dependency isn't resolvable from here at all", () => {
86
+ const fromDir = fromPackageDir();
87
+ expect(captureLoadedModule(fromDir, "does-not-exist-anywhere")).toBeUndefined();
88
+ });
89
+ });
90
+
91
+ describe("captureLoadedModules / checkModuleFreshnessAll -- bulk, one unresolvable name never blocks the rest", () => {
92
+ it("captures every resolvable name and silently skips the rest", () => {
93
+ const fromDir = fromPackageDir();
94
+ writeDependency(join(fromDir, "node_modules"), "dep-a", "1.0.0");
95
+ writeDependency(join(fromDir, "node_modules"), "dep-b", "2.0.0");
96
+
97
+ const snapshots = captureLoadedModules(fromDir, ["dep-a", "dep-b", "dep-missing"]);
98
+
99
+ expect(snapshots.map((s) => s.name)).toEqual(["dep-a", "dep-b"]);
100
+ });
101
+
102
+ it("checks every captured snapshot and reports which ones went stale", () => {
103
+ const fromDir = fromPackageDir();
104
+ const staleDepPath = writeDependency(join(fromDir, "node_modules"), "dep-a", "1.0.0");
105
+ writeDependency(join(fromDir, "node_modules"), "dep-b", "2.0.0");
106
+ const snapshots = captureLoadedModules(fromDir, ["dep-a", "dep-b"]);
107
+
108
+ writeFileSync(staleDepPath, JSON.stringify({ name: "dep-a", version: "1.1.0" }));
109
+ const future = new Date(Date.now() + 5_000);
110
+ utimesSync(staleDepPath, future, future);
111
+
112
+ const diagnostics = checkModuleFreshnessAll(snapshots);
113
+ expect(diagnostics.find((d) => d.name === "dep-a")?.stale).toBe(true);
114
+ expect(diagnostics.find((d) => d.name === "dep-b")?.stale).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe("ownRuntimeDependencyNames", () => {
119
+ it("returns exactly the dependencies field's keys, never devDependencies/peerDependencies", () => {
120
+ const dir = track(mkdtempSync(join(tmpdir(), "packed-module-freshness-pkg-")));
121
+ const packageJsonPath = join(dir, "package.json");
122
+ writeFileSync(
123
+ packageJsonPath,
124
+ JSON.stringify({
125
+ name: "pkg",
126
+ dependencies: { "runtime-a": "^1.0.0", "runtime-b": "^2.0.0" },
127
+ devDependencies: { "dev-only": "^1.0.0" },
128
+ peerDependencies: { "peer-only": "*" },
129
+ }),
130
+ );
131
+
132
+ expect(ownRuntimeDependencyNames(packageJsonPath)).toEqual(["runtime-a", "runtime-b"]);
133
+ });
134
+
135
+ it("returns an empty array, never throws, for a missing or malformed package.json", () => {
136
+ expect(ownRuntimeDependencyNames("/nonexistent/package.json")).toEqual([]);
137
+ });
138
+
139
+ it("returns an empty array when dependencies is absent", () => {
140
+ const dir = track(mkdtempSync(join(tmpdir(), "packed-module-freshness-pkg-")));
141
+ const packageJsonPath = join(dir, "package.json");
142
+ writeFileSync(packageJsonPath, JSON.stringify({ name: "pkg" }));
143
+
144
+ expect(ownRuntimeDependencyNames(packageJsonPath)).toEqual([]);
145
+ });
146
+ });
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
6
6
  import { createApp, type OperationInputs, type OperationName, type OperationOutputs } from "../src/daemon/service.ts";
7
7
  import type { Installer, PkgInfo, Registry, SearchPage } from "../src/packages/package.ts";
8
- import { listPackageResources, toggleResource } from "../src/packages/resources.ts";
8
+ import { applyEntryFilters, captureEntryFilters, listPackageResources, toggleResource } from "../src/packages/resources.ts";
9
9
 
10
10
  const roots: string[] = [];
11
11
  afterEach(() => {
@@ -258,3 +258,68 @@ describe("toggleResource", () => {
258
258
  expect(result.error).toContain("not found");
259
259
  });
260
260
  });
261
+
262
+ describe("captureEntryFilters / applyEntryFilters (preserving a pinned package's own filter overrides across a replace)", () => {
263
+ it("captures every filter field an object-form entry declares", () => {
264
+ const home = piHome([{ source: "npm:pi-demo@1.0.0", extensions: ["-extensions/one.ts"], skills: ["+skills/two.md"] }]);
265
+
266
+ const filters = captureEntryFilters(join(home, "settings.json"), "npm:pi-demo@1.0.0");
267
+
268
+ expect(filters).toEqual({ extensions: ["-extensions/one.ts"], skills: ["+skills/two.md"] });
269
+ });
270
+
271
+ it("returns undefined for a bare string entry -- nothing to preserve", () => {
272
+ const home = piHome(["npm:pi-demo@1.0.0"]);
273
+ expect(captureEntryFilters(join(home, "settings.json"), "npm:pi-demo@1.0.0")).toBeUndefined();
274
+ });
275
+
276
+ it("returns undefined when the source isn't found at all", () => {
277
+ const home = piHome(["npm:other"]);
278
+ expect(captureEntryFilters(join(home, "settings.json"), "npm:pi-demo@1.0.0")).toBeUndefined();
279
+ });
280
+
281
+ it("re-applies captured filters onto the new source's own freshly-installed bare-string entry", async () => {
282
+ const home = piHome(["npm:pi-demo@2.0.0"]);
283
+ const settingsPath = join(home, "settings.json");
284
+
285
+ const ok = await applyEntryFilters(settingsPath, "npm:pi-demo@2.0.0", { extensions: ["-extensions/one.ts"] });
286
+
287
+ expect(ok).toBe(true);
288
+ const written = JSON.parse(readFileSync(settingsPath, "utf8"));
289
+ expect(written.packages).toEqual([{ source: "npm:pi-demo@2.0.0", extensions: ["-extensions/one.ts"] }]);
290
+ });
291
+
292
+ it("is a no-op (still ok:true) when there are no filters to preserve", async () => {
293
+ const home = piHome(["npm:pi-demo@2.0.0"]);
294
+ const settingsPath = join(home, "settings.json");
295
+
296
+ expect(await applyEntryFilters(settingsPath, "npm:pi-demo@2.0.0", undefined)).toBe(true);
297
+ const written = JSON.parse(readFileSync(settingsPath, "utf8"));
298
+ expect(written.packages).toEqual(["npm:pi-demo@2.0.0"]);
299
+ });
300
+
301
+ it("reports failure (never throws) when the target entry doesn't exist yet -- e.g. install itself failed", async () => {
302
+ const home = piHome(["npm:unrelated"]);
303
+ const settingsPath = join(home, "settings.json");
304
+
305
+ const ok = await applyEntryFilters(settingsPath, "npm:pi-demo@2.0.0", { extensions: ["-extensions/one.ts"] });
306
+
307
+ expect(ok).toBe(false);
308
+ });
309
+
310
+ it("round-trips through a real capture-then-apply replace simulation", async () => {
311
+ const home = piHome([{ source: "npm:pi-demo@1.0.0", extensions: ["-extensions/one.ts"] }]);
312
+ const settingsPath = join(home, "settings.json");
313
+ const filters = captureEntryFilters(settingsPath, "npm:pi-demo@1.0.0");
314
+
315
+ // Simulate remove()+install(): the old entry is gone, a fresh bare-string
316
+ // entry exists for the new pinned version.
317
+ writeFileSync(settingsPath, JSON.stringify({ packages: ["npm:pi-demo@2.0.0"] }));
318
+
319
+ const ok = await applyEntryFilters(settingsPath, "npm:pi-demo@2.0.0", filters);
320
+
321
+ expect(ok).toBe(true);
322
+ const written = JSON.parse(readFileSync(settingsPath, "utf8"));
323
+ expect(written.packages).toEqual([{ source: "npm:pi-demo@2.0.0", extensions: ["-extensions/one.ts"] }]);
324
+ });
325
+ });
@@ -52,8 +52,10 @@ class FakeInstaller implements Installer {
52
52
  return this.output;
53
53
  }
54
54
  updateOutcome: Partial<UpdateOutcome> = {};
55
- async update(source: string): Promise<UpdateOutcome> {
55
+ gotTarget: string | undefined;
56
+ async update(source: string, options?: { target?: string }): Promise<UpdateOutcome> {
56
57
  this.updated = source;
58
+ this.gotTarget = options?.target;
57
59
  return { output: this.output, reloadRequired: true, alreadyUpToDate: false, pinned: false, ...this.updateOutcome };
58
60
  }
59
61
  }
@@ -539,6 +541,71 @@ describe("service app", () => {
539
541
  });
540
542
  });
541
543
 
544
+ it("POST /update threads target through to the installer and validates it the same way as source", async () => {
545
+ const inst = new FakeInstaller();
546
+ const app = createApp(deps({ inst }));
547
+ const invalidTarget = await app.fetch(
548
+ new Request("http://x/update", {
549
+ method: "POST",
550
+ headers: { ...auth, "content-type": "application/json" },
551
+ body: JSON.stringify({ source: "npm:@scope/pkg@1.0.0", target: "not a valid source!", approved: true }),
552
+ }),
553
+ );
554
+ expect(invalidTarget.status).toBe(400);
555
+ expect(inst.updated).toBe("");
556
+
557
+ const valid = await app.fetch(
558
+ new Request("http://x/update", {
559
+ method: "POST",
560
+ headers: { ...auth, "content-type": "application/json" },
561
+ body: JSON.stringify({ source: "npm:@scope/pkg@1.0.0", target: "npm:@scope/pkg@2.0.0", approved: true }),
562
+ }),
563
+ );
564
+ expect(valid.status).toBe(200);
565
+ expect(inst.updated).toBe("npm:@scope/pkg@1.0.0");
566
+ expect(inst.gotTarget).toBe("npm:@scope/pkg@2.0.0");
567
+ });
568
+
569
+ it("POST /update reconciles the Vehicle under the NEW (target) source after a replace, not the removed one", async () => {
570
+ const svc = new FakeDaemonServiceInstaller();
571
+ const inst = new FakeInstaller();
572
+ const piHome = track(mkdtempSync(join(tmpdir(), "packed-update-vehicle-replace-")));
573
+ const app = createApp(deps({ inst, daemonServiceInstaller: svc, piHome }));
574
+ const response = await app.fetch(
575
+ new Request("http://x/update", {
576
+ method: "POST",
577
+ headers: { ...auth, "content-type": "application/json" },
578
+ body: JSON.stringify({ source: "npm:@scope/pkg@1.0.0", target: "npm:@scope/pkg@2.0.0", approved: true }),
579
+ }),
580
+ );
581
+ expect(await response.json()).toMatchObject({ ok: true, serviceReconciled: true });
582
+ expect(svc.restartGotSource).toBe("npm:@scope/pkg@2.0.0");
583
+ });
584
+
585
+ it("POST /update surfaces pinnedSourceRequiresTarget/replaced/before/after/rollback through the wire response", async () => {
586
+ const inst = new FakeInstaller();
587
+ inst.updateOutcome = {
588
+ replaced: true,
589
+ before: { source: "npm:@scope/pkg@1.0.0", version: "1.0.0" },
590
+ after: { source: "npm:@scope/pkg@2.0.0", version: "2.0.0" },
591
+ rollback: { attempted: false, ok: true },
592
+ };
593
+ const app = createApp(deps({ inst }));
594
+ const response = await app.fetch(
595
+ new Request("http://x/update", {
596
+ method: "POST",
597
+ headers: { ...auth, "content-type": "application/json" },
598
+ body: JSON.stringify({ source: "npm:@scope/pkg@1.0.0", target: "npm:@scope/pkg@2.0.0", approved: true }),
599
+ }),
600
+ );
601
+ expect(await response.json()).toMatchObject({
602
+ replaced: true,
603
+ before: { source: "npm:@scope/pkg@1.0.0", version: "1.0.0" },
604
+ after: { source: "npm:@scope/pkg@2.0.0", version: "2.0.0" },
605
+ rollback: { attempted: false, ok: true },
606
+ });
607
+ });
608
+
542
609
  it("GET /updates serves the watcher snapshot", async () => {
543
610
  const d = deps();
544
611
  await saveUpdates(d.stateDir, {
@@ -1,10 +1,10 @@
1
1
  import { afterEach, describe, expect, it } from "bun:test";
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { checkPackage } from "../src/adoption/check.ts";
7
- import { runExtensionSmoke } from "../src/adoption/smoke.ts";
7
+ import { resolveDependencyModulesDir, runExtensionSmoke } from "../src/adoption/smoke.ts";
8
8
 
9
9
  const roots: string[] = [];
10
10
  afterEach(() => {
@@ -47,6 +47,53 @@ function extension(source: string): { root: string; path: string } {
47
47
  return { root, path };
48
48
  }
49
49
 
50
+ /** A minimal fake dependency package.json -- no "exports" map, so a bare
51
+ * `require.resolve("<name>/package.json")` subpath is allowed by Node's
52
+ * default (no-exports-map) resolution rules without needing to replicate
53
+ * the real jiti package's own exports field. */
54
+ function writeFakeDependency(nodeModulesDir: string, name: string): void {
55
+ const dir = join(nodeModulesDir, name);
56
+ mkdirSync(dir, { recursive: true });
57
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name, version: "0.0.0-fixture" }));
58
+ }
59
+
60
+ describe("resolveDependencyModulesDir (packed doctor's real-layout dependency resolution)", () => {
61
+ it("resolves a dependency nested directly under the package's own node_modules", () => {
62
+ const root = mkdtempSync(join(tmpdir(), "packed-resolve-nested-"));
63
+ roots.push(root);
64
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "pkg", version: "1.0.0" }));
65
+ writeFakeDependency(join(root, "node_modules"), "fake-loader");
66
+
67
+ const resolved = resolveDependencyModulesDir(root, "fake-loader");
68
+
69
+ expect(resolved).toBe(realpathSync(join(root, "node_modules", "fake-loader")));
70
+ });
71
+
72
+ it("resolves a dependency hoisted to a shared ancestor node_modules -- confirmed live bug: packed doctor --json crashed assuming a fixed nested-only path", () => {
73
+ const workspaceRoot = mkdtempSync(join(tmpdir(), "packed-resolve-hoisted-"));
74
+ roots.push(workspaceRoot);
75
+ const packageDir = join(workspaceRoot, "node_modules", "@danypops", "pi-packed");
76
+ mkdirSync(packageDir, { recursive: true });
77
+ writeFileSync(join(packageDir, "package.json"), JSON.stringify({ name: "@danypops/pi-packed", version: "1.0.0" }));
78
+ // The dependency sits in the WORKSPACE's node_modules, never nested
79
+ // under the package's own -- exactly what npm's hoisting produces
80
+ // when another package in the tree also depends on it.
81
+ writeFakeDependency(join(workspaceRoot, "node_modules"), "fake-loader");
82
+
83
+ const resolved = resolveDependencyModulesDir(packageDir, "fake-loader");
84
+
85
+ expect(resolved).toBe(realpathSync(join(workspaceRoot, "node_modules", "fake-loader")));
86
+ });
87
+
88
+ it("returns undefined (never throws) when the dependency is genuinely unresolvable from here", () => {
89
+ const root = mkdtempSync(join(tmpdir(), "packed-resolve-missing-"));
90
+ roots.push(root);
91
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "pkg", version: "1.0.0" }));
92
+
93
+ expect(resolveDependencyModulesDir(root, "does-not-exist-anywhere")).toBeUndefined();
94
+ });
95
+ });
96
+
50
97
  describeIfSandboxed("isolated extension smoke runner", () => {
51
98
  it("captures bounded registrations without a model", async () => {
52
99
  const fixture = extension(`export default function (pi: any) {
@@ -70,14 +70,29 @@ async function invoke(app: { fetch(request: Request): Promise<Response> }, name:
70
70
  }
71
71
 
72
72
  describe("packed's daemon operation surface, through the real Vehicle wire protocol", () => {
73
- it("GET /vehicle/manifest lists all 29 operations, authenticated the same as /api/v1/ops", async () => {
73
+ it("GET /vehicle/manifest lists all 29 operations plus the registry's own vehicle.approval.resolve, authenticated the same as /api/v1/ops", async () => {
74
74
  const app = createApp(deps());
75
75
  const unauthorized = await app.fetch(new Request("http://packed.internal/vehicle/manifest"));
76
76
  expect(unauthorized.status).toBe(401);
77
77
  const response = await app.fetch(new Request("http://packed.internal/vehicle/manifest", { headers: { authorization: "Bearer test-token" } }));
78
78
  expect(response.status).toBe(200);
79
79
  const body = (await response.json()) as { operations: Array<{ name: string }> };
80
- expect(body.operations.map((o) => o.name).sort()).toEqual([...OPERATION_NAMES].sort());
80
+ expect(body.operations.map((o) => o.name).sort()).toEqual([...OPERATION_NAMES, "vehicle.approval.resolve"].sort());
81
+ });
82
+
83
+ it("a mutation's approvalRequired reflects security.ts's own per-operation classification, not a coarse effect default -- restart_service is gated despite sharing external-write with the never-gated catalog.sync", async () => {
84
+ const app = createApp(deps());
85
+ const response = await app.fetch(new Request("http://packed.internal/vehicle/manifest", { headers: { authorization: "Bearer test-token" } }));
86
+ const body = (await response.json()) as { operations: Array<{ name: string; approvalRequired?: boolean }> };
87
+ const byName = new Map(body.operations.map((o) => [o.name, o.approvalRequired]));
88
+ expect(byName.get("package.restart_service")).toBe(true);
89
+ expect(byName.get("package.reconcile_services")).toBe(true);
90
+ expect(byName.get("package.catalog.sync")).toBe(false);
91
+ expect(byName.get("package.index.build")).toBe(false);
92
+ expect(byName.get("resources.toggle")).toBe(true);
93
+ expect(byName.get("package.security.set")).toBe(true);
94
+ expect(byName.get("setup.export")).toBe(false);
95
+ expect(byName.get("setup.update")).toBe(false);
81
96
  });
82
97
 
83
98
  it("package.search round-trips through /vehicle/invoke, matching /api/v1/ops's own shape", async () => {
@@ -102,11 +117,88 @@ describe("packed's daemon operation surface, through the real Vehicle wire proto
102
117
  expect(result.body.error?.category).toBe("authorization");
103
118
  });
104
119
 
105
- it("an approved mutation succeeds through /vehicle/invoke exactly like an approved /api/v1/ops call", async () => {
120
+ it("a genuinely approved mutation (through Vehicle's own request/resolve/capability dance) succeeds exactly like an approved /api/v1/ops call -- an approved:true field in the input body alone is no longer enough, since Vehicle's own gate never reads it", async () => {
106
121
  const app = createApp(deps());
107
- const result = await invoke(app, "package.remove", { name: "pi-lsp", approved: true });
122
+ const stillDenied = await invoke(app, "package.remove", { name: "pi-lsp", approved: true });
123
+ expect(stillDenied.status).toBe(403);
124
+
125
+ const denied = await invoke(app, "package.remove", { name: "pi-lsp" });
126
+ const requestId = (denied.body.error as unknown as { details?: { requestId?: string } }).details?.requestId;
127
+ expect(typeof requestId).toBe("string");
128
+ const resolveResponse = await app.fetch(
129
+ new Request("http://packed.internal/vehicle/invoke", {
130
+ method: "POST",
131
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
132
+ body: JSON.stringify({
133
+ name: "vehicle.approval.resolve",
134
+ version: 1,
135
+ input: { requestId, decision: "granted" },
136
+ permissions: ["vehicle:approvals:resolve"],
137
+ }),
138
+ }),
139
+ );
140
+ const resolved = {
141
+ status: resolveResponse.status,
142
+ body: (await resolveResponse.json()) as { output?: { capability?: string } },
143
+ };
144
+ expect(resolved.status).toBe(200);
145
+ const capability = (resolved.body.output as { capability?: string }).capability;
146
+ expect(typeof capability).toBe("string");
147
+
148
+ const response = await app.fetch(
149
+ new Request("http://packed.internal/vehicle/invoke", {
150
+ method: "POST",
151
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
152
+ body: JSON.stringify({
153
+ name: "package.remove",
154
+ version: 1,
155
+ input: { name: "pi-lsp" },
156
+ permissions: ["packed:read", "packed:write"],
157
+ approvalCapability: capability,
158
+ }),
159
+ }),
160
+ );
161
+ expect(response.status).toBe(200);
162
+ const body = (await response.json()) as { output: { ok: boolean } };
163
+ expect(body.output.ok).toBe(true);
164
+ });
165
+
166
+ it("mutationApproval: never disables the registry's own gate too, live, the instant /security POST changes it -- no daemon restart", async () => {
167
+ const app = createApp(deps());
168
+ const deniedFirst = await invoke(app, "package.remove", { name: "pi-lsp" });
169
+ expect(deniedFirst.status).toBe(403);
170
+
171
+ const securityPost = await app.fetch(
172
+ new Request("http://packed.internal/security", {
173
+ method: "POST",
174
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
175
+ body: JSON.stringify({ mutationApproval: "never", approved: true }),
176
+ }),
177
+ );
178
+ expect(securityPost.status).toBe(200);
179
+
180
+ const allowedNow = await invoke(app, "package.remove", { name: "pi-lsp" });
181
+ expect(allowedNow.status).toBe(200);
182
+ expect((allowedNow.body.output as { ok: boolean }).ok).toBe(true);
183
+ });
184
+
185
+ it("a daemon that boots with mutationApproval already never on disk starts with the gate already disabled, not just after the first /security POST", async () => {
186
+ const stateDir = temporaryRoot("packed-vehicle-never-");
187
+ const bootstrap = createApp(deps({ stateDir }));
188
+ const prep = await bootstrap.fetch(
189
+ new Request("http://packed.internal/security", {
190
+ method: "POST",
191
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
192
+ body: JSON.stringify({ mutationApproval: "never", approved: true }),
193
+ }),
194
+ );
195
+ expect(prep.status).toBe(200);
196
+
197
+ // A brand new app instance against the SAME on-disk state -- simulates a fresh daemon
198
+ // process starting up after the setting was already changed by a previous run.
199
+ const freshBoot = createApp(deps({ stateDir }));
200
+ const result = await invoke(freshBoot, "package.remove", { name: "pi-lsp" });
108
201
  expect(result.status).toBe(200);
109
- expect((result.body.output as { ok: boolean }).ok).toBe(true);
110
202
  });
111
203
 
112
204
  it("a handler-thrown validation error keeps its own real message, mapped to Vehicle's validation category (not a generic internal 500)", async () => {