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.
- package/README.md +111 -0
- package/dist/apps/daemon/src/appearance-preferences.js +218 -0
- package/dist/apps/daemon/src/auth.js +88 -0
- package/dist/apps/daemon/src/bin.js +123 -0
- package/dist/apps/daemon/src/cli.js +48 -0
- package/dist/apps/daemon/src/event-hub.js +155 -0
- package/dist/apps/daemon/src/index.js +102 -0
- package/dist/apps/daemon/src/launcher-control.js +114 -0
- package/dist/apps/daemon/src/launcher.js +73 -0
- package/dist/apps/daemon/src/pi-auth.js +290 -0
- package/dist/apps/daemon/src/pi-resources.js +182 -0
- package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
- package/dist/apps/daemon/src/pi-sessions.js +265 -0
- package/dist/apps/daemon/src/runtime-process.js +241 -0
- package/dist/apps/daemon/src/secret.js +71 -0
- package/dist/apps/daemon/src/server.js +1662 -0
- package/dist/apps/daemon/src/session-projection.js +117 -0
- package/dist/apps/daemon/src/state-lock.js +31 -0
- package/dist/apps/daemon/src/static-web.js +53 -0
- package/dist/apps/daemon/src/task-archive.js +152 -0
- package/dist/apps/daemon/src/task-commit.js +503 -0
- package/dist/apps/daemon/src/task-merge.js +912 -0
- package/dist/apps/daemon/src/task-review.js +204 -0
- package/dist/apps/daemon/src/task-runtime.js +1124 -0
- package/dist/apps/daemon/src/task-validation.js +352 -0
- package/dist/apps/daemon/src/workspace-store.js +140 -0
- package/dist/apps/daemon/src/workspace.js +795 -0
- package/dist/extensions/webdesk.js +34 -0
- package/dist/packages/git/src/commit.js +675 -0
- package/dist/packages/git/src/errors.js +55 -0
- package/dist/packages/git/src/fingerprint.js +286 -0
- package/dist/packages/git/src/index.js +123 -0
- package/dist/packages/git/src/merge.js +1008 -0
- package/dist/packages/git/src/paths.js +58 -0
- package/dist/packages/git/src/repository.js +77 -0
- package/dist/packages/git/src/review.js +396 -0
- package/dist/packages/git/src/runner.js +110 -0
- package/dist/packages/git/src/validation.js +263 -0
- package/dist/packages/git/src/worktree.js +233 -0
- package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
- package/dist/packages/pi-bridge/src/auth.js +80 -0
- package/dist/packages/pi-bridge/src/errors.js +19 -0
- package/dist/packages/pi-bridge/src/handshake.js +43 -0
- package/dist/packages/pi-bridge/src/index.js +76 -0
- package/dist/packages/pi-bridge/src/jsonl.js +105 -0
- package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
- package/dist/packages/pi-bridge/src/resolve.js +59 -0
- package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
- package/dist/packages/pi-bridge/src/resources.js +481 -0
- package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
- package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
- package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
- package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
- package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
- package/dist/packages/pi-bridge/src/runtime.js +0 -0
- package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
- package/dist/packages/pi-bridge/src/sessions.js +314 -0
- package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
- package/dist/packages/protocol/src/index.js +1863 -0
- package/dist/web/assets/index-BOw_fhvO.css +2 -0
- package/dist/web/assets/index-oXs7yAAo.js +119 -0
- package/dist/web/index.html +14 -0
- package/package.json +69 -0
- 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
|
+
};
|