@danypops/pi-packed 0.19.9 → 0.19.10

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 (87) hide show
  1. package/dist/client.d.ts +109 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +1 -0
  4. package/dist/protocol.d.ts +221 -0
  5. package/dist/protocol.d.ts.map +1 -0
  6. package/dist/protocol.js +1 -0
  7. package/extension/src/approval/permission.ts +1 -1
  8. package/extension/src/packed.ts +2 -2
  9. package/extension/src/tabs/security-tui.ts +1 -1
  10. package/extension/src/tool-output.ts +1 -1
  11. package/package.json +31 -8
  12. package/service/schema/pi-setup-v1.schema.json +70 -0
  13. package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
  14. package/service/src/adoption/advisories.ts +268 -0
  15. package/service/src/adoption/check.ts +872 -0
  16. package/service/src/adoption/commit-freshness.ts +167 -0
  17. package/service/src/adoption/doctor.ts +135 -0
  18. package/service/src/adoption/install-validation.ts +187 -0
  19. package/service/src/adoption/pack.ts +291 -0
  20. package/service/src/adoption/score.ts +466 -0
  21. package/service/src/adoption/smoke-child.ts +113 -0
  22. package/service/src/adoption/smoke.ts +282 -0
  23. package/service/src/cli/cli.ts +926 -0
  24. package/service/src/daemon/cleanup.ts +76 -0
  25. package/service/src/daemon/client.ts +412 -0
  26. package/service/src/daemon/daemon-service.ts +249 -0
  27. package/service/src/daemon/daemon.ts +110 -0
  28. package/service/src/daemon/service.ts +664 -0
  29. package/service/src/daemon/watcher.ts +92 -0
  30. package/service/src/index/build-index.ts +256 -0
  31. package/service/src/packages/catalog.ts +61 -0
  32. package/service/src/packages/db.ts +224 -0
  33. package/service/src/packages/install.ts +60 -0
  34. package/service/src/packages/installed.ts +123 -0
  35. package/service/src/packages/package.ts +141 -0
  36. package/service/src/packages/resources.ts +203 -0
  37. package/service/src/pi/pi-version.ts +171 -0
  38. package/service/src/public/atomic-json.ts +32 -0
  39. package/service/src/public/client.ts +277 -0
  40. package/service/src/public/protocol.ts +169 -0
  41. package/service/src/publish/publish.ts +855 -0
  42. package/service/src/registry/registry.ts +246 -0
  43. package/service/src/security/security.ts +128 -0
  44. package/service/src/self-update/self-update.ts +148 -0
  45. package/service/src/setup/setup.ts +761 -0
  46. package/service/src/shared/atomic-json.ts +33 -0
  47. package/service/src/shared/cache.ts +21 -0
  48. package/service/src/shared/constants.ts +73 -0
  49. package/service/src/shared/log.ts +21 -0
  50. package/service/src/shared/paths.ts +88 -0
  51. package/service/src/shared/state.ts +15 -0
  52. package/service/src/shared/version.ts +46 -0
  53. package/service/test/advisories.test.ts +287 -0
  54. package/service/test/check.test.ts +368 -0
  55. package/service/test/cleanup.test.ts +220 -0
  56. package/service/test/cli.test.ts +1303 -0
  57. package/service/test/core.test.ts +181 -0
  58. package/service/test/daemon-kit-migration.test.ts +181 -0
  59. package/service/test/daemon-service.test.ts +238 -0
  60. package/service/test/db.test.ts +178 -0
  61. package/service/test/doctor.test.ts +234 -0
  62. package/service/test/domain.test.ts +291 -0
  63. package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
  64. package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
  65. package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
  66. package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
  67. package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
  68. package/service/test/index.test.ts +353 -0
  69. package/service/test/install-validation.test.ts +114 -0
  70. package/service/test/install.test.ts +113 -0
  71. package/service/test/log.test.ts +42 -0
  72. package/service/test/pack-score.test.ts +513 -0
  73. package/service/test/pi-version.test.ts +318 -0
  74. package/service/test/public-boundary.test.ts +54 -0
  75. package/service/test/public-client.test.ts +127 -0
  76. package/service/test/public-consumer.ts +8 -0
  77. package/service/test/publish.test.ts +333 -0
  78. package/service/test/registry-contract.test.ts +148 -0
  79. package/service/test/resources.test.ts +255 -0
  80. package/service/test/security.test.ts +89 -0
  81. package/service/test/self-update.test.ts +257 -0
  82. package/service/test/service.test.ts +555 -0
  83. package/service/test/setup.test.ts +375 -0
  84. package/service/test/smoke.test.ts +118 -0
  85. package/service/test/version.test.ts +37 -0
  86. package/service/tsconfig.consumer.json +13 -0
  87. package/service/tsconfig.public.json +12 -0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * packed's own atomic JSON persistence convention, layered on
3
+ * @danypops/vehicle-core's shared createAtomicJsonWriter: a private (0700)
4
+ * parent directory, refusing to overwrite a symlinked destination (defense
5
+ * against a symlink-swap attack on a predictable state-directory path),
6
+ * then the shared temp-write+rename+cleanup mechanics for the real write.
7
+ * Replaces 6 independent hand-rolled copies of the same
8
+ * writeFileSync(tmp)+renameSync dance across resources.ts, security.ts,
9
+ * build-index.ts, and setup.ts (twice).
10
+ */
11
+ import { existsSync, lstatSync, mkdirSync } from "node:fs";
12
+ import { dirname } from "node:path";
13
+ import { createAtomicJsonWriter } from "@danypops/vehicle-core";
14
+ import { createNodeAtomicJsonFsAdapter } from "@danypops/vehicle-server/atomic-json";
15
+
16
+ const writer = createAtomicJsonWriter({ fs: createNodeAtomicJsonFsAdapter() });
17
+
18
+ export interface WriteJsonAtomicOptions {
19
+ /** POSIX mode for the written file. Omitted means the OS/adapter's own default. */
20
+ mode?: number;
21
+ /** Pretty-prints with 2-space indentation. Defaults to false (compact), matching this repo's own prior per-site defaults. */
22
+ pretty?: boolean;
23
+ /** Appends a trailing newline, the convention every existing packed persistence site but writeIndex already followed. Defaults to true. */
24
+ trailingNewline?: boolean;
25
+ /** Parent directory mode, created if missing. Defaults to 0o700 (private), matching every existing packed persistence site. */
26
+ dirMode?: number;
27
+ }
28
+
29
+ export async function writeJsonAtomic(path: string, value: unknown, options: WriteJsonAtomicOptions = {}): Promise<void> {
30
+ mkdirSync(dirname(path), { recursive: true, mode: options.dirMode ?? 0o700 });
31
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error(`ATOMIC_JSON_PATH_UNSAFE: refusing to replace symlink ${path}`);
32
+ await writer.write(path, value, { mode: options.mode, pretty: options.pretty, trailingNewline: options.trailingNewline ?? true });
33
+ }
@@ -0,0 +1,21 @@
1
+ /** TTLCache — the smart-proxy concern, nothing more. */
2
+ import { CACHE_TTL_MS } from "./constants.ts";
3
+
4
+ export class TTLCache {
5
+ private m = new Map<string, { body: string; expires: number }>();
6
+ constructor(private ttlMs = CACHE_TTL_MS) {}
7
+
8
+ get(key: string): string | undefined {
9
+ const e = this.m.get(key);
10
+ if (!e) return undefined;
11
+ if (Date.now() > e.expires) {
12
+ this.m.delete(key);
13
+ return undefined;
14
+ }
15
+ return e.body;
16
+ }
17
+
18
+ set(key: string, body: string): void {
19
+ this.m.set(key, { body, expires: Date.now() + this.ttlMs });
20
+ }
21
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * constants.ts — every magic value in one place, named.
3
+ * (lexicon: replace-magic-number-with-symbolic-constant)
4
+ */
5
+
6
+ // --- Upstream ---
7
+ export const NPM_REGISTRY_BASE = "https://registry.npmjs.org";
8
+
9
+ // --- Search / pagination ---
10
+ export const SEARCH_DEFAULT_LIMIT = 10;
11
+ export const SEARCH_MAX_LIMIT = 50;
12
+ export const SEARCH_PAGE_SIZE = 250; // npm registry max page size
13
+ export const PI_PACKAGE_KEYWORD = "keywords:pi-package";
14
+
15
+ // --- Native tool presentation bounds ---
16
+ export const TOOL_MODEL_CONTENT_MAX_CHARACTERS = 2_000;
17
+ export const TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS = 32_000;
18
+ export const TOOL_DETAILS_MAX_PACKAGES = 50;
19
+ export const TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS = 240;
20
+ export const TOOL_DETAILS_MAX_OUTPUT_CHARACTERS = 1_000;
21
+ export const TOOL_DETAILS_MAX_KEYWORDS = 20;
22
+ export const TOOL_DETAILS_MAX_CAPABILITIES = 12;
23
+ export const TOOL_COLLAPSED_PACKAGE_PREVIEW = 3;
24
+
25
+ // --- Upstream etiquette (429s) ---
26
+ export const RETRY_MAX_ATTEMPTS = 6;
27
+ export const RETRY_BASE_DELAY_MS = 2_000; // 2+4+8+16+32s spans npm's ~60s search window
28
+ export const PAGE_DELAY_MS = 100; // politeness pause between catalog pages
29
+ export const MIRROR_PAGE_DELAY_MS = 400; // manual full-sync: extra polite (burst limits)
30
+
31
+ // --- GitHub self-throttling (single-candidate commit lookup only, never bulk) ---
32
+ // Confirmed against GitHub's own docs and @octokit/plugin-throttling's reference
33
+ // shape: retry a short secondary-limit or transient failure, but never block an
34
+ // interactive `packed score` call for anywhere near the full primary-limit reset
35
+ // window (which can be up to an hour away).
36
+ export const GITHUB_RETRY_MAX_ATTEMPTS = 4;
37
+ export const GITHUB_MAX_TOTAL_BACKOFF_MS = 60_000; // hard cap across every retry combined
38
+ export const GITHUB_SECONDARY_RATE_LIMIT_FALLBACK_MS = 60_000; // matches plugin-throttling's own fallbackSecondaryRateRetryAfter default when no Retry-After header is present
39
+ export const GITHUB_TRANSIENT_BASE_DELAY_MS = 1_000; // 1+2+4s for a plain network blip or 5xx, well under the total cap
40
+
41
+ // --- Cache / fetch ---
42
+ export const CACHE_TTL_MS = 5 * 60_000;
43
+ export const PROBE_TIMEOUT_MS = 800;
44
+ export const REGISTRY_FETCH_TIMEOUT_MS = 15_000;
45
+ export const MIRROR_OPERATION_TIMEOUT_MS = 2 * 60_000;
46
+ // Confirmed live: 500 packages (index build's own bound) took just over
47
+ // MIRROR_OPERATION_TIMEOUT_MS end to end -- a dedicated, more generous
48
+ // budget, matching the same per-operation-timeout pattern mirror() uses.
49
+ export const INDEX_OPERATION_TIMEOUT_MS = 10 * 60_000;
50
+
51
+ // --- Daemon ---
52
+ export const WATCH_INTERVAL_DEFAULT_MS = 30 * 60_000; // updates diff cadence
53
+ export const CATALOG_INTERVAL_DEFAULT_MS = 6 * 3_600_000; // full mirror TTL
54
+ export const INDEX_INTERVAL_DEFAULT_MS = 6 * 3_600_000; // static index regeneration TTL, same cadence as the catalog mirror it reads from
55
+ export const IDLE_BUDGET_DEFAULT_MS = 10 * 60_000; // on-demand self-exit
56
+ export const WATCHDOG_TICK_MS = 15_000;
57
+
58
+ // --- State file names ---
59
+ export const UPDATES_FILE = "updates.json";
60
+ export const DB_FILE = "packed.db";
61
+ export const INDEX_FILE = "index.json";
62
+ export const SETTINGS_FILE = "settings.json";
63
+ export const SECURITY_FILE = "security.json";
64
+
65
+ // --- Environment knobs ---
66
+ export const ENV = {
67
+ HOME: "PI_PACKED_HOME",
68
+ PI_HOME: "PI_PACKED_PI_HOME",
69
+ WATCH_SECS: "PI_PACKED_WATCH_SECS",
70
+ CATALOG_SECS: "PI_PACKED_CATALOG_SECS",
71
+ INDEX_SECS: "PI_PACKED_INDEX_SECS",
72
+ IDLE_SECS: "PI_PACKED_IDLE_SECS",
73
+ } as const;
@@ -0,0 +1,21 @@
1
+ import { createLogger as createDaemonLogger, type LogFields, type Logger, type LogLevel } from "@danypops/vehicle-server/logging";
2
+
3
+ export type { LogFields, Logger, LogLevel };
4
+ export type LogSink = (line: string) => void;
5
+
6
+ /** Keeps Packed's injectable test sink while delegating level handling and serialization to vehicle-server. */
7
+ export function createLogger(component: string, sink?: LogSink, minLevel?: LogLevel): Logger {
8
+ const destination = sink
9
+ ? {
10
+ write(chunk: string) {
11
+ sink(chunk.trimEnd());
12
+ return true;
13
+ },
14
+ }
15
+ : undefined;
16
+ return createDaemonLogger(component, {
17
+ level: minLevel,
18
+ levelEnvVar: "PI_PACKED_LOG_LEVEL",
19
+ destination,
20
+ });
21
+ }
@@ -0,0 +1,88 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { type DaemonPaths, type PathEnvironment, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
5
+ import { DB_FILE, ENV, SECURITY_FILE, SETTINGS_FILE, UPDATES_FILE } from "./constants.ts";
6
+
7
+ const PATH_NAMES = {
8
+ stateDirectoryName: "pi-packed",
9
+ databaseFilename: DB_FILE,
10
+ tokenFilename: "token",
11
+ handleFilename: "handle.json",
12
+ systemdUnitName: "pi-packed.service",
13
+ } as const;
14
+
15
+ export interface PackedPaths extends DaemonPaths {
16
+ stateDirectory: string;
17
+ }
18
+
19
+ export function resolvePackedPaths(options: PathEnvironment = {}): PackedPaths {
20
+ const env = options.env ?? process.env;
21
+ const override = env[ENV.HOME];
22
+ if (override) {
23
+ const directory = resolve(override);
24
+ return {
25
+ database: join(directory, DB_FILE),
26
+ token: join(directory, PATH_NAMES.tokenFilename),
27
+ handle: join(directory, PATH_NAMES.handleFilename),
28
+ serviceDescriptor: join(directory, PATH_NAMES.systemdUnitName),
29
+ stateDirectory: directory,
30
+ };
31
+ }
32
+ const paths = resolveDaemonPaths(PATH_NAMES, options);
33
+ return { ...paths, stateDirectory: dirname(paths.token) };
34
+ }
35
+
36
+ export function legacyPackedStateDirectory(options: Pick<PathEnvironment, "env" | "home"> = {}): string {
37
+ const env = options.env ?? process.env;
38
+ const home = options.home ?? homedir();
39
+ return join(env.XDG_CACHE_HOME ?? join(home, ".cache"), "pi-packed");
40
+ }
41
+
42
+ function copyIfMissing(source: string, destination: string): void {
43
+ if (!existsSync(source) || existsSync(destination)) return;
44
+ mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
45
+ const temporary = `${destination}.${process.pid}.tmp`;
46
+ try {
47
+ copyFileSync(source, temporary);
48
+ chmodSync(temporary, 0o600);
49
+ renameSync(temporary, destination);
50
+ } finally {
51
+ rmSync(temporary, { force: true });
52
+ }
53
+ }
54
+
55
+ function copyDatabaseIfMissing(source: string, destination: string): void {
56
+ if (!existsSync(source) || existsSync(destination)) return;
57
+ mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
58
+ const temporary = `${destination}.${process.pid}.migration`;
59
+ const suffixes = ["", "-wal", "-shm"] as const;
60
+ try {
61
+ for (const suffix of suffixes) {
62
+ if (!existsSync(`${source}${suffix}`)) continue;
63
+ copyFileSync(`${source}${suffix}`, `${temporary}${suffix}`);
64
+ chmodSync(`${temporary}${suffix}`, 0o600);
65
+ }
66
+ for (const suffix of ["-shm", "-wal"] as const) {
67
+ if (existsSync(`${temporary}${suffix}`)) renameSync(`${temporary}${suffix}`, `${destination}${suffix}`);
68
+ }
69
+ renameSync(temporary, destination);
70
+ } finally {
71
+ for (const suffix of suffixes) rmSync(`${temporary}${suffix}`, { force: true });
72
+ }
73
+ }
74
+
75
+ /** Keeps existing installs usable while moving process discovery into vehicle-server's XDG split. */
76
+ export function migrateLegacyPackedState(paths: PackedPaths, legacyDirectory = legacyPackedStateDirectory()): void {
77
+ if (resolve(legacyDirectory) === resolve(paths.stateDirectory)) return;
78
+ copyDatabaseIfMissing(join(legacyDirectory, DB_FILE), paths.database);
79
+ for (const filename of [SECURITY_FILE, SETTINGS_FILE, UPDATES_FILE]) {
80
+ copyIfMissing(join(legacyDirectory, filename), join(paths.stateDirectory, filename));
81
+ }
82
+ const legacyToken = join(legacyDirectory, PATH_NAMES.tokenFilename);
83
+ try {
84
+ if (/^[a-f0-9]{64}$/.test(readFileSync(legacyToken, "utf8").trim())) copyIfMissing(legacyToken, paths.token);
85
+ } catch {
86
+ // Old Packed tokens were 128-bit. vehicle-server intentionally rotates them to 256-bit.
87
+ }
88
+ }
@@ -0,0 +1,15 @@
1
+ import { resolvePackedPaths } from "./paths.ts";
2
+
3
+ /** Compatibility accessor for code that stores non-database daemon state. */
4
+ export function stateDir(): string {
5
+ return resolvePackedPaths().stateDirectory;
6
+ }
7
+
8
+ /** Converts a non-negative seconds environment setting to milliseconds. */
9
+ export function envMs(key: string, defaultMs: number): number {
10
+ const raw = process.env[key];
11
+ if (raw === undefined) return defaultMs;
12
+ const value = Number(raw);
13
+ if (!Number.isFinite(value) || value < 0) return defaultMs;
14
+ return value * 1000;
15
+ }
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ function packageVersion(): string {
4
+ const manifest = JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")) as unknown;
5
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
6
+ throw new Error("packed package manifest must be an object");
7
+ }
8
+ const version = (manifest as Record<string, unknown>).version;
9
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
10
+ throw new Error("packed package manifest has an invalid version");
11
+ }
12
+ return version;
13
+ }
14
+
15
+ /** Runtime package version; package.json is the single release source of truth. */
16
+ export const VERSION = packageVersion();
17
+
18
+ export interface VersionReport {
19
+ installed: string;
20
+ /** Undefined when no daemon is reachable at all -- not a drift concern. */
21
+ daemon?: { version: string; stale: boolean };
22
+ }
23
+
24
+ export function buildVersionReport(installed: string, daemonVersion: string | undefined): VersionReport {
25
+ return {
26
+ installed,
27
+ ...(daemonVersion !== undefined ? { daemon: { version: daemonVersion, stale: daemonVersion !== installed } } : {}),
28
+ };
29
+ }
30
+
31
+ /** A running daemon holding stale in-memory code is otherwise invisible --
32
+ * it stays reachable and correctly answers every RPC, just with whatever
33
+ * logic was current when it started. Confirmed live: an 11-hour-old daemon
34
+ * kept flagging false "update available" rows from a same-day comparison
35
+ * fix it predated. */
36
+ export function formatVersionReport(report: VersionReport): string {
37
+ const lines = [`packed ${report.installed} (installed)`];
38
+ if (report.daemon) {
39
+ lines.push(
40
+ report.daemon.stale
41
+ ? `daemon running v${report.daemon.version} -- STALE, restart required to pick up v${report.installed} (e.g. systemctl --user restart pi-packed.service)`
42
+ : `daemon running v${report.daemon.version} -- up to date`,
43
+ );
44
+ }
45
+ return `${lines.join("\n")}\n`;
46
+ }
@@ -0,0 +1,287 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
6
+ import type { Server } from "bun";
7
+ import { fetchBulkAdvisories, PATCHED_VERSION_FRESH_DAYS, scanInstalledPackages } from "../src/adoption/advisories.ts";
8
+ import { createApp, type OperationInputs, type OperationName, type OperationOutputs } from "../src/daemon/service.ts";
9
+ import type { Installer, PkgInfo, Registry, SearchPage, UpdateOutcome } from "../src/packages/package.ts";
10
+
11
+ class NoopRegistry implements Registry {
12
+ async search(): Promise<SearchPage> {
13
+ return { results: [], total: 0 };
14
+ }
15
+ async searchPage(): Promise<SearchPage> {
16
+ return { results: [], total: 0 };
17
+ }
18
+ async searchAll() {
19
+ return [];
20
+ }
21
+ async info(name: string): Promise<PkgInfo> {
22
+ return { name, version: "1.0.0" };
23
+ }
24
+ }
25
+
26
+ class NoopInstaller implements Installer {
27
+ async install() {
28
+ return "ok";
29
+ }
30
+ async remove() {
31
+ return "ok";
32
+ }
33
+ async update(): Promise<UpdateOutcome> {
34
+ return { output: "ok", reloadRequired: false, alreadyUpToDate: true, pinned: false };
35
+ }
36
+ }
37
+
38
+ let server: Server<undefined> | undefined;
39
+ afterEach(() => {
40
+ server?.stop(true);
41
+ server = undefined;
42
+ });
43
+
44
+ const daysAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000).toISOString();
45
+
46
+ describe("fetchBulkAdvisories", () => {
47
+ it("parses a real bulk-endpoint response shape (confirmed against npm/cli's own fixture)", async () => {
48
+ server = Bun.serve({
49
+ port: 0,
50
+ fetch: (req) => {
51
+ if (req.method === "POST" && new URL(req.url).pathname === "/-/npm/v1/security/advisories/bulk") {
52
+ return Response.json({
53
+ handlebars: [
54
+ {
55
+ id: 755,
56
+ url: "https://npmjs.com/advisories/755",
57
+ title: "Prototype Pollution",
58
+ severity: "critical",
59
+ vulnerable_versions: "<=4.0.13 || >=4.1.0 <4.1.2",
60
+ },
61
+ ],
62
+ });
63
+ }
64
+ return new Response("not found", { status: 404 });
65
+ },
66
+ });
67
+ const result = await fetchBulkAdvisories({ handlebars: ["4.0.5"] }, { registryBase: `http://127.0.0.1:${server.port}` });
68
+ expect(result).toEqual({
69
+ handlebars: [
70
+ {
71
+ id: 755,
72
+ url: "https://npmjs.com/advisories/755",
73
+ title: "Prototype Pollution",
74
+ severity: "critical",
75
+ vulnerableVersions: "<=4.0.13 || >=4.1.0 <4.1.2",
76
+ },
77
+ ],
78
+ });
79
+ });
80
+
81
+ it("never makes a network call for an empty package set", async () => {
82
+ server = Bun.serve({
83
+ port: 0,
84
+ fetch: () => {
85
+ throw new Error("must never be called");
86
+ },
87
+ });
88
+ expect(await fetchBulkAdvisories({}, { registryBase: `http://127.0.0.1:${server.port}` })).toEqual({});
89
+ });
90
+
91
+ it("resolves to {} on a non-2xx response, never throws", async () => {
92
+ server = Bun.serve({ port: 0, fetch: () => new Response("nope", { status: 500 }) });
93
+ expect(await fetchBulkAdvisories({ foo: ["1.0.0"] }, { registryBase: `http://127.0.0.1:${server.port}` })).toEqual({});
94
+ });
95
+
96
+ it("resolves to {} on a malformed body, never throws", async () => {
97
+ server = Bun.serve({ port: 0, fetch: () => new Response("not json") });
98
+ expect(await fetchBulkAdvisories({ foo: ["1.0.0"] }, { registryBase: `http://127.0.0.1:${server.port}` })).toEqual({});
99
+ });
100
+
101
+ it("resolves to {} on an unreachable host, never throws or hangs", async () => {
102
+ expect(await fetchBulkAdvisories({ foo: ["1.0.0"] }, { registryBase: "http://127.0.0.1:1", timeoutMs: 500 })).toEqual({});
103
+ });
104
+
105
+ it("defaults an unrecognized severity to high rather than dropping the advisory", async () => {
106
+ server = Bun.serve({ port: 0, fetch: () => Response.json({ foo: [{ id: 1, url: "u", title: "t", vulnerable_versions: "*" }] }) });
107
+ const result = await fetchBulkAdvisories({ foo: ["1.0.0"] }, { registryBase: `http://127.0.0.1:${server.port}` });
108
+ expect(result.foo?.[0]?.severity).toBe("high");
109
+ });
110
+ });
111
+
112
+ describe("scanInstalledPackages", () => {
113
+ function fixtureServer(handlers: { bulk?: unknown; packument?: (name: string) => unknown }): Server<undefined> {
114
+ return Bun.serve({
115
+ port: 0,
116
+ fetch: (req) => {
117
+ const url = new URL(req.url);
118
+ if (req.method === "POST" && url.pathname === "/-/npm/v1/security/advisories/bulk") return Response.json(handlers.bulk ?? {});
119
+ const name = decodeURIComponent(url.pathname.slice(1));
120
+ if (handlers.packument) return Response.json(handlers.packument(name));
121
+ return new Response("not found", { status: 404 });
122
+ },
123
+ });
124
+ }
125
+
126
+ it("computes patched-version-age as a fresh signal for a very recently published fix", async () => {
127
+ server = fixtureServer({
128
+ bulk: {
129
+ "pi-demo": [
130
+ { id: 1, url: "https://example.test/1", title: "Prototype Pollution", severity: "high", vulnerable_versions: "<2.0.0" },
131
+ ],
132
+ },
133
+ packument: () => ({
134
+ versions: { "1.0.0": {}, "2.0.0": {}, "2.0.1": {} },
135
+ time: { "1.0.0": daysAgo(400), "2.0.0": daysAgo(2), "2.0.1": daysAgo(1) },
136
+ }),
137
+ });
138
+ const report = await scanInstalledPackages({ "pi-demo": "1.0.0" }, { registryBase: `http://127.0.0.1:${server.port}` });
139
+ expect(report.findings).toHaveLength(1);
140
+ const finding = report.findings[0]!;
141
+ expect(finding.patchedVersionAge?.version).toBe("2.0.0"); // smallest version outside the vulnerable range
142
+ expect(finding.patchedVersionAge?.fresh).toBe(true);
143
+ expect(finding.patchedVersionAge?.ageDays).toBeLessThan(PATCHED_VERSION_FRESH_DAYS);
144
+ });
145
+
146
+ it("reports fresh:false for a fix that has aged past the threshold", async () => {
147
+ // computed once, reused in both the fixture and the assertion -- a
148
+ // wall-clock ISO string recomputed on each side of an async boundary
149
+ // can differ by a millisecond and make an exact-equality assertion
150
+ // flaky under real scheduling, independent of the implementation.
151
+ const publishedAt = daysAgo(365);
152
+ server = fixtureServer({
153
+ bulk: { "pi-demo": [{ id: 1, url: "u", title: "t", severity: "high", vulnerable_versions: "<2.0.0" }] },
154
+ packument: () => ({ versions: { "1.0.0": {}, "2.0.0": {} }, time: { "1.0.0": daysAgo(400), "2.0.0": publishedAt } }),
155
+ });
156
+ const report = await scanInstalledPackages({ "pi-demo": "1.0.0" }, { registryBase: `http://127.0.0.1:${server.port}` });
157
+ expect(report.findings[0]!.patchedVersionAge).toEqual({ version: "2.0.0", publishedAt, ageDays: 365, fresh: false });
158
+ });
159
+
160
+ it("never guesses a patched version when nothing published escapes the vulnerable range", async () => {
161
+ server = fixtureServer({
162
+ bulk: { "pi-demo": [{ id: 1, url: "u", title: "t", severity: "high", vulnerable_versions: "*" }] },
163
+ packument: () => ({ versions: { "1.0.0": {} }, time: { "1.0.0": daysAgo(10) } }),
164
+ });
165
+ const report = await scanInstalledPackages({ "pi-demo": "1.0.0" }, { registryBase: `http://127.0.0.1:${server.port}` });
166
+ expect(report.findings[0]!.patchedVersionAge).toBeUndefined();
167
+ expect(report.diagnostics[0]!.fix).toBeUndefined();
168
+ });
169
+
170
+ it("maps advisory severity to diagnostic severity independent of patched-version freshness -- never folds one into the other", async () => {
171
+ server = fixtureServer({
172
+ bulk: {
173
+ "fresh-critical": [{ id: 1, url: "u", title: "t", severity: "critical", vulnerable_versions: "<2.0.0" }],
174
+ "old-critical": [{ id: 2, url: "u", title: "t", severity: "critical", vulnerable_versions: "<2.0.0" }],
175
+ },
176
+ packument: (name) =>
177
+ name === "fresh-critical"
178
+ ? { versions: { "1.0.0": {}, "2.0.0": {} }, time: { "1.0.0": daysAgo(400), "2.0.0": daysAgo(1) } }
179
+ : { versions: { "1.0.0": {}, "2.0.0": {} }, time: { "1.0.0": daysAgo(400), "2.0.0": daysAgo(900) } },
180
+ });
181
+ const report = await scanInstalledPackages(
182
+ { "fresh-critical": "1.0.0", "old-critical": "1.0.0" },
183
+ { registryBase: `http://127.0.0.1:${server.port}` },
184
+ );
185
+ const fresh = report.diagnostics.find((d) => d.path === "fresh-critical")!;
186
+ const old = report.diagnostics.find((d) => d.path === "old-critical")!;
187
+ expect(fresh.severity).toBe("error");
188
+ expect(old.severity).toBe("error"); // same severity as fresh -- freshness never changes it
189
+ expect(fresh.message).toContain("weaker signal");
190
+ expect(old.message).not.toContain("weaker signal");
191
+ });
192
+
193
+ it("maps moderate to warning and low to info", async () => {
194
+ server = fixtureServer({
195
+ bulk: {
196
+ pkga: [{ id: 1, url: "u", title: "t", severity: "moderate", vulnerable_versions: "*" }],
197
+ pkgb: [{ id: 2, url: "u", title: "t", severity: "low", vulnerable_versions: "*" }],
198
+ },
199
+ });
200
+ const report = await scanInstalledPackages({ pkga: "1.0.0", pkgb: "1.0.0" }, { registryBase: `http://127.0.0.1:${server.port}` });
201
+ expect(report.diagnostics.find((d) => d.path === "pkga")!.severity).toBe("warning");
202
+ expect(report.diagnostics.find((d) => d.path === "pkgb")!.severity).toBe("info");
203
+ });
204
+
205
+ it("reports zero findings for a package with no advisories", async () => {
206
+ server = fixtureServer({ bulk: {} });
207
+ const report = await scanInstalledPackages({ "pi-demo": "1.0.0" }, { registryBase: `http://127.0.0.1:${server.port}` });
208
+ expect(report.findings).toEqual([]);
209
+ expect(report.diagnostics).toEqual([]);
210
+ expect(report.scanned).toBe(1);
211
+ });
212
+
213
+ it("never executes package code -- scanning is pure metadata comparison", async () => {
214
+ // no eval/require/child_process anywhere in the module; structural
215
+ // guarantee, not just an assertion about this one test run
216
+ const source = await Bun.file(new URL("../src/adoption/advisories.ts", import.meta.url)).text();
217
+ expect(source).not.toMatch(/\brequire\(|child_process|Bun\.spawn|eval\(/);
218
+ });
219
+ });
220
+
221
+ describe("advisories.scan operation (real daemon route)", () => {
222
+ it("resolves real installed npm packages' on-disk versions and routes through the authenticated operation registry", async () => {
223
+ const piHome = mkdtempSync(join(tmpdir(), "packed-advisories-daemon-"));
224
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:pi-vuln"] }));
225
+ const pkgDir = join(piHome, "npm", "node_modules", "pi-vuln");
226
+ mkdirSync(pkgDir, { recursive: true });
227
+ writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "pi-vuln", version: "1.0.0" }));
228
+
229
+ let receivedInstalled: Record<string, string> | undefined;
230
+ const app = createApp({
231
+ reg: new NoopRegistry(),
232
+ inst: new NoopInstaller(),
233
+ token: "test-token",
234
+ stateDir: mkdtempSync(join(tmpdir(), "packed-advisories-state-")),
235
+ dataDir: mkdtempSync(join(tmpdir(), "packed-advisories-data-")),
236
+ piHome,
237
+ // injected exactly like pi.status's piVersion seam -- never a real
238
+ // network call to the live npm registry from an automated test.
239
+ advisories: {
240
+ async scan(installed) {
241
+ receivedInstalled = installed;
242
+ return { scanned: Object.keys(installed).length, findings: [], diagnostics: [], truncated: false };
243
+ },
244
+ },
245
+ });
246
+ const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
247
+ label: "Packed",
248
+ transport: (request) => app.fetch(request),
249
+ });
250
+ expect(await client.operations()).toContain("advisories.scan");
251
+ const report = await client.call("advisories.scan", {});
252
+ expect(report.scanned).toBe(1);
253
+ expect(receivedInstalled).toEqual({ "pi-vuln": "1.0.0" }); // real on-disk resolution, not a guess
254
+ });
255
+
256
+ it("is unguarded read access -- never requires approval", async () => {
257
+ const app = createApp({
258
+ reg: new NoopRegistry(),
259
+ inst: new NoopInstaller(),
260
+ token: "test-token",
261
+ stateDir: mkdtempSync(join(tmpdir(), "packed-advisories-state-")),
262
+ dataDir: mkdtempSync(join(tmpdir(), "packed-advisories-data-")),
263
+ piHome: mkdtempSync(join(tmpdir(), "packed-advisories-pihome-")),
264
+ });
265
+ const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
266
+ label: "Packed",
267
+ transport: (request) => app.fetch(request),
268
+ });
269
+ expect(await client.call("advisories.scan", {})).toEqual({ scanned: 0, findings: [], diagnostics: [], truncated: false });
270
+ });
271
+
272
+ it("rejects an oversized name argument rather than passing it through", async () => {
273
+ const app = createApp({
274
+ reg: new NoopRegistry(),
275
+ inst: new NoopInstaller(),
276
+ token: "test-token",
277
+ stateDir: mkdtempSync(join(tmpdir(), "packed-advisories-state-")),
278
+ dataDir: mkdtempSync(join(tmpdir(), "packed-advisories-data-")),
279
+ piHome: mkdtempSync(join(tmpdir(), "packed-advisories-pihome-")),
280
+ });
281
+ const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
282
+ label: "Packed",
283
+ transport: (request) => app.fetch(request),
284
+ });
285
+ await expect(client.call("advisories.scan", { name: "x".repeat(300) })).rejects.toThrow();
286
+ });
287
+ });