remote-codex 0.11.22 → 0.11.23

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.
@@ -183,7 +183,7 @@ var RelayStore = class _RelayStore {
183
183
  if (!device || device.ownerUserId !== ownerUserId) {
184
184
  throw new RelayStoreError(404, "not_found", "Device was not found.");
185
185
  }
186
- const target = this.getUserByUsername(input.targetUsername);
186
+ const target = this.getUserByIdentifier(input.targetIdentifier);
187
187
  if (!target || !target.enabled) {
188
188
  throw new RelayStoreError(404, "not_found", "Target user was not found.");
189
189
  }
@@ -203,7 +203,13 @@ var RelayStore = class _RelayStore {
203
203
  ).get(ownerUserId, target.id, input.deviceId, input.threadId)
204
204
  );
205
205
  if (existing) {
206
- return existing;
206
+ return this.updateShare(existing.id, {
207
+ label: input.label?.trim() || null,
208
+ workspaceId: input.workspaceId?.trim() || null,
209
+ threadAccess: normalizeThreadAccess(input.threadAccess),
210
+ workspaceAccess: normalizeWorkspaceAccess(input.workspaceAccess),
211
+ expiresAt: normalizeExpiresAt(input.expiresAt)
212
+ });
207
213
  }
208
214
  const share = {
209
215
  id: crypto.randomUUID(),
@@ -214,9 +220,13 @@ var RelayStore = class _RelayStore {
214
220
  deviceId: input.deviceId,
215
221
  deviceName: device.name,
216
222
  threadId: input.threadId,
223
+ workspaceId: input.workspaceId?.trim() || null,
217
224
  label: input.label?.trim() || null,
225
+ threadAccess: normalizeThreadAccess(input.threadAccess),
226
+ workspaceAccess: normalizeWorkspaceAccess(input.workspaceAccess),
218
227
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
219
- revokedAt: null
228
+ revokedAt: null,
229
+ expiresAt: normalizeExpiresAt(input.expiresAt)
220
230
  };
221
231
  this.insertShare(share);
222
232
  return share;
@@ -232,24 +242,83 @@ var RelayStore = class _RelayStore {
232
242
  this.sqlite.prepare("UPDATE relay_shares SET revoked_at = ? WHERE id = ?").run(revokedAt, shareId);
233
243
  return { ...share, revokedAt };
234
244
  }
235
- canAccessDevice(userId, deviceId, threadId) {
245
+ effectiveAccess(userId, deviceId, scope = {}) {
236
246
  const owned = this.sqlite.prepare("SELECT 1 FROM relay_devices WHERE id = ? AND owner_user_id = ?").get(deviceId, userId);
237
247
  if (owned) {
238
- return true;
239
- }
240
- if (!threadId) {
241
- return false;
248
+ return {
249
+ kind: "owner",
250
+ share: null,
251
+ threadAccess: "control",
252
+ workspaceAccess: "write",
253
+ workspaceId: null
254
+ };
255
+ }
256
+ const now = (/* @__PURE__ */ new Date()).toISOString();
257
+ if (scope.threadId) {
258
+ const share = this.rowToShare(
259
+ this.sqlite.prepare(
260
+ `
261
+ SELECT * FROM relay_shares
262
+ WHERE target_user_id = ?
263
+ AND device_id = ?
264
+ AND thread_id = ?
265
+ AND revoked_at IS NULL
266
+ AND (expires_at IS NULL OR expires_at > ?)
267
+ ORDER BY created_at DESC
268
+ LIMIT 1
269
+ `
270
+ ).get(userId, deviceId, scope.threadId, now)
271
+ );
272
+ if (!share) {
273
+ return null;
274
+ }
275
+ if (scope.workspaceId && (!share.workspaceId || share.workspaceId !== scope.workspaceId || share.workspaceAccess === "none")) {
276
+ return null;
277
+ }
278
+ return {
279
+ kind: "shared",
280
+ share,
281
+ threadAccess: share.threadAccess,
282
+ workspaceAccess: share.workspaceAccess,
283
+ workspaceId: share.workspaceId
284
+ };
285
+ }
286
+ if (scope.workspaceId) {
287
+ const share = this.rowToShare(
288
+ this.sqlite.prepare(
289
+ `
290
+ SELECT * FROM relay_shares
291
+ WHERE target_user_id = ?
292
+ AND device_id = ?
293
+ AND workspace_id = ?
294
+ AND workspace_access <> 'none'
295
+ AND revoked_at IS NULL
296
+ AND (expires_at IS NULL OR expires_at > ?)
297
+ ORDER BY
298
+ CASE workspace_access WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC,
299
+ created_at DESC
300
+ LIMIT 1
301
+ `
302
+ ).get(userId, deviceId, scope.workspaceId, now)
303
+ );
304
+ if (!share) {
305
+ return null;
306
+ }
307
+ return {
308
+ kind: "shared",
309
+ share,
310
+ threadAccess: share.threadAccess,
311
+ workspaceAccess: share.workspaceAccess,
312
+ workspaceId: share.workspaceId
313
+ };
242
314
  }
315
+ return null;
316
+ }
317
+ canAccessDevice(userId, deviceId, threadId) {
243
318
  return Boolean(
244
- this.sqlite.prepare(
245
- `
246
- SELECT 1 FROM relay_shares
247
- WHERE target_user_id = ?
248
- AND device_id = ?
249
- AND thread_id = ?
250
- AND revoked_at IS NULL
251
- `
252
- ).get(userId, deviceId, threadId)
319
+ this.effectiveAccess(userId, deviceId, {
320
+ threadId: threadId ?? null
321
+ })
253
322
  );
254
323
  }
255
324
  portalSummary(userId, connectedDevices) {
@@ -385,9 +454,13 @@ var RelayStore = class _RelayStore {
385
454
  device_id TEXT NOT NULL REFERENCES relay_devices(id) ON DELETE CASCADE,
386
455
  device_name TEXT,
387
456
  thread_id TEXT NOT NULL,
457
+ workspace_id TEXT,
388
458
  label TEXT,
459
+ thread_access TEXT NOT NULL DEFAULT 'control',
460
+ workspace_access TEXT NOT NULL DEFAULT 'none',
389
461
  created_at TEXT NOT NULL,
390
- revoked_at TEXT
462
+ revoked_at TEXT,
463
+ expires_at TEXT
391
464
  );
392
465
 
393
466
  CREATE INDEX IF NOT EXISTS relay_shares_owner_idx ON relay_shares(owner_user_id);
@@ -395,6 +468,10 @@ var RelayStore = class _RelayStore {
395
468
  CREATE INDEX IF NOT EXISTS relay_shares_device_thread_idx ON relay_shares(device_id, thread_id);
396
469
  `);
397
470
  this.ensureColumn("relay_devices", "token", "TEXT");
471
+ this.ensureColumn("relay_shares", "workspace_id", "TEXT");
472
+ this.ensureColumn("relay_shares", "thread_access", "TEXT NOT NULL DEFAULT 'control'");
473
+ this.ensureColumn("relay_shares", "workspace_access", "TEXT NOT NULL DEFAULT 'none'");
474
+ this.ensureColumn("relay_shares", "expires_at", "TEXT");
398
475
  }
399
476
  ensureColumn(table, column, definition) {
400
477
  const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all();
@@ -573,8 +650,9 @@ var RelayStore = class _RelayStore {
573
650
  `
574
651
  INSERT INTO relay_shares (
575
652
  id, owner_user_id, owner_username, target_user_id, target_username,
576
- device_id, device_name, thread_id, label, created_at, revoked_at
577
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
653
+ device_id, device_name, thread_id, workspace_id, label,
654
+ thread_access, workspace_access, created_at, revoked_at, expires_at
655
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
578
656
  `
579
657
  ).run(
580
658
  share.id,
@@ -585,9 +663,36 @@ var RelayStore = class _RelayStore {
585
663
  share.deviceId,
586
664
  share.deviceName,
587
665
  share.threadId,
666
+ share.workspaceId,
588
667
  share.label,
668
+ share.threadAccess,
669
+ share.workspaceAccess,
589
670
  share.createdAt,
590
- share.revokedAt
671
+ share.revokedAt,
672
+ share.expiresAt
673
+ );
674
+ }
675
+ updateShare(shareId, input) {
676
+ this.sqlite.prepare(
677
+ `
678
+ UPDATE relay_shares
679
+ SET label = ?,
680
+ workspace_id = ?,
681
+ thread_access = ?,
682
+ workspace_access = ?,
683
+ expires_at = ?
684
+ WHERE id = ?
685
+ `
686
+ ).run(
687
+ input.label,
688
+ input.workspaceId,
689
+ input.threadAccess,
690
+ input.workspaceAccess,
691
+ input.expiresAt,
692
+ shareId
693
+ );
694
+ return this.rowToShare(
695
+ this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ?").get(shareId)
591
696
  );
592
697
  }
593
698
  getUser(id) {
@@ -616,10 +721,10 @@ var RelayStore = class _RelayStore {
616
721
  return this.sqlite.prepare("SELECT * FROM relay_devices WHERE owner_user_id = ? ORDER BY created_at ASC").all(ownerUserId).map((row) => this.rowToDevice(row)).filter((device) => Boolean(device));
617
722
  }
618
723
  getSharesByOwner(ownerUserId) {
619
- return this.sqlite.prepare("SELECT * FROM relay_shares WHERE owner_user_id = ? AND revoked_at IS NULL ORDER BY created_at ASC").all(ownerUserId).map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
724
+ return this.sqlite.prepare("SELECT * FROM relay_shares 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.rowToShare(row)).filter((share) => Boolean(share));
620
725
  }
621
726
  getSharesByTarget(targetUserId) {
622
- return this.sqlite.prepare("SELECT * FROM relay_shares WHERE target_user_id = ? AND revoked_at IS NULL ORDER BY created_at ASC").all(targetUserId).map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
727
+ return this.sqlite.prepare("SELECT * FROM relay_shares 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.rowToShare(row)).filter((share) => Boolean(share));
623
728
  }
624
729
  rowToUser(row) {
625
730
  if (!row) return null;
@@ -657,9 +762,13 @@ var RelayStore = class _RelayStore {
657
762
  deviceId: row.device_id,
658
763
  deviceName: row.device_name ?? "Remote Codex device",
659
764
  threadId: row.thread_id,
765
+ workspaceId: row.workspace_id ?? null,
660
766
  label: row.label,
767
+ threadAccess: normalizeThreadAccess(row.thread_access),
768
+ workspaceAccess: normalizeWorkspaceAccess(row.workspace_access),
661
769
  createdAt: row.created_at,
662
- revokedAt: row.revoked_at
770
+ revokedAt: row.revoked_at,
771
+ expiresAt: row.expires_at ?? null
663
772
  };
664
773
  }
665
774
  };
@@ -675,6 +784,22 @@ var RelayStoreError = class extends Error {
675
784
  function normalizeUsername(value) {
676
785
  return value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
677
786
  }
787
+ function normalizeThreadAccess(value) {
788
+ return value === "read" ? "read" : "control";
789
+ }
790
+ function normalizeWorkspaceAccess(value) {
791
+ if (value === "read" || value === "write") {
792
+ return value;
793
+ }
794
+ return "none";
795
+ }
796
+ function normalizeExpiresAt(value) {
797
+ if (!value) {
798
+ return null;
799
+ }
800
+ const timestamp = Date.parse(value);
801
+ return Number.isNaN(timestamp) ? null : new Date(timestamp).toISOString();
802
+ }
678
803
  function hashSecret(secret, salt) {
679
804
  return crypto.scryptSync(secret, salt, 32).toString("base64url");
680
805
  }
@@ -700,7 +825,8 @@ function safeEqual(left, right) {
700
825
  var RELAY_REQUEST_TIMEOUT_MS = 3e4;
701
826
  var WEBSOCKET_OPEN = 1;
702
827
  var RELAY_COOKIE_NAME = "remote_codex_relay_session";
703
- var THREAD_SHARED_HTTP_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PATCH"]);
828
+ var threadAccessSchema = z.enum(["read", "control"]);
829
+ var workspaceAccessSchema = z.enum(["none", "read", "write"]);
704
830
  var loginSchema = z.object({
705
831
  identifier: z.string().trim().min(1),
706
832
  password: z.string().min(1)
@@ -715,10 +841,18 @@ var createDeviceSchema = z.object({
715
841
  name: z.string().trim().min(1).max(120)
716
842
  });
717
843
  var createShareSchema = z.object({
718
- targetUsername: z.string().trim().min(3),
844
+ targetIdentifier: z.string().trim().min(1).optional(),
845
+ targetUsername: z.string().trim().min(3).optional(),
719
846
  deviceId: z.string().uuid(),
720
847
  threadId: z.string().trim().min(1),
721
- label: z.string().trim().min(1).max(160).optional()
848
+ workspaceId: z.string().uuid().nullable().optional(),
849
+ label: z.string().trim().min(1).max(160).nullable().optional(),
850
+ threadAccess: threadAccessSchema.default("control"),
851
+ workspaceAccess: workspaceAccessSchema.default("none"),
852
+ expiresAt: z.string().datetime().nullable().optional()
853
+ }).refine((input) => input.targetIdentifier || input.targetUsername, {
854
+ message: "targetIdentifier is required.",
855
+ path: ["targetIdentifier"]
722
856
  });
723
857
  var setEnabledSchema = z.object({
724
858
  enabled: z.boolean()
@@ -730,6 +864,11 @@ var updatePasswordSchema = z.object({
730
864
  currentPassword: z.string().min(1),
731
865
  newPassword: z.string().min(8)
732
866
  });
867
+ var relayAccessQuerySchema = z.object({
868
+ deviceId: z.string().uuid(),
869
+ threadId: z.string().trim().min(1).optional(),
870
+ workspaceId: z.string().uuid().optional()
871
+ });
733
872
  var DEFAULT_WEBVIEW_CORS_ORIGINS = /* @__PURE__ */ new Set([
734
873
  "null",
735
874
  "capacitor://localhost",
@@ -846,6 +985,25 @@ function buildRelayServer(config2, options = {}) {
846
985
  }
847
986
  return store.portalSummary(user.id, connectionStatus(state));
848
987
  });
988
+ app2.get("/relay/access", async (request, reply) => {
989
+ const user = requireRelayUser(request, reply, store);
990
+ if (!user) {
991
+ return;
992
+ }
993
+ const query = relayAccessQuerySchema.parse(request.query ?? {});
994
+ const access = store.effectiveAccess(user.id, query.deviceId, {
995
+ threadId: query.threadId ?? null,
996
+ workspaceId: query.workspaceId ?? null
997
+ });
998
+ if (!access) {
999
+ reply.status(403).send({
1000
+ code: "forbidden",
1001
+ message: "Device access is not allowed."
1002
+ });
1003
+ return;
1004
+ }
1005
+ return relayAccessDto(access);
1006
+ });
849
1007
  app2.post("/relay/devices", async (request, reply) => {
850
1008
  const user = requireRelayUser(request, reply, store);
851
1009
  if (!user) {
@@ -870,10 +1028,14 @@ function buildRelayServer(config2, options = {}) {
870
1028
  }
871
1029
  const body = createShareSchema.parse(request.body ?? {});
872
1030
  return store.createShare(user.id, {
873
- targetUsername: body.targetUsername,
1031
+ targetIdentifier: body.targetIdentifier ?? body.targetUsername,
874
1032
  deviceId: body.deviceId,
875
1033
  threadId: body.threadId,
876
- label: body.label ?? null
1034
+ workspaceId: body.workspaceId ?? null,
1035
+ label: body.label ?? null,
1036
+ threadAccess: body.threadAccess,
1037
+ workspaceAccess: body.workspaceAccess,
1038
+ expiresAt: body.expiresAt ?? null
877
1039
  });
878
1040
  });
879
1041
  app2.delete("/relay/shares/:shareId", async (request, reply) => {
@@ -1059,7 +1221,8 @@ function buildRelayServer(config2, options = {}) {
1059
1221
  socket.close(1008, "Relay login is required.");
1060
1222
  return;
1061
1223
  }
1062
- if (!store.canAccessDevice(session.user.id, deviceId, threadId)) {
1224
+ const access = store.effectiveAccess(session.user.id, deviceId, { threadId });
1225
+ if (!access) {
1063
1226
  socket.close(1008, "Device access is not allowed.");
1064
1227
  return;
1065
1228
  }
@@ -1068,7 +1231,7 @@ function buildRelayServer(config2, options = {}) {
1068
1231
  socket.close(1013, "No supervisor is connected for this device.");
1069
1232
  return;
1070
1233
  }
1071
- connectRelayWebsocket(supervisor, socket, threadId);
1234
+ connectRelayWebsocket(supervisor, socket, threadId, access);
1072
1235
  }
1073
1236
  });
1074
1237
  realtimeApp.route({
@@ -1089,11 +1252,16 @@ function buildRelayServer(config2, options = {}) {
1089
1252
  const threadId = queryString(request.query, "threadId");
1090
1253
  const deviceId = firstAccessibleConnectedDevice(state, store, session.user.id, threadId);
1091
1254
  const supervisor = deviceId ? state.supervisors.get(deviceId) : null;
1255
+ const access = deviceId ? store.effectiveAccess(session.user.id, deviceId, { threadId }) : null;
1092
1256
  if (!deviceId || !supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
1093
1257
  socket.close(1013, "No accessible supervisor is connected to this relay.");
1094
1258
  return;
1095
1259
  }
1096
- connectRelayWebsocket(supervisor, socket, threadId);
1260
+ if (!access) {
1261
+ socket.close(1008, "Device access is not allowed.");
1262
+ return;
1263
+ }
1264
+ connectRelayWebsocket(supervisor, socket, threadId, access);
1097
1265
  }
1098
1266
  });
1099
1267
  });
@@ -1141,14 +1309,26 @@ function applyWebViewCorsHeaders(reply, origin) {
1141
1309
  }
1142
1310
  async function forwardRelayHttp(input) {
1143
1311
  const threadId = threadIdFromPath(input.targetPath);
1144
- if (!input.store.canAccessDevice(input.user.id, input.deviceId, threadId)) {
1312
+ const workspaceId = workspaceIdFromPath(input.targetPath);
1313
+ const access = input.store.effectiveAccess(input.user.id, input.deviceId, {
1314
+ threadId,
1315
+ workspaceId
1316
+ });
1317
+ if (!access) {
1145
1318
  input.reply.status(403).send({
1146
1319
  code: "forbidden",
1147
1320
  message: "Device access is not allowed."
1148
1321
  });
1149
1322
  return;
1150
1323
  }
1151
- if (!isAllowedForRelayUser(input.store, input.user.id, input.deviceId, input.request.method, input.targetPath)) {
1324
+ if (!isAllowedRelayTarget(input.targetPath)) {
1325
+ input.reply.status(403).send({
1326
+ code: "forbidden",
1327
+ message: "This relay path is not allowed."
1328
+ });
1329
+ return;
1330
+ }
1331
+ if (!isAllowedForRelayAccess(access, input.request.method, input.targetPath)) {
1152
1332
  input.reply.status(403).send({
1153
1333
  code: "forbidden",
1154
1334
  message: "This shared session does not allow that operation."
@@ -1163,13 +1343,6 @@ async function forwardRelayHttp(input) {
1163
1343
  });
1164
1344
  return;
1165
1345
  }
1166
- if (!isAllowedRelayTarget(input.targetPath)) {
1167
- input.reply.status(403).send({
1168
- code: "forbidden",
1169
- message: "This relay path is not allowed."
1170
- });
1171
- return;
1172
- }
1173
1346
  try {
1174
1347
  const requestId = randomUUID();
1175
1348
  const requestBody = relayRequestBody(input.request.body);
@@ -1206,9 +1379,9 @@ function relayResponseBody(response) {
1206
1379
  }
1207
1380
  return response.body;
1208
1381
  }
1209
- function connectRelayWebsocket(supervisor, socket, threadId) {
1382
+ function connectRelayWebsocket(supervisor, socket, threadId, access) {
1210
1383
  const clientId = randomUUID();
1211
- supervisor.clientSockets.set(clientId, { socket, threadId });
1384
+ supervisor.clientSockets.set(clientId, { socket, threadId, access });
1212
1385
  sendToSupervisor(supervisor, {
1213
1386
  type: "relay.client.connected",
1214
1387
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1221,6 +1394,10 @@ function connectRelayWebsocket(supervisor, socket, threadId) {
1221
1394
  } catch {
1222
1395
  return;
1223
1396
  }
1397
+ if (access.kind === "shared" && access.threadAccess !== "control") {
1398
+ socket.close(1008, "Shared read-only session cannot control supervisor.");
1399
+ return;
1400
+ }
1224
1401
  sendToSupervisor(supervisor, {
1225
1402
  type: "relay.client.message",
1226
1403
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1361,6 +1538,15 @@ function connectionStatus(state) {
1361
1538
  }
1362
1539
  return statuses;
1363
1540
  }
1541
+ function relayAccessDto(access) {
1542
+ return {
1543
+ kind: access.kind,
1544
+ shareId: access.share?.id ?? null,
1545
+ threadAccess: access.threadAccess,
1546
+ workspaceAccess: access.workspaceAccess,
1547
+ workspaceId: access.workspaceId
1548
+ };
1549
+ }
1364
1550
  function firstAccessibleConnectedDevice(state, store, userId, threadId) {
1365
1551
  for (const deviceId of state.supervisors.keys()) {
1366
1552
  if (store.canAccessDevice(userId, deviceId, threadId)) {
@@ -1378,33 +1564,91 @@ function threadIdFromPath(pathValue) {
1378
1564
  const match = /^\/api\/threads\/([^/?#]+)/.exec(pathname);
1379
1565
  return match ? decodeURIComponent(match[1]) : null;
1380
1566
  }
1381
- function isAllowedForRelayUser(store, userId, deviceId, method, pathValue) {
1382
- if (store.canAccessDevice(userId, deviceId, null)) {
1567
+ function workspaceIdFromPath(pathValue) {
1568
+ const pathname = new URL(pathValue, "http://relay.local").pathname;
1569
+ const match = /^\/api\/workspaces\/([^/?#]+)/.exec(pathname);
1570
+ return match ? decodeURIComponent(match[1]) : null;
1571
+ }
1572
+ function isAllowedForRelayAccess(access, method, pathValue) {
1573
+ if (access.kind === "owner") {
1383
1574
  return true;
1384
1575
  }
1385
1576
  const pathname = new URL(pathValue, "http://relay.local").pathname;
1577
+ const methodName = method.toUpperCase();
1386
1578
  const threadId = threadIdFromPath(pathValue);
1387
- if (!threadId || !store.canAccessDevice(userId, deviceId, threadId)) {
1388
- return false;
1579
+ if (threadId) {
1580
+ return isAllowedSharedThreadPath(access, methodName, pathname, threadId);
1581
+ }
1582
+ const workspaceId = workspaceIdFromPath(pathValue);
1583
+ if (workspaceId) {
1584
+ return isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId);
1389
1585
  }
1390
- if (!THREAD_SHARED_HTTP_METHODS.has(method.toUpperCase())) {
1586
+ return false;
1587
+ }
1588
+ function isAllowedSharedThreadPath(access, methodName, pathname, threadId) {
1589
+ if (access.kind !== "shared" || access.share.threadId !== threadId) {
1391
1590
  return false;
1392
1591
  }
1393
1592
  const escapedThreadId = escapeRegExp(encodeURIComponent(threadId));
1394
- const allowed = [
1593
+ const readPatterns = [
1395
1594
  new RegExp(`^/api/threads/${escapedThreadId}$`),
1396
1595
  new RegExp(`^/api/threads/${escapedThreadId}/items/[^/]+/detail$`),
1397
1596
  new RegExp(`^/api/threads/${escapedThreadId}/export-turns$`),
1597
+ new RegExp(`^/api/threads/${escapedThreadId}/exports/pdf$`),
1598
+ new RegExp(`^/api/threads/${escapedThreadId}/assets/image$`),
1398
1599
  new RegExp(`^/api/threads/${escapedThreadId}/goal$`),
1399
1600
  new RegExp(`^/api/threads/${escapedThreadId}/skills$`),
1400
1601
  new RegExp(`^/api/threads/${escapedThreadId}/mcp-servers$`),
1401
- new RegExp(`^/api/threads/${escapedThreadId}/hooks$`),
1602
+ new RegExp(`^/api/threads/${escapedThreadId}/hooks$`)
1603
+ ];
1604
+ if (methodName === "GET" && readPatterns.some((pattern) => pattern.test(pathname))) {
1605
+ return true;
1606
+ }
1607
+ if (access.threadAccess !== "control") {
1608
+ return false;
1609
+ }
1610
+ const controlPatterns = [
1611
+ new RegExp(`^/api/threads/${escapedThreadId}/goal$`),
1402
1612
  new RegExp(`^/api/threads/${escapedThreadId}/resume$`),
1403
1613
  new RegExp(`^/api/threads/${escapedThreadId}/prompt$`),
1404
1614
  new RegExp(`^/api/threads/${escapedThreadId}/interrupt$`),
1405
1615
  new RegExp(`^/api/threads/${escapedThreadId}/requests/[^/]+/respond$`)
1406
1616
  ];
1407
- return allowed.some((pattern) => pattern.test(pathname));
1617
+ if (methodName === "PATCH") {
1618
+ return new RegExp(`^/api/threads/${escapedThreadId}/goal$`).test(pathname);
1619
+ }
1620
+ if (methodName === "POST") {
1621
+ return controlPatterns.some((pattern) => pattern.test(pathname));
1622
+ }
1623
+ return false;
1624
+ }
1625
+ function isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId) {
1626
+ if (access.kind !== "shared" || access.workspaceAccess === "none" || access.workspaceId !== workspaceId) {
1627
+ return false;
1628
+ }
1629
+ const escapedWorkspaceId = escapeRegExp(encodeURIComponent(workspaceId));
1630
+ const readPatterns = [
1631
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}$`),
1632
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/tree$`),
1633
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/preview$`),
1634
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/raw$`),
1635
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/download$`),
1636
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts$`),
1637
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts/[^/]+$`),
1638
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts/[^/]+/download$`)
1639
+ ];
1640
+ if (methodName === "GET" && readPatterns.some((pattern) => pattern.test(pathname))) {
1641
+ return true;
1642
+ }
1643
+ if (access.workspaceAccess !== "write") {
1644
+ return false;
1645
+ }
1646
+ const writePatterns = [
1647
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files$`),
1648
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/upload$`),
1649
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/move$`)
1650
+ ];
1651
+ return ["POST", "PUT", "PATCH", "DELETE"].includes(methodName) && writePatterns.some((pattern) => pattern.test(pathname));
1408
1652
  }
1409
1653
  function shouldForwardSocketEvent(event, threadId) {
1410
1654
  if (!threadId) {