@blackbelt-technology/pi-agent-dashboard 0.2.8 → 0.3.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 +114 -9
- package/README.md +218 -97
- package/docs/architecture.md +107 -7
- package/package.json +9 -4
- package/packages/extension/package.json +1 -1
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
- package/packages/extension/src/ask-user-tool.ts +289 -20
- package/packages/extension/src/bridge.ts +38 -4
- package/packages/extension/src/command-handler.ts +34 -39
- package/packages/extension/src/prompt-expander.ts +25 -4
- package/packages/server/package.json +2 -1
- package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
- package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
- package/packages/server/src/__tests__/browse-endpoint.test.ts +229 -10
- package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
- package/packages/server/src/__tests__/cors.test.ts +34 -2
- package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
- package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +3 -2
- package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
- package/packages/server/src/__tests__/git-operations.test.ts +9 -7
- package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
- package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +122 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
- package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
- package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
- package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
- package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
- package/packages/server/src/__tests__/tunnel.test.ts +91 -0
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
- package/packages/server/src/browse.ts +100 -6
- package/packages/server/src/browser-gateway.ts +16 -3
- package/packages/server/src/editor-manager.ts +20 -1
- package/packages/server/src/editor-pid-registry.ts +198 -0
- package/packages/server/src/fix-pty-permissions.ts +44 -0
- package/packages/server/src/headless-pid-registry.ts +9 -0
- package/packages/server/src/npm-search-proxy.ts +71 -0
- package/packages/server/src/openspec-tasks.ts +158 -0
- package/packages/server/src/package-manager-wrapper.ts +31 -0
- package/packages/server/src/pi-core-checker.ts +290 -0
- package/packages/server/src/pi-core-updater.ts +166 -0
- package/packages/server/src/pi-gateway.ts +7 -0
- package/packages/server/src/process-manager.ts +1 -1
- package/packages/server/src/routes/file-routes.ts +30 -3
- package/packages/server/src/routes/openspec-routes.ts +83 -1
- package/packages/server/src/routes/pi-core-routes.ts +117 -0
- package/packages/server/src/routes/provider-auth-routes.ts +4 -2
- package/packages/server/src/routes/provider-routes.ts +12 -2
- package/packages/server/src/routes/recommended-routes.ts +227 -0
- package/packages/server/src/routes/system-routes.ts +10 -1
- package/packages/server/src/server.ts +151 -15
- package/packages/server/src/terminal-manager.ts +4 -0
- package/packages/server/src/test-env-guard.ts +26 -0
- package/packages/server/src/test-support/test-server.ts +63 -0
- package/packages/server/src/tunnel.ts +132 -8
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/config.test.ts +3 -3
- package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +123 -0
- package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
- package/packages/shared/src/browser-protocol.ts +23 -1
- package/packages/shared/src/openspec-poller.ts +8 -3
- package/packages/shared/src/recommended-extensions.ts +180 -0
- package/packages/shared/src/rest-api.ts +71 -0
- package/packages/shared/src/source-matching.ts +126 -0
- package/packages/shared/src/test-support/setup-home.ts +74 -0
- package/packages/shared/src/types.ts +7 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST route for the dashboard's curated "recommended extensions" list.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/packages/recommended
|
|
5
|
+
*
|
|
6
|
+
* Returns the static RECOMMENDED_EXTENSIONS manifest enriched with:
|
|
7
|
+
* - live description + version from npm or GitHub (falls back to
|
|
8
|
+
* fallbackDescription on network failure)
|
|
9
|
+
* - installed.scope cross-reference via packageManagerWrapper
|
|
10
|
+
* - activeInPi flag from ~/.pi/agent/settings.json packages[]
|
|
11
|
+
* - updateAvailable flag
|
|
12
|
+
*
|
|
13
|
+
* Results are cached for 60 seconds. The cache is busted when any package
|
|
14
|
+
* install / remove / update operation completes successfully.
|
|
15
|
+
*/
|
|
16
|
+
import type { FastifyInstance } from "fastify";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
21
|
+
import type { EnrichedRecommendedExtension } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
22
|
+
import {
|
|
23
|
+
RECOMMENDED_EXTENSIONS,
|
|
24
|
+
type RecommendedExtension,
|
|
25
|
+
} from "@blackbelt-technology/pi-dashboard-shared/recommended-extensions.js";
|
|
26
|
+
import {
|
|
27
|
+
parseSourceKey,
|
|
28
|
+
sourcesMatch,
|
|
29
|
+
type SourceKey,
|
|
30
|
+
} from "@blackbelt-technology/pi-dashboard-shared/source-matching.js";
|
|
31
|
+
export { parseSourceKey, sourcesMatch, type SourceKey };
|
|
32
|
+
import {
|
|
33
|
+
fetchPackageMeta,
|
|
34
|
+
fetchGithubPackageJson,
|
|
35
|
+
type PackageMeta,
|
|
36
|
+
} from "../npm-search-proxy.js";
|
|
37
|
+
import type { PackageManagerWrapper } from "../package-manager-wrapper.js";
|
|
38
|
+
|
|
39
|
+
const CACHE_TTL_MS = 60 * 1000;
|
|
40
|
+
|
|
41
|
+
interface CacheEntry {
|
|
42
|
+
at: number;
|
|
43
|
+
data: EnrichedRecommendedExtension[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let cache: CacheEntry | null = null;
|
|
47
|
+
|
|
48
|
+
/** Invalidate the recommended-extensions cache. */
|
|
49
|
+
export function invalidateRecommendedCache(): void {
|
|
50
|
+
cache = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse a pi install source into a lookup key for matching against
|
|
55
|
+
* listInstalled() results.
|
|
56
|
+
*
|
|
57
|
+
* Supported forms (matches pi's DefaultPackageManager.parseSource):
|
|
58
|
+
* npm:<name>[@<version>]
|
|
59
|
+
* git@<host>:<owner>/<repo>.git
|
|
60
|
+
* git:<host>/<owner>/<repo>[#<ref>]
|
|
61
|
+
* https://<host>/<owner>/<repo>[.git][#<ref>]
|
|
62
|
+
*
|
|
63
|
+
* Returns:
|
|
64
|
+
* { kind: "npm", name } for npm sources
|
|
65
|
+
* { kind: "git", host, owner, repo } for git sources
|
|
66
|
+
* { kind: "raw", source } for anything else (local paths)
|
|
67
|
+
*
|
|
68
|
+
* Source-matching logic lives in
|
|
69
|
+
* `@blackbelt-technology/pi-dashboard-shared/source-matching.js` so the
|
|
70
|
+
* Electron wizard's bootstrap enricher can apply the same rules without
|
|
71
|
+
* depending on the server runtime. We re-export above so existing
|
|
72
|
+
* imports from this module keep working.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** Read pi's project-local `.pi/settings.json` (if any) for the given cwd. */
|
|
76
|
+
function readLocalSources(cwd: string): string[] {
|
|
77
|
+
const settingsPath = path.join(cwd, ".pi", "settings.json");
|
|
78
|
+
try {
|
|
79
|
+
if (!fs.existsSync(settingsPath)) return [];
|
|
80
|
+
const raw = fs.readFileSync(settingsPath, "utf-8").trim();
|
|
81
|
+
if (!raw) return [];
|
|
82
|
+
const data = JSON.parse(raw);
|
|
83
|
+
const pkgs = Array.isArray(data?.packages) ? (data.packages as unknown[]) : [];
|
|
84
|
+
return pkgs.filter((p): p is string => typeof p === "string");
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Collect active package sources from both the user's global
|
|
91
|
+
* `~/.pi/agent/settings.json` and the project's `<cwd>/.pi/settings.json`.
|
|
92
|
+
* Mirrors pi's SettingsManager behavior: a package is "active" in pi if
|
|
93
|
+
* it appears in EITHER scope's packages[] list. */
|
|
94
|
+
function readActiveSources(cwd?: string): string[] {
|
|
95
|
+
const sources: string[] = [];
|
|
96
|
+
|
|
97
|
+
const globalPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
98
|
+
try {
|
|
99
|
+
if (fs.existsSync(globalPath)) {
|
|
100
|
+
const raw = fs.readFileSync(globalPath, "utf-8").trim();
|
|
101
|
+
if (raw) {
|
|
102
|
+
const data = JSON.parse(raw);
|
|
103
|
+
const pkgs = Array.isArray(data?.packages) ? (data.packages as unknown[]) : [];
|
|
104
|
+
for (const p of pkgs) if (typeof p === "string") sources.push(p);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
/* ignore corrupt global settings */
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (cwd) {
|
|
112
|
+
for (const p of readLocalSources(cwd)) sources.push(p);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return sources;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function semverOlder(installed: string | undefined, latest: string | undefined): boolean {
|
|
119
|
+
if (!installed || !latest) return false;
|
|
120
|
+
if (installed === latest) return false;
|
|
121
|
+
// Very conservative comparison: if they differ textually, assume an
|
|
122
|
+
// update may be available. The Packages tab's check-updates flow can
|
|
123
|
+
// resolve the definitive answer.
|
|
124
|
+
return installed !== latest;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function enrichEntry(
|
|
128
|
+
entry: RecommendedExtension,
|
|
129
|
+
installedGlobal: Array<{ source: string; installedPath?: string }>,
|
|
130
|
+
installedLocal: Array<{ source: string; installedPath?: string }>,
|
|
131
|
+
activeSources: string[],
|
|
132
|
+
): Promise<EnrichedRecommendedExtension> {
|
|
133
|
+
const key = parseSourceKey(entry.source);
|
|
134
|
+
let meta: PackageMeta | null = null;
|
|
135
|
+
|
|
136
|
+
if (key.kind === "npm") {
|
|
137
|
+
meta = await fetchPackageMeta(key.name);
|
|
138
|
+
} else if (key.kind === "git" && key.host.toLowerCase() === "github.com") {
|
|
139
|
+
meta = await fetchGithubPackageJson(key.owner, key.repo);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const description = meta?.description ?? entry.fallbackDescription;
|
|
143
|
+
const version = meta?.version;
|
|
144
|
+
|
|
145
|
+
const inGlobal = installedGlobal.some((p) => sourcesMatch(p.source, entry.source));
|
|
146
|
+
const inLocal = installedLocal.some((p) => sourcesMatch(p.source, entry.source));
|
|
147
|
+
const installedScope: "global" | "local" | null = inGlobal
|
|
148
|
+
? "global"
|
|
149
|
+
: inLocal
|
|
150
|
+
? "local"
|
|
151
|
+
: null;
|
|
152
|
+
|
|
153
|
+
const activeInPi = activeSources.some((s) => sourcesMatch(s, entry.source));
|
|
154
|
+
|
|
155
|
+
// Best-effort update indicator: for npm sources, try to read the installed
|
|
156
|
+
// package.json version and compare to the live registry version. For git
|
|
157
|
+
// sources we currently don't track ref pins, so updateAvailable defaults
|
|
158
|
+
// to false (the Packages-tab check-updates action handles this separately).
|
|
159
|
+
let updateAvailable = false;
|
|
160
|
+
if (version && key.kind === "npm" && installedScope) {
|
|
161
|
+
const installed = inGlobal ? installedGlobal : installedLocal;
|
|
162
|
+
const match = installed.find((p) => sourcesMatch(p.source, entry.source));
|
|
163
|
+
if (match?.installedPath) {
|
|
164
|
+
try {
|
|
165
|
+
const pj = path.join(match.installedPath, "package.json");
|
|
166
|
+
if (fs.existsSync(pj)) {
|
|
167
|
+
const parsed = JSON.parse(fs.readFileSync(pj, "utf-8"));
|
|
168
|
+
updateAvailable = semverOlder(parsed?.version, version);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
/* ignore */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
...entry,
|
|
178
|
+
description,
|
|
179
|
+
version,
|
|
180
|
+
installed: { scope: installedScope },
|
|
181
|
+
activeInPi,
|
|
182
|
+
updateAvailable,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function registerRecommendedRoutes(
|
|
187
|
+
fastify: FastifyInstance,
|
|
188
|
+
deps: { packageManagerWrapper: PackageManagerWrapper },
|
|
189
|
+
): void {
|
|
190
|
+
fastify.get("/api/packages/recommended", async () => {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
if (cache && now - cache.at < CACHE_TTL_MS) {
|
|
193
|
+
return { success: true, data: { recommended: cache.data } } satisfies ApiResponse<{
|
|
194
|
+
recommended: EnrichedRecommendedExtension[];
|
|
195
|
+
}>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let installedGlobal: Array<{ source: string; installedPath?: string }> = [];
|
|
199
|
+
let installedLocal: Array<{ source: string; installedPath?: string }> = [];
|
|
200
|
+
try {
|
|
201
|
+
installedGlobal = (await deps.packageManagerWrapper.listInstalled("global")) as any[];
|
|
202
|
+
} catch {
|
|
203
|
+
/* proceed with empty */
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
installedLocal = (await deps.packageManagerWrapper.listInstalled("local")) as any[];
|
|
207
|
+
} catch {
|
|
208
|
+
/* proceed with empty */
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Include both global + project-local settings.json `packages[]`.
|
|
212
|
+
// The server's CWD is a reasonable proxy for the active project.
|
|
213
|
+
const activeSources = readActiveSources(process.cwd());
|
|
214
|
+
|
|
215
|
+
const enriched = await Promise.all(
|
|
216
|
+
RECOMMENDED_EXTENSIONS.map((entry) =>
|
|
217
|
+
enrichEntry(entry, installedGlobal, installedLocal, activeSources),
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
cache = { at: now, data: enriched };
|
|
222
|
+
|
|
223
|
+
return { success: true, data: { recommended: enriched } } satisfies ApiResponse<{
|
|
224
|
+
recommended: EnrichedRecommendedExtension[];
|
|
225
|
+
}>;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -147,7 +147,9 @@ export function registerSystemRoutes(
|
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
fastify.post("/api/tunnel-disconnect", async () => {
|
|
150
|
-
|
|
150
|
+
// Pass port so orphan zrok processes bound to this endpoint are also
|
|
151
|
+
// swept (not just the one we tracked via pid-file).
|
|
152
|
+
await deleteTunnel(config.port);
|
|
151
153
|
return { ok: true };
|
|
152
154
|
});
|
|
153
155
|
|
|
@@ -186,6 +188,9 @@ export function registerSystemRoutes(
|
|
|
186
188
|
async () => {
|
|
187
189
|
metaPersistence.flushAll();
|
|
188
190
|
preferencesStore.flush();
|
|
191
|
+
// Tear down the zrok tunnel (and sweep orphans on our port) so restarts
|
|
192
|
+
// don't leak reservations that leave stale URLs backed by nothing.
|
|
193
|
+
try { await deleteTunnel(config.port); } catch { /* best-effort */ }
|
|
189
194
|
setTimeout(() => process.exit(0), 100);
|
|
190
195
|
return { ok: true };
|
|
191
196
|
},
|
|
@@ -199,6 +204,10 @@ export function registerSystemRoutes(
|
|
|
199
204
|
metaPersistence.flushAll();
|
|
200
205
|
preferencesStore.flush();
|
|
201
206
|
|
|
207
|
+
// Tear down tunnel before spawning the replacement process so the new
|
|
208
|
+
// server doesn't race an orphan zrok agent on the same port.
|
|
209
|
+
try { await deleteTunnel(config.port); } catch { /* best-effort */ }
|
|
210
|
+
|
|
202
211
|
const cliPath = process.argv[1];
|
|
203
212
|
if (!cliPath) return { ok: false, error: "Cannot determine CLI path" };
|
|
204
213
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import Fastify from "fastify";
|
|
5
5
|
import fastifyStatic from "@fastify/static";
|
|
6
6
|
import cors from "@fastify/cors";
|
|
7
|
+
import compress from "@fastify/compress";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
import os from "node:os";
|
|
@@ -29,7 +30,7 @@ import { createIdleTimer } from "./idle-timer.js";
|
|
|
29
30
|
import { discoverAndBroadcastSessions } from "./session-bootstrap.js";
|
|
30
31
|
import { scanAllSessions } from "./session-scanner.js";
|
|
31
32
|
import { needsMigration, runMigration } from "./migrate-persistence.js";
|
|
32
|
-
import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel } from "./tunnel.js";
|
|
33
|
+
import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel, scavengeOrphanZrokProcesses, getTunnelUrl } from "./tunnel.js";
|
|
33
34
|
import { registerAuthPlugin, validateWsUpgrade } from "./auth-plugin.js";
|
|
34
35
|
import { findBundledExtension, registerBridgeExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
|
|
35
36
|
import { createNetworkGuard, isLoopback, isBypassedHost } from "./localhost-guard.js";
|
|
@@ -42,9 +43,14 @@ import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
|
|
|
42
43
|
import { registerSystemRoutes } from "./routes/system-routes.js";
|
|
43
44
|
import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
|
|
44
45
|
import { registerPackageRoutes } from "./routes/package-routes.js";
|
|
46
|
+
import { registerRecommendedRoutes, invalidateRecommendedCache } from "./routes/recommended-routes.js";
|
|
47
|
+
import { registerPiCoreRoutes } from "./routes/pi-core-routes.js";
|
|
48
|
+
import { PiCoreChecker } from "./pi-core-checker.js";
|
|
49
|
+
import { PiCoreUpdater } from "./pi-core-updater.js";
|
|
45
50
|
import { registerProviderRoutes } from "./routes/provider-routes.js";
|
|
46
51
|
import { PackageManagerWrapper } from "./package-manager-wrapper.js";
|
|
47
52
|
import { createEditorManager, type EditorManager } from "./editor-manager.js";
|
|
53
|
+
import { createEditorPidRegistry } from "./editor-pid-registry.js";
|
|
48
54
|
import { registerEditorRoutes } from "./routes/editor-routes.js";
|
|
49
55
|
import { registerKnownServersRoutes } from "./routes/known-servers-routes.js";
|
|
50
56
|
import { registerEditorProxy, handleEditorUpgrade } from "./editor-proxy.js";
|
|
@@ -79,6 +85,10 @@ export interface DashboardServer {
|
|
|
79
85
|
sessionManager: SessionManager;
|
|
80
86
|
eventStore: EventStore;
|
|
81
87
|
browserGateway: BrowserGateway;
|
|
88
|
+
/** Resolved HTTP port after start() (useful for port:0 in tests). Returns null if not listening. */
|
|
89
|
+
httpPort(): number | null;
|
|
90
|
+
/** Resolved pi gateway port after start(). Returns null if not listening. */
|
|
91
|
+
piPort(): number | null;
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
export async function createServer(config: ServerConfig): Promise<DashboardServer> {
|
|
@@ -190,9 +200,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
190
200
|
|
|
191
201
|
// Create editor manager for code-server instances
|
|
192
202
|
const editorDetection = detectCodeServerBinary(config.editor);
|
|
203
|
+
const editorPidRegistry = createEditorPidRegistry();
|
|
193
204
|
const editorManager = createEditorManager({
|
|
194
205
|
config: config.editor,
|
|
195
206
|
detection: editorDetection,
|
|
207
|
+
pidRegistry: editorPidRegistry,
|
|
196
208
|
onStatusChange: (cwd, id, status) => {
|
|
197
209
|
browserGateway.broadcastToAll({ type: "editor_status", cwd, id, status });
|
|
198
210
|
},
|
|
@@ -243,23 +255,62 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
243
255
|
connectionTimeout: 10_000,
|
|
244
256
|
});
|
|
245
257
|
|
|
246
|
-
//
|
|
258
|
+
// Compression: gzip/deflate for HTTP responses. Critical for large client
|
|
259
|
+
// bundles (~3 MB JS) served over tunnels like zrok which abort big transfers.
|
|
260
|
+
// Brotli is intentionally disabled — zrok's free public proxy has been
|
|
261
|
+
// observed to truncate/stream-reset `content-encoding: br` responses under
|
|
262
|
+
// parallel browser load (curl succeeds, Chrome reports ERR_ABORTED 500).
|
|
263
|
+
// gzip is universally supported and round-trips cleanly through zrok.
|
|
264
|
+
// threshold=1024 skips tiny responses; global=true compresses all routes.
|
|
265
|
+
await fastify.register(compress, {
|
|
266
|
+
global: true,
|
|
267
|
+
threshold: 1024,
|
|
268
|
+
encodings: ["gzip", "deflate"],
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// CORS: allow localhost, the active zrok tunnel URL, any *.share.zrok.io
|
|
272
|
+
// host (so tunnel URL rotation doesn't break loads), and configured origins.
|
|
273
|
+
//
|
|
274
|
+
// Two critical correctness notes:
|
|
275
|
+
// (1) Vite emits `<script type="module" crossorigin>` tags, which browsers
|
|
276
|
+
// always request in CORS mode — even when same-origin. If the server
|
|
277
|
+
// doesn't emit `Access-Control-Allow-Origin` for the request's own
|
|
278
|
+
// origin, the browser aborts the script with ERR_ABORTED 500. So when
|
|
279
|
+
// accessed via a tunnel URL, that URL MUST be in the allow list or all
|
|
280
|
+
// asset loads fail in the browser (while curl — which sends no Origin
|
|
281
|
+
// header — works fine). This is the exact failure mode that looked
|
|
282
|
+
// like a zrok problem for hours of debugging.
|
|
283
|
+
// (2) On origin mismatch, return `cb(null, false)` (no CORS headers) rather
|
|
284
|
+
// than `cb(new Error(…), false)`. The latter causes @fastify/cors to
|
|
285
|
+
// surface the error as HTTP 500 on every asset — far worse than
|
|
286
|
+
// silently omitting CORS headers and letting the browser enforce its
|
|
287
|
+
// own same-origin policy.
|
|
247
288
|
const corsAllowedOrigins = config.corsAllowedOrigins ?? [];
|
|
248
289
|
await fastify.register(cors, {
|
|
249
290
|
origin: (origin, cb) => {
|
|
250
|
-
// Same-origin (no Origin header) — always allow
|
|
291
|
+
// Same-origin navigation (no Origin header) — always allow.
|
|
251
292
|
if (!origin) return cb(null, true);
|
|
252
|
-
// Localhost / 127.0.0.1 / [::1] — any port
|
|
253
293
|
try {
|
|
254
294
|
const u = new URL(origin);
|
|
255
295
|
const host = u.hostname;
|
|
296
|
+
// Loopback — any port.
|
|
256
297
|
if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1") {
|
|
257
298
|
return cb(null, true);
|
|
258
299
|
}
|
|
259
|
-
|
|
260
|
-
|
|
300
|
+
// Active zrok tunnel URL — checked dynamically so URL rotation is
|
|
301
|
+
// picked up without a server restart.
|
|
302
|
+
const tunnelUrl = getTunnelUrl();
|
|
303
|
+
if (tunnelUrl && origin === tunnelUrl) return cb(null, true);
|
|
304
|
+
// Any *.share.zrok.io host — covers the brief window between a new
|
|
305
|
+
// reservation being created and the in-memory `activeTunnelUrl`
|
|
306
|
+
// being populated, plus any other zrok share the user points at us.
|
|
307
|
+
if (host.endsWith(".share.zrok.io")) return cb(null, true);
|
|
308
|
+
} catch { /* ignore URL parse errors */ }
|
|
309
|
+
// Explicitly configured origins.
|
|
261
310
|
if (corsAllowedOrigins.includes(origin)) return cb(null, true);
|
|
262
|
-
|
|
311
|
+
// Unknown cross-origin request — don't emit CORS headers, but don't
|
|
312
|
+
// 500 either. Browser will block the request for us.
|
|
313
|
+
cb(null, false);
|
|
263
314
|
},
|
|
264
315
|
credentials: true,
|
|
265
316
|
});
|
|
@@ -294,7 +345,16 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
294
345
|
registerSessionRoutes(fastify, { sessionManager, eventStore, networkGuard });
|
|
295
346
|
registerGitRoutes(fastify, { networkGuard });
|
|
296
347
|
registerFileRoutes(fastify, { sessionManager, preferencesStore, networkGuard });
|
|
297
|
-
registerOpenSpecRoutes(fastify, {
|
|
348
|
+
registerOpenSpecRoutes(fastify, {
|
|
349
|
+
sessionManager,
|
|
350
|
+
preferencesStore,
|
|
351
|
+
directoryService,
|
|
352
|
+
networkGuard,
|
|
353
|
+
onOpenSpecChanged: (cwd) => {
|
|
354
|
+
const data = directoryService.getOpenSpecData(cwd);
|
|
355
|
+
if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
|
|
356
|
+
},
|
|
357
|
+
});
|
|
298
358
|
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion });
|
|
299
359
|
// Package management
|
|
300
360
|
const packageManagerWrapper = new PackageManagerWrapper();
|
|
@@ -304,7 +364,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
304
364
|
browserGateway.broadcastToAll({ type: "package_progress", operationId, event } as any);
|
|
305
365
|
});
|
|
306
366
|
|
|
307
|
-
// On completion: broadcast to browsers
|
|
367
|
+
// On completion: broadcast to browsers + invalidate the recommended cache
|
|
308
368
|
packageManagerWrapper.setCompleteListener((result) => {
|
|
309
369
|
browserGateway.broadcastToAll({
|
|
310
370
|
type: "package_operation_complete",
|
|
@@ -316,6 +376,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
316
376
|
error: result.error,
|
|
317
377
|
sessionsReloaded: (result as any).sessionsReloaded,
|
|
318
378
|
} as any);
|
|
379
|
+
if (result.success) invalidateRecommendedCache();
|
|
319
380
|
});
|
|
320
381
|
|
|
321
382
|
// Reload all active sessions after a successful package operation
|
|
@@ -337,14 +398,60 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
337
398
|
});
|
|
338
399
|
|
|
339
400
|
registerPackageRoutes(fastify, { packageManagerWrapper });
|
|
401
|
+
registerRecommendedRoutes(fastify, { packageManagerWrapper });
|
|
402
|
+
|
|
403
|
+
// Pi core version check + update (complements the extension package manager).
|
|
404
|
+
const piCoreChecker = new PiCoreChecker();
|
|
405
|
+
const piCoreUpdater = new PiCoreUpdater({
|
|
406
|
+
packageManagerWrapper,
|
|
407
|
+
onAllComplete: async () => {
|
|
408
|
+
const connectedIds = piGateway.getConnectedSessionIds();
|
|
409
|
+
let count = 0;
|
|
410
|
+
for (const sid of connectedIds) {
|
|
411
|
+
const session = sessionManager.get(sid);
|
|
412
|
+
if (session && session.status !== "ended") {
|
|
413
|
+
piGateway.sendToSession(sid, {
|
|
414
|
+
type: "send_prompt",
|
|
415
|
+
sessionId: sid,
|
|
416
|
+
text: "/reload",
|
|
417
|
+
});
|
|
418
|
+
count++;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return count;
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
piCoreUpdater.setProgressListener((event) => {
|
|
425
|
+
browserGateway.broadcastToAll({
|
|
426
|
+
type: "pi_core_update_progress",
|
|
427
|
+
name: event.name,
|
|
428
|
+
phase: event.phase,
|
|
429
|
+
message: event.message,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
registerPiCoreRoutes(fastify, {
|
|
433
|
+
piCoreChecker,
|
|
434
|
+
piCoreUpdater,
|
|
435
|
+
onUpdateComplete: (payload) => {
|
|
436
|
+
browserGateway.broadcastToAll({
|
|
437
|
+
type: "pi_core_update_complete",
|
|
438
|
+
results: payload.results,
|
|
439
|
+
sessionsReloaded: payload.sessionsReloaded,
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
});
|
|
340
443
|
|
|
341
|
-
// Editor (code-server) routes and proxy
|
|
444
|
+
// Editor (code-server) routes and proxy.
|
|
445
|
+
// NOTE: routes are *registered* here but cannot dispatch until fastify.listen runs
|
|
446
|
+
// inside server.start(). The orphan sweep in editorPidRegistry.cleanupOrphans()
|
|
447
|
+
// runs at the top of server.start() BEFORE fastify.listen, so any
|
|
448
|
+
// POST /api/editor/start call is guaranteed to see a post-sweep clean state.
|
|
342
449
|
registerEditorRoutes(fastify, editorManager, { networkGuard });
|
|
343
450
|
registerEditorProxy(fastify, editorManager);
|
|
344
451
|
|
|
345
|
-
registerProviderAuthRoutes(fastify, { piGateway });
|
|
452
|
+
registerProviderAuthRoutes(fastify, { piGateway, browserGateway });
|
|
346
453
|
registerKnownServersRoutes(fastify, { networkGuard, getPeerServers: () => peerServers });
|
|
347
|
-
registerProviderRoutes(fastify, { networkGuard });
|
|
454
|
+
registerProviderRoutes(fastify, { networkGuard, piGateway, browserGateway });
|
|
348
455
|
|
|
349
456
|
// Serve static files / SPA fallback
|
|
350
457
|
// Search order: npm package → workspace sibling → legacy dist/client
|
|
@@ -371,6 +478,13 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
371
478
|
await fastify.register(fastifyStatic, {
|
|
372
479
|
root: clientDir,
|
|
373
480
|
prefix: "/",
|
|
481
|
+
// Serve pre-compressed sibling files (assets/foo.js.gz alongside foo.js)
|
|
482
|
+
// directly when the client accepts gzip. This gives every compressed
|
|
483
|
+
// response a stable Content-Length header — dynamic compression via
|
|
484
|
+
// @fastify/compress streams responses without Content-Length, which
|
|
485
|
+
// some HTTP/2 proxy chains (notably zrok free-tier) occasionally
|
|
486
|
+
// stream-reset as ERR_ABORTED 500 in browsers.
|
|
487
|
+
preCompressed: true,
|
|
374
488
|
setHeaders: (res, filePath) => {
|
|
375
489
|
if (filePath.endsWith(".html")) {
|
|
376
490
|
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
@@ -435,10 +549,23 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
435
549
|
eventStore,
|
|
436
550
|
browserGateway,
|
|
437
551
|
|
|
552
|
+
httpPort() {
|
|
553
|
+
const addr = fastify.server.address();
|
|
554
|
+
if (addr && typeof addr === "object") return addr.port;
|
|
555
|
+
return null;
|
|
556
|
+
},
|
|
557
|
+
piPort() {
|
|
558
|
+
return piGateway.address();
|
|
559
|
+
},
|
|
560
|
+
|
|
438
561
|
async start() {
|
|
439
562
|
// Clean up orphan headless processes from a previous server instance
|
|
440
563
|
browserGateway.headlessPidRegistry.cleanupOrphans();
|
|
441
564
|
|
|
565
|
+
// Clean up orphan code-server processes from a previous server instance.
|
|
566
|
+
// Runs before fastify.listen, so no editor start request can race with the sweep.
|
|
567
|
+
await editorPidRegistry.cleanupOrphans();
|
|
568
|
+
|
|
442
569
|
piGateway.start(config.piPort);
|
|
443
570
|
|
|
444
571
|
fastify.server.on("upgrade", (request, socket, head) => {
|
|
@@ -501,10 +628,19 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
501
628
|
console.warn(`mDNS browser failed (peer discovery disabled):`, err);
|
|
502
629
|
}
|
|
503
630
|
|
|
631
|
+
// Always sweep leftover zrok processes on startup, even when tunnel is
|
|
632
|
+
// disabled (--no-tunnel). Orphans from a previous run hold reservations
|
|
633
|
+
// on the zrok edge and keep old URLs "alive but broken" until their
|
|
634
|
+
// agents are killed. Scavenge runs unconditionally when the binary is
|
|
635
|
+
// present; the tunnel-creation branch below is gated separately.
|
|
636
|
+
const hasZrok = detectZrokBinary();
|
|
637
|
+
if (hasZrok) {
|
|
638
|
+
cleanupStaleZrok();
|
|
639
|
+
scavengeOrphanZrokProcesses(config.port);
|
|
640
|
+
}
|
|
641
|
+
|
|
504
642
|
if (config.tunnel) {
|
|
505
|
-
const hasZrok = detectZrokBinary();
|
|
506
643
|
if (hasZrok) {
|
|
507
|
-
cleanupStaleZrok();
|
|
508
644
|
const tunnelUrl = await createTunnel(config.port, config.tunnelReservedToken);
|
|
509
645
|
if (tunnelUrl) {
|
|
510
646
|
console.log(`🌐 Tunnel: ${tunnelUrl}`);
|
|
@@ -534,7 +670,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
534
670
|
preferencesStore.flush();
|
|
535
671
|
preferencesStore.dispose();
|
|
536
672
|
|
|
537
|
-
await deleteTunnel();
|
|
673
|
+
await deleteTunnel(config.port);
|
|
538
674
|
piGateway.stop();
|
|
539
675
|
for (const client of browserGateway.wss.clients) {
|
|
540
676
|
client.terminate();
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import * as pty from "node-pty";
|
|
5
5
|
import type { IPty } from "node-pty";
|
|
6
6
|
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { fixPtyPermissions } from "./fix-pty-permissions.js";
|
|
7
8
|
import type { TerminalSession, TerminalControlMessage } from "@blackbelt-technology/pi-dashboard-shared/terminal-types.js";
|
|
8
9
|
import type { WebSocket } from "ws";
|
|
9
10
|
|
|
@@ -99,6 +100,9 @@ function generateId(): string {
|
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
export function createTerminalManager(options?: TerminalManagerOptions): TerminalManager {
|
|
103
|
+
// Fix node-pty spawn-helper permissions at runtime (defense in depth)
|
|
104
|
+
fixPtyPermissions();
|
|
105
|
+
|
|
102
106
|
const entries = new Map<string, TerminalEntry>();
|
|
103
107
|
const bufferSize = options?.bufferSize ?? DEFAULT_BUFFER_SIZE;
|
|
104
108
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defense-in-depth guard against destructive PID-registry sweeps during tests.
|
|
3
|
+
*
|
|
4
|
+
* Production startup code paths (headlessPidRegistry.cleanupOrphans/killAll,
|
|
5
|
+
* editorPidRegistry.cleanupOrphans) read PID files and send SIGTERM. If they
|
|
6
|
+
* ever run under vitest AGAINST the developer's real $HOME, they can kill
|
|
7
|
+
* live pi sessions.
|
|
8
|
+
*
|
|
9
|
+
* This guard returns true when:
|
|
10
|
+
* - we appear to be inside a vitest run (VITEST env var), AND
|
|
11
|
+
* - HOME still points at the real user home (tripwire missed).
|
|
12
|
+
*
|
|
13
|
+
* Callers SHOULD `console.warn` and return without performing destructive work
|
|
14
|
+
* when this returns true.
|
|
15
|
+
*
|
|
16
|
+
* Normal production servers (VITEST not set) always get `false` and behave as
|
|
17
|
+
* before.
|
|
18
|
+
*/
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
|
|
21
|
+
export function isUnsafeTestHomeScan(): boolean {
|
|
22
|
+
if (process.env.VITEST !== "true") return false;
|
|
23
|
+
const currentHome = process.env.HOME ?? "";
|
|
24
|
+
const realHome = os.userInfo().homedir;
|
|
25
|
+
return currentHome === "" || currentHome === realHome;
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createTestServer — boot a real DashboardServer on OS-assigned ports for
|
|
3
|
+
* integration tests, with safe defaults (no auto-shutdown, no tunnel).
|
|
4
|
+
*
|
|
5
|
+
* Use with the `setup-home` vitest setupFile (in @blackbelt-technology/pi-dashboard-shared/test-support)
|
|
6
|
+
* so that HOME is also isolated.
|
|
7
|
+
*
|
|
8
|
+
* Example:
|
|
9
|
+
* const { server, httpPort, piPort, stop } = await createTestServer();
|
|
10
|
+
* const res = await fetch(`http://localhost:${httpPort}/api/health`);
|
|
11
|
+
* ...
|
|
12
|
+
* await stop();
|
|
13
|
+
*/
|
|
14
|
+
import { createServer, type DashboardServer, type ServerConfig } from "../server.js";
|
|
15
|
+
|
|
16
|
+
export interface TestServerHandle {
|
|
17
|
+
server: DashboardServer;
|
|
18
|
+
httpPort: number;
|
|
19
|
+
piPort: number;
|
|
20
|
+
stop: () => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type TestServerOverrides = Partial<ServerConfig>;
|
|
24
|
+
|
|
25
|
+
const DEFAULTS: ServerConfig = {
|
|
26
|
+
port: 0,
|
|
27
|
+
piPort: 0,
|
|
28
|
+
dev: true,
|
|
29
|
+
autoShutdown: false,
|
|
30
|
+
shutdownIdleSeconds: 999,
|
|
31
|
+
tunnel: false,
|
|
32
|
+
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function createTestServer(
|
|
36
|
+
overrides: TestServerOverrides = {},
|
|
37
|
+
): Promise<TestServerHandle> {
|
|
38
|
+
const config: ServerConfig = { ...DEFAULTS, ...overrides };
|
|
39
|
+
const server = await createServer(config);
|
|
40
|
+
await server.start();
|
|
41
|
+
|
|
42
|
+
const httpPort = server.httpPort();
|
|
43
|
+
const piPort = server.piPort();
|
|
44
|
+
if (httpPort == null || piPort == null) {
|
|
45
|
+
await server.stop();
|
|
46
|
+
throw new Error(
|
|
47
|
+
`createTestServer: failed to resolve ports (httpPort=${httpPort}, piPort=${piPort})`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
server,
|
|
53
|
+
httpPort,
|
|
54
|
+
piPort,
|
|
55
|
+
stop: async () => {
|
|
56
|
+
try {
|
|
57
|
+
await server.stop();
|
|
58
|
+
} catch {
|
|
59
|
+
// best-effort — tests may race on shutdown
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|