@monotykamary/localterm-server 2.33.0 → 2.35.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 (100) hide show
  1. package/dist/caffeinate-battery.d.ts +5 -0
  2. package/dist/caffeinate-battery.d.ts.map +1 -1
  3. package/dist/caffeinate-battery.js +69 -1
  4. package/dist/caffeinate-battery.js.map +1 -1
  5. package/dist/caffeinate-controller.d.ts.map +1 -1
  6. package/dist/caffeinate-controller.js +3 -18
  7. package/dist/caffeinate-controller.js.map +1 -1
  8. package/dist/caffeinate-manager.js +5 -5
  9. package/dist/caffeinate-manager.js.map +1 -1
  10. package/dist/caffeinate-platform.d.ts +10 -0
  11. package/dist/caffeinate-platform.d.ts.map +1 -0
  12. package/dist/caffeinate-platform.js +73 -0
  13. package/dist/caffeinate-platform.js.map +1 -0
  14. package/dist/cdp/cdp-client.d.ts +30 -0
  15. package/dist/cdp/cdp-client.d.ts.map +1 -1
  16. package/dist/cdp/cdp-client.js +80 -0
  17. package/dist/cdp/cdp-client.js.map +1 -1
  18. package/dist/constants.d.ts +19 -0
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/constants.js +57 -8
  21. package/dist/constants.js.map +1 -1
  22. package/dist/daemon-config-store.d.ts +2 -0
  23. package/dist/daemon-config-store.d.ts.map +1 -1
  24. package/dist/daemon-config-store.js +14 -1
  25. package/dist/daemon-config-store.js.map +1 -1
  26. package/dist/identity/credential-store.d.ts +18 -0
  27. package/dist/identity/credential-store.d.ts.map +1 -0
  28. package/dist/identity/credential-store.js +76 -0
  29. package/dist/identity/credential-store.js.map +1 -0
  30. package/dist/identity/factory.d.ts +3 -0
  31. package/dist/identity/factory.d.ts.map +1 -0
  32. package/dist/identity/factory.js +19 -0
  33. package/dist/identity/factory.js.map +1 -0
  34. package/dist/identity/header-provider.d.ts +3 -0
  35. package/dist/identity/header-provider.d.ts.map +1 -0
  36. package/dist/identity/header-provider.js +33 -0
  37. package/dist/identity/header-provider.js.map +1 -0
  38. package/dist/identity/oidc-provider.d.ts +4 -0
  39. package/dist/identity/oidc-provider.d.ts.map +1 -0
  40. package/dist/identity/oidc-provider.js +172 -0
  41. package/dist/identity/oidc-provider.js.map +1 -0
  42. package/dist/identity/passkey-provider.d.ts +3 -0
  43. package/dist/identity/passkey-provider.d.ts.map +1 -0
  44. package/dist/identity/passkey-provider.js +233 -0
  45. package/dist/identity/passkey-provider.js.map +1 -0
  46. package/dist/identity/proxy-allowlist.d.ts +5 -0
  47. package/dist/identity/proxy-allowlist.d.ts.map +1 -0
  48. package/dist/identity/proxy-allowlist.js +64 -0
  49. package/dist/identity/proxy-allowlist.js.map +1 -0
  50. package/dist/identity/resolve.d.ts +11 -0
  51. package/dist/identity/resolve.d.ts.map +1 -0
  52. package/dist/identity/resolve.js +57 -0
  53. package/dist/identity/resolve.js.map +1 -0
  54. package/dist/identity/session-cookie.d.ts +10 -0
  55. package/dist/identity/session-cookie.d.ts.map +1 -0
  56. package/dist/identity/session-cookie.js +92 -0
  57. package/dist/identity/session-cookie.js.map +1 -0
  58. package/dist/identity/types.d.ts +49 -0
  59. package/dist/identity/types.d.ts.map +1 -0
  60. package/dist/identity/types.js +2 -0
  61. package/dist/identity/types.js.map +1 -0
  62. package/dist/identity/user-store.d.ts +16 -0
  63. package/dist/identity/user-store.d.ts.map +1 -0
  64. package/dist/identity/user-store.js +77 -0
  65. package/dist/identity/user-store.js.map +1 -0
  66. package/dist/index.d.ts +16 -5
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +112 -31
  69. package/dist/index.js.map +1 -1
  70. package/dist/protocol.d.ts +2 -1
  71. package/dist/protocol.d.ts.map +1 -1
  72. package/dist/protocol.js +1 -1
  73. package/dist/protocol.js.map +1 -1
  74. package/dist/schemas.d.ts +79 -0
  75. package/dist/schemas.d.ts.map +1 -1
  76. package/dist/schemas.js +58 -3
  77. package/dist/schemas.js.map +1 -1
  78. package/dist/secret-store.d.ts.map +1 -1
  79. package/dist/secret-store.js +4 -1
  80. package/dist/secret-store.js.map +1 -1
  81. package/dist/session-automation.d.ts +7 -2
  82. package/dist/session-automation.d.ts.map +1 -1
  83. package/dist/session-automation.js +27 -8
  84. package/dist/session-automation.js.map +1 -1
  85. package/dist/session-manager.d.ts +20 -17
  86. package/dist/session-manager.d.ts.map +1 -1
  87. package/dist/session-manager.js +63 -44
  88. package/dist/session-manager.js.map +1 -1
  89. package/dist/utils/find-binary-on-path.d.ts +2 -0
  90. package/dist/utils/find-binary-on-path.d.ts.map +1 -0
  91. package/dist/utils/find-binary-on-path.js +24 -0
  92. package/dist/utils/find-binary-on-path.js.map +1 -0
  93. package/dist/utils/open-chrome-inspect.d.ts.map +1 -1
  94. package/dist/utils/open-chrome-inspect.js +55 -5
  95. package/dist/utils/open-chrome-inspect.js.map +1 -1
  96. package/dist/utils/timing-safe-equal.d.ts +2 -0
  97. package/dist/utils/timing-safe-equal.d.ts.map +1 -0
  98. package/dist/utils/timing-safe-equal.js +12 -0
  99. package/dist/utils/timing-safe-equal.js.map +1 -0
  100. 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.list().find((session) => session.id === context.req.param("id"));
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 managed = registry.list().find((session) => session.id === id);
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 id = registry.spawnDetached({ cwd, cols: parsed.data.cols, rows: parsed.data.rows, env: parsed.data.env }, false);
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";