@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2
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/AGENTS.md +26 -5
- package/README.md +49 -7
- package/docs/architecture.md +129 -1
- package/package.json +15 -15
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +68 -4
- package/packages/extension/src/bridge.ts +79 -11
- package/packages/extension/src/command-handler.ts +95 -15
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +8 -7
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +2 -2
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +27 -10
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +62 -82
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +90 -6
- package/packages/server/src/headless-pid-registry.ts +344 -37
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +182 -11
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -1
- package/packages/server/src/routes/provider-routes.ts +28 -5
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +254 -60
- package/packages/server/src/session-api.ts +63 -4
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +70 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +71 -26
- package/packages/shared/src/protocol.ts +27 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +62 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
- package/packages/shared/src/resolve-jiti.ts +0 -102
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for the provider-catalogue cache.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* The cache is now a single global snapshot of the most-recent
|
|
5
|
+
* `providers_list` push. No per-session split, no `changed` signal —
|
|
6
|
+
* see change: simplify-model-selection-channels for why.
|
|
4
7
|
*/
|
|
5
8
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
6
9
|
import {
|
|
7
10
|
setCatalogueForSession,
|
|
8
|
-
getCatalogueForSession,
|
|
9
11
|
getLatestCatalogue,
|
|
10
|
-
clearForSession,
|
|
11
12
|
_resetForTests,
|
|
12
13
|
} from "../provider-catalogue-cache.js";
|
|
13
14
|
import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
@@ -18,37 +19,26 @@ const B: ProviderInfo = { id: "b", displayName: "B", hasOAuth: false, configured
|
|
|
18
19
|
describe("provider-catalogue-cache", () => {
|
|
19
20
|
beforeEach(() => _resetForTests());
|
|
20
21
|
|
|
21
|
-
it("
|
|
22
|
+
it("getLatestCatalogue returns [] before any push", () => {
|
|
22
23
|
expect(getLatestCatalogue()).toEqual([]);
|
|
23
|
-
expect(getCatalogueForSession("s1")).toBeUndefined();
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
it("
|
|
27
|
-
setCatalogueForSession("s1", [A]);
|
|
28
|
-
expect(getCatalogueForSession("s1")).toEqual([A]);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("latestSnapshot reflects most recent push across sessions", () => {
|
|
26
|
+
it("setCatalogueForSession overwrites the global snapshot", () => {
|
|
32
27
|
setCatalogueForSession("s1", [A]);
|
|
33
28
|
expect(getLatestCatalogue()).toEqual([A]);
|
|
34
|
-
setCatalogueForSession("s2", [A, B]);
|
|
35
|
-
expect(getLatestCatalogue()).toEqual([A, B]);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("clearForSession removes that session and clears latest only when empty", () => {
|
|
39
|
-
setCatalogueForSession("s1", [A]);
|
|
40
29
|
setCatalogueForSession("s2", [B]);
|
|
41
|
-
clearForSession("s1");
|
|
42
|
-
expect(getCatalogueForSession("s1")).toBeUndefined();
|
|
43
30
|
expect(getLatestCatalogue()).toEqual([B]);
|
|
44
|
-
clearForSession("s2");
|
|
45
|
-
expect(getLatestCatalogue()).toEqual([]);
|
|
46
31
|
});
|
|
47
32
|
|
|
48
|
-
it("
|
|
33
|
+
it("last writer wins regardless of sessionId", () => {
|
|
34
|
+
setCatalogueForSession("s1", [A, B]);
|
|
35
|
+
setCatalogueForSession("s2", [A]);
|
|
36
|
+
expect(getLatestCatalogue()).toEqual([A]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("_resetForTests clears the snapshot", () => {
|
|
49
40
|
setCatalogueForSession("s1", [A]);
|
|
50
41
|
_resetForTests();
|
|
51
42
|
expect(getLatestCatalogue()).toEqual([]);
|
|
52
|
-
expect(getCatalogueForSession("s1")).toBeUndefined();
|
|
53
43
|
});
|
|
54
44
|
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for recursion guard wired into PUT /api/providers (task 10.4).
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Self-pointing baseUrl → 400 with code RECURSIVE_PROXY
|
|
6
|
+
* - Valid external baseUrl → accepted (2xx, written to disk)
|
|
7
|
+
* - Existing providers untouched on validation failure
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import Fastify from "fastify";
|
|
11
|
+
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { registerProviderRoutes } from "../routes/provider-routes.js";
|
|
15
|
+
|
|
16
|
+
const PROVIDERS_PATH = join(homedir(), ".pi", "agent", "providers.json");
|
|
17
|
+
const PROVIDERS_DIR = join(homedir(), ".pi", "agent");
|
|
18
|
+
|
|
19
|
+
// Back up / restore providers.json around each test
|
|
20
|
+
let backup: string | null = null;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
try { backup = require("fs").readFileSync(PROVIDERS_PATH, "utf-8"); } catch { backup = null; }
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
try {
|
|
26
|
+
if (backup !== null) {
|
|
27
|
+
writeFileSync(PROVIDERS_PATH, backup);
|
|
28
|
+
} else {
|
|
29
|
+
rmSync(PROVIDERS_PATH, { force: true });
|
|
30
|
+
}
|
|
31
|
+
} catch {}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function buildApp(port = 8000) {
|
|
35
|
+
const app = Fastify({ logger: false });
|
|
36
|
+
const networkGuard = async () => {};
|
|
37
|
+
mkdirSync(PROVIDERS_DIR, { recursive: true });
|
|
38
|
+
registerProviderRoutes(app, { networkGuard, port });
|
|
39
|
+
await app.ready();
|
|
40
|
+
return app;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("recursion guard on PUT /api/providers (task 10.4)", () => {
|
|
44
|
+
it("localhost self-pointing baseUrl → 400 RECURSIVE_PROXY", async () => {
|
|
45
|
+
const app = await buildApp(8000);
|
|
46
|
+
|
|
47
|
+
const res = await app.inject({
|
|
48
|
+
method: "PUT",
|
|
49
|
+
url: "/api/providers",
|
|
50
|
+
headers: { "content-type": "application/json" },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
providers: {
|
|
53
|
+
self: { baseUrl: "http://localhost:8000/v1", apiKey: "" },
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(res.statusCode).toBe(400);
|
|
59
|
+
const body = JSON.parse(res.body);
|
|
60
|
+
expect(body.code).toBe("RECURSIVE_PROXY");
|
|
61
|
+
expect(body.offendingBaseUrl).toBe("http://localhost:8000/v1");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("127.0.0.1 variant also caught", async () => {
|
|
65
|
+
const app = await buildApp(8000);
|
|
66
|
+
|
|
67
|
+
const res = await app.inject({
|
|
68
|
+
method: "PUT",
|
|
69
|
+
url: "/api/providers",
|
|
70
|
+
headers: { "content-type": "application/json" },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
providers: {
|
|
73
|
+
self: { baseUrl: "http://127.0.0.1:8000/v1", apiKey: "" },
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(res.statusCode).toBe(400);
|
|
79
|
+
expect(JSON.parse(res.body).code).toBe("RECURSIVE_PROXY");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("external baseUrl passes validation", async () => {
|
|
83
|
+
const app = await buildApp(8000);
|
|
84
|
+
|
|
85
|
+
const res = await app.inject({
|
|
86
|
+
method: "PUT",
|
|
87
|
+
url: "/api/providers",
|
|
88
|
+
headers: { "content-type": "application/json" },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
providers: {
|
|
91
|
+
openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-test" },
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Should succeed (200/204) or return a non-400 error
|
|
97
|
+
expect(res.statusCode).not.toBe(400);
|
|
98
|
+
const body = JSON.parse(res.body);
|
|
99
|
+
expect(body.code).not.toBe("RECURSIVE_PROXY");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("validation failure leaves existing providers untouched", async () => {
|
|
103
|
+
// Pre-populate providers.json with a valid provider
|
|
104
|
+
mkdirSync(PROVIDERS_DIR, { recursive: true });
|
|
105
|
+
writeFileSync(PROVIDERS_PATH, JSON.stringify({
|
|
106
|
+
providers: { openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-existing" } },
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const app = await buildApp(8000);
|
|
110
|
+
|
|
111
|
+
// Attempt to add a recursive provider — should fail
|
|
112
|
+
const res = await app.inject({
|
|
113
|
+
method: "PUT",
|
|
114
|
+
url: "/api/providers",
|
|
115
|
+
headers: { "content-type": "application/json" },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
providers: {
|
|
118
|
+
openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-existing" },
|
|
119
|
+
self: { baseUrl: "http://localhost:8000/v1", apiKey: "" },
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(res.statusCode).toBe(400);
|
|
125
|
+
|
|
126
|
+
// Read providers.json — existing provider should still be there
|
|
127
|
+
const stored = JSON.parse(require("fs").readFileSync(PROVIDERS_PATH, "utf-8"));
|
|
128
|
+
expect(stored.providers?.openai?.baseUrl).toBe("https://api.openai.com/v1");
|
|
129
|
+
expect(stored.providers?.self).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -8,7 +8,7 @@ import os from "node:os";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
|
|
10
10
|
// Mock pi dependency (pulled transitively by package-manager-wrapper)
|
|
11
|
-
vi.mock("@
|
|
11
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
12
12
|
DefaultPackageManager: function () {
|
|
13
13
|
return {};
|
|
14
14
|
},
|
|
@@ -195,7 +195,7 @@ describe("GET /api/packages/recommended", () => {
|
|
|
195
195
|
return fastify;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
it("returns the
|
|
198
|
+
it("returns the 6 manifest entries with default (offline) descriptions", async () => {
|
|
199
199
|
vi.mocked(fetchPackageMeta).mockResolvedValue(null);
|
|
200
200
|
vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
|
|
201
201
|
await setupRoute();
|
|
@@ -208,7 +208,7 @@ describe("GET /api/packages/recommended", () => {
|
|
|
208
208
|
const body = JSON.parse(res.payload);
|
|
209
209
|
expect(body.success).toBe(true);
|
|
210
210
|
const entries = body.data.recommended;
|
|
211
|
-
expect(entries).toHaveLength(
|
|
211
|
+
expect(entries).toHaveLength(6);
|
|
212
212
|
// Every entry falls back to fallbackDescription and has no version.
|
|
213
213
|
for (const e of entries) {
|
|
214
214
|
expect(typeof e.description).toBe("string");
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration: verify the kill-fork-kills-parent bug stays fixed.
|
|
3
|
+
*
|
|
4
|
+
* Simulates the race window where parent + fork are both registered in the
|
|
5
|
+
* same cwd and bridges connect in arbitrary order. Asserts the registry
|
|
6
|
+
* resolves each sessionId to its OWN PID via the three-tier link.
|
|
7
|
+
*
|
|
8
|
+
* See change: spawn-correlation-token.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from "vitest";
|
|
11
|
+
import { EventEmitter } from "node:events";
|
|
12
|
+
import { mkdtempSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { createHeadlessPidRegistry } from "../headless-pid-registry.js";
|
|
16
|
+
import { createPendingForkRegistry } from "../pending-fork-registry.js";
|
|
17
|
+
import { mintSpawnToken } from "../spawn-token.js";
|
|
18
|
+
|
|
19
|
+
function mockProc() {
|
|
20
|
+
return new EventEmitter() as any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function tmpPidFile() {
|
|
24
|
+
return join(mkdtempSync(join(tmpdir(), "spawn-corr-")), "pids.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("spawn-correlation-token: kill-fork-doesn't-kill-parent regression", () => {
|
|
28
|
+
it("two same-cwd spawns: each session resolves to its OWN pid via token", () => {
|
|
29
|
+
// Setup: simulate dashboard spawning parent, then fork, in the same cwd.
|
|
30
|
+
// Each spawn mints a token; registry stores entries by pid + token.
|
|
31
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: tmpPidFile() });
|
|
32
|
+
const tokenParent = mintSpawnToken();
|
|
33
|
+
const tokenFork = mintSpawnToken();
|
|
34
|
+
expect(tokenParent).not.toBe(tokenFork);
|
|
35
|
+
|
|
36
|
+
registry.register(1000, "/proj", mockProc(), tokenParent);
|
|
37
|
+
registry.register(1234, "/proj", mockProc(), tokenFork);
|
|
38
|
+
|
|
39
|
+
// Bridge connect order is reversed (fork first, parent second) — the
|
|
40
|
+
// worst-case race that produced the original bug.
|
|
41
|
+
expect(registry.linkByToken(tokenFork, "S_fork")).toBe(true);
|
|
42
|
+
expect(registry.linkByToken(tokenParent, "S_parent")).toBe(true);
|
|
43
|
+
|
|
44
|
+
// Critical: each session resolves to its OWN pid. Pre-fix this would
|
|
45
|
+
// have given S_fork → 1000 (parent) and S_parent → 1234 (fork) due to
|
|
46
|
+
// cwd-FIFO ordering.
|
|
47
|
+
expect(registry.getPid("S_fork")).toBe(1234);
|
|
48
|
+
expect(registry.getPid("S_parent")).toBe(1000);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("legacy bridge fallback: linkByPid is exact even without tokens", () => {
|
|
52
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: tmpPidFile() });
|
|
53
|
+
// Legacy bridges don't send spawnToken; only pid.
|
|
54
|
+
registry.register(1000, "/proj", mockProc()); // no token
|
|
55
|
+
registry.register(1234, "/proj", mockProc()); // no token
|
|
56
|
+
|
|
57
|
+
// Race-order register messages, but pid-link is direct lookup.
|
|
58
|
+
expect(registry.linkByPid("S_fork", 1234)).toBe(true);
|
|
59
|
+
expect(registry.linkByPid("S_parent", 1000)).toBe(true);
|
|
60
|
+
|
|
61
|
+
expect(registry.getPid("S_fork")).toBe(1234);
|
|
62
|
+
expect(registry.getPid("S_parent")).toBe(1000);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("fork registry: per-token keying separates two forks in same cwd", () => {
|
|
66
|
+
const forkRegistry = createPendingForkRegistry();
|
|
67
|
+
const tokenA = mintSpawnToken();
|
|
68
|
+
const tokenB = mintSpawnToken();
|
|
69
|
+
|
|
70
|
+
// Two forks issued in the same cwd, each with its own token. Pre-fix
|
|
71
|
+
// (cwd-keyed registry) the second recordFork would overwrite the first.
|
|
72
|
+
forkRegistry.recordFork(tokenA, "parent-A");
|
|
73
|
+
forkRegistry.recordFork(tokenB, "parent-B");
|
|
74
|
+
|
|
75
|
+
// Bridge connect order arbitrary; each token resolves to its OWN parent.
|
|
76
|
+
expect(forkRegistry.consumeFork(tokenB)).toBe("parent-B");
|
|
77
|
+
expect(forkRegistry.consumeFork(tokenA)).toBe("parent-A");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("stale token (server-restart-mid-spawn) degrades to lower-tier match", () => {
|
|
81
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: tmpPidFile() });
|
|
82
|
+
// Bridge holds an env-var token from before server restart; the new
|
|
83
|
+
// server has no entry for that token. linkByToken returns false; the
|
|
84
|
+
// event-wiring caller falls through to linkByPid.
|
|
85
|
+
registry.register(1000, "/proj", mockProc()); // post-restart entry, no token
|
|
86
|
+
expect(registry.linkByToken("stale_tok_from_old_server", "S")).toBe(false);
|
|
87
|
+
// Caller falls back:
|
|
88
|
+
expect(registry.linkByPid("S", 1000)).toBe(true);
|
|
89
|
+
expect(registry.getPid("S")).toBe(1000);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -164,3 +164,87 @@ describe("SpawnRegisterWatchdog", () => {
|
|
|
164
164
|
expect(recoveries).toHaveLength(0);
|
|
165
165
|
});
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
// See change: spawn-correlation-token — third index by token.
|
|
169
|
+
describe("SpawnRegisterWatchdog: byToken index", () => {
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
vi.useFakeTimers();
|
|
172
|
+
});
|
|
173
|
+
afterEach(() => {
|
|
174
|
+
vi.useRealTimers();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("clearByToken cancels the watchdog", () => {
|
|
178
|
+
const w = new SpawnRegisterWatchdog(30_000);
|
|
179
|
+
const { ws, messages } = makeMockWs();
|
|
180
|
+
w.arm({ pid: 100, cwd: "/p", mechanism: "headless", ws, spawnToken: "tok_a" });
|
|
181
|
+
w.clearByToken("tok_a");
|
|
182
|
+
vi.advanceTimersByTime(60_000);
|
|
183
|
+
expect(messages.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("clearByToken removes entry from cwd and pid indices too", () => {
|
|
187
|
+
const w = new SpawnRegisterWatchdog(30_000);
|
|
188
|
+
const { ws } = makeMockWs();
|
|
189
|
+
w.arm({ pid: 100, cwd: "/p", mechanism: "headless", ws, spawnToken: "tok_a" });
|
|
190
|
+
w.clearByToken("tok_a");
|
|
191
|
+
// Subsequent clearByPid / clearByCwd are no-ops (entry already removed).
|
|
192
|
+
w.clearByPid(100);
|
|
193
|
+
w.clearByCwd("/p");
|
|
194
|
+
// No exception, no double-clear.
|
|
195
|
+
expect(true).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("clearByPid also clears the token index", () => {
|
|
199
|
+
const w = new SpawnRegisterWatchdog(30_000);
|
|
200
|
+
const { ws, messages } = makeMockWs();
|
|
201
|
+
w.arm({ pid: 100, cwd: "/p", mechanism: "headless", ws, spawnToken: "tok_a" });
|
|
202
|
+
w.clearByPid(100);
|
|
203
|
+
// Token-keyed clear is now a no-op (already cleaned up).
|
|
204
|
+
w.clearByToken("tok_a");
|
|
205
|
+
vi.advanceTimersByTime(60_000);
|
|
206
|
+
expect(messages.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("tmux arm without pid: token clears watchdog", () => {
|
|
210
|
+
const w = new SpawnRegisterWatchdog(30_000);
|
|
211
|
+
const { ws, messages } = makeMockWs();
|
|
212
|
+
w.arm({ cwd: "/p", mechanism: "tmux", ws, spawnToken: "tok_b" });
|
|
213
|
+
w.clearByToken("tok_b");
|
|
214
|
+
vi.advanceTimersByTime(60_000);
|
|
215
|
+
expect(messages.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("late clearByToken after timeout emits recovered", () => {
|
|
219
|
+
const w = new SpawnRegisterWatchdog(30_000);
|
|
220
|
+
const { ws, messages } = makeMockWs();
|
|
221
|
+
w.arm({ pid: 100, cwd: "/p", mechanism: "headless", ws, spawnToken: "tok_c" });
|
|
222
|
+
vi.advanceTimersByTime(31_000); // timeout fires
|
|
223
|
+
expect(messages.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(1);
|
|
224
|
+
w.clearByToken("tok_c");
|
|
225
|
+
expect(messages.filter((m) => m.includes("spawn_register_recovered"))).toHaveLength(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("two simultaneous arms with distinct tokens, distinct cwds: token-clears each independently", () => {
|
|
229
|
+
const w = new SpawnRegisterWatchdog(30_000);
|
|
230
|
+
const { ws: ws1, messages: m1 } = makeMockWs();
|
|
231
|
+
const { ws: ws2, messages: m2 } = makeMockWs();
|
|
232
|
+
w.arm({ pid: 100, cwd: "/p1", mechanism: "headless", ws: ws1, spawnToken: "tok_x" });
|
|
233
|
+
w.arm({ pid: 200, cwd: "/p2", mechanism: "headless", ws: ws2, spawnToken: "tok_y" });
|
|
234
|
+
w.clearByToken("tok_y");
|
|
235
|
+
vi.advanceTimersByTime(31_000);
|
|
236
|
+
// Only the first arm's timeout fired (second was cleared).
|
|
237
|
+
expect(m1.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(1);
|
|
238
|
+
expect(m2.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("arm without spawnToken behaves as before", () => {
|
|
242
|
+
const w = new SpawnRegisterWatchdog(30_000);
|
|
243
|
+
const { ws, messages } = makeMockWs();
|
|
244
|
+
w.arm({ pid: 100, cwd: "/p", mechanism: "headless", ws });
|
|
245
|
+
// Token-clear with empty / unknown token is a no-op.
|
|
246
|
+
w.clearByToken("tok_unknown");
|
|
247
|
+
vi.advanceTimersByTime(31_000);
|
|
248
|
+
expect(messages.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(1);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for spawn correlation token primitives.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - `mintSpawnToken()` returns distinct UUIDv4 strings.
|
|
6
|
+
* - `buildSpawnEnv(env, { spawnToken })` injects `PI_DASHBOARD_SPAWN_TOKEN`.
|
|
7
|
+
* - Without `spawnToken`, env is unchanged (no leakage).
|
|
8
|
+
*
|
|
9
|
+
* See change: spawn-correlation-token.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { mintSpawnToken, SPAWN_TOKEN_ENV_VAR } from "../spawn-token.js";
|
|
13
|
+
import { buildSpawnEnv } from "../process-manager.js";
|
|
14
|
+
|
|
15
|
+
describe("mintSpawnToken", () => {
|
|
16
|
+
it("returns a UUIDv4 string", () => {
|
|
17
|
+
const t = mintSpawnToken();
|
|
18
|
+
expect(typeof t).toBe("string");
|
|
19
|
+
// UUIDv4: 8-4-4-4-12 hex with version=4 nibble
|
|
20
|
+
expect(t).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns distinct tokens on each call", () => {
|
|
24
|
+
const tokens = new Set<string>();
|
|
25
|
+
for (let i = 0; i < 50; i++) tokens.add(mintSpawnToken());
|
|
26
|
+
expect(tokens.size).toBe(50);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("buildSpawnEnv: spawnToken injection", () => {
|
|
31
|
+
it("sets PI_DASHBOARD_SPAWN_TOKEN when spawnToken is provided", () => {
|
|
32
|
+
const env = buildSpawnEnv({ HOME: "/tmp" }, { spawnToken: "tok_test_123" });
|
|
33
|
+
expect(env[SPAWN_TOKEN_ENV_VAR]).toBe("tok_test_123");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not set PI_DASHBOARD_SPAWN_TOKEN when spawnToken is omitted", () => {
|
|
37
|
+
const env = buildSpawnEnv({ HOME: "/tmp" });
|
|
38
|
+
expect(env[SPAWN_TOKEN_ENV_VAR]).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("does not set PI_DASHBOARD_SPAWN_TOKEN when opts is empty", () => {
|
|
42
|
+
const env = buildSpawnEnv({ HOME: "/tmp" }, {});
|
|
43
|
+
expect(env[SPAWN_TOKEN_ENV_VAR]).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("preserves baseEnv variables unchanged when injecting", () => {
|
|
47
|
+
const env = buildSpawnEnv(
|
|
48
|
+
{ HOME: "/tmp", FOO: "bar", PATH: "/usr/bin" },
|
|
49
|
+
{ spawnToken: "tok_xyz" },
|
|
50
|
+
);
|
|
51
|
+
expect(env.HOME).toBe("/tmp");
|
|
52
|
+
expect(env.FOO).toBe("bar");
|
|
53
|
+
expect(env[SPAWN_TOKEN_ENV_VAR]).toBe("tok_xyz");
|
|
54
|
+
// PATH may be mutated by managed-node prepend, but the raw value should still appear in it.
|
|
55
|
+
expect(env.PATH).toContain("/usr/bin");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
probeTunnel,
|
|
4
|
+
startTunnelWatchdog,
|
|
5
|
+
stopTunnelWatchdog,
|
|
6
|
+
getTunnelWatchdogStatus,
|
|
7
|
+
_runTickForTest,
|
|
8
|
+
_resetForTest,
|
|
9
|
+
} from "../tunnel-watchdog.js";
|
|
10
|
+
|
|
11
|
+
const URL = "https://abc.share.zrok.io";
|
|
12
|
+
|
|
13
|
+
function makeFetch(responses: Array<Response | Error>): typeof fetch {
|
|
14
|
+
let i = 0;
|
|
15
|
+
return (async () => {
|
|
16
|
+
const r = responses[Math.min(i, responses.length - 1)];
|
|
17
|
+
i += 1;
|
|
18
|
+
if (r instanceof Error) throw r;
|
|
19
|
+
return r;
|
|
20
|
+
}) as unknown as typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("probeTunnel", () => {
|
|
24
|
+
it("returns ok on 2xx", async () => {
|
|
25
|
+
const f = makeFetch([new Response("{}", { status: 200 })]);
|
|
26
|
+
expect(await probeTunnel(URL, 1000, f)).toEqual({ ok: true, status: 200 });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns ok on 4xx (auth gate proves edge↔local works)", async () => {
|
|
30
|
+
const f = makeFetch([new Response("", { status: 401 })]);
|
|
31
|
+
expect(await probeTunnel(URL, 1000, f)).toEqual({ ok: true, status: 401 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns NOT ok on 5xx", async () => {
|
|
35
|
+
const f = makeFetch([new Response("bad gateway", { status: 502 })]);
|
|
36
|
+
const r = await probeTunnel(URL, 1000, f);
|
|
37
|
+
expect(r.ok).toBe(false);
|
|
38
|
+
expect(r.status).toBe(502);
|
|
39
|
+
expect(r.reason).toMatch(/502/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns NOT ok on network error", async () => {
|
|
43
|
+
const f = makeFetch([new Error("ENOTFOUND")]);
|
|
44
|
+
const r = await probeTunnel(URL, 1000, f);
|
|
45
|
+
expect(r.ok).toBe(false);
|
|
46
|
+
expect(r.reason).toMatch(/ENOTFOUND/);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("watchdog lifecycle", () => {
|
|
51
|
+
beforeEach(() => { _resetForTest(); });
|
|
52
|
+
afterEach(() => { _resetForTest(); });
|
|
53
|
+
|
|
54
|
+
it("does not start when disabled", () => {
|
|
55
|
+
startTunnelWatchdog(
|
|
56
|
+
{ getUrl: () => URL, recycle: vi.fn(async () => URL) },
|
|
57
|
+
{ enabled: false },
|
|
58
|
+
);
|
|
59
|
+
expect(getTunnelWatchdogStatus()).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("recycles after threshold consecutive 5xx", async () => {
|
|
63
|
+
const recycle = vi.fn(async () => URL);
|
|
64
|
+
const fetchFn = makeFetch([
|
|
65
|
+
new Response("", { status: 502 }),
|
|
66
|
+
new Response("", { status: 502 }),
|
|
67
|
+
]);
|
|
68
|
+
startTunnelWatchdog(
|
|
69
|
+
{ getUrl: () => URL, recycle, fetchFn, log: () => {} },
|
|
70
|
+
{ intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
|
|
71
|
+
);
|
|
72
|
+
await _runTickForTest();
|
|
73
|
+
expect(recycle).not.toHaveBeenCalled();
|
|
74
|
+
expect(getTunnelWatchdogStatus()?.consecutiveFailures).toBe(1);
|
|
75
|
+
|
|
76
|
+
await _runTickForTest();
|
|
77
|
+
expect(recycle).toHaveBeenCalledTimes(1);
|
|
78
|
+
const s = getTunnelWatchdogStatus()!;
|
|
79
|
+
expect(s.consecutiveFailures).toBe(0);
|
|
80
|
+
expect(s.recycleCount).toBe(1);
|
|
81
|
+
expect(s.lastRecycleAt).toBeGreaterThan(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not recycle on a single failure surrounded by success", async () => {
|
|
85
|
+
const recycle = vi.fn(async () => URL);
|
|
86
|
+
const fetchFn = makeFetch([
|
|
87
|
+
new Response("", { status: 200 }),
|
|
88
|
+
new Response("", { status: 502 }),
|
|
89
|
+
new Response("", { status: 200 }),
|
|
90
|
+
]);
|
|
91
|
+
startTunnelWatchdog(
|
|
92
|
+
{ getUrl: () => URL, recycle, fetchFn, log: () => {} },
|
|
93
|
+
{ intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
|
|
94
|
+
);
|
|
95
|
+
await _runTickForTest();
|
|
96
|
+
await _runTickForTest();
|
|
97
|
+
await _runTickForTest();
|
|
98
|
+
expect(recycle).not.toHaveBeenCalled();
|
|
99
|
+
expect(getTunnelWatchdogStatus()?.consecutiveFailures).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("treats recycle failure as a no-op for stats but flags it for backoff", async () => {
|
|
103
|
+
const recycle = vi.fn(async () => null); // recycle returned no URL
|
|
104
|
+
const fetchFn = makeFetch([
|
|
105
|
+
new Response("", { status: 502 }),
|
|
106
|
+
new Response("", { status: 502 }),
|
|
107
|
+
]);
|
|
108
|
+
startTunnelWatchdog(
|
|
109
|
+
{ getUrl: () => URL, recycle, fetchFn, log: () => {} },
|
|
110
|
+
{ intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
|
|
111
|
+
);
|
|
112
|
+
await _runTickForTest();
|
|
113
|
+
await _runTickForTest();
|
|
114
|
+
expect(recycle).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(getTunnelWatchdogStatus()?.recycleCount).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("skips probing when no tunnel URL", async () => {
|
|
119
|
+
const recycle = vi.fn(async () => URL);
|
|
120
|
+
const fetchFn = vi.fn();
|
|
121
|
+
startTunnelWatchdog(
|
|
122
|
+
{ getUrl: () => null, recycle, fetchFn: fetchFn as any, log: () => {} },
|
|
123
|
+
{ intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
|
|
124
|
+
);
|
|
125
|
+
await _runTickForTest();
|
|
126
|
+
expect(fetchFn).not.toHaveBeenCalled();
|
|
127
|
+
expect(recycle).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("stop clears state", () => {
|
|
131
|
+
startTunnelWatchdog(
|
|
132
|
+
{ getUrl: () => URL, recycle: vi.fn(async () => URL), log: () => {} },
|
|
133
|
+
{ intervalMs: 1000 },
|
|
134
|
+
);
|
|
135
|
+
expect(getTunnelWatchdogStatus()).not.toBeNull();
|
|
136
|
+
stopTunnelWatchdog();
|
|
137
|
+
expect(getTunnelWatchdogStatus()).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -253,6 +253,9 @@ export async function registerAuthPlugin(
|
|
|
253
253
|
// Skip health endpoint
|
|
254
254
|
if (request.url === "/api/health") return;
|
|
255
255
|
|
|
256
|
+
// Skip /v1/* — proxy auth gate handles those
|
|
257
|
+
if (request.url.startsWith("/v1/")) return;
|
|
258
|
+
|
|
256
259
|
// Skip configured bypass URL prefixes
|
|
257
260
|
if (isBypassed(request.url, authState.bypassUrls)) return;
|
|
258
261
|
|
|
@@ -69,6 +69,16 @@ export interface BootstrapState {
|
|
|
69
69
|
/** Package names that failed to install. */
|
|
70
70
|
failed: string[];
|
|
71
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* Legacy `@mariozechner/pi-coding-agent` installs detected on disk.
|
|
74
|
+
* Populated at server start and after every cleanup POST. See
|
|
75
|
+
* `legacy-pi-cleanup.ts`.
|
|
76
|
+
*/
|
|
77
|
+
legacyPiInstalls?: Array<{
|
|
78
|
+
scope: "npm-global" | "npx-cache" | "managed";
|
|
79
|
+
path: string;
|
|
80
|
+
version: string | null;
|
|
81
|
+
}>;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
export type BootstrapListener = (state: BootstrapState) => void;
|