@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.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.
- package/AGENTS.md +339 -190
- package/README.md +31 -0
- package/docs/architecture.md +238 -23
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/bridge.ts +110 -1
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +5 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +21 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +48 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +8 -7
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +46 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +18 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +57 -0
|
@@ -11,6 +11,8 @@ import path from "node:path";
|
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
13
|
import type { PiCorePackage, PiCoreUpdateResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
14
|
+
import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
|
|
15
|
+
import { prependManagedNodeToPath } from "@blackbelt-technology/pi-dashboard-shared/platform/managed-node-path.js";
|
|
14
16
|
import type { PackageManagerWrapper } from "./package-manager-wrapper.js";
|
|
15
17
|
|
|
16
18
|
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 min per package
|
|
@@ -33,10 +35,44 @@ export interface PiCoreUpdaterOptions {
|
|
|
33
35
|
onAllComplete?: () => Promise<number>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Test seams for `defaultRunNpmUpdate`. Production callers omit
|
|
40
|
+
* `_seams`; tests inject fakes to avoid real spawns.
|
|
41
|
+
*
|
|
42
|
+
* `_resolveNpm` defaults to `getDefaultRegistry().resolveExecutor("npm")`.
|
|
43
|
+
* `_spawn` defaults to `node:child_process` `spawn`.
|
|
44
|
+
* `_envBuilder` defaults to `prependManagedNodeToPath(process.env)`.
|
|
45
|
+
*/
|
|
46
|
+
export interface DefaultRunNpmUpdateSeams {
|
|
47
|
+
_resolveNpm?: () =>
|
|
48
|
+
| { ok: true; argv: string[] }
|
|
49
|
+
| { ok: false; reason: string };
|
|
50
|
+
_spawn?: typeof spawn;
|
|
51
|
+
_envBuilder?: () => NodeJS.ProcessEnv;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Default npm-update runner.
|
|
56
|
+
*
|
|
57
|
+
* After change `embed-managed-node-runtime`:
|
|
58
|
+
* - Resolves the `npm` binary via `ToolRegistry.resolve("npm")` so
|
|
59
|
+
* the managed-Node runtime (when installed) is preferred over the
|
|
60
|
+
* system PATH — the user-visible regression class this change
|
|
61
|
+
* exists to prevent (`npm update exited with code 1` on a fresh
|
|
62
|
+
* Windows install with no system Node).
|
|
63
|
+
* - Refuses to spawn a bare `"npm"` if the registry can't resolve
|
|
64
|
+
* it. Surfaces a clear `npm` unresolved error per the spec
|
|
65
|
+
* scenario "ToolRegistry resolution failure surfaces a clear
|
|
66
|
+
* error".
|
|
67
|
+
* - Prepends the managed Node directory to the spawned child's
|
|
68
|
+
* `PATH` via `prependManagedNodeToPath`, so any nested `node` /
|
|
69
|
+
* `npm` invocation inside the npm subprocess also resolves to the
|
|
70
|
+
* managed runtime.
|
|
71
|
+
*/
|
|
72
|
+
export function defaultRunNpmUpdate(
|
|
38
73
|
pkg: PiCorePackage,
|
|
39
74
|
onOutput: (line: string) => void,
|
|
75
|
+
seams: DefaultRunNpmUpdateSeams = {},
|
|
40
76
|
): Promise<void> {
|
|
41
77
|
return new Promise((resolve, reject) => {
|
|
42
78
|
const args =
|
|
@@ -50,15 +86,35 @@ function defaultRunNpmUpdate(
|
|
|
50
86
|
return;
|
|
51
87
|
}
|
|
52
88
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
89
|
+
// Resolve npm via ToolRegistry: managed runtime > override > PATH.
|
|
90
|
+
// On unresolved, refuse — do not fall back to bare spawn("npm").
|
|
91
|
+
const resolveNpm =
|
|
92
|
+
seams._resolveNpm ??
|
|
93
|
+
(() => {
|
|
94
|
+
const r = getDefaultRegistry().resolveExecutor("npm");
|
|
95
|
+
return r.ok && r.path
|
|
96
|
+
? { ok: true as const, argv: r.argv }
|
|
97
|
+
: { ok: false as const, reason: "no override, no managed runtime, no npm on PATH" };
|
|
98
|
+
});
|
|
99
|
+
const npmRes = resolveNpm();
|
|
100
|
+
if (!npmRes.ok) {
|
|
101
|
+
reject(new Error(
|
|
102
|
+
`npm could not be resolved (${npmRes.reason}). ` +
|
|
103
|
+
"Install Node.js or run `pi-dashboard repair` to restore the managed Node runtime.",
|
|
104
|
+
));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// `argv` is ready-to-spawn: on Windows + an npm-cli.js resolution
|
|
109
|
+
// it is `[node.exe, npm-cli.js]` (bypasses the .cmd shim and the
|
|
110
|
+
// cmd.exe console flash); elsewhere it is `[npm]`.
|
|
111
|
+
const [cmd, ...argvPrefix] = npmRes.argv;
|
|
112
|
+
const spawnFn = seams._spawn ?? spawn;
|
|
113
|
+
const envFn = seams._envBuilder ?? (() => prependManagedNodeToPath(process.env));
|
|
114
|
+
const child = spawnFn(cmd, [...argvPrefix, ...args], {
|
|
58
115
|
cwd,
|
|
59
116
|
stdio: ["ignore", "pipe", "pipe"],
|
|
60
|
-
env:
|
|
61
|
-
shell: process.platform === "win32", // platform-branch-ok: shell:true required on Windows so PATHEXT resolves npm.cmd (spawn('npm') without .cmd ENOENTs)
|
|
117
|
+
env: envFn(),
|
|
62
118
|
windowsHide: true,
|
|
63
119
|
});
|
|
64
120
|
|
|
@@ -5,6 +5,7 @@ import { WebSocketServer, WebSocket } from "ws";
|
|
|
5
5
|
import type { ExtensionToServerMessage, ServerToExtensionMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
|
|
6
6
|
import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
7
7
|
import type { SessionManager } from "./memory-session-manager.js";
|
|
8
|
+
import { getSpawnRegisterWatchdog } from "./spawn-register-watchdog.js";
|
|
8
9
|
|
|
9
10
|
export const HEARTBEAT_TIMEOUT = 180_000;
|
|
10
11
|
export const WS_PING_INTERVAL = 60_000;
|
|
@@ -273,6 +274,11 @@ export function createPiGateway(
|
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
if (msg.type === "session_register") {
|
|
277
|
+
// Clear spawn-register watchdog BEFORE any throwing logic. See change: spawn-failure-diagnostics.
|
|
278
|
+
const watchdog = getSpawnRegisterWatchdog();
|
|
279
|
+
if (msg.pid !== undefined) watchdog.clearByPid(msg.pid);
|
|
280
|
+
watchdog.clearByCwd(msg.cwd);
|
|
281
|
+
|
|
276
282
|
// If session ID changed (e.g., after /reload), clean up the old placeholder
|
|
277
283
|
if (currentSessionId && currentSessionId !== msg.sessionId) {
|
|
278
284
|
const oldSession = sessionManager.get(currentSessionId);
|
|
@@ -15,13 +15,14 @@
|
|
|
15
15
|
*
|
|
16
16
|
* See change: consolidate-windows-spawn-and-platform-handlers.
|
|
17
17
|
*/
|
|
18
|
-
import { existsSync, mkdirSync, openSync, closeSync } from "node:fs";
|
|
18
|
+
import { existsSync, mkdirSync, openSync, closeSync, readFileSync } from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import os from "node:os";
|
|
21
21
|
import type { ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
|
|
22
22
|
import type { SpawnStrategy } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
23
23
|
import { MANAGED_BIN } from "@blackbelt-technology/pi-dashboard-shared/managed-paths.js";
|
|
24
24
|
import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
|
|
25
|
+
import { prependManagedNodeToPath } from "@blackbelt-technology/pi-dashboard-shared/platform/managed-node-path.js";
|
|
25
26
|
import { execSync, spawnSync, buildSafeArgv } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
|
|
26
27
|
import {
|
|
27
28
|
spawnDetached,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
type SpawnMechanism,
|
|
35
36
|
type UserSpawnStrategy,
|
|
36
37
|
} from "@blackbelt-technology/pi-dashboard-shared/platform/spawn-mechanism.js";
|
|
38
|
+
import type { SpawnFailureCode } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
|
|
37
39
|
|
|
38
40
|
// ── Resolver seam (injectable for tests) ────────────────────────────────────
|
|
39
41
|
|
|
@@ -64,11 +66,29 @@ export interface SpawnResult {
|
|
|
64
66
|
process?: ChildProcess;
|
|
65
67
|
/** True when spawned from the dashboard (for writing session meta) */
|
|
66
68
|
dashboardSpawned?: boolean;
|
|
69
|
+
/** Structured failure classifier. Set on every { success: false } path. See change: spawn-failure-diagnostics. */
|
|
70
|
+
code?: SpawnFailureCode;
|
|
71
|
+
/** Tail of pi's stderr log (Windows headless PI_CRASHED only). See change: spawn-failure-diagnostics. */
|
|
72
|
+
stderr?: string;
|
|
73
|
+
/** Path to the per-session stderr log (Windows headless). Forwarded to watchdog. See change: spawn-failure-diagnostics. */
|
|
74
|
+
logPath?: string;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Build env for pi-session spawns.
|
|
79
|
+
*
|
|
80
|
+
* Order of PATH prepends (highest priority first):
|
|
81
|
+
* 1. Managed Node runtime (`<managedDir>/node/{bin,}`) when installed.
|
|
82
|
+
* See change: embed-managed-node-runtime.
|
|
83
|
+
* 2. Managed bin (`<managedDir>/node_modules/.bin`).
|
|
84
|
+
* 3. Current Node binary dir, extra bin dirs, common user bin dirs.
|
|
85
|
+
*
|
|
86
|
+
* The managed-Node prepend happens AFTER the resolver's prepends so it
|
|
87
|
+
* lands at the very head of `PATH` — spawned children invoking plain
|
|
88
|
+
* `node` / `npm` resolve to the managed runtime first.
|
|
89
|
+
*/
|
|
70
90
|
export function buildSpawnEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
71
|
-
return resolver.buildSpawnEnv(baseEnv);
|
|
91
|
+
return prependManagedNodeToPath(resolver.buildSpawnEnv(baseEnv));
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
/**
|
|
@@ -256,7 +276,7 @@ export async function spawnPiSession(
|
|
|
256
276
|
options?: SessionOptions & { electronMode?: boolean },
|
|
257
277
|
): Promise<SpawnResult> {
|
|
258
278
|
if (!existsSync(cwd)) {
|
|
259
|
-
return { success: false, message: `Directory does not exist: ${cwd}` };
|
|
279
|
+
return { success: false, code: "DIR_MISSING", message: `Directory does not exist: ${cwd}` };
|
|
260
280
|
}
|
|
261
281
|
|
|
262
282
|
const mechanism = chooseMechanism(options, options?.electronMode ?? false);
|
|
@@ -282,7 +302,7 @@ function spawnTmux(cwd: string, options?: SessionOptions): SpawnResult {
|
|
|
282
302
|
message: `Pi session spawned in tmux (${exists ? "new window" : "new session"})`,
|
|
283
303
|
};
|
|
284
304
|
} catch (err: any) {
|
|
285
|
-
return { success: false, message: `Failed to spawn session: ${err.message}` };
|
|
305
|
+
return { success: false, code: "TMUX_MISSING", message: `Failed to spawn session: ${err.message}` };
|
|
286
306
|
}
|
|
287
307
|
}
|
|
288
308
|
|
|
@@ -292,18 +312,18 @@ function spawnWslTmux(cwd: string, options?: SessionOptions): SpawnResult {
|
|
|
292
312
|
execSync(cmd, { stdio: "ignore" });
|
|
293
313
|
return { success: true, dashboardSpawned: true, message: "Pi session spawned via WSL tmux" };
|
|
294
314
|
} catch (err: any) {
|
|
295
|
-
return { success: false, message: `Failed to spawn via WSL tmux: ${err.message}` };
|
|
315
|
+
return { success: false, code: "TMUX_MISSING", message: `Failed to spawn via WSL tmux (wsl-tmux mechanism): ${err.message}` };
|
|
296
316
|
}
|
|
297
317
|
}
|
|
298
318
|
|
|
299
319
|
async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResult> {
|
|
300
320
|
const wt = resolver.which("wt");
|
|
301
321
|
if (!wt) {
|
|
302
|
-
return { success: false, message: "Windows Terminal (wt.exe) not found" };
|
|
322
|
+
return { success: false, code: "WT_MISSING", message: "Windows Terminal (wt.exe) not found" };
|
|
303
323
|
}
|
|
304
324
|
const piCmd = resolvePiCommand();
|
|
305
325
|
if (!piCmd) {
|
|
306
|
-
return { success: false, message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
|
|
326
|
+
return { success: false, code: "PI_NOT_FOUND", message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
|
|
307
327
|
}
|
|
308
328
|
|
|
309
329
|
const piArgv = [...piCmd, ...buildInteractivePiArgs(options)];
|
|
@@ -317,7 +337,7 @@ async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResu
|
|
|
317
337
|
});
|
|
318
338
|
|
|
319
339
|
if (!r.ok) {
|
|
320
|
-
return { success: false, message: `Failed to launch Windows Terminal: ${r.error}` };
|
|
340
|
+
return { success: false, code: "SPAWN_ERRNO", message: `Failed to launch Windows Terminal: ${r.error}` };
|
|
321
341
|
}
|
|
322
342
|
|
|
323
343
|
return {
|
|
@@ -334,7 +354,7 @@ async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<Spa
|
|
|
334
354
|
const env = buildSpawnEnv();
|
|
335
355
|
const piCmd = resolvePiCommand();
|
|
336
356
|
if (!piCmd) {
|
|
337
|
-
return { success: false, message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
|
|
357
|
+
return { success: false, code: "PI_NOT_FOUND", message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
|
|
338
358
|
}
|
|
339
359
|
const [bin, ...prefixArgs] = piCmd;
|
|
340
360
|
|
|
@@ -356,7 +376,7 @@ async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<Spa
|
|
|
356
376
|
env,
|
|
357
377
|
});
|
|
358
378
|
if (!r.ok) {
|
|
359
|
-
return { success: false, message: `Failed to spawn headless (Unix): ${r.error}` };
|
|
379
|
+
return { success: false, code: "SPAWN_ERRNO", message: `Failed to spawn headless (Unix): ${r.error}` };
|
|
360
380
|
}
|
|
361
381
|
return {
|
|
362
382
|
success: true,
|
|
@@ -397,6 +417,7 @@ async function spawnHeadlessDetached(
|
|
|
397
417
|
if (bin.toLowerCase().endsWith(".cmd") || bin.toLowerCase().endsWith(".bat")) {
|
|
398
418
|
return {
|
|
399
419
|
success: false,
|
|
420
|
+
code: "WIN_PI_CMD_ONLY",
|
|
400
421
|
message:
|
|
401
422
|
"Windows pi spawn requires node.exe + cli.js (managed install). " +
|
|
402
423
|
"Found only pi.cmd on PATH. Run the dashboard setup wizard or " +
|
|
@@ -465,6 +486,8 @@ async function spawnHeadlessDetached(
|
|
|
465
486
|
if (!r.ok || !r.process || !r.pid) {
|
|
466
487
|
return {
|
|
467
488
|
success: false,
|
|
489
|
+
code: "SPAWN_ERRNO",
|
|
490
|
+
logPath: logFd !== undefined ? logPath : undefined,
|
|
468
491
|
message: `Failed to spawn pi: ${r.error ?? "unknown error"}. Command: ${cmdForLog}`,
|
|
469
492
|
};
|
|
470
493
|
}
|
|
@@ -473,8 +496,16 @@ async function spawnHeadlessDetached(
|
|
|
473
496
|
// but still catch immediate crashes (missing modules, config errors).
|
|
474
497
|
const gate = await waitForNoCrash({ child: r.process, windowMs: 300 });
|
|
475
498
|
if (!gate.ok) {
|
|
499
|
+
// Read last 4 KB of stderr log for diagnostic forwarding. See change: spawn-failure-diagnostics.
|
|
500
|
+
let stderrTail: string | undefined;
|
|
501
|
+
if (logFd !== undefined) {
|
|
502
|
+
stderrTail = readLogTail(logPath);
|
|
503
|
+
}
|
|
476
504
|
return {
|
|
477
505
|
success: false,
|
|
506
|
+
code: "PI_CRASHED",
|
|
507
|
+
logPath: logFd !== undefined ? logPath : undefined,
|
|
508
|
+
stderr: stderrTail,
|
|
478
509
|
message:
|
|
479
510
|
`Pi process exited immediately (code ${gate.exitCode}). ` +
|
|
480
511
|
`See ${logPath} for details.\nCommand: ${cmdForLog}`,
|
|
@@ -487,5 +518,25 @@ async function spawnHeadlessDetached(
|
|
|
487
518
|
message: `Pi session spawned headless (pid ${r.pid})`,
|
|
488
519
|
pid: r.pid,
|
|
489
520
|
process: r.process,
|
|
521
|
+
logPath: logFd !== undefined ? logPath : undefined,
|
|
490
522
|
};
|
|
491
523
|
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Read last `maxBytes` bytes of `filePath`, stripping leading UTF-8 continuation bytes.
|
|
527
|
+
* Returns `undefined` on any error or if file is empty.
|
|
528
|
+
* See change: spawn-failure-diagnostics.
|
|
529
|
+
*/
|
|
530
|
+
function readLogTail(filePath: string, maxBytes = 4096): string | undefined {
|
|
531
|
+
try {
|
|
532
|
+
const buf = readFileSync(filePath);
|
|
533
|
+
if (!buf.length) return undefined;
|
|
534
|
+
const slice = buf.length <= maxBytes ? buf : buf.slice(buf.length - maxBytes);
|
|
535
|
+
// Strip leading UTF-8 continuation bytes (0x80..0xBF)
|
|
536
|
+
let start = 0;
|
|
537
|
+
while (start < slice.length && (slice[start]! & 0xC0) === 0x80) start++;
|
|
538
|
+
return slice.slice(start).toString("utf-8");
|
|
539
|
+
} catch {
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
@@ -29,6 +29,8 @@ export function generateState(): string {
|
|
|
29
29
|
export interface AuthCodeHandler {
|
|
30
30
|
flowType: "auth_code";
|
|
31
31
|
providerId: string;
|
|
32
|
+
/** Human-readable name surfaced to the UI. */
|
|
33
|
+
displayName: string;
|
|
32
34
|
/** Port registered with the OAuth provider for the redirect URI */
|
|
33
35
|
callbackPort: number;
|
|
34
36
|
/** Path registered with the OAuth provider for the redirect URI */
|
|
@@ -40,6 +42,8 @@ export interface AuthCodeHandler {
|
|
|
40
42
|
export interface DeviceCodeHandler {
|
|
41
43
|
flowType: "device_code";
|
|
42
44
|
providerId: string;
|
|
45
|
+
/** Human-readable name surfaced to the UI. */
|
|
46
|
+
displayName: string;
|
|
43
47
|
requestDeviceCode(enterpriseDomain?: string): Promise<DeviceCodeData>;
|
|
44
48
|
pollForToken(deviceCode: string, interval: number, expiresIn: number, extra?: Record<string, unknown>): Promise<OAuthCredential>;
|
|
45
49
|
}
|
|
@@ -93,6 +97,7 @@ const ANTHROPIC_SCOPES = "org:create_api_key user:profile user:inference user:se
|
|
|
93
97
|
export const anthropicHandler: AuthCodeHandler = {
|
|
94
98
|
flowType: "auth_code",
|
|
95
99
|
providerId: "anthropic",
|
|
100
|
+
displayName: "Anthropic (Claude Pro/Max)",
|
|
96
101
|
callbackPort: 53692,
|
|
97
102
|
callbackPath: "/callback",
|
|
98
103
|
|
|
@@ -146,6 +151,7 @@ const CODEX_SCOPE = "openid profile email offline_access";
|
|
|
146
151
|
export const codexHandler: AuthCodeHandler = {
|
|
147
152
|
flowType: "auth_code",
|
|
148
153
|
providerId: "openai-codex",
|
|
154
|
+
displayName: "ChatGPT Plus/Pro (Codex)",
|
|
149
155
|
callbackPort: 1455,
|
|
150
156
|
callbackPath: "/auth/callback",
|
|
151
157
|
|
|
@@ -206,6 +212,7 @@ function githubUrls(domain: string) {
|
|
|
206
212
|
export const githubCopilotHandler: DeviceCodeHandler = {
|
|
207
213
|
flowType: "device_code",
|
|
208
214
|
providerId: "github-copilot",
|
|
215
|
+
displayName: "GitHub Copilot",
|
|
209
216
|
|
|
210
217
|
async requestDeviceCode(enterpriseDomain) {
|
|
211
218
|
const domain = enterpriseDomain || "github.com";
|
|
@@ -336,6 +343,7 @@ const GEMINI_SCOPES = [
|
|
|
336
343
|
export const geminiCliHandler: AuthCodeHandler = {
|
|
337
344
|
flowType: "auth_code",
|
|
338
345
|
providerId: "google-gemini-cli",
|
|
346
|
+
displayName: "Google Gemini CLI",
|
|
339
347
|
callbackPort: 8085,
|
|
340
348
|
callbackPath: "/oauth2callback",
|
|
341
349
|
|
|
@@ -384,6 +392,7 @@ const AG_SCOPES = [
|
|
|
384
392
|
export const antigravityHandler: AuthCodeHandler = {
|
|
385
393
|
flowType: "auth_code",
|
|
386
394
|
providerId: "google-antigravity",
|
|
395
|
+
displayName: "Antigravity",
|
|
387
396
|
callbackPort: 51121,
|
|
388
397
|
callbackPath: "/oauth-callback",
|
|
389
398
|
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read/write ~/.pi/agent/auth.json for pi provider credentials.
|
|
3
3
|
* Uses lockfile + atomic write to avoid race conditions with running pi sessions.
|
|
4
|
+
*
|
|
5
|
+
* The OAuth provider list derives from the local handler registry
|
|
6
|
+
* (`getAllHandlers()` in provider-auth-handlers.ts). The API-key list
|
|
7
|
+
* derives from the bridge-pushed catalogue (provider-catalogue-cache.ts).
|
|
8
|
+
* See change: replace-hardcoded-provider-lists.
|
|
4
9
|
*/
|
|
5
10
|
import fs from "node:fs";
|
|
6
11
|
import path from "node:path";
|
|
7
12
|
import os from "node:os";
|
|
8
13
|
import type { ProviderAuthStatus } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
14
|
+
import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
15
|
+
import { getAllHandlers, type ProviderHandler } from "./provider-auth-handlers.js";
|
|
16
|
+
import { getLatestCatalogue } from "./provider-catalogue-cache.js";
|
|
9
17
|
|
|
10
18
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
11
19
|
|
|
@@ -21,33 +29,12 @@ export type OAuthCredential = { type: "oauth"; refresh: string; access: string;
|
|
|
21
29
|
export type AuthCredential = ApiKeyCredential | OAuthCredential;
|
|
22
30
|
export type AuthData = Record<string, AuthCredential>;
|
|
23
31
|
|
|
24
|
-
// ── OAuth provider metadata (for status display) ────────────────────────────
|
|
25
|
-
|
|
26
32
|
interface OAuthProviderMeta {
|
|
27
33
|
id: string;
|
|
28
34
|
name: string;
|
|
29
35
|
flowType: "auth_code" | "device_code";
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
const OAUTH_PROVIDERS: OAuthProviderMeta[] = [
|
|
33
|
-
{ id: "anthropic", name: "Anthropic (Claude Pro/Max)", flowType: "auth_code" },
|
|
34
|
-
{ id: "openai-codex", name: "ChatGPT Plus/Pro (Codex)", flowType: "auth_code" },
|
|
35
|
-
{ id: "github-copilot", name: "GitHub Copilot", flowType: "device_code" },
|
|
36
|
-
{ id: "google-gemini-cli", name: "Google Gemini CLI", flowType: "auth_code" },
|
|
37
|
-
{ id: "google-antigravity", name: "Antigravity", flowType: "auth_code" },
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
const API_KEY_PROVIDERS = [
|
|
41
|
-
{ id: "anthropic-api", authJsonKey: "anthropic", name: "Anthropic (API Key)" },
|
|
42
|
-
{ id: "openai", authJsonKey: "openai", name: "OpenAI" },
|
|
43
|
-
{ id: "google", authJsonKey: "google", name: "Google Gemini (API Key)" },
|
|
44
|
-
{ id: "mistral", authJsonKey: "mistral", name: "Mistral" },
|
|
45
|
-
{ id: "groq", authJsonKey: "groq", name: "Groq" },
|
|
46
|
-
{ id: "xai", authJsonKey: "xai", name: "xAI" },
|
|
47
|
-
{ id: "openrouter", authJsonKey: "openrouter", name: "OpenRouter" },
|
|
48
|
-
{ id: "zai", authJsonKey: "zai", name: "Z.ai" },
|
|
49
|
-
];
|
|
50
|
-
|
|
51
38
|
// ── Lock helpers ─────────────────────────────────────────────────────────────
|
|
52
39
|
|
|
53
40
|
function acquireLock(): void {
|
|
@@ -114,7 +101,7 @@ function writeAuthJson(data: AuthData): void {
|
|
|
114
101
|
fs.renameSync(tmp, AUTH_PATH);
|
|
115
102
|
}
|
|
116
103
|
|
|
117
|
-
// ── Public API
|
|
104
|
+
// ── Public API: write/remove ─────────────────────────────────────────────────
|
|
118
105
|
|
|
119
106
|
export function writeCredential(provider: string, credential: AuthCredential): void {
|
|
120
107
|
acquireLock();
|
|
@@ -138,63 +125,108 @@ export function removeCredential(provider: string): void {
|
|
|
138
125
|
}
|
|
139
126
|
}
|
|
140
127
|
|
|
141
|
-
|
|
142
|
-
|
|
128
|
+
// ── Pure status builder (testable) ───────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Pure derivation of `ProviderAuthStatus[]` from auth.json data, the
|
|
132
|
+
* bridge-pushed provider catalogue, and the local OAuth handler set.
|
|
133
|
+
* No I/O. See change: replace-hardcoded-provider-lists.
|
|
134
|
+
*/
|
|
135
|
+
export function _buildAuthStatus(
|
|
136
|
+
catalogue: ProviderInfo[],
|
|
137
|
+
authData: AuthData,
|
|
138
|
+
oauthHandlers: ProviderHandler[],
|
|
139
|
+
): ProviderAuthStatus[] {
|
|
143
140
|
const statuses: ProviderAuthStatus[] = [];
|
|
141
|
+
const oauthIds = new Set(oauthHandlers.map((h) => h.providerId));
|
|
144
142
|
|
|
145
|
-
// OAuth
|
|
146
|
-
for (const
|
|
147
|
-
const cred =
|
|
143
|
+
// OAuth rows from local handler registry.
|
|
144
|
+
for (const h of oauthHandlers) {
|
|
145
|
+
const cred = authData[h.providerId];
|
|
148
146
|
if (cred && cred.type === "oauth") {
|
|
149
147
|
statuses.push({
|
|
150
|
-
id:
|
|
151
|
-
name:
|
|
152
|
-
flowType:
|
|
148
|
+
id: h.providerId,
|
|
149
|
+
name: h.displayName,
|
|
150
|
+
flowType: h.flowType,
|
|
153
151
|
authenticated: true,
|
|
154
152
|
expires: (cred as OAuthCredential).expires,
|
|
155
153
|
});
|
|
156
154
|
} else {
|
|
157
155
|
statuses.push({
|
|
158
|
-
id:
|
|
159
|
-
name:
|
|
160
|
-
flowType:
|
|
156
|
+
id: h.providerId,
|
|
157
|
+
name: h.displayName,
|
|
158
|
+
flowType: h.flowType,
|
|
161
159
|
authenticated: false,
|
|
162
160
|
});
|
|
163
161
|
}
|
|
164
162
|
}
|
|
165
163
|
|
|
166
|
-
// API
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
164
|
+
// API-key rows from bridge-pushed catalogue.
|
|
165
|
+
// Skip custom providers (registered via pi.registerProvider() from
|
|
166
|
+
// ~/.pi/agent/providers.json) — those are managed by the dedicated
|
|
167
|
+
// LLM Providers settings section. OAuth rows for custom providers
|
|
168
|
+
// were already emitted above when the OAuth handler registry has
|
|
169
|
+
// a matching id.
|
|
170
|
+
for (const entry of catalogue) {
|
|
171
|
+
if (entry.custom) continue;
|
|
172
|
+
const hasOAuthCollision = oauthIds.has(entry.id);
|
|
173
|
+
const uiId = hasOAuthCollision ? `${entry.id}-api` : entry.id;
|
|
174
|
+
const displayName = hasOAuthCollision
|
|
175
|
+
? `${entry.displayName} (API Key)`
|
|
176
|
+
: entry.displayName;
|
|
177
|
+
const authJsonKey = entry.id;
|
|
178
|
+
const cred = authData[authJsonKey];
|
|
179
|
+
const hasStoredKey = !!(cred && cred.type === "api_key" && (cred as ApiKeyCredential).key);
|
|
180
|
+
|
|
181
|
+
const row: ProviderAuthStatus = {
|
|
182
|
+
id: uiId,
|
|
183
|
+
name: displayName,
|
|
175
184
|
flowType: "api_key",
|
|
176
|
-
authenticated:
|
|
185
|
+
authenticated: hasStoredKey || !!entry.ambient,
|
|
177
186
|
};
|
|
178
|
-
if (
|
|
187
|
+
if (hasStoredKey) {
|
|
179
188
|
const key = (cred as ApiKeyCredential).key;
|
|
180
|
-
|
|
189
|
+
row.maskedKey = key.length >= 12 ? `${key.slice(0, 5)}...${key.slice(-3)}` : "****";
|
|
190
|
+
} else if (entry.ambient) {
|
|
191
|
+
row.maskedKey = "(ambient)";
|
|
181
192
|
}
|
|
182
|
-
|
|
193
|
+
if (entry.envVar) row.envVar = entry.envVar;
|
|
194
|
+
if (entry.ambient) row.ambient = true;
|
|
195
|
+
statuses.push(row);
|
|
183
196
|
}
|
|
184
197
|
|
|
185
198
|
return statuses;
|
|
186
199
|
}
|
|
187
200
|
|
|
201
|
+
// ── Public API: status / OAuth meta / id resolution ─────────────────────────
|
|
202
|
+
|
|
203
|
+
export function getAuthStatus(): ProviderAuthStatus[] {
|
|
204
|
+
return _buildAuthStatus(getLatestCatalogue(), readAuthJson(), getAllHandlers());
|
|
205
|
+
}
|
|
206
|
+
|
|
188
207
|
export function getOAuthProvidersMeta(): OAuthProviderMeta[] {
|
|
189
|
-
return
|
|
208
|
+
return getAllHandlers().map((h) => ({
|
|
209
|
+
id: h.providerId,
|
|
210
|
+
name: h.displayName,
|
|
211
|
+
flowType: h.flowType,
|
|
212
|
+
}));
|
|
190
213
|
}
|
|
191
214
|
|
|
192
215
|
/**
|
|
193
216
|
* Resolve a UI provider ID to the auth.json key.
|
|
194
|
-
*
|
|
195
|
-
*
|
|
217
|
+
*
|
|
218
|
+
* The catalogue encodes API-key rows with `<id>-api` suffix when an
|
|
219
|
+
* OAuth handler exists for the same id. This unwraps the suffix back
|
|
220
|
+
* to the underlying auth.json key. OAuth ids pass through unchanged
|
|
221
|
+
* (their UI id == their auth.json key). Unknown ids pass through too,
|
|
222
|
+
* matching the previous behavior.
|
|
196
223
|
*/
|
|
197
224
|
export function resolveAuthJsonKey(providerId: string): string {
|
|
198
|
-
const
|
|
199
|
-
|
|
225
|
+
const oauthIds = new Set(getAllHandlers().map((h) => h.providerId));
|
|
226
|
+
// <id>-api suffix → strip suffix iff the bare id is an OAuth handler.
|
|
227
|
+
if (providerId.endsWith("-api")) {
|
|
228
|
+
const bare = providerId.slice(0, -"-api".length);
|
|
229
|
+
if (oauthIds.has(bare)) return bare;
|
|
230
|
+
}
|
|
231
|
+
return providerId;
|
|
200
232
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache of provider catalogues pushed by bridges.
|
|
3
|
+
*
|
|
4
|
+
* Each pi process pushes a `providers_list` over WS, derived from its
|
|
5
|
+
* `ModelRegistry`. The server caches per-session and tracks the most-recent
|
|
6
|
+
* snapshot. `GET /api/provider-auth/status` reads `getLatestCatalogue()`.
|
|
7
|
+
*
|
|
8
|
+
* See change: replace-hardcoded-provider-lists.
|
|
9
|
+
*/
|
|
10
|
+
import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
11
|
+
|
|
12
|
+
const bySession = new Map<string, ProviderInfo[]>();
|
|
13
|
+
let latest: ProviderInfo[] | null = null;
|
|
14
|
+
|
|
15
|
+
export function setCatalogueForSession(sessionId: string, providers: ProviderInfo[]): void {
|
|
16
|
+
bySession.set(sessionId, providers);
|
|
17
|
+
latest = providers;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getCatalogueForSession(sessionId: string): ProviderInfo[] | undefined {
|
|
21
|
+
return bySession.get(sessionId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Most recent catalogue across any session. Returns [] when no bridge
|
|
26
|
+
* has pushed yet — callers should treat that as "waiting for pi".
|
|
27
|
+
*/
|
|
28
|
+
export function getLatestCatalogue(): ProviderInfo[] {
|
|
29
|
+
return latest ?? [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function clearForSession(sessionId: string): void {
|
|
33
|
+
bySession.delete(sessionId);
|
|
34
|
+
if (bySession.size === 0) latest = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Test-only: reset all cached state. */
|
|
38
|
+
export function _resetForTests(): void {
|
|
39
|
+
bySession.clear();
|
|
40
|
+
latest = null;
|
|
41
|
+
}
|