@monotykamary/localterm-server 2.34.0 → 2.35.1
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/dist/cdp/cdp-client.d.ts +30 -0
- package/dist/cdp/cdp-client.d.ts.map +1 -1
- package/dist/cdp/cdp-client.js +80 -0
- package/dist/cdp/cdp-client.js.map +1 -1
- package/dist/constants.d.ts +17 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +27 -0
- package/dist/constants.js.map +1 -1
- package/dist/daemon-config-store.d.ts +2 -0
- package/dist/daemon-config-store.d.ts.map +1 -1
- package/dist/daemon-config-store.js +14 -1
- package/dist/daemon-config-store.js.map +1 -1
- package/dist/identity/credential-store.d.ts +18 -0
- package/dist/identity/credential-store.d.ts.map +1 -0
- package/dist/identity/credential-store.js +76 -0
- package/dist/identity/credential-store.js.map +1 -0
- package/dist/identity/factory.d.ts +3 -0
- package/dist/identity/factory.d.ts.map +1 -0
- package/dist/identity/factory.js +19 -0
- package/dist/identity/factory.js.map +1 -0
- package/dist/identity/header-provider.d.ts +3 -0
- package/dist/identity/header-provider.d.ts.map +1 -0
- package/dist/identity/header-provider.js +33 -0
- package/dist/identity/header-provider.js.map +1 -0
- package/dist/identity/oidc-provider.d.ts +4 -0
- package/dist/identity/oidc-provider.d.ts.map +1 -0
- package/dist/identity/oidc-provider.js +172 -0
- package/dist/identity/oidc-provider.js.map +1 -0
- package/dist/identity/passkey-provider.d.ts +3 -0
- package/dist/identity/passkey-provider.d.ts.map +1 -0
- package/dist/identity/passkey-provider.js +233 -0
- package/dist/identity/passkey-provider.js.map +1 -0
- package/dist/identity/proxy-allowlist.d.ts +5 -0
- package/dist/identity/proxy-allowlist.d.ts.map +1 -0
- package/dist/identity/proxy-allowlist.js +64 -0
- package/dist/identity/proxy-allowlist.js.map +1 -0
- package/dist/identity/resolve.d.ts +11 -0
- package/dist/identity/resolve.d.ts.map +1 -0
- package/dist/identity/resolve.js +57 -0
- package/dist/identity/resolve.js.map +1 -0
- package/dist/identity/session-cookie.d.ts +10 -0
- package/dist/identity/session-cookie.d.ts.map +1 -0
- package/dist/identity/session-cookie.js +92 -0
- package/dist/identity/session-cookie.js.map +1 -0
- package/dist/identity/types.d.ts +49 -0
- package/dist/identity/types.d.ts.map +1 -0
- package/dist/identity/types.js +2 -0
- package/dist/identity/types.js.map +1 -0
- package/dist/identity/user-store.d.ts +16 -0
- package/dist/identity/user-store.d.ts.map +1 -0
- package/dist/identity/user-store.js +77 -0
- package/dist/identity/user-store.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +112 -31
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +2 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/schemas.d.ts +79 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +55 -1
- package/dist/schemas.js.map +1 -1
- package/dist/secret-store.d.ts.map +1 -1
- package/dist/secret-store.js +4 -1
- package/dist/secret-store.js.map +1 -1
- package/dist/session-automation.d.ts +7 -2
- package/dist/session-automation.d.ts.map +1 -1
- package/dist/session-automation.js +27 -8
- package/dist/session-automation.js.map +1 -1
- package/dist/session-manager.d.ts +20 -17
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +63 -44
- package/dist/session-manager.js.map +1 -1
- package/dist/utils/timing-safe-equal.d.ts +2 -0
- package/dist/utils/timing-safe-equal.d.ts.map +1 -0
- package/dist/utils/timing-safe-equal.js +12 -0
- package/dist/utils/timing-safe-equal.js.map +1 -0
- package/package.json +4 -1
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { CdpClient } from "./cdp/cdp-client.js";
|
|
|
18
18
|
import { detectWithExplicitPort } from "./cdp/discover-explicit-endpoint.js";
|
|
19
19
|
import { DaemonConfigStore } from "./daemon-config-store.js";
|
|
20
20
|
import { z } from "zod";
|
|
21
|
-
import { ACTIVITY_DIRNAME, ACTIVITY_REFRESH_DEBOUNCE_MS, ACTIVITY_WATCHED_PROGRAMS, AUTOMATION_EVENT_DEBOUNCE_MS, AUTOMATION_RECONCILE_MIN_DOWNTIME_MS, AUTOMATION_RUN_QUERY_PARAM, AUTOMATION_WATCH_DEBOUNCE_MS, AUTOMATION_WATCH_POST_RUN_GRACE_MS, AUTOMATION_WEBHOOK_DEBOUNCE_MS, DEFAULT_HOST, DEFAULT_PORT, FRIENDLY_HOSTNAME, GIT_MAX_REF_LENGTH, HTTP_STATUS_ACCEPTED, HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CONFLICT, HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, MAX_AUTOMATIONS, MAX_PROCESSES, MAX_SECRETS, MS_PER_MINUTE, PROCESSES_FILENAME, SECRETS_FILENAME, SECRETS_SHIMS_DIRNAME, SERVER_STOP_GRACE_MS, SESSION_ID_QUERY_PARAM, SESSION_ACTIVITY_WINDOW_MS, WAIT_DEFAULT_TIMEOUT_MS, WS_BACKPRESSURE_THRESHOLD_BYTES, WS_CLOSE_BACKPRESSURE, WS_CLOSE_CAPACITY_REACHED, WS_CLOSE_POLICY_VIOLATION, WS_HEARTBEAT_GRACE_MS, WS_HEARTBEAT_INTERVAL_MS, WS_HEARTBEAT_TIMEOUT_MS, WS_READY_STATE_OPEN, } from "./constants.js";
|
|
21
|
+
import { ACTIVITY_DIRNAME, ACTIVITY_REFRESH_DEBOUNCE_MS, ACTIVITY_WATCHED_PROGRAMS, AUTOMATION_EVENT_DEBOUNCE_MS, AUTOMATION_RECONCILE_MIN_DOWNTIME_MS, AUTOMATION_RUN_QUERY_PARAM, AUTOMATION_WATCH_DEBOUNCE_MS, AUTOMATION_WATCH_POST_RUN_GRACE_MS, AUTOMATION_WEBHOOK_DEBOUNCE_MS, DEFAULT_HOST, DEFAULT_PORT, FRIENDLY_HOSTNAME, GIT_MAX_REF_LENGTH, HTTP_STATUS_ACCEPTED, HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CONFLICT, HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, MAX_AUTOMATIONS, MAX_PROCESSES, MAX_SECRETS, MS_PER_MINUTE, PROCESSES_FILENAME, SECRETS_FILENAME, SECRETS_SHIMS_DIRNAME, SERVER_STOP_GRACE_MS, SESSION_ID_QUERY_PARAM, SESSION_ACTIVITY_WINDOW_MS, WAIT_DEFAULT_TIMEOUT_MS, WS_BACKPRESSURE_THRESHOLD_BYTES, WS_CLOSE_BACKPRESSURE, WS_CLOSE_CAPACITY_REACHED, WS_CLOSE_POLICY_VIOLATION, WS_HEARTBEAT_GRACE_MS, WS_HEARTBEAT_INTERVAL_MS, WS_HEARTBEAT_TIMEOUT_MS, WS_READY_STATE_OPEN, AUTH_SECRET_FILENAME, AUTH_COOKIE_NAME, } from "./constants.js";
|
|
22
22
|
import { getDefaultShell } from "./default-shell.js";
|
|
23
23
|
import { shellPathForUserShell } from "./utils/shell-path.js";
|
|
24
24
|
import { openChromeInspect } from "./utils/open-chrome-inspect.js";
|
|
@@ -38,6 +38,9 @@ import { createGitWorktree, listGitWorktrees, removeGitWorktree } from "./git-wo
|
|
|
38
38
|
import { defaultSnapshotListeners, isSessionDescendantPid, listSessionListeningPorts, } from "./listening-ports.js";
|
|
39
39
|
import { clientToServerMessageSchema, createAutomationInputSchema, createSessionInputSchema, createWorktreeInputSchema, execInputSchema, execOneShotInputSchema, launchInputSchema, resetAutomationInputSchema, secretEntrySchema, secretSetInputSchema, sessionInputSchema, sessionResizeSchema, processNameSchema, processSetInputSchema, updateAutomationInputSchema, updateDaemonConfigInputSchema, updateSessionInputSchema, updateWorktreeConfigInputSchema, worktreeIncludeFileInputSchema, waitInputSchema, mouseInputSchema, } from "./schemas.js";
|
|
40
40
|
import { createNetworkPolicyMiddleware, isAllowedSourceIp, isLoopbackHost } from "./security.js";
|
|
41
|
+
import { createIdentityProvider } from "./identity/factory.js";
|
|
42
|
+
import { loadOrCreateAuthSecret, signSessionToken } from "./identity/session-cookie.js";
|
|
43
|
+
import { createAuthGateMiddleware, createIdentityResolver, getRequestSourceIp, toSessionOwner, } from "./identity/resolve.js";
|
|
41
44
|
import { SessionManager, } from "./session-manager.js";
|
|
42
45
|
import { capturePanePng, sendMouse } from "./session-automation.js";
|
|
43
46
|
import { encodeClick, encodeDrag, encodeMove, encodeScroll } from "./utils/sgr-mouse.js";
|
|
@@ -168,7 +171,7 @@ const normalizeMouseAction = (input) => {
|
|
|
168
171
|
};
|
|
169
172
|
const buildApiRoutes = (ctx) => {
|
|
170
173
|
const api = new Hono();
|
|
171
|
-
const { registry, cdpClient, secretBackend, secretStore, shimsDir, processStore, syncSecretShims, automationStore, broadcastAutomations, syncFolderWatchers, syncSessionEventListeners, webhookTriggerManager, worktreeConfigStore, portsSnapshotProcesses, portsSnapshotListeners, toAutomationWithNextRun, listAutomationsWithNextRun, tryLaunch, getCdpPort, applyCdpPort, getGraceSeconds, applyGraceSeconds, connectCdpNow, buildTabUrl, } = ctx;
|
|
174
|
+
const { registry, ownerFor, cdpClient, secretBackend, secretStore, shimsDir, processStore, syncSecretShims, automationStore, broadcastAutomations, syncFolderWatchers, syncSessionEventListeners, webhookTriggerManager, worktreeConfigStore, portsSnapshotProcesses, portsSnapshotListeners, toAutomationWithNextRun, listAutomationsWithNextRun, tryLaunch, getCdpPort, applyCdpPort, getGraceSeconds, applyGraceSeconds, connectCdpNow, buildTabUrl, mintViewerCookie, } = ctx;
|
|
172
175
|
// Headless SGR-1006 fallback for `mouse` when no CDP tab is reachable:
|
|
173
176
|
// encode the gesture as SGR bytes and write them straight to the PTY. Closes
|
|
174
177
|
// over `registry` so the automation layer stays CDP-agnostic. Coords arrive
|
|
@@ -203,9 +206,9 @@ const buildApiRoutes = (ctx) => {
|
|
|
203
206
|
// switch to one by id or kill one it no longer wants. `clients` is the count
|
|
204
207
|
// of attached sockets — 0 marks a dormant shell left behind by a closed tab,
|
|
205
208
|
// which is exactly the row the picker exists to surface.
|
|
206
|
-
api.get("/sessions", (context) => context.json({ sessions: registry.list() }));
|
|
209
|
+
api.get("/sessions", (context) => context.json({ sessions: registry.list(ownerFor(context)) }));
|
|
207
210
|
api.delete("/sessions/:id", (context) => {
|
|
208
|
-
const killed = registry.kill(context.req.param("id"));
|
|
211
|
+
const killed = registry.kill(context.req.param("id"), ownerFor(context));
|
|
209
212
|
if (!killed)
|
|
210
213
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
211
214
|
return context.json({ ok: true });
|
|
@@ -219,7 +222,9 @@ const buildApiRoutes = (ctx) => {
|
|
|
219
222
|
// network-policy middleware already on `*`; the daemon hands out unrestricted
|
|
220
223
|
// shells, so driving one programmatically is no escalation.
|
|
221
224
|
api.get("/sessions/:id", (context) => {
|
|
222
|
-
const managed = registry
|
|
225
|
+
const managed = registry
|
|
226
|
+
.list(ownerFor(context))
|
|
227
|
+
.find((session) => session.id === context.req.param("id"));
|
|
223
228
|
if (!managed)
|
|
224
229
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
225
230
|
return context.json({ session: managed });
|
|
@@ -244,12 +249,12 @@ const buildApiRoutes = (ctx) => {
|
|
|
244
249
|
cols: parsed.data.cols,
|
|
245
250
|
rows: parsed.data.rows,
|
|
246
251
|
initialCommand: parsed.data.command,
|
|
247
|
-
}, parsed.data.pinned ?? true);
|
|
252
|
+
}, parsed.data.pinned ?? true, ownerFor(context));
|
|
248
253
|
if (!id)
|
|
249
254
|
return context.json({ error: "capacity" }, HTTP_STATUS_CONFLICT);
|
|
250
255
|
if (parsed.data.name)
|
|
251
|
-
registry.setTitleById(id, parsed.data.name);
|
|
252
|
-
const session = registry.list().find((item) => item.id === id);
|
|
256
|
+
registry.setTitleById(id, parsed.data.name, ownerFor(context));
|
|
257
|
+
const session = registry.list(ownerFor(context)).find((item) => item.id === id);
|
|
253
258
|
return context.json({ session }, HTTP_STATUS_CREATED);
|
|
254
259
|
});
|
|
255
260
|
// Rename (sets the title) and/or toggle pin. A pin change re-arms the grace
|
|
@@ -259,11 +264,12 @@ const buildApiRoutes = (ctx) => {
|
|
|
259
264
|
if (!parsed.success)
|
|
260
265
|
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
261
266
|
const id = context.req.param("id");
|
|
267
|
+
const owner = ownerFor(context);
|
|
262
268
|
if (parsed.data.name !== undefined)
|
|
263
|
-
registry.setTitleById(id, parsed.data.name);
|
|
269
|
+
registry.setTitleById(id, parsed.data.name, owner);
|
|
264
270
|
if (parsed.data.pinned !== undefined)
|
|
265
|
-
registry.setPinned(id, parsed.data.pinned);
|
|
266
|
-
const session = registry.list().find((item) => item.id === id);
|
|
271
|
+
registry.setPinned(id, parsed.data.pinned, owner);
|
|
272
|
+
const session = registry.list(owner).find((item) => item.id === id);
|
|
267
273
|
if (!session)
|
|
268
274
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
269
275
|
return context.json({ session });
|
|
@@ -278,9 +284,10 @@ const buildApiRoutes = (ctx) => {
|
|
|
278
284
|
if (!parsed.success)
|
|
279
285
|
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
280
286
|
const id = context.req.param("id");
|
|
287
|
+
const owner = ownerFor(context);
|
|
281
288
|
const written = parsed.data.named
|
|
282
|
-
? registry.pressKeysById(id, parsed.data.data)
|
|
283
|
-
: registry.writeInputById(id, parsed.data.data);
|
|
289
|
+
? registry.pressKeysById(id, parsed.data.data, owner)
|
|
290
|
+
: registry.writeInputById(id, parsed.data.data, owner);
|
|
284
291
|
if (!written)
|
|
285
292
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
286
293
|
return context.json({ ok: true });
|
|
@@ -289,7 +296,7 @@ const buildApiRoutes = (ctx) => {
|
|
|
289
296
|
const parsed = sessionResizeSchema.safeParse(await readJsonBody(context));
|
|
290
297
|
if (!parsed.success)
|
|
291
298
|
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
292
|
-
const resized = registry.resizeById(context.req.param("id"), parsed.data.cols, parsed.data.rows);
|
|
299
|
+
const resized = registry.resizeById(context.req.param("id"), parsed.data.cols, parsed.data.rows, ownerFor(context));
|
|
293
300
|
if (!resized)
|
|
294
301
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
295
302
|
return context.json({ ok: true });
|
|
@@ -301,9 +308,16 @@ const buildApiRoutes = (ctx) => {
|
|
|
301
308
|
// headless capture renderer's grid, which works with no browser at all.
|
|
302
309
|
api.get("/sessions/:id/pane", async (context) => {
|
|
303
310
|
const id = context.req.param("id");
|
|
311
|
+
// PNG is rasterized by a CDP tab the daemon opens minted the owner's
|
|
312
|
+
// session cookie, so gate both formats on ownership before delegating — a
|
|
313
|
+
// cross-tenant id surfaces as not-found, not a screenshot of someone else's shell.
|
|
314
|
+
const owner = ownerFor(context);
|
|
315
|
+
if (!registry.list(owner).some((session) => session.id === id)) {
|
|
316
|
+
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
317
|
+
}
|
|
304
318
|
const format = context.req.query("format");
|
|
305
319
|
if (format === "png") {
|
|
306
|
-
const png = await capturePanePng({ cdpClient, buildTabUrl }, registry, id);
|
|
320
|
+
const png = await capturePanePng({ cdpClient, buildTabUrl, mintViewerCookie }, registry, id, owner);
|
|
307
321
|
if (!png)
|
|
308
322
|
return context.json({ error: "no_browser" }, HTTP_STATUS_CONFLICT);
|
|
309
323
|
return new Response(png, { headers: { "content-type": "image/png" } });
|
|
@@ -313,7 +327,7 @@ const buildApiRoutes = (ctx) => {
|
|
|
313
327
|
if (lines !== undefined && (!Number.isInteger(lines) || lines <= 0)) {
|
|
314
328
|
return context.json({ error: "invalid_lines" }, HTTP_STATUS_BAD_REQUEST);
|
|
315
329
|
}
|
|
316
|
-
const text = await registry.capturePane(id, lines);
|
|
330
|
+
const text = await registry.capturePane(id, lines, owner);
|
|
317
331
|
if (text === null)
|
|
318
332
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
319
333
|
return context.json({ text });
|
|
@@ -332,7 +346,7 @@ const buildApiRoutes = (ctx) => {
|
|
|
332
346
|
if (!predicate)
|
|
333
347
|
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
334
348
|
const idleMs = parsed.data.mode === "idle" ? (parsed.data.idleMs ?? SESSION_ACTIVITY_WINDOW_MS) : undefined;
|
|
335
|
-
const result = await registry.waitFor(id, predicate, timeoutMs, idleMs);
|
|
349
|
+
const result = await registry.waitFor(id, predicate, timeoutMs, idleMs, ownerFor(context));
|
|
336
350
|
if (!result)
|
|
337
351
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
338
352
|
return context.json(result);
|
|
@@ -345,23 +359,31 @@ const buildApiRoutes = (ctx) => {
|
|
|
345
359
|
if (!parsed.success)
|
|
346
360
|
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
347
361
|
const id = context.req.param("id");
|
|
362
|
+
// sendMouse drives the session via a CDP tab the daemon opens minted the
|
|
363
|
+
// owner's session cookie, so gate on ownership here (the manager calls
|
|
364
|
+
// inside sendMouse aren't owner-scoped) — a cross-tenant id surfaces as not-found.
|
|
365
|
+
const owner = ownerFor(context);
|
|
366
|
+
if (!registry.list(owner).some((session) => session.id === id)) {
|
|
367
|
+
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
368
|
+
}
|
|
348
369
|
const action = normalizeMouseAction(parsed.data);
|
|
349
370
|
if (!action)
|
|
350
371
|
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
351
|
-
const result = await sendMouse({ cdpClient, buildTabUrl }, registry, id, action, writeSgrMouseFallback);
|
|
372
|
+
const result = await sendMouse({ cdpClient, buildTabUrl, mintViewerCookie }, registry, id, action, owner, writeSgrMouseFallback);
|
|
352
373
|
return context.json(result);
|
|
353
374
|
});
|
|
354
375
|
// mouse state: whether the session's foreground app enabled mouse tracking
|
|
355
376
|
// (gates the SGR fallback) plus the viewport size.
|
|
356
377
|
api.get("/sessions/:id/mouse/state", (context) => {
|
|
357
378
|
const id = context.req.param("id");
|
|
358
|
-
const
|
|
379
|
+
const owner = ownerFor(context);
|
|
380
|
+
const managed = registry.list(owner).find((session) => session.id === id);
|
|
359
381
|
if (!managed)
|
|
360
382
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
361
383
|
return context.json({
|
|
362
|
-
enabled: registry.mouseEnabledFor(id),
|
|
363
|
-
cols: registry.sessionSizeFor(id).cols,
|
|
364
|
-
rows: registry.sessionSizeFor(id).rows,
|
|
384
|
+
enabled: registry.mouseEnabledFor(id, owner),
|
|
385
|
+
cols: registry.sessionSizeFor(id, owner).cols,
|
|
386
|
+
rows: registry.sessionSizeFor(id, owner).rows,
|
|
365
387
|
});
|
|
366
388
|
});
|
|
367
389
|
// In-session exec: run a single command line inside a persistent session,
|
|
@@ -375,7 +397,7 @@ const buildApiRoutes = (ctx) => {
|
|
|
375
397
|
const result = await registry.execInSession(context.req.param("id"), parsed.data.command, {
|
|
376
398
|
timeoutMs: parsed.data.timeoutMs,
|
|
377
399
|
outputLimitBytes: parsed.data.outputLimitBytes,
|
|
378
|
-
});
|
|
400
|
+
}, ownerFor(context));
|
|
379
401
|
if (!result)
|
|
380
402
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
381
403
|
return context.json(result);
|
|
@@ -396,14 +418,15 @@ const buildApiRoutes = (ctx) => {
|
|
|
396
418
|
if (registry.atCapacity()) {
|
|
397
419
|
return context.json({ error: "capacity" }, HTTP_STATUS_CONFLICT);
|
|
398
420
|
}
|
|
399
|
-
const
|
|
421
|
+
const owner = ownerFor(context);
|
|
422
|
+
const id = registry.spawnDetached({ cwd, cols: parsed.data.cols, rows: parsed.data.rows, env: parsed.data.env }, false, owner);
|
|
400
423
|
if (!id)
|
|
401
424
|
return context.json({ error: "capacity" }, HTTP_STATUS_CONFLICT);
|
|
402
425
|
const result = await registry.execInSession(id, parsed.data.command, {
|
|
403
426
|
timeoutMs: parsed.data.timeoutMs,
|
|
404
427
|
outputLimitBytes: parsed.data.outputLimitBytes,
|
|
405
|
-
});
|
|
406
|
-
registry.kill(id);
|
|
428
|
+
}, owner);
|
|
429
|
+
registry.kill(id, owner);
|
|
407
430
|
if (!result)
|
|
408
431
|
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
409
432
|
return context.json(result);
|
|
@@ -553,7 +576,7 @@ const buildApiRoutes = (ctx) => {
|
|
|
553
576
|
// each row carries the session's title/cwd for the modal to badge without a
|
|
554
577
|
// second fetch.
|
|
555
578
|
api.get("/ports", async (context) => {
|
|
556
|
-
const sessions = registry.list();
|
|
579
|
+
const sessions = registry.list(ownerFor(context));
|
|
557
580
|
const sessionPids = sessions.map((session) => session.pid);
|
|
558
581
|
const [snapshot, listeners] = await Promise.all([
|
|
559
582
|
portsSnapshotProcesses(),
|
|
@@ -1230,6 +1253,54 @@ export const createServer = async (options = {}) => {
|
|
|
1230
1253
|
// the next `connect()` picks up the new port without re-wiring.
|
|
1231
1254
|
const daemonConfigStore = new DaemonConfigStore(path.join(stateDirectory, "config.json"));
|
|
1232
1255
|
let cdpPort = daemonConfigStore.getCdpPort();
|
|
1256
|
+
// Identity provider (config-file `identity`, overridable via `ServerOptions`).
|
|
1257
|
+
// `null` = no provider → single-authority mode: every request is the operator
|
|
1258
|
+
// tier, the registry stays unscoped, byte-identical to no-auth. A configured
|
|
1259
|
+
// provider resolves an `Identity` per request to partition the registry by
|
|
1260
|
+
// user. Built once at start; changing it requires a restart (unlike the
|
|
1261
|
+
// live cdpPort/graceSeconds knobs).
|
|
1262
|
+
const identityConfig = options.identity ?? daemonConfigStore.getIdentity();
|
|
1263
|
+
// The HMAC secret for the passkey provider's signed session cookie.
|
|
1264
|
+
// Generated once and persisted; losing it invalidates every live session
|
|
1265
|
+
// (users re-log in) — never silently reused. Unused by the `header` provider.
|
|
1266
|
+
const authSecret = loadOrCreateAuthSecret(path.join(stateDirectory, AUTH_SECRET_FILENAME));
|
|
1267
|
+
const identityProviderDeps = {
|
|
1268
|
+
secret: authSecret,
|
|
1269
|
+
getOrigin: () => localOrigin ?? publicOrigin ?? null,
|
|
1270
|
+
stateDirectory,
|
|
1271
|
+
};
|
|
1272
|
+
const identityProvider = identityConfig
|
|
1273
|
+
? createIdentityProvider(identityConfig, identityProviderDeps)
|
|
1274
|
+
: null;
|
|
1275
|
+
const identityResolver = createIdentityResolver(identityProvider);
|
|
1276
|
+
const resolveIdentity = (context, sourceIp) => identityResolver.resolve(context, sourceIp ?? getRequestSourceIp(context));
|
|
1277
|
+
const ownerFor = (context) => toSessionOwner(resolveIdentity(context));
|
|
1278
|
+
// In an auth-gated mode, mint a signed session cookie for the daemon's own
|
|
1279
|
+
// CDP viewer tabs (capture-pane --png / real-browser mouse) so their /ws
|
|
1280
|
+
// upgrade passes the auth gate — those tabs carry no browser session.
|
|
1281
|
+
// Undefined when no provider is configured (the gate is open); null for the
|
|
1282
|
+
// operator tier (passkey mode has no operator sessions, so that degrades).
|
|
1283
|
+
const mintViewerCookie = identityProvider?.denyUnauthenticated
|
|
1284
|
+
? (owner) => owner ? { name: AUTH_COOKIE_NAME, value: signSessionToken(authSecret, owner) } : null
|
|
1285
|
+
: undefined;
|
|
1286
|
+
// Reject unauthenticated requests at the door for providers that own their
|
|
1287
|
+
// login (passkey/oidc): a request with no valid session is 401 (HTTP) or
|
|
1288
|
+
// never reaches the WS upgrade. Exempts `/api/health` (readiness) and
|
|
1289
|
+
// everything outside `/api` and `/ws` (the static terminal app + the `/auth`
|
|
1290
|
+
// login flow must load before there's a session). The `header` provider opts
|
|
1291
|
+
// out (denyUnauthenticated: false) — its no-header case IS the operator tier.
|
|
1292
|
+
app.use("*", createAuthGateMiddleware(identityProvider, resolveIdentity));
|
|
1293
|
+
if (identityProvider?.routes)
|
|
1294
|
+
app.route("/auth", identityProvider.routes());
|
|
1295
|
+
// Unauthenticated meta endpoint the terminal app / CLI hit before login to
|
|
1296
|
+
// learn which login flow to offer: the provider kind, and (for passkey)
|
|
1297
|
+
// whether registration is open. Exempt from the gate (it's under /auth).
|
|
1298
|
+
app.get("/auth/provider", (context) => context.json({
|
|
1299
|
+
provider: identityConfig?.provider ?? null,
|
|
1300
|
+
registration: identityConfig?.provider === "passkey"
|
|
1301
|
+
? (identityConfig.registration ?? "open")
|
|
1302
|
+
: undefined,
|
|
1303
|
+
}));
|
|
1233
1304
|
// One persistent CDP socket for the daemon's lifetime — opened once at start
|
|
1234
1305
|
// (below), so the user clears the browser's remote-debugging prompt a single
|
|
1235
1306
|
// time rather than on every run. Skipped when a caller injects its own
|
|
@@ -1514,6 +1585,8 @@ export const createServer = async (options = {}) => {
|
|
|
1514
1585
|
};
|
|
1515
1586
|
const ctx = {
|
|
1516
1587
|
registry,
|
|
1588
|
+
resolveIdentity,
|
|
1589
|
+
ownerFor,
|
|
1517
1590
|
cdpClient,
|
|
1518
1591
|
secretBackend,
|
|
1519
1592
|
secretStore,
|
|
@@ -1541,6 +1614,7 @@ export const createServer = async (options = {}) => {
|
|
|
1541
1614
|
url.searchParams.set(SESSION_ID_QUERY_PARAM, sessionId);
|
|
1542
1615
|
return url.toString();
|
|
1543
1616
|
},
|
|
1617
|
+
mintViewerCookie,
|
|
1544
1618
|
};
|
|
1545
1619
|
const api = buildApiRoutes(ctx);
|
|
1546
1620
|
app.route("/api", api);
|
|
@@ -1616,18 +1690,25 @@ export const createServer = async (options = {}) => {
|
|
|
1616
1690
|
return {
|
|
1617
1691
|
onOpen(_event, ws) {
|
|
1618
1692
|
activeWs = ws;
|
|
1693
|
+
const remoteAddress = extractRemoteAddress(ws.raw);
|
|
1619
1694
|
if (!isLoopbackBind) {
|
|
1620
|
-
const remoteAddress = extractRemoteAddress(ws.raw);
|
|
1621
1695
|
if (remoteAddress && !isAllowedSourceIp(remoteAddress, host)) {
|
|
1622
1696
|
ws.close(WS_CLOSE_POLICY_VIOLATION, "source IP not allowed");
|
|
1623
1697
|
return;
|
|
1624
1698
|
}
|
|
1625
1699
|
}
|
|
1700
|
+
// The partition key for this tab. The WS upgrade carries the same
|
|
1701
|
+
// headers as an HTTP request, so `resolveIdentity` reads the
|
|
1702
|
+
// provider's header here too — using the raw socket's source IP
|
|
1703
|
+
// (more authoritative than conninfo at upgrade time) for the
|
|
1704
|
+
// trusted-proxy check. `null` = operator tier (full access); a
|
|
1705
|
+
// non-null value scopes attach + spawn to that user.
|
|
1706
|
+
const owner = toSessionOwner(resolveIdentity(context, remoteAddress));
|
|
1626
1707
|
// Reattach if `?sid=` names a PTY the manager still has live (a
|
|
1627
1708
|
// transient drop, or a switch from the session picker). A miss
|
|
1628
1709
|
// (shell exited while dormant, killed, or reaped by the idle
|
|
1629
1710
|
// sweep) falls through to a fresh spawn.
|
|
1630
|
-
const attached = requestedSid ? registry.attach(ws, requestedSid) : null;
|
|
1711
|
+
const attached = requestedSid ? registry.attach(ws, requestedSid, owner) : null;
|
|
1631
1712
|
if (attached) {
|
|
1632
1713
|
managed = attached;
|
|
1633
1714
|
sessionId = attached.id;
|
|
@@ -1657,7 +1738,7 @@ export const createServer = async (options = {}) => {
|
|
|
1657
1738
|
cwd: sessionCwd,
|
|
1658
1739
|
initialCommand: claimedRun?.command ?? requestedInitialCommand,
|
|
1659
1740
|
env: claimedRun?.env,
|
|
1660
|
-
}, automation);
|
|
1741
|
+
}, automation, owner);
|
|
1661
1742
|
if (!spawned) {
|
|
1662
1743
|
ws.close(WS_CLOSE_CAPACITY_REACHED, "session capacity reached");
|
|
1663
1744
|
return;
|
|
@@ -1967,7 +2048,7 @@ export const createServer = async (options = {}) => {
|
|
|
1967
2048
|
export { CaffeinateController } from "./caffeinate-controller.js";
|
|
1968
2049
|
export { DEFAULT_HOST, DEFAULT_PORT, WS_CLOSE_BACKPRESSURE } from "./constants.js";
|
|
1969
2050
|
export { isLoopbackHost, isPrivateHost, isAllowedSourceIp } from "./security.js";
|
|
1970
|
-
export { healthSchema, cdpHealthSchema, daemonConfigSchema, updateDaemonConfigInputSchema, } from "./schemas.js";
|
|
2051
|
+
export { healthSchema, cdpHealthSchema, daemonConfigSchema, identityConfigSchema, oidcConfigSchema, passkeyConfigSchema, updateDaemonConfigInputSchema, } from "./schemas.js";
|
|
1971
2052
|
export { createSessionInputSchema, sessionResponseSchema, updateSessionInputSchema, sessionInputSchema, sessionResizeSchema, execInputSchema, execOneShotInputSchema, execResultSchema, capturePaneResponseSchema, sessionsListResponseSchema, } from "./schemas.js";
|
|
1972
2053
|
export { createDefaultSecretBackend } from "./secret-backend.js";
|
|
1973
2054
|
export { detectChromiumBrowsers } from "./cdp/detect-chromium.js";
|