pi-webdesk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +111 -0
  2. package/dist/apps/daemon/src/appearance-preferences.js +218 -0
  3. package/dist/apps/daemon/src/auth.js +88 -0
  4. package/dist/apps/daemon/src/bin.js +123 -0
  5. package/dist/apps/daemon/src/cli.js +48 -0
  6. package/dist/apps/daemon/src/event-hub.js +155 -0
  7. package/dist/apps/daemon/src/index.js +102 -0
  8. package/dist/apps/daemon/src/launcher-control.js +114 -0
  9. package/dist/apps/daemon/src/launcher.js +73 -0
  10. package/dist/apps/daemon/src/pi-auth.js +290 -0
  11. package/dist/apps/daemon/src/pi-resources.js +182 -0
  12. package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
  13. package/dist/apps/daemon/src/pi-sessions.js +265 -0
  14. package/dist/apps/daemon/src/runtime-process.js +241 -0
  15. package/dist/apps/daemon/src/secret.js +71 -0
  16. package/dist/apps/daemon/src/server.js +1662 -0
  17. package/dist/apps/daemon/src/session-projection.js +117 -0
  18. package/dist/apps/daemon/src/state-lock.js +31 -0
  19. package/dist/apps/daemon/src/static-web.js +53 -0
  20. package/dist/apps/daemon/src/task-archive.js +152 -0
  21. package/dist/apps/daemon/src/task-commit.js +503 -0
  22. package/dist/apps/daemon/src/task-merge.js +912 -0
  23. package/dist/apps/daemon/src/task-review.js +204 -0
  24. package/dist/apps/daemon/src/task-runtime.js +1124 -0
  25. package/dist/apps/daemon/src/task-validation.js +352 -0
  26. package/dist/apps/daemon/src/workspace-store.js +140 -0
  27. package/dist/apps/daemon/src/workspace.js +795 -0
  28. package/dist/extensions/webdesk.js +34 -0
  29. package/dist/packages/git/src/commit.js +675 -0
  30. package/dist/packages/git/src/errors.js +55 -0
  31. package/dist/packages/git/src/fingerprint.js +286 -0
  32. package/dist/packages/git/src/index.js +123 -0
  33. package/dist/packages/git/src/merge.js +1008 -0
  34. package/dist/packages/git/src/paths.js +58 -0
  35. package/dist/packages/git/src/repository.js +77 -0
  36. package/dist/packages/git/src/review.js +396 -0
  37. package/dist/packages/git/src/runner.js +110 -0
  38. package/dist/packages/git/src/validation.js +263 -0
  39. package/dist/packages/git/src/worktree.js +233 -0
  40. package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
  41. package/dist/packages/pi-bridge/src/auth.js +80 -0
  42. package/dist/packages/pi-bridge/src/errors.js +19 -0
  43. package/dist/packages/pi-bridge/src/handshake.js +43 -0
  44. package/dist/packages/pi-bridge/src/index.js +76 -0
  45. package/dist/packages/pi-bridge/src/jsonl.js +105 -0
  46. package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
  47. package/dist/packages/pi-bridge/src/resolve.js +59 -0
  48. package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
  49. package/dist/packages/pi-bridge/src/resources.js +481 -0
  50. package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
  51. package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
  52. package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
  53. package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
  54. package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
  55. package/dist/packages/pi-bridge/src/runtime.js +0 -0
  56. package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
  57. package/dist/packages/pi-bridge/src/sessions.js +314 -0
  58. package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
  59. package/dist/packages/protocol/src/index.js +1863 -0
  60. package/dist/web/assets/index-BOw_fhvO.css +2 -0
  61. package/dist/web/assets/index-oXs7yAAo.js +119 -0
  62. package/dist/web/index.html +14 -0
  63. package/package.json +69 -0
  64. package/scripts/prepare.mjs +7 -0
@@ -0,0 +1,265 @@
1
+ // apps/daemon/src/pi-sessions.ts
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstat, realpath } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { inspectRepository } from "../../../packages/git/src/index.js";
6
+ import {
7
+ PiBridgeError,
8
+ createPiSessionCatalog,
9
+ sanitizeApprovalDisplayText
10
+ } from "../../../packages/pi-bridge/src/index.js";
11
+ import {
12
+ PI_SESSION_CATALOG_MAX_ENTRIES,
13
+ PROTOCOL_VERSION,
14
+ continuePiSessionResultSchema,
15
+ piSessionCatalogSnapshotSchema
16
+ } from "../../../packages/protocol/src/index.js";
17
+ var DEFAULT_SELECTION_TTL_MS = 10 * 60 * 1e3;
18
+ var MAX_SOURCE_SESSION_BYTES = 128 * 1024 * 1024;
19
+ var PiSessionCatalogOperationError = class extends Error {
20
+ name = "PiSessionCatalogOperationError";
21
+ code;
22
+ status;
23
+ constructor(code, message, status, options) {
24
+ super(message, options);
25
+ this.code = code;
26
+ this.status = status;
27
+ }
28
+ };
29
+ function isWithin(root, candidate) {
30
+ const relative = path.relative(root, candidate);
31
+ return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== "..";
32
+ }
33
+ function redactCredentialText(text) {
34
+ return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/giu, "$1[redacted]@").replace(
35
+ /([?&](?:access[_-]?token|api[_-]?key|auth|credential|password|secret|token)=)[^&#\s]*/giu,
36
+ "$1[redacted]"
37
+ ).replace(/\b(?:github_pat_|gh[pousr]_|sk-)[a-z0-9_-]{8,}\b/giu, "[redacted]");
38
+ }
39
+ function display(text, maximum, fallback = "") {
40
+ const safe = sanitizeApprovalDisplayText(redactCredentialText(text).slice(0, maximum));
41
+ const bounded = safe.length > maximum ? safe.slice(0, maximum) : safe;
42
+ return bounded.trim() === "" ? fallback : bounded;
43
+ }
44
+ function continuationFailureMessage(error) {
45
+ if (error instanceof PiSessionCatalogOperationError) return error.message;
46
+ if (error instanceof PiBridgeError && error.code === "PI_REQUEST_TIMEOUT") {
47
+ return "The Pi session took too long to continue. The isolated task was still created and will start with a fresh Pi session.";
48
+ }
49
+ return "Pi could not create the child session. The isolated task was still created and will start with a fresh Pi session; the original session was not changed.";
50
+ }
51
+ function createPiSessionCatalogService(options) {
52
+ const catalog = options.catalog ?? createPiSessionCatalog();
53
+ const now = options.now ?? Date.now;
54
+ const createId = options.createId ?? randomUUID;
55
+ const selectionTtlMs = options.selectionTtlMs ?? DEFAULT_SELECTION_TTL_MS;
56
+ if (!Number.isSafeInteger(selectionTtlMs) || selectionTtlMs < 1e3 || selectionTtlMs > 36e5) {
57
+ throw new Error("Pi session selection TTL must be between one second and one hour");
58
+ }
59
+ let selections = /* @__PURE__ */ new Map();
60
+ let refreshInFlight = null;
61
+ async function refreshCatalog() {
62
+ let inspection;
63
+ try {
64
+ inspection = await catalog.list();
65
+ } catch (error) {
66
+ throw new PiSessionCatalogOperationError(
67
+ "session-unavailable",
68
+ error instanceof PiBridgeError && error.code === "PI_REQUEST_TIMEOUT" ? "Pi session discovery took too long. Check unusually large session directories and retry." : "Pi sessions could not be inspected. Check the daemon output for details.",
69
+ error instanceof PiBridgeError && error.code === "PI_REQUEST_TIMEOUT" ? 504 : 503,
70
+ { cause: error }
71
+ );
72
+ }
73
+ if (!path.isAbsolute(inspection.sessionsRoot)) {
74
+ throw new PiSessionCatalogOperationError(
75
+ "session-unavailable",
76
+ "Pi returned an invalid session directory. Check the daemon output for details.",
77
+ 503
78
+ );
79
+ }
80
+ const expiresAtMs = now() + selectionTtlMs;
81
+ const nextSelections = /* @__PURE__ */ new Map();
82
+ const sessions = inspection.sessions.slice(0, PI_SESSION_CATALOG_MAX_ENTRIES).flatMap((session) => {
83
+ if (!path.isAbsolute(session.path) || !path.isAbsolute(session.cwd) || session.id.trim() === "" || !Number.isSafeInteger(session.messageCount) || session.messageCount < 0) {
84
+ return [];
85
+ }
86
+ const selectionId = createId();
87
+ nextSelections.set(selectionId, {
88
+ id: selectionId,
89
+ expiresAtMs,
90
+ sessionsRoot: path.normalize(inspection.sessionsRoot),
91
+ session
92
+ });
93
+ return [
94
+ {
95
+ selectionId,
96
+ sessionId: display(session.id, 500, "unknown-session"),
97
+ name: session.name === null ? null : display(session.name, 200, "Untitled session"),
98
+ cwd: path.normalize(session.cwd),
99
+ firstMessage: display(session.firstMessage, 500),
100
+ createdAtMs: session.createdAtMs,
101
+ modifiedAtMs: session.modifiedAtMs,
102
+ messageCount: session.messageCount
103
+ }
104
+ ];
105
+ });
106
+ selections = nextSelections;
107
+ return piSessionCatalogSnapshotSchema.parse({
108
+ protocol: PROTOCOL_VERSION,
109
+ type: "pi-session-catalog",
110
+ sessions,
111
+ omitted: inspection.omitted + Math.max(0, inspection.sessions.length - PI_SESSION_CATALOG_MAX_ENTRIES),
112
+ expiresAtMs
113
+ });
114
+ }
115
+ async function verifySource(selectionId, repositoryId, ignoreExpiry = false) {
116
+ const selection = selections.get(selectionId);
117
+ if (selection === void 0 || !ignoreExpiry && now() > selection.expiresAtMs) {
118
+ throw new PiSessionCatalogOperationError(
119
+ "selection-expired",
120
+ "This Pi session selection expired. Refresh the session list and choose it again.",
121
+ 409
122
+ );
123
+ }
124
+ const snapshot = await options.workspace.snapshot();
125
+ const repository = snapshot.state.repositories.find((entry) => entry.id === repositoryId);
126
+ if (repository === void 0) {
127
+ throw new PiSessionCatalogOperationError(
128
+ "invalid-request",
129
+ "Choose a registered repository for the continued task.",
130
+ 404
131
+ );
132
+ }
133
+ try {
134
+ const [canonicalRoot, sourceDetails, cwdDetails] = await Promise.all([
135
+ realpath(selection.sessionsRoot),
136
+ lstat(selection.session.path),
137
+ lstat(selection.session.cwd)
138
+ ]);
139
+ if (!sourceDetails.isFile() || sourceDetails.isSymbolicLink()) {
140
+ throw new Error("session path is not a regular file");
141
+ }
142
+ if (sourceDetails.size > MAX_SOURCE_SESSION_BYTES) {
143
+ throw new Error("session file exceeds the continuation limit");
144
+ }
145
+ if (!cwdDetails.isDirectory()) throw new Error("session cwd is not a directory");
146
+ const [sessionPath, cwd] = await Promise.all([
147
+ realpath(selection.session.path),
148
+ realpath(selection.session.cwd)
149
+ ]);
150
+ if (!isWithin(canonicalRoot, sessionPath)) {
151
+ throw new Error("session resolves outside Pi's configured session directory");
152
+ }
153
+ const sourceRepository = await inspectRepository(cwd);
154
+ if (sourceRepository.commonDir !== repository.commonDir) {
155
+ throw new PiSessionCatalogOperationError(
156
+ "repository-mismatch",
157
+ "The selected Pi session belongs to a different Git repository.",
158
+ 409
159
+ );
160
+ }
161
+ return { selection, sessionPath, cwd, repository };
162
+ } catch (error) {
163
+ if (error instanceof PiSessionCatalogOperationError) throw error;
164
+ throw new PiSessionCatalogOperationError(
165
+ "session-unavailable",
166
+ "The selected Pi session or its project is no longer available. Refresh and try again.",
167
+ 409,
168
+ { cause: error }
169
+ );
170
+ }
171
+ }
172
+ async function continueInNewTask(request) {
173
+ const source = await verifySource(request.selectionId, request.repositoryId);
174
+ if (options.workspace.createPreparedTask === void 0) {
175
+ throw new PiSessionCatalogOperationError(
176
+ "continuation-unavailable",
177
+ "This workspace service does not support isolated Pi session continuation.",
178
+ 503
179
+ );
180
+ }
181
+ const taskId = createId();
182
+ const creation = await options.workspace.createPreparedTask(
183
+ {
184
+ repositoryId: request.repositoryId,
185
+ title: request.title,
186
+ baseRef: request.baseRef
187
+ },
188
+ {
189
+ taskId,
190
+ prepare: async (task) => {
191
+ const currentSource = await verifySource(
192
+ request.selectionId,
193
+ request.repositoryId,
194
+ true
195
+ );
196
+ if (currentSource.sessionPath !== source.sessionPath || currentSource.selection.session.id !== source.selection.session.id) {
197
+ throw new PiSessionCatalogOperationError(
198
+ "session-unavailable",
199
+ "The selected Pi session changed identity while the task was being created.",
200
+ 409
201
+ );
202
+ }
203
+ const targetCwd = await realpath(task.worktreePath);
204
+ const targetRepository = await inspectRepository(targetCwd);
205
+ if (targetRepository.root !== targetCwd || targetRepository.commonDir !== source.repository.commonDir || targetRepository.branch !== task.branch) {
206
+ throw new PiSessionCatalogOperationError(
207
+ "continuation-unavailable",
208
+ "The new task worktree could not be verified before continuing Pi.",
209
+ 409
210
+ );
211
+ }
212
+ const forked = await catalog.fork(
213
+ currentSource.sessionPath,
214
+ targetCwd,
215
+ `pita-${createId()}`
216
+ );
217
+ if (path.normalize(forked.sourceCwd) !== path.normalize(source.selection.session.cwd) || forked.sourceSessionId !== source.selection.session.id || path.normalize(forked.cwd) !== targetCwd || forked.parentSession === null || path.normalize(forked.parentSession) !== currentSource.sessionPath) {
218
+ throw new PiSessionCatalogOperationError(
219
+ "continuation-unavailable",
220
+ "Pi returned a child session that did not match the selected source and task worktree.",
221
+ 503
222
+ );
223
+ }
224
+ const [canonicalSessionsRoot, childDetails, childSessionFile] = await Promise.all([
225
+ realpath(currentSource.selection.sessionsRoot),
226
+ lstat(forked.sessionFile),
227
+ realpath(forked.sessionFile)
228
+ ]);
229
+ if (!childDetails.isFile() || childDetails.isSymbolicLink() || !isWithin(canonicalSessionsRoot, childSessionFile)) {
230
+ throw new PiSessionCatalogOperationError(
231
+ "continuation-unavailable",
232
+ "Pi created the child session outside its configured session directory.",
233
+ 503
234
+ );
235
+ }
236
+ return { piSessionFile: childSessionFile };
237
+ }
238
+ }
239
+ );
240
+ return continuePiSessionResultSchema.parse({
241
+ protocol: PROTOCOL_VERSION,
242
+ type: "pi-session-continuation-result",
243
+ taskId: creation.taskId,
244
+ sourceSessionId: display(source.selection.session.id, 500, "unknown-session"),
245
+ continuation: creation.preparationError === null ? { status: "continued" } : {
246
+ status: "failed",
247
+ message: continuationFailureMessage(creation.preparationError)
248
+ },
249
+ snapshot: creation.snapshot
250
+ });
251
+ }
252
+ return {
253
+ refresh() {
254
+ refreshInFlight ??= refreshCatalog().finally(() => {
255
+ refreshInFlight = null;
256
+ });
257
+ return refreshInFlight;
258
+ },
259
+ continueInNewTask
260
+ };
261
+ }
262
+ export {
263
+ PiSessionCatalogOperationError,
264
+ createPiSessionCatalogService
265
+ };
@@ -0,0 +1,241 @@
1
+ // apps/daemon/src/runtime-process.ts
2
+ import { execFile } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { readFile } from "node:fs/promises";
5
+ import { promisify } from "node:util";
6
+ var execFileAsync = promisify(execFile);
7
+ var RuntimeProcessRecoveryError = class extends Error {
8
+ name = "RuntimeProcessRecoveryError";
9
+ };
10
+ function errorCode(error) {
11
+ return error !== null && typeof error === "object" && "code" in error ? String(error.code) : null;
12
+ }
13
+ function fingerprint(value) {
14
+ return createHash("sha256").update(value).digest("hex");
15
+ }
16
+ async function processGroupIdentity(pid) {
17
+ try {
18
+ const fields = process.platform === "linux" ? "pgid=,sid=" : "pgid=";
19
+ const { stdout } = await execFileAsync("ps", ["-o", fields, "-p", String(pid)], {
20
+ encoding: "utf8",
21
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
22
+ timeout: 2e3,
23
+ maxBuffer: 16 * 1024
24
+ });
25
+ const [group, linuxSession] = stdout.trim().split(/\s+/).map(Number);
26
+ const session = process.platform === "darwin" && group === pid ? pid : linuxSession;
27
+ return Number.isSafeInteger(group) && group > 0 && Number.isSafeInteger(session) && session > 0 ? { processGroupId: group, sessionId: session } : null;
28
+ } catch (error) {
29
+ if (errorCode(error) === "ESRCH" || error.code === 1) return null;
30
+ throw error;
31
+ }
32
+ }
33
+ async function linuxProcessFingerprints(pid) {
34
+ try {
35
+ const [stat, bootId, command] = await Promise.all([
36
+ readFile(`/proc/${pid}/stat`, "utf8"),
37
+ readFile("/proc/sys/kernel/random/boot_id", "utf8"),
38
+ readFile(`/proc/${pid}/cmdline`)
39
+ ]);
40
+ const closingParen = stat.lastIndexOf(")");
41
+ if (closingParen < 0) throw new Error("malformed /proc stat record");
42
+ const fields = stat.slice(closingParen + 1).trim().split(/\s+/);
43
+ if (fields[0]?.startsWith("Z")) return null;
44
+ const startTicks = fields[19];
45
+ if (startTicks === void 0 || !/^\d+$/.test(startTicks)) {
46
+ throw new Error("missing Linux process start time");
47
+ }
48
+ return {
49
+ bootFingerprint: fingerprint(`linux\0${bootId.trim()}`),
50
+ startFingerprint: fingerprint(
51
+ `linux\0${bootId.trim()}\0${startTicks}\0${command.toString("hex")}`
52
+ )
53
+ };
54
+ } catch (error) {
55
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ESRCH") return null;
56
+ throw error;
57
+ }
58
+ }
59
+ async function darwinProcessFingerprints(pid) {
60
+ try {
61
+ const [
62
+ { stdout: started },
63
+ { stdout: command },
64
+ { stdout: state },
65
+ { stdout: bootSessionId }
66
+ ] = await Promise.all([
67
+ execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)], {
68
+ encoding: "utf8",
69
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
70
+ timeout: 2e3,
71
+ maxBuffer: 16 * 1024
72
+ }),
73
+ execFileAsync("ps", ["-ww", "-o", "command=", "-p", String(pid)], {
74
+ encoding: "utf8",
75
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
76
+ timeout: 2e3,
77
+ maxBuffer: 16 * 1024
78
+ }),
79
+ execFileAsync("ps", ["-o", "stat=", "-p", String(pid)], {
80
+ encoding: "utf8",
81
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
82
+ timeout: 2e3,
83
+ maxBuffer: 16 * 1024
84
+ }),
85
+ execFileAsync("sysctl", ["-n", "kern.bootsessionuuid"], {
86
+ encoding: "utf8",
87
+ timeout: 2e3,
88
+ maxBuffer: 16 * 1024
89
+ })
90
+ ]);
91
+ if (started.trim() === "" || state.trim().startsWith("Z")) return null;
92
+ return {
93
+ bootFingerprint: fingerprint(`darwin\0${bootSessionId.trim()}`),
94
+ startFingerprint: fingerprint(
95
+ `darwin\0${bootSessionId.trim()}\0${started.trim()}\0${command.trim()}`
96
+ )
97
+ };
98
+ } catch (error) {
99
+ if (error.code === 1 || errorCode(error) === "ESRCH") return null;
100
+ throw error;
101
+ }
102
+ }
103
+ function createRuntimeProcessInspector() {
104
+ if (process.platform !== "linux" && process.platform !== "darwin") {
105
+ throw new RuntimeProcessRecoveryError(
106
+ `Supervised runtime process recovery is unsupported on ${process.platform}`
107
+ );
108
+ }
109
+ return {
110
+ async currentBootFingerprint() {
111
+ if (process.platform === "linux") {
112
+ const bootId = await readFile("/proc/sys/kernel/random/boot_id", "utf8");
113
+ return fingerprint(`linux\0${bootId.trim()}`);
114
+ }
115
+ const { stdout } = await execFileAsync("sysctl", ["-n", "kern.bootsessionuuid"], {
116
+ encoding: "utf8",
117
+ timeout: 2e3,
118
+ maxBuffer: 16 * 1024
119
+ });
120
+ return fingerprint(`darwin\0${stdout.trim()}`);
121
+ },
122
+ async inspect(pid) {
123
+ if (!Number.isSafeInteger(pid) || pid < 1) return null;
124
+ const [fingerprints, observedGroup] = await Promise.all([
125
+ process.platform === "linux" ? linuxProcessFingerprints(pid) : darwinProcessFingerprints(pid),
126
+ processGroupIdentity(pid)
127
+ ]);
128
+ if (fingerprints === null || observedGroup === null) return null;
129
+ return { ...fingerprints, ...observedGroup };
130
+ },
131
+ async groupExists(group) {
132
+ try {
133
+ const { stdout } = await execFileAsync("ps", ["-axo", "pgid=,stat="], {
134
+ encoding: "utf8",
135
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
136
+ timeout: 1e3,
137
+ maxBuffer: 1024 * 1024
138
+ });
139
+ return stdout.split("\n").some((line) => {
140
+ const [observedGroup, state] = line.trim().split(/\s+/);
141
+ return Number(observedGroup) === group && state !== void 0 && !state.startsWith("Z");
142
+ });
143
+ } catch {
144
+ }
145
+ try {
146
+ process.kill(-group, 0);
147
+ return true;
148
+ } catch (error) {
149
+ return errorCode(error) === "EPERM";
150
+ }
151
+ },
152
+ signalGroup(group, signal) {
153
+ process.kill(-group, signal);
154
+ }
155
+ };
156
+ }
157
+ async function captureRuntimeProcess(pid, inspector = createRuntimeProcessInspector()) {
158
+ if (pid === process.pid) {
159
+ throw new RuntimeProcessRecoveryError("Refusing to supervise the daemon as a Pi child");
160
+ }
161
+ const observed = await inspector.inspect(pid);
162
+ if (observed === null) {
163
+ throw new RuntimeProcessRecoveryError("Pi exited before Webdesk could record its process identity");
164
+ }
165
+ if (observed.processGroupId !== pid || observed.sessionId !== pid) {
166
+ throw new RuntimeProcessRecoveryError(
167
+ "Pi did not start as the leader of its dedicated process group"
168
+ );
169
+ }
170
+ return {
171
+ pid,
172
+ processGroupId: pid,
173
+ sessionId: pid,
174
+ bootFingerprint: observed.bootFingerprint,
175
+ startFingerprint: observed.startFingerprint
176
+ };
177
+ }
178
+ async function recoveryDisposition(record, inspector) {
179
+ const observed = await inspector.inspect(record.pid);
180
+ if (observed !== null) {
181
+ if (observed.startFingerprint === record.startFingerprint && observed.processGroupId === record.processGroupId && observed.sessionId === record.sessionId && record.processGroupId === record.pid && record.sessionId === record.pid) {
182
+ return "exact-leader";
183
+ }
184
+ throw new RuntimeProcessRecoveryError(
185
+ `Runtime process ${record.pid} no longer matches its recorded launch identity`
186
+ );
187
+ }
188
+ if (await inspector.groupExists(record.processGroupId)) {
189
+ throw new RuntimeProcessRecoveryError(
190
+ `Runtime process ${record.pid} is gone but its process group cannot be verified`
191
+ );
192
+ }
193
+ return "gone";
194
+ }
195
+ async function waitUntilGroupGone(processGroupId, inspector, timeoutMs) {
196
+ const deadline = Date.now() + timeoutMs;
197
+ while (Date.now() < deadline) {
198
+ if (!await inspector.groupExists(processGroupId)) return true;
199
+ await new Promise((resolve) => setTimeout(resolve, 25));
200
+ }
201
+ return !await inspector.groupExists(processGroupId);
202
+ }
203
+ async function recoverRuntimeProcess(record, options = {}) {
204
+ const inspector = options.inspector ?? createRuntimeProcessInspector();
205
+ if (record.bootFingerprint !== void 0 && await inspector.currentBootFingerprint() !== record.bootFingerprint) {
206
+ return false;
207
+ }
208
+ if (record.pid === process.pid) {
209
+ throw new RuntimeProcessRecoveryError(
210
+ `Runtime process ${record.pid} conflicts with the current daemon identity. Restart the Webdesk daemon before retrying`
211
+ );
212
+ }
213
+ const graceMs = options.graceMs ?? 2e3;
214
+ let signalled = false;
215
+ for (const signal of ["SIGTERM", "SIGKILL"]) {
216
+ const disposition = await recoveryDisposition(record, inspector);
217
+ if (disposition === "gone") return signalled;
218
+ try {
219
+ inspector.signalGroup(record.processGroupId, signal);
220
+ signalled = true;
221
+ } catch (error) {
222
+ if (errorCode(error) === "ESRCH") return true;
223
+ throw new RuntimeProcessRecoveryError(
224
+ `Could not signal orphaned Pi process group ${record.processGroupId}`,
225
+ { cause: error }
226
+ );
227
+ }
228
+ if (await waitUntilGroupGone(record.processGroupId, inspector, graceMs)) {
229
+ return true;
230
+ }
231
+ }
232
+ throw new RuntimeProcessRecoveryError(
233
+ `Orphaned Pi process ${record.pid} did not exit after SIGKILL`
234
+ );
235
+ }
236
+ export {
237
+ RuntimeProcessRecoveryError,
238
+ captureRuntimeProcess,
239
+ createRuntimeProcessInspector,
240
+ recoverRuntimeProcess
241
+ };
@@ -0,0 +1,71 @@
1
+ // apps/daemon/src/secret.ts
2
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { randomBytes } from "node:crypto";
4
+ import path from "node:path";
5
+ var DaemonStateError = class extends Error {
6
+ name = "DaemonStateError";
7
+ };
8
+ var SESSION_SECRET_FILE = "session-secret";
9
+ var SECRET_BYTES = 32;
10
+ var SECRET_HEX_PATTERN = /^[0-9a-f]{64}$/;
11
+ function sessionSecretPath(stateDir) {
12
+ return path.join(stateDir, SESSION_SECRET_FILE);
13
+ }
14
+ function loadOrCreateSessionSecret(stateDir) {
15
+ mkdirSync(stateDir, { recursive: true, mode: 448 });
16
+ if (process.platform !== "win32") {
17
+ const state = lstatSync(stateDir);
18
+ if (!state.isDirectory() || state.isSymbolicLink()) {
19
+ throw new DaemonStateError(
20
+ `Daemon state path ${stateDir} must be a real directory, not a symlink or other file`
21
+ );
22
+ }
23
+ const mode = state.mode & 511;
24
+ if ((mode & 63) !== 0) {
25
+ throw new DaemonStateError(
26
+ `Daemon state directory ${stateDir} is accessible by other users (mode ${mode.toString(8).padStart(3, "0")}); set its permissions to 700`
27
+ );
28
+ }
29
+ }
30
+ const file = sessionSecretPath(stateDir);
31
+ let raw;
32
+ try {
33
+ raw = readFileSync(file, "utf8");
34
+ } catch (error) {
35
+ if (error.code !== "ENOENT") {
36
+ throw error;
37
+ }
38
+ const secret = randomBytes(SECRET_BYTES);
39
+ writeFileSync(file, `${secret.toString("hex")}
40
+ `, { mode: 384, flag: "wx" });
41
+ chmodSync(file, 384);
42
+ return secret;
43
+ }
44
+ if (process.platform !== "win32") {
45
+ const state = lstatSync(file);
46
+ if (!state.isFile() || state.isSymbolicLink()) {
47
+ throw new DaemonStateError(
48
+ `Session secret ${file} must be a regular file, not a symlink or other file`
49
+ );
50
+ }
51
+ const mode = state.mode & 511;
52
+ if ((mode & 63) !== 0) {
53
+ throw new DaemonStateError(
54
+ `Session secret ${file} is readable by other users (mode ${mode.toString(8).padStart(3, "0")}); set its permissions to 600`
55
+ );
56
+ }
57
+ }
58
+ const hex = raw.trim();
59
+ if (!SECRET_HEX_PATTERN.test(hex)) {
60
+ throw new DaemonStateError(
61
+ `Session secret ${file} is malformed (expected 64 lowercase hex characters). Delete the file to generate a new secret; existing browser sessions will be signed out.`
62
+ );
63
+ }
64
+ return Buffer.from(hex, "hex");
65
+ }
66
+ export {
67
+ DaemonStateError,
68
+ SESSION_SECRET_FILE,
69
+ loadOrCreateSessionSecret,
70
+ sessionSecretPath
71
+ };