remote-codex 0.11.29 → 0.11.31

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 (30) hide show
  1. package/apps/relay-server/dist/index.js +713 -39
  2. package/apps/supervisor-api/dist/index.js +64 -10
  3. package/apps/supervisor-web/dist/apple-touch-icon.png +0 -0
  4. package/apps/supervisor-web/dist/assets/{core-oEok_Crl.js → core-DODVy7wn.js} +1 -1
  5. package/apps/supervisor-web/dist/assets/{graph-vendor-DVPtkh3h.js → graph-vendor-DVQUpZ8C.js} +1 -1
  6. package/apps/supervisor-web/dist/assets/index-BkwJP0KB.js +6 -0
  7. package/apps/supervisor-web/dist/assets/index-CJFMmjP5.css +1 -0
  8. package/apps/supervisor-web/dist/assets/{markdown-vendor-BQJfKm05.js → markdown-vendor-RZk8L7-L.js} +1 -1
  9. package/apps/supervisor-web/dist/assets/{react-vendor-CgLzZcV4.js → react-vendor-Dfg_6BLf.js} +1 -1
  10. package/apps/supervisor-web/dist/assets/{terminal-vendor-B365Go3Z.js → terminal-vendor-C5bTa-Ka.js} +1 -1
  11. package/apps/supervisor-web/dist/assets/{thread-ui-DXUs5Aqt.js → thread-ui-BOqbjoiB.js} +27 -27
  12. package/apps/supervisor-web/dist/assets/{ui-vendor-CeKGesq3.js → ui-vendor-CuR8GHb0.js} +1 -1
  13. package/apps/supervisor-web/dist/favicon-16x16.png +0 -0
  14. package/apps/supervisor-web/dist/favicon-32x32.png +0 -0
  15. package/apps/supervisor-web/dist/favicon-48x48.png +0 -0
  16. package/apps/supervisor-web/dist/icon-192.png +0 -0
  17. package/apps/supervisor-web/dist/icon-512.png +0 -0
  18. package/apps/supervisor-web/dist/index.html +15 -9
  19. package/apps/supervisor-web/dist/remote-codex-icon.png +0 -0
  20. package/apps/supervisor-web/dist/site.webmanifest +19 -0
  21. package/config/codex-model-pricing.json +44 -0
  22. package/package.json +1 -1
  23. package/packages/agent-runtime/src/model-pricing.ts +66 -6
  24. package/packages/claude/src/runtimeAdapter.ts +4 -0
  25. package/packages/codex/src/modelPricing.test.ts +84 -0
  26. package/packages/codex/src/types.ts +3 -1
  27. package/packages/opencode/src/runtimeAdapter.ts +15 -0
  28. package/packages/shared/src/index.ts +86 -1
  29. package/apps/supervisor-web/dist/assets/index-FOSXdyuj.css +0 -1
  30. package/apps/supervisor-web/dist/assets/index-fi_pAm4J.js +0 -6
@@ -284,6 +284,82 @@ var RelayStore = class _RelayStore {
284
284
  this.insertShare(share);
285
285
  return share;
286
286
  }
287
+ createGrant(ownerUserId, input) {
288
+ const owner = this.requireUser(ownerUserId);
289
+ const device = this.getDevice(input.deviceId);
290
+ if (!device || device.ownerUserId !== ownerUserId) {
291
+ throw new RelayStoreError(404, "not_found", "Device was not found.");
292
+ }
293
+ const target = this.getUserByIdentifier(input.targetIdentifier);
294
+ if (!target || !target.enabled) {
295
+ throw new RelayStoreError(404, "not_found", "Target user was not found.");
296
+ }
297
+ if (target.id === ownerUserId) {
298
+ throw new RelayStoreError(400, "bad_request", "You cannot share access with yourself.");
299
+ }
300
+ const scope = normalizeShareScope(input.scope);
301
+ const threadId = scope === "thread" ? input.threadId?.trim() || null : null;
302
+ const workspaceId = scope === "workspace" ? input.workspaceId?.trim() || null : null;
303
+ if (scope === "thread" && !threadId) {
304
+ throw new RelayStoreError(400, "bad_request", "threadId is required for thread grants.");
305
+ }
306
+ if (scope === "workspace" && !workspaceId) {
307
+ throw new RelayStoreError(400, "bad_request", "workspaceId is required for workspace grants.");
308
+ }
309
+ const existing = this.rowToGrant(
310
+ this.sqlite.prepare(
311
+ `
312
+ SELECT * FROM relay_access_grants
313
+ WHERE owner_user_id = ?
314
+ AND target_user_id = ?
315
+ AND device_id = ?
316
+ AND scope = ?
317
+ AND COALESCE(thread_id, '') = COALESCE(?, '')
318
+ AND COALESCE(workspace_id, '') = COALESCE(?, '')
319
+ AND revoked_at IS NULL
320
+ `
321
+ ).get(ownerUserId, target.id, input.deviceId, scope, threadId, workspaceId)
322
+ );
323
+ if (existing) {
324
+ return this.updateGrantRecord(existing.id, {
325
+ label: input.label?.trim() || null,
326
+ workspaceScope: normalizeWorkspaceScope(input.workspaceScope),
327
+ workspaceIds: normalizeWorkspaceIds(input.workspaceIds),
328
+ threadAccess: normalizeThreadAccess(input.threadAccess),
329
+ workspaceAccess: normalizeWorkspaceAccess(input.workspaceAccess),
330
+ canCreateThreads: Boolean(input.canCreateThreads),
331
+ expiresAt: normalizeExpiresAt(input.expiresAt)
332
+ });
333
+ }
334
+ const grant = {
335
+ id: crypto.randomUUID(),
336
+ ownerUserId,
337
+ ownerUsername: owner.username,
338
+ targetUserId: target.id,
339
+ targetUsername: target.username,
340
+ deviceId: input.deviceId,
341
+ deviceName: device.name,
342
+ scope,
343
+ threadId,
344
+ threadTitle: null,
345
+ workspaceId,
346
+ workspaceLabel: null,
347
+ workspaceScope: normalizeWorkspaceScope(input.workspaceScope),
348
+ workspaceIds: normalizeWorkspaceIds(input.workspaceIds),
349
+ label: input.label?.trim() || null,
350
+ threadAccess: normalizeThreadAccess(input.threadAccess),
351
+ workspaceAccess: normalizeWorkspaceAccess(input.workspaceAccess),
352
+ canCreateThreads: Boolean(input.canCreateThreads),
353
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
354
+ revokedAt: null,
355
+ expiresAt: normalizeExpiresAt(input.expiresAt),
356
+ lastAccessedAt: null,
357
+ lastAccessedByUsername: null,
358
+ accessEvents: []
359
+ };
360
+ this.insertGrant(grant);
361
+ return grant;
362
+ }
287
363
  updateShare(userId, shareId, input) {
288
364
  const share = this.rowToShare(
289
365
  this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL").get(shareId, userId)
@@ -312,15 +388,49 @@ var RelayStore = class _RelayStore {
312
388
  this.sqlite.prepare("UPDATE relay_shares SET revoked_at = ? WHERE id = ?").run(revokedAt, shareId);
313
389
  return { ...share, revokedAt };
314
390
  }
391
+ updateGrant(userId, grantId, input) {
392
+ const grant = this.rowToGrant(
393
+ this.sqlite.prepare("SELECT * FROM relay_access_grants WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL").get(grantId, userId)
394
+ );
395
+ if (!grant) {
396
+ throw new RelayStoreError(404, "not_found", "Grant was not found.");
397
+ }
398
+ return this.publicGrant(
399
+ this.updateGrantRecord(grantId, {
400
+ label: input.label !== void 0 ? input.label?.trim() || null : grant.label,
401
+ workspaceScope: input.workspaceScope !== void 0 ? normalizeWorkspaceScope(input.workspaceScope) : grant.workspaceScope,
402
+ workspaceIds: input.workspaceIds !== void 0 ? normalizeWorkspaceIds(input.workspaceIds) : grant.workspaceIds,
403
+ threadAccess: input.threadAccess !== void 0 ? normalizeThreadAccess(input.threadAccess) : grant.threadAccess,
404
+ workspaceAccess: input.workspaceAccess !== void 0 ? normalizeWorkspaceAccess(input.workspaceAccess) : grant.workspaceAccess,
405
+ canCreateThreads: input.canCreateThreads !== void 0 ? Boolean(input.canCreateThreads) : grant.canCreateThreads,
406
+ expiresAt: input.expiresAt !== void 0 ? normalizeExpiresAt(input.expiresAt) : grant.expiresAt
407
+ })
408
+ );
409
+ }
410
+ revokeGrant(userId, grantId) {
411
+ const grant = this.rowToGrant(
412
+ this.sqlite.prepare("SELECT * FROM relay_access_grants WHERE id = ? AND owner_user_id = ?").get(grantId, userId)
413
+ );
414
+ if (!grant) {
415
+ throw new RelayStoreError(404, "not_found", "Grant was not found.");
416
+ }
417
+ const revokedAt = (/* @__PURE__ */ new Date()).toISOString();
418
+ this.sqlite.prepare("UPDATE relay_access_grants SET revoked_at = ? WHERE id = ?").run(revokedAt, grantId);
419
+ return { ...grant, revokedAt };
420
+ }
315
421
  effectiveAccess(userId, deviceId, scope = {}) {
316
422
  const owned = this.sqlite.prepare("SELECT 1 FROM relay_devices WHERE id = ? AND owner_user_id = ?").get(deviceId, userId);
317
423
  if (owned) {
318
424
  return {
319
425
  kind: "owner",
320
426
  share: null,
427
+ grant: null,
428
+ scope: "owner",
321
429
  threadAccess: "control",
322
430
  workspaceAccess: "write",
323
- workspaceId: null
431
+ workspaceId: null,
432
+ workspaceScope: null,
433
+ canCreateThreads: true
324
434
  };
325
435
  }
326
436
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -339,19 +449,25 @@ var RelayStore = class _RelayStore {
339
449
  `
340
450
  ).get(userId, deviceId, scope.threadId, now)
341
451
  );
342
- if (!share) {
343
- return null;
344
- }
345
- if (scope.workspaceId && (!share.workspaceId || share.workspaceId !== scope.workspaceId || share.workspaceAccess === "none")) {
346
- return null;
452
+ if (share) {
453
+ if (scope.workspaceId && (!share.workspaceId || share.workspaceId !== scope.workspaceId || share.workspaceAccess === "none")) {
454
+ const grant3 = this.findBestGrant(userId, deviceId, scope);
455
+ return grant3 ? this.accessFromGrant(grant3) : null;
456
+ }
457
+ return {
458
+ kind: "shared",
459
+ share,
460
+ grant: this.grantFromShare(share),
461
+ scope: "thread",
462
+ threadAccess: share.threadAccess,
463
+ workspaceAccess: share.workspaceAccess,
464
+ workspaceId: share.workspaceId,
465
+ workspaceScope: "selected",
466
+ canCreateThreads: false
467
+ };
347
468
  }
348
- return {
349
- kind: "shared",
350
- share,
351
- threadAccess: share.threadAccess,
352
- workspaceAccess: share.workspaceAccess,
353
- workspaceId: share.workspaceId
354
- };
469
+ const grant2 = this.findBestGrant(userId, deviceId, scope);
470
+ return grant2 ? this.accessFromGrant(grant2) : null;
355
471
  }
356
472
  if (scope.workspaceId) {
357
473
  const share = this.rowToShare(
@@ -371,18 +487,24 @@ var RelayStore = class _RelayStore {
371
487
  `
372
488
  ).get(userId, deviceId, scope.workspaceId, now)
373
489
  );
374
- if (!share) {
375
- return null;
490
+ if (share) {
491
+ return {
492
+ kind: "shared",
493
+ share,
494
+ grant: this.grantFromShare(share),
495
+ scope: "thread",
496
+ threadAccess: share.threadAccess,
497
+ workspaceAccess: share.workspaceAccess,
498
+ workspaceId: share.workspaceId,
499
+ workspaceScope: "selected",
500
+ canCreateThreads: false
501
+ };
376
502
  }
377
- return {
378
- kind: "shared",
379
- share,
380
- threadAccess: share.threadAccess,
381
- workspaceAccess: share.workspaceAccess,
382
- workspaceId: share.workspaceId
383
- };
503
+ const grant2 = this.findBestGrant(userId, deviceId, scope);
504
+ return grant2 ? this.accessFromGrant(grant2) : null;
384
505
  }
385
- return null;
506
+ const grant = this.findBestGrant(userId, deviceId, scope);
507
+ return grant ? this.accessFromGrant(grant) : null;
386
508
  }
387
509
  canAccessDevice(userId, deviceId, threadId) {
388
510
  return Boolean(
@@ -396,11 +518,22 @@ var RelayStore = class _RelayStore {
396
518
  const devices = this.getDevicesByOwner(userId);
397
519
  const sharedWithMe = this.getSharesByTarget(userId);
398
520
  const sharedByMe = this.getSharesByOwner(userId);
521
+ const grantsWithMe = this.getGrantsByTarget(userId);
522
+ const grantsByMe = this.getGrantsByOwner(userId);
399
523
  return {
400
524
  user: this.publicUser(user),
401
525
  devices: devices.map((device) => this.publicDevice(device, connectedDevices.get(device.id) ?? null)),
402
526
  sharedWithMe: sharedWithMe.map((share) => this.publicShare(share)),
403
- sharedByMe: sharedByMe.map((share) => this.publicShare(share))
527
+ sharedByMe: sharedByMe.map((share) => this.publicShare(share)),
528
+ sharedDevicesWithMe: grantsWithMe.filter((grant) => grant.scope === "device").map((grant) => this.publicGrant(grant)),
529
+ sharedThreadsWithMe: [
530
+ ...sharedWithMe.map((share) => this.publicGrant(this.grantFromShare(share))),
531
+ ...grantsWithMe.filter((grant) => grant.scope !== "device").map((grant) => this.publicGrant(grant))
532
+ ],
533
+ grantsByMe: [
534
+ ...sharedByMe.map((share) => this.publicGrant(this.grantFromShare(share))),
535
+ ...grantsByMe.map((grant) => this.publicGrant(grant))
536
+ ]
404
537
  };
405
538
  }
406
539
  sharedThreadsForDevice(userId, deviceId) {
@@ -628,7 +761,7 @@ var RelayStore = class _RelayStore {
628
761
  threads: metadata?.threadsByDeviceId?.get(device.id) ?? []
629
762
  };
630
763
  }
631
- recordShareAccess(share, user) {
764
+ recordShareAccess(share, user, kind = "access") {
632
765
  if (share.revokedAt || share.expiresAt && share.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
633
766
  return;
634
767
  }
@@ -636,10 +769,23 @@ var RelayStore = class _RelayStore {
636
769
  this.sqlite.prepare(
637
770
  `
638
771
  INSERT INTO relay_share_access_events (
639
- id, share_id, user_id, username, accessed_at
640
- ) VALUES (?, ?, ?, ?, ?)
772
+ id, share_id, user_id, username, kind, accessed_at
773
+ ) VALUES (?, ?, ?, ?, ?, ?)
641
774
  `
642
- ).run(crypto.randomUUID(), share.id, user.id, user.username, accessedAt);
775
+ ).run(crypto.randomUUID(), share.id, user.id, user.username, kind, accessedAt);
776
+ }
777
+ recordGrantAccess(grant, user, kind = "access") {
778
+ if (grant.revokedAt || grant.expiresAt && grant.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
779
+ return;
780
+ }
781
+ const accessedAt = (/* @__PURE__ */ new Date()).toISOString();
782
+ this.sqlite.prepare(
783
+ `
784
+ INSERT INTO relay_access_grant_events (
785
+ id, grant_id, user_id, username, kind, accessed_at
786
+ ) VALUES (?, ?, ?, ?, ?, ?)
787
+ `
788
+ ).run(crypto.randomUUID(), grant.id, user.id, user.username, kind, accessedAt);
643
789
  }
644
790
  publicShare(share) {
645
791
  const owner = this.getUser(share.ownerUserId);
@@ -657,6 +803,22 @@ var RelayStore = class _RelayStore {
657
803
  accessEvents
658
804
  };
659
805
  }
806
+ publicGrant(grant) {
807
+ const owner = this.getUser(grant.ownerUserId);
808
+ const target = this.getUser(grant.targetUserId);
809
+ const device = this.getDevice(grant.deviceId);
810
+ const accessEvents = this.getGrantAccessEvents(grant.id);
811
+ const lastAccess = accessEvents[0] ?? null;
812
+ return {
813
+ ...grant,
814
+ ownerUsername: grant.ownerUsername ?? owner?.username ?? "unknown",
815
+ targetUsername: grant.targetUsername ?? target?.username ?? "unknown",
816
+ deviceName: grant.deviceName ?? device?.name ?? "Remote Codex device",
817
+ lastAccessedAt: lastAccess?.accessedAt ?? null,
818
+ lastAccessedByUsername: lastAccess?.username ?? null,
819
+ accessEvents
820
+ };
821
+ }
660
822
  migrate() {
661
823
  this.sqlite.exec(`
662
824
  CREATE TABLE IF NOT EXISTS relay_settings (
@@ -717,11 +879,51 @@ var RelayStore = class _RelayStore {
717
879
  share_id TEXT NOT NULL REFERENCES relay_shares(id) ON DELETE CASCADE,
718
880
  user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
719
881
  username TEXT NOT NULL,
882
+ kind TEXT NOT NULL DEFAULT 'access',
720
883
  accessed_at TEXT NOT NULL
721
884
  );
722
885
 
723
886
  CREATE INDEX IF NOT EXISTS relay_share_access_events_share_idx ON relay_share_access_events(share_id, accessed_at DESC);
724
887
 
888
+ CREATE TABLE IF NOT EXISTS relay_access_grants (
889
+ id TEXT PRIMARY KEY,
890
+ owner_user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
891
+ owner_username TEXT,
892
+ target_user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
893
+ target_username TEXT,
894
+ device_id TEXT NOT NULL REFERENCES relay_devices(id) ON DELETE CASCADE,
895
+ device_name TEXT,
896
+ scope TEXT NOT NULL CHECK (scope IN ('thread', 'workspace', 'device')),
897
+ thread_id TEXT,
898
+ thread_title TEXT,
899
+ workspace_id TEXT,
900
+ workspace_label TEXT,
901
+ workspace_scope TEXT NOT NULL DEFAULT 'all',
902
+ workspace_ids TEXT NOT NULL DEFAULT '[]',
903
+ label TEXT,
904
+ thread_access TEXT NOT NULL DEFAULT 'control',
905
+ workspace_access TEXT NOT NULL DEFAULT 'none',
906
+ can_create_threads INTEGER NOT NULL DEFAULT 0,
907
+ created_at TEXT NOT NULL,
908
+ revoked_at TEXT,
909
+ expires_at TEXT
910
+ );
911
+
912
+ CREATE INDEX IF NOT EXISTS relay_access_grants_owner_idx ON relay_access_grants(owner_user_id);
913
+ CREATE INDEX IF NOT EXISTS relay_access_grants_target_idx ON relay_access_grants(target_user_id);
914
+ CREATE INDEX IF NOT EXISTS relay_access_grants_device_scope_idx ON relay_access_grants(device_id, scope);
915
+
916
+ CREATE TABLE IF NOT EXISTS relay_access_grant_events (
917
+ id TEXT PRIMARY KEY,
918
+ grant_id TEXT NOT NULL REFERENCES relay_access_grants(id) ON DELETE CASCADE,
919
+ user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
920
+ username TEXT NOT NULL,
921
+ kind TEXT NOT NULL DEFAULT 'access',
922
+ accessed_at TEXT NOT NULL
923
+ );
924
+
925
+ CREATE INDEX IF NOT EXISTS relay_access_grant_events_grant_idx ON relay_access_grant_events(grant_id, accessed_at DESC);
926
+
725
927
  CREATE TABLE IF NOT EXISTS relay_conversation_events (
726
928
  id TEXT PRIMARY KEY,
727
929
  user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
@@ -755,6 +957,11 @@ var RelayStore = class _RelayStore {
755
957
  this.ensureColumn("relay_shares", "thread_access", "TEXT NOT NULL DEFAULT 'control'");
756
958
  this.ensureColumn("relay_shares", "workspace_access", "TEXT NOT NULL DEFAULT 'none'");
757
959
  this.ensureColumn("relay_shares", "expires_at", "TEXT");
960
+ this.ensureColumn("relay_access_grants", "workspace_scope", "TEXT NOT NULL DEFAULT 'all'");
961
+ this.ensureColumn("relay_access_grants", "workspace_ids", "TEXT NOT NULL DEFAULT '[]'");
962
+ this.ensureColumn("relay_access_grants", "can_create_threads", "INTEGER NOT NULL DEFAULT 0");
963
+ this.ensureColumn("relay_share_access_events", "kind", "TEXT NOT NULL DEFAULT 'access'");
964
+ this.ensureColumn("relay_access_grant_events", "kind", "TEXT NOT NULL DEFAULT 'access'");
758
965
  }
759
966
  ensureColumn(table, column, definition) {
760
967
  const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all();
@@ -962,6 +1169,40 @@ var RelayStore = class _RelayStore {
962
1169
  share.expiresAt
963
1170
  );
964
1171
  }
1172
+ insertGrant(grant) {
1173
+ this.sqlite.prepare(
1174
+ `
1175
+ INSERT INTO relay_access_grants (
1176
+ id, owner_user_id, owner_username, target_user_id, target_username,
1177
+ device_id, device_name, scope, thread_id, thread_title, workspace_id,
1178
+ workspace_label, workspace_scope, workspace_ids, label, thread_access,
1179
+ workspace_access, can_create_threads, created_at, revoked_at, expires_at
1180
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1181
+ `
1182
+ ).run(
1183
+ grant.id,
1184
+ grant.ownerUserId,
1185
+ grant.ownerUsername,
1186
+ grant.targetUserId,
1187
+ grant.targetUsername,
1188
+ grant.deviceId,
1189
+ grant.deviceName,
1190
+ grant.scope,
1191
+ grant.threadId,
1192
+ grant.threadTitle,
1193
+ grant.workspaceId,
1194
+ grant.workspaceLabel,
1195
+ grant.workspaceScope,
1196
+ JSON.stringify(grant.workspaceIds),
1197
+ grant.label,
1198
+ grant.threadAccess,
1199
+ grant.workspaceAccess,
1200
+ grant.canCreateThreads ? 1 : 0,
1201
+ grant.createdAt,
1202
+ grant.revokedAt,
1203
+ grant.expiresAt
1204
+ );
1205
+ }
965
1206
  updateShareMetadata(shareId, input) {
966
1207
  const threadTitle = normalizeOptionalMetadata(input.threadTitle);
967
1208
  const workspaceLabel = normalizeOptionalMetadata(input.workspaceLabel);
@@ -995,6 +1236,24 @@ var RelayStore = class _RelayStore {
995
1236
  shareId: row.share_id,
996
1237
  userId: row.user_id,
997
1238
  username: row.username,
1239
+ kind: normalizeAccessEventKind(row.kind),
1240
+ accessedAt: row.accessed_at
1241
+ }));
1242
+ }
1243
+ getGrantAccessEvents(grantId) {
1244
+ return this.sqlite.prepare(
1245
+ `
1246
+ SELECT * FROM relay_access_grant_events
1247
+ WHERE grant_id = ?
1248
+ ORDER BY accessed_at DESC
1249
+ LIMIT 8
1250
+ `
1251
+ ).all(grantId).map((row) => ({
1252
+ id: row.id,
1253
+ grantId: row.grant_id,
1254
+ userId: row.user_id,
1255
+ username: row.username,
1256
+ kind: normalizeAccessEventKind(row.kind),
998
1257
  accessedAt: row.accessed_at
999
1258
  }));
1000
1259
  }
@@ -1021,6 +1280,33 @@ var RelayStore = class _RelayStore {
1021
1280
  this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ?").get(shareId)
1022
1281
  );
1023
1282
  }
1283
+ updateGrantRecord(grantId, input) {
1284
+ this.sqlite.prepare(
1285
+ `
1286
+ UPDATE relay_access_grants
1287
+ SET label = ?,
1288
+ workspace_scope = ?,
1289
+ workspace_ids = ?,
1290
+ thread_access = ?,
1291
+ workspace_access = ?,
1292
+ can_create_threads = ?,
1293
+ expires_at = ?
1294
+ WHERE id = ?
1295
+ `
1296
+ ).run(
1297
+ input.label,
1298
+ input.workspaceScope,
1299
+ JSON.stringify(input.workspaceIds),
1300
+ input.threadAccess,
1301
+ input.workspaceAccess,
1302
+ input.canCreateThreads ? 1 : 0,
1303
+ input.expiresAt,
1304
+ grantId
1305
+ );
1306
+ return this.rowToGrant(
1307
+ this.sqlite.prepare("SELECT * FROM relay_access_grants WHERE id = ?").get(grantId)
1308
+ );
1309
+ }
1024
1310
  getUser(id) {
1025
1311
  return this.rowToUser(this.sqlite.prepare("SELECT * FROM relay_users WHERE id = ?").get(id));
1026
1312
  }
@@ -1057,6 +1343,107 @@ var RelayStore = class _RelayStore {
1057
1343
  const rows = options.includeRevoked ? this.sqlite.prepare(sql).all() : this.sqlite.prepare(sql).all((/* @__PURE__ */ new Date()).toISOString());
1058
1344
  return rows.map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
1059
1345
  }
1346
+ getGrantsByOwner(ownerUserId) {
1347
+ return this.sqlite.prepare("SELECT * FROM relay_access_grants WHERE owner_user_id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC").all(ownerUserId, (/* @__PURE__ */ new Date()).toISOString()).map((row) => this.rowToGrant(row)).filter((grant) => Boolean(grant));
1348
+ }
1349
+ getGrantsByTarget(targetUserId) {
1350
+ return this.sqlite.prepare("SELECT * FROM relay_access_grants WHERE target_user_id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC").all(targetUserId, (/* @__PURE__ */ new Date()).toISOString()).map((row) => this.rowToGrant(row)).filter((grant) => Boolean(grant));
1351
+ }
1352
+ findBestGrant(userId, deviceId, scope) {
1353
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1354
+ const rows = this.sqlite.prepare(
1355
+ `
1356
+ SELECT * FROM relay_access_grants
1357
+ WHERE target_user_id = ?
1358
+ AND device_id = ?
1359
+ AND revoked_at IS NULL
1360
+ AND (expires_at IS NULL OR expires_at > ?)
1361
+ ORDER BY
1362
+ CASE scope
1363
+ WHEN 'thread' THEN 3
1364
+ WHEN 'workspace' THEN 2
1365
+ WHEN 'device' THEN 1
1366
+ ELSE 0
1367
+ END DESC,
1368
+ CASE thread_access WHEN 'control' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC,
1369
+ CASE workspace_access WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC,
1370
+ can_create_threads DESC,
1371
+ created_at DESC
1372
+ `
1373
+ ).all(userId, deviceId, now);
1374
+ const grants = rows.map((row) => this.rowToGrant(row)).filter((grant) => Boolean(grant));
1375
+ const matchingGrants = grants.filter((grant) => this.grantMatchesScope(grant, scope));
1376
+ return matchingGrants.length > 0 ? mergeMatchingGrants(matchingGrants, scope) : null;
1377
+ }
1378
+ grantMatchesScope(grant, scope) {
1379
+ if (grant.scope === "thread") {
1380
+ if (!scope.threadId || grant.threadId !== scope.threadId) {
1381
+ return false;
1382
+ }
1383
+ return true;
1384
+ }
1385
+ if (grant.scope === "workspace") {
1386
+ if (!scope.workspaceId || grant.workspaceId !== scope.workspaceId || grant.workspaceAccess === "none") {
1387
+ return false;
1388
+ }
1389
+ return true;
1390
+ }
1391
+ if (grant.scope === "device") {
1392
+ if (scope.workspaceId && grant.workspaceAccess === "none") {
1393
+ return false;
1394
+ }
1395
+ return true;
1396
+ }
1397
+ return false;
1398
+ }
1399
+ accessFromGrant(grant) {
1400
+ return {
1401
+ kind: "shared",
1402
+ share: null,
1403
+ grant,
1404
+ scope: grant.scope,
1405
+ threadAccess: grant.threadAccess,
1406
+ workspaceAccess: grant.workspaceAccess,
1407
+ workspaceId: grant.workspaceId,
1408
+ workspaceScope: grant.workspaceScope,
1409
+ canCreateThreads: grant.canCreateThreads
1410
+ };
1411
+ }
1412
+ grantFromShare(share) {
1413
+ return {
1414
+ id: share.id,
1415
+ ownerUserId: share.ownerUserId,
1416
+ ownerUsername: share.ownerUsername,
1417
+ targetUserId: share.targetUserId,
1418
+ targetUsername: share.targetUsername,
1419
+ deviceId: share.deviceId,
1420
+ deviceName: share.deviceName,
1421
+ scope: "thread",
1422
+ threadId: share.threadId,
1423
+ threadTitle: share.threadTitle,
1424
+ workspaceId: share.workspaceId,
1425
+ workspaceLabel: share.workspaceLabel,
1426
+ workspaceScope: "selected",
1427
+ workspaceIds: share.workspaceId ? [share.workspaceId] : [],
1428
+ label: share.label,
1429
+ threadAccess: share.threadAccess,
1430
+ workspaceAccess: share.workspaceAccess,
1431
+ canCreateThreads: false,
1432
+ createdAt: share.createdAt,
1433
+ revokedAt: share.revokedAt,
1434
+ expiresAt: share.expiresAt,
1435
+ lastAccessedAt: share.lastAccessedAt,
1436
+ lastAccessedByUsername: share.lastAccessedByUsername,
1437
+ accessEvents: share.accessEvents.map((event) => ({
1438
+ id: event.id,
1439
+ grantId: event.shareId,
1440
+ userId: event.userId,
1441
+ username: event.username,
1442
+ kind: event.kind,
1443
+ accessedAt: event.accessedAt
1444
+ }))
1445
+ };
1446
+ }
1060
1447
  conversationCountsByUser(days) {
1061
1448
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1e3).toISOString();
1062
1449
  const rows = this.sqlite.prepare(
@@ -1169,6 +1556,35 @@ var RelayStore = class _RelayStore {
1169
1556
  accessEvents: []
1170
1557
  };
1171
1558
  }
1559
+ rowToGrant(row) {
1560
+ if (!row) return null;
1561
+ return {
1562
+ id: row.id,
1563
+ ownerUserId: row.owner_user_id,
1564
+ ownerUsername: row.owner_username ?? "unknown",
1565
+ targetUserId: row.target_user_id,
1566
+ targetUsername: row.target_username ?? "unknown",
1567
+ deviceId: row.device_id,
1568
+ deviceName: row.device_name ?? "Remote Codex device",
1569
+ scope: normalizeShareScope(row.scope),
1570
+ threadId: row.thread_id ?? null,
1571
+ threadTitle: row.thread_title ?? null,
1572
+ workspaceId: row.workspace_id ?? null,
1573
+ workspaceLabel: row.workspace_label ?? null,
1574
+ workspaceScope: normalizeWorkspaceScope(row.workspace_scope),
1575
+ workspaceIds: parseWorkspaceIds(row.workspace_ids),
1576
+ label: row.label,
1577
+ threadAccess: normalizeThreadAccess(row.thread_access),
1578
+ workspaceAccess: normalizeWorkspaceAccess(row.workspace_access),
1579
+ canCreateThreads: Boolean(row.can_create_threads),
1580
+ createdAt: row.created_at,
1581
+ revokedAt: row.revoked_at,
1582
+ expiresAt: row.expires_at ?? null,
1583
+ lastAccessedAt: null,
1584
+ lastAccessedByUsername: null,
1585
+ accessEvents: []
1586
+ };
1587
+ }
1172
1588
  rowToPendingRegistration(row) {
1173
1589
  if (!row) return null;
1174
1590
  return {
@@ -1205,6 +1621,100 @@ function normalizeWorkspaceAccess(value) {
1205
1621
  }
1206
1622
  return "none";
1207
1623
  }
1624
+ function normalizeShareScope(value) {
1625
+ if (value === "workspace" || value === "device") {
1626
+ return value;
1627
+ }
1628
+ return "thread";
1629
+ }
1630
+ function normalizeWorkspaceScope(value) {
1631
+ return value === "selected" ? "selected" : "all";
1632
+ }
1633
+ function normalizeWorkspaceIds(values) {
1634
+ if (!Array.isArray(values)) {
1635
+ return [];
1636
+ }
1637
+ return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).sort();
1638
+ }
1639
+ function mergeMatchingGrants(grants, scope) {
1640
+ const sorted = [...grants].sort(compareGrantCapability);
1641
+ const grantScope = mergedGrantScope(sorted);
1642
+ const representative = sorted.find((grant) => grant.scope === grantScope) ?? sorted[0];
1643
+ const threadAccess = sorted.reduce(
1644
+ (best, grant) => maxThreadAccess(best, grant.threadAccess),
1645
+ "read"
1646
+ );
1647
+ const workspaceAccess = sorted.reduce(
1648
+ (best, grant) => maxWorkspaceAccess(best, grant.workspaceAccess),
1649
+ "none"
1650
+ );
1651
+ return {
1652
+ ...representative,
1653
+ scope: grantScope,
1654
+ threadId: grantScope === "thread" ? scope.threadId ?? representative.threadId : null,
1655
+ workspaceId: grantScope === "workspace" ? scope.workspaceId ?? representative.workspaceId : null,
1656
+ threadAccess,
1657
+ workspaceAccess,
1658
+ canCreateThreads: sorted.some((grant) => grant.canCreateThreads)
1659
+ };
1660
+ }
1661
+ function mergedGrantScope(grants) {
1662
+ if (grants.some((grant) => grant.scope === "device")) {
1663
+ return "device";
1664
+ }
1665
+ if (grants.some((grant) => grant.scope === "workspace")) {
1666
+ return "workspace";
1667
+ }
1668
+ return "thread";
1669
+ }
1670
+ function maxThreadAccess(left, right) {
1671
+ return threadAccessScore(right) > threadAccessScore(left) ? right : left;
1672
+ }
1673
+ function maxWorkspaceAccess(left, right) {
1674
+ return workspaceAccessScore(right) > workspaceAccessScore(left) ? right : left;
1675
+ }
1676
+ function compareGrantCapability(left, right) {
1677
+ return threadAccessScore(right.threadAccess) - threadAccessScore(left.threadAccess) || workspaceAccessScore(right.workspaceAccess) - workspaceAccessScore(left.workspaceAccess) || Number(right.canCreateThreads) - Number(left.canCreateThreads) || grantScopeScore(right.scope) - grantScopeScore(left.scope) || right.createdAt.localeCompare(left.createdAt);
1678
+ }
1679
+ function threadAccessScore(value) {
1680
+ return value === "control" ? 2 : value === "read" ? 1 : 0;
1681
+ }
1682
+ function workspaceAccessScore(value) {
1683
+ return value === "write" ? 2 : value === "read" ? 1 : 0;
1684
+ }
1685
+ function grantScopeScore(value) {
1686
+ if (value === "thread") {
1687
+ return 3;
1688
+ }
1689
+ if (value === "workspace") {
1690
+ return 2;
1691
+ }
1692
+ return 1;
1693
+ }
1694
+ function normalizeAccessEventKind(value) {
1695
+ switch (value) {
1696
+ case "open_device":
1697
+ case "open_thread":
1698
+ case "create_thread":
1699
+ case "send_prompt":
1700
+ case "read_workspace_file":
1701
+ case "write_workspace_file":
1702
+ return value;
1703
+ default:
1704
+ return "access";
1705
+ }
1706
+ }
1707
+ function parseWorkspaceIds(value) {
1708
+ if (!value) {
1709
+ return [];
1710
+ }
1711
+ try {
1712
+ const parsed = JSON.parse(value);
1713
+ return normalizeWorkspaceIds(Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []);
1714
+ } catch {
1715
+ return [];
1716
+ }
1717
+ }
1208
1718
  function normalizeExpiresAt(value) {
1209
1719
  if (!value) {
1210
1720
  return null;
@@ -1252,6 +1762,8 @@ var WEBSOCKET_OPEN = 1;
1252
1762
  var RELAY_COOKIE_NAME = "remote_codex_relay_session";
1253
1763
  var threadAccessSchema = z.enum(["read", "control"]);
1254
1764
  var workspaceAccessSchema = z.enum(["none", "read", "write"]);
1765
+ var grantScopeSchema = z.enum(["thread", "workspace", "device"]);
1766
+ var workspaceScopeSchema = z.enum(["all", "selected"]);
1255
1767
  var loginSchema = z.object({
1256
1768
  identifier: z.string().trim().min(1),
1257
1769
  password: z.string().min(1)
@@ -1286,6 +1798,40 @@ var updateShareSchema = z.object({
1286
1798
  workspaceAccess: workspaceAccessSchema.optional(),
1287
1799
  expiresAt: z.string().datetime().nullable().optional()
1288
1800
  });
1801
+ var createGrantSchema = z.object({
1802
+ targetIdentifier: z.string().trim().min(1).optional(),
1803
+ targetUsername: z.string().trim().min(3).optional(),
1804
+ deviceId: z.string().uuid(),
1805
+ scope: grantScopeSchema,
1806
+ threadId: z.string().trim().min(1).nullable().optional(),
1807
+ workspaceId: z.string().uuid().nullable().optional(),
1808
+ workspaceScope: workspaceScopeSchema.default("all"),
1809
+ workspaceIds: z.array(z.string().uuid()).default([]),
1810
+ label: z.string().trim().min(1).max(160).nullable().optional(),
1811
+ threadAccess: threadAccessSchema.default("control"),
1812
+ workspaceAccess: workspaceAccessSchema.default("none"),
1813
+ canCreateThreads: z.boolean().optional(),
1814
+ expiresAt: z.string().datetime().nullable().optional()
1815
+ }).refine((input) => input.targetIdentifier || input.targetUsername, {
1816
+ message: "targetIdentifier is required.",
1817
+ path: ["targetIdentifier"]
1818
+ }).refine((input) => input.scope !== "thread" || Boolean(input.threadId), {
1819
+ message: "threadId is required for thread grants.",
1820
+ path: ["threadId"]
1821
+ }).refine((input) => input.scope !== "workspace" || Boolean(input.workspaceId), {
1822
+ message: "workspaceId is required for workspace grants.",
1823
+ path: ["workspaceId"]
1824
+ });
1825
+ var updateGrantSchema = z.object({
1826
+ workspaceId: z.string().uuid().nullable().optional(),
1827
+ workspaceScope: workspaceScopeSchema.optional(),
1828
+ workspaceIds: z.array(z.string().uuid()).optional(),
1829
+ label: z.string().trim().min(1).max(160).nullable().optional(),
1830
+ threadAccess: threadAccessSchema.optional(),
1831
+ workspaceAccess: workspaceAccessSchema.optional(),
1832
+ canCreateThreads: z.boolean().optional(),
1833
+ expiresAt: z.string().datetime().nullable().optional()
1834
+ });
1289
1835
  var setEnabledSchema = z.object({
1290
1836
  enabled: z.boolean()
1291
1837
  });
@@ -1565,6 +2111,44 @@ function buildRelayServer(config2, options = {}) {
1565
2111
  const { shareId } = z.object({ shareId: z.string().uuid() }).parse(request.params);
1566
2112
  return store.revokeShare(user.id, shareId);
1567
2113
  });
2114
+ app2.post("/relay/grants", async (request, reply) => {
2115
+ const user = requireRelayUser(request, reply, store);
2116
+ if (!user) {
2117
+ return;
2118
+ }
2119
+ const body = createGrantSchema.parse(request.body ?? {});
2120
+ return store.createGrant(user.id, {
2121
+ targetIdentifier: body.targetIdentifier ?? body.targetUsername,
2122
+ deviceId: body.deviceId,
2123
+ scope: body.scope,
2124
+ threadId: body.threadId ?? null,
2125
+ workspaceId: body.workspaceId ?? null,
2126
+ workspaceScope: body.workspaceScope,
2127
+ workspaceIds: body.workspaceIds,
2128
+ label: body.label ?? null,
2129
+ threadAccess: body.threadAccess,
2130
+ workspaceAccess: body.workspaceAccess,
2131
+ canCreateThreads: body.canCreateThreads ?? false,
2132
+ expiresAt: body.expiresAt ?? null
2133
+ });
2134
+ });
2135
+ app2.patch("/relay/grants/:grantId", async (request, reply) => {
2136
+ const user = requireRelayUser(request, reply, store);
2137
+ if (!user) {
2138
+ return;
2139
+ }
2140
+ const { grantId } = z.object({ grantId: z.string().uuid() }).parse(request.params);
2141
+ const body = updateGrantSchema.parse(request.body ?? {});
2142
+ return store.updateGrant(user.id, grantId, body);
2143
+ });
2144
+ app2.delete("/relay/grants/:grantId", async (request, reply) => {
2145
+ const user = requireRelayUser(request, reply, store);
2146
+ if (!user) {
2147
+ return;
2148
+ }
2149
+ const { grantId } = z.object({ grantId: z.string().uuid() }).parse(request.params);
2150
+ return store.revokeGrant(user.id, grantId);
2151
+ });
1568
2152
  app2.get("/relay/admin", async (request, reply) => {
1569
2153
  const user = requireRelayUser(request, reply, store, { admin: true });
1570
2154
  if (!user) {
@@ -1751,6 +2335,18 @@ function buildRelayServer(config2, options = {}) {
1751
2335
  }
1752
2336
  if (parsed.type === "relay.server.message") {
1753
2337
  const clientConnection = connection.clientSockets.get(parsed.clientId);
2338
+ if (clientConnection) {
2339
+ const eventThreadId = threadIdFromSocketPayload(parsed.payload);
2340
+ const freshAccess = store.effectiveAccess(clientConnection.user.id, clientConnection.deviceId, {
2341
+ threadId: clientConnection.threadId ?? eventThreadId
2342
+ });
2343
+ if (!freshAccess) {
2344
+ connection.clientSockets.delete(parsed.clientId);
2345
+ clientConnection.socket.close(1008, "Shared access is no longer allowed.");
2346
+ return;
2347
+ }
2348
+ clientConnection.access = freshAccess;
2349
+ }
1754
2350
  if (clientConnection && clientConnection.socket.readyState === WEBSOCKET_OPEN && shouldForwardSocketEvent(parsed.payload, clientConnection.threadId)) {
1755
2351
  clientConnection.socket.send(JSON.stringify(parsed.payload));
1756
2352
  }
@@ -1798,9 +2394,9 @@ function buildRelayServer(config2, options = {}) {
1798
2394
  return;
1799
2395
  }
1800
2396
  if (access.kind === "shared") {
1801
- store.recordShareAccess(access.share, session.user);
2397
+ recordRelayAccess(store, access, session.user, threadId ? "open_thread" : "open_device");
1802
2398
  }
1803
- connectRelayWebsocket(supervisor, socket, threadId, access);
2399
+ connectRelayWebsocket(supervisor, socket, store, session.user, deviceId, threadId, access);
1804
2400
  }
1805
2401
  });
1806
2402
  realtimeApp.route({
@@ -1831,9 +2427,9 @@ function buildRelayServer(config2, options = {}) {
1831
2427
  return;
1832
2428
  }
1833
2429
  if (access.kind === "shared") {
1834
- store.recordShareAccess(access.share, session.user);
2430
+ recordRelayAccess(store, access, session.user, threadId ? "open_thread" : "open_device");
1835
2431
  }
1836
- connectRelayWebsocket(supervisor, socket, threadId, access);
2432
+ connectRelayWebsocket(supervisor, socket, store, session.user, deviceId, threadId, access);
1837
2433
  }
1838
2434
  });
1839
2435
  });
@@ -1932,7 +2528,13 @@ async function forwardRelayHttp(input) {
1932
2528
  return;
1933
2529
  }
1934
2530
  if (access?.kind === "shared") {
1935
- input.store.recordShareAccess(access.share, input.user);
2531
+ const accessEventKind = relayAccessEventKindFromRequest(
2532
+ input.request.method,
2533
+ input.targetPath
2534
+ );
2535
+ if (accessEventKind) {
2536
+ recordRelayAccess(input.store, access, input.user, accessEventKind);
2537
+ }
1936
2538
  }
1937
2539
  const conversationEvent = conversationEventFromRequest(
1938
2540
  input.request.method,
@@ -2015,9 +2617,9 @@ function relayResponseBody(response) {
2015
2617
  }
2016
2618
  return response.body;
2017
2619
  }
2018
- function connectRelayWebsocket(supervisor, socket, threadId, access) {
2620
+ function connectRelayWebsocket(supervisor, socket, store, user, deviceId, threadId, access) {
2019
2621
  const clientId = randomUUID();
2020
- supervisor.clientSockets.set(clientId, { socket, threadId, access });
2622
+ supervisor.clientSockets.set(clientId, { socket, threadId, deviceId, user, access });
2021
2623
  sendToSupervisor(supervisor, {
2022
2624
  type: "relay.client.connected",
2023
2625
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2030,7 +2632,19 @@ function connectRelayWebsocket(supervisor, socket, threadId, access) {
2030
2632
  } catch {
2031
2633
  return;
2032
2634
  }
2033
- if (access.kind === "shared" && access.threadAccess !== "control") {
2635
+ const freshAccess = store.effectiveAccess(user.id, deviceId, {
2636
+ threadId: threadId ?? threadIdFromSocketPayload(payload)
2637
+ });
2638
+ if (!freshAccess) {
2639
+ supervisor.clientSockets.delete(clientId);
2640
+ socket.close(1008, "Shared access is no longer allowed.");
2641
+ return;
2642
+ }
2643
+ const clientConnection = supervisor.clientSockets.get(clientId);
2644
+ if (clientConnection) {
2645
+ clientConnection.access = freshAccess;
2646
+ }
2647
+ if (freshAccess.kind === "shared" && freshAccess.threadAccess !== "control") {
2034
2648
  socket.close(1008, "Shared read-only session cannot control supervisor.");
2035
2649
  return;
2036
2650
  }
@@ -2050,6 +2664,9 @@ function connectRelayWebsocket(supervisor, socket, threadId, access) {
2050
2664
  });
2051
2665
  });
2052
2666
  }
2667
+ function threadIdFromSocketPayload(payload) {
2668
+ return isObject(payload) && typeof payload.threadId === "string" ? payload.threadId : null;
2669
+ }
2053
2670
  function sendToSupervisor(supervisor, message) {
2054
2671
  if (supervisor.socket.readyState === WEBSOCKET_OPEN) {
2055
2672
  supervisor.socket.send(JSON.stringify(message));
@@ -2390,12 +3007,26 @@ function stringField(value, field) {
2390
3007
  function relayAccessDto(access) {
2391
3008
  return {
2392
3009
  kind: access.kind,
3010
+ grantId: access.grant?.id ?? null,
2393
3011
  shareId: access.share?.id ?? null,
3012
+ scope: access.scope,
2394
3013
  threadAccess: access.threadAccess,
2395
3014
  workspaceAccess: access.workspaceAccess,
2396
- workspaceId: access.workspaceId
3015
+ workspaceId: access.workspaceId,
3016
+ workspaceScope: access.workspaceScope,
3017
+ canCreateThreads: access.canCreateThreads
2397
3018
  };
2398
3019
  }
3020
+ function recordRelayAccess(store, access, user, kind) {
3021
+ if (access.kind !== "shared") {
3022
+ return;
3023
+ }
3024
+ if (access.share) {
3025
+ store.recordShareAccess(access.share, user, kind);
3026
+ return;
3027
+ }
3028
+ store.recordGrantAccess(access.grant, user, kind);
3029
+ }
2399
3030
  function firstAccessibleConnectedDevice(state, store, userId, threadId) {
2400
3031
  for (const deviceId of state.supervisors.keys()) {
2401
3032
  if (store.canAccessDevice(userId, deviceId, threadId)) {
@@ -2415,6 +3046,9 @@ function isAllowedSharedRuntimeMetadataRequest(method, pathname) {
2415
3046
  if (pathname === "/api/agent-runtimes") {
2416
3047
  return true;
2417
3048
  }
3049
+ if (pathname === "/api/plugins") {
3050
+ return true;
3051
+ }
2418
3052
  return /^\/api\/agent-runtimes\/[^/]+\/(?:status|models)$/.test(pathname);
2419
3053
  }
2420
3054
  function threadIdFromPath(pathValue) {
@@ -2447,6 +3081,29 @@ function conversationEventFromRequest(method, pathValue, body) {
2447
3081
  }
2448
3082
  return null;
2449
3083
  }
3084
+ function relayAccessEventKindFromRequest(method, pathValue) {
3085
+ const methodName = method.toUpperCase();
3086
+ const pathname = new URL(pathValue, "http://relay.local").pathname;
3087
+ if (methodName === "POST" && pathname === "/api/threads/start") {
3088
+ return "create_thread";
3089
+ }
3090
+ if (methodName === "POST" && /^\/api\/threads\/[^/]+\/prompt$/.test(pathname)) {
3091
+ return "send_prompt";
3092
+ }
3093
+ if (methodName === "GET" && /^\/api\/threads\/[^/]+$/.test(pathname)) {
3094
+ return "open_thread";
3095
+ }
3096
+ if (methodName === "GET" && /^\/api\/workspaces\/[^/]+$/.test(pathname)) {
3097
+ return "open_device";
3098
+ }
3099
+ if (methodName === "GET" && /^\/api\/workspaces\/[^/]+\/(?:files\/(?:tree|preview|raw|download)|artifacts(?:\/[^/]+(?:\/download)?)?)$/.test(pathname)) {
3100
+ return "read_workspace_file";
3101
+ }
3102
+ if (["POST", "PUT", "PATCH", "DELETE"].includes(methodName) && /^\/api\/workspaces\/[^/]+\/files(?:\/(?:upload|move))?$/.test(pathname)) {
3103
+ return "write_workspace_file";
3104
+ }
3105
+ return null;
3106
+ }
2450
3107
  function relayClientIp(request) {
2451
3108
  const forwarded = firstHeaderValue(request.headers["cf-connecting-ip"]) ?? firstHeaderValue(request.headers["x-real-ip"]) ?? firstHeaderValue(request.headers["x-forwarded-for"]);
2452
3109
  if (forwarded) {
@@ -2466,6 +3123,12 @@ function isAllowedForRelayAccess(access, method, pathValue) {
2466
3123
  }
2467
3124
  const pathname = new URL(pathValue, "http://relay.local").pathname;
2468
3125
  const methodName = method.toUpperCase();
3126
+ if (isAllowedSharedRuntimeMetadataRequest(methodName, pathname)) {
3127
+ return true;
3128
+ }
3129
+ if (access.scope === "device" && methodName === "POST" && pathname === "/api/threads/start") {
3130
+ return access.canCreateThreads && access.threadAccess === "control";
3131
+ }
2469
3132
  const threadId = threadIdFromPath(pathValue);
2470
3133
  if (threadId) {
2471
3134
  return isAllowedSharedThreadPath(access, methodName, pathname, threadId);
@@ -2474,10 +3137,18 @@ function isAllowedForRelayAccess(access, method, pathValue) {
2474
3137
  if (workspaceId) {
2475
3138
  return isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId);
2476
3139
  }
3140
+ if (access.scope === "device") {
3141
+ if (methodName === "GET" && (pathname === "/api/threads" || pathname === "/api/workspaces")) {
3142
+ return true;
3143
+ }
3144
+ }
2477
3145
  return false;
2478
3146
  }
2479
3147
  function isAllowedSharedThreadPath(access, methodName, pathname, threadId) {
2480
- if (access.kind !== "shared" || access.share.threadId !== threadId) {
3148
+ if (access.kind !== "shared") {
3149
+ return false;
3150
+ }
3151
+ if (access.scope !== "device" && access.grant.threadId !== threadId) {
2481
3152
  return false;
2482
3153
  }
2483
3154
  const escapedThreadId = escapeRegExp(encodeURIComponent(threadId));
@@ -2534,7 +3205,10 @@ function isAllowedSharedThreadPath(access, methodName, pathname, threadId) {
2534
3205
  return false;
2535
3206
  }
2536
3207
  function isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId) {
2537
- if (access.kind !== "shared" || access.workspaceAccess === "none" || access.workspaceId !== workspaceId) {
3208
+ if (access.kind !== "shared" || access.workspaceAccess === "none") {
3209
+ return false;
3210
+ }
3211
+ if (access.scope !== "device" && access.workspaceId !== workspaceId) {
2538
3212
  return false;
2539
3213
  }
2540
3214
  const escapedWorkspaceId = escapeRegExp(encodeURIComponent(workspaceId));