remote-codex 0.11.22 → 0.11.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -110
- package/apps/relay-server/dist/index.js +442 -54
- package/apps/supervisor-api/dist/index.js +68 -27
- package/apps/supervisor-web/dist/assets/index-DeQ67jTv.js +5 -0
- package/apps/supervisor-web/dist/assets/index-DgSdRu7a.css +1 -0
- package/apps/supervisor-web/dist/assets/thread-ui-Ck4oSYRQ.js +3665 -0
- package/apps/supervisor-web/dist/index.html +3 -3
- package/package.json +2 -1
- package/packages/shared/src/index.ts +45 -0
- package/apps/supervisor-web/dist/assets/index-BfspE5mQ.js +0 -5
- package/apps/supervisor-web/dist/assets/index-BmBS1Wzk.css +0 -1
- package/apps/supervisor-web/dist/assets/thread-ui-CDgAOcDh.js +0 -3631
|
@@ -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.
|
|
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.updateShareRecord(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,13 +220,37 @@ 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),
|
|
230
|
+
lastAccessedAt: null,
|
|
231
|
+
lastAccessedByUsername: null,
|
|
232
|
+
accessEvents: []
|
|
220
233
|
};
|
|
221
234
|
this.insertShare(share);
|
|
222
235
|
return share;
|
|
223
236
|
}
|
|
237
|
+
updateShare(userId, shareId, input) {
|
|
238
|
+
const share = this.rowToShare(
|
|
239
|
+
this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL").get(shareId, userId)
|
|
240
|
+
);
|
|
241
|
+
if (!share) {
|
|
242
|
+
throw new RelayStoreError(404, "not_found", "Share was not found.");
|
|
243
|
+
}
|
|
244
|
+
return this.publicShare(
|
|
245
|
+
this.updateShareRecord(shareId, {
|
|
246
|
+
label: input.label !== void 0 ? input.label?.trim() || null : share.label,
|
|
247
|
+
workspaceId: input.workspaceId !== void 0 ? input.workspaceId?.trim() || null : share.workspaceId,
|
|
248
|
+
threadAccess: input.threadAccess !== void 0 ? normalizeThreadAccess(input.threadAccess) : share.threadAccess,
|
|
249
|
+
workspaceAccess: input.workspaceAccess !== void 0 ? normalizeWorkspaceAccess(input.workspaceAccess) : share.workspaceAccess,
|
|
250
|
+
expiresAt: input.expiresAt !== void 0 ? normalizeExpiresAt(input.expiresAt) : share.expiresAt
|
|
251
|
+
})
|
|
252
|
+
);
|
|
253
|
+
}
|
|
224
254
|
revokeShare(userId, shareId) {
|
|
225
255
|
const share = this.rowToShare(
|
|
226
256
|
this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ?").get(shareId, userId)
|
|
@@ -232,24 +262,83 @@ var RelayStore = class _RelayStore {
|
|
|
232
262
|
this.sqlite.prepare("UPDATE relay_shares SET revoked_at = ? WHERE id = ?").run(revokedAt, shareId);
|
|
233
263
|
return { ...share, revokedAt };
|
|
234
264
|
}
|
|
235
|
-
|
|
265
|
+
effectiveAccess(userId, deviceId, scope = {}) {
|
|
236
266
|
const owned = this.sqlite.prepare("SELECT 1 FROM relay_devices WHERE id = ? AND owner_user_id = ?").get(deviceId, userId);
|
|
237
267
|
if (owned) {
|
|
238
|
-
return
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
268
|
+
return {
|
|
269
|
+
kind: "owner",
|
|
270
|
+
share: null,
|
|
271
|
+
threadAccess: "control",
|
|
272
|
+
workspaceAccess: "write",
|
|
273
|
+
workspaceId: null
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
277
|
+
if (scope.threadId) {
|
|
278
|
+
const share = this.rowToShare(
|
|
279
|
+
this.sqlite.prepare(
|
|
280
|
+
`
|
|
281
|
+
SELECT * FROM relay_shares
|
|
282
|
+
WHERE target_user_id = ?
|
|
283
|
+
AND device_id = ?
|
|
284
|
+
AND thread_id = ?
|
|
285
|
+
AND revoked_at IS NULL
|
|
286
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
287
|
+
ORDER BY created_at DESC
|
|
288
|
+
LIMIT 1
|
|
289
|
+
`
|
|
290
|
+
).get(userId, deviceId, scope.threadId, now)
|
|
291
|
+
);
|
|
292
|
+
if (!share) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
if (scope.workspaceId && (!share.workspaceId || share.workspaceId !== scope.workspaceId || share.workspaceAccess === "none")) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
kind: "shared",
|
|
300
|
+
share,
|
|
301
|
+
threadAccess: share.threadAccess,
|
|
302
|
+
workspaceAccess: share.workspaceAccess,
|
|
303
|
+
workspaceId: share.workspaceId
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (scope.workspaceId) {
|
|
307
|
+
const share = this.rowToShare(
|
|
308
|
+
this.sqlite.prepare(
|
|
309
|
+
`
|
|
310
|
+
SELECT * FROM relay_shares
|
|
311
|
+
WHERE target_user_id = ?
|
|
312
|
+
AND device_id = ?
|
|
313
|
+
AND workspace_id = ?
|
|
314
|
+
AND workspace_access <> 'none'
|
|
315
|
+
AND revoked_at IS NULL
|
|
316
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
317
|
+
ORDER BY
|
|
318
|
+
CASE workspace_access WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC,
|
|
319
|
+
created_at DESC
|
|
320
|
+
LIMIT 1
|
|
321
|
+
`
|
|
322
|
+
).get(userId, deviceId, scope.workspaceId, now)
|
|
323
|
+
);
|
|
324
|
+
if (!share) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
kind: "shared",
|
|
329
|
+
share,
|
|
330
|
+
threadAccess: share.threadAccess,
|
|
331
|
+
workspaceAccess: share.workspaceAccess,
|
|
332
|
+
workspaceId: share.workspaceId
|
|
333
|
+
};
|
|
242
334
|
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
canAccessDevice(userId, deviceId, threadId) {
|
|
243
338
|
return Boolean(
|
|
244
|
-
this.
|
|
245
|
-
|
|
246
|
-
|
|
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)
|
|
339
|
+
this.effectiveAccess(userId, deviceId, {
|
|
340
|
+
threadId: threadId ?? null
|
|
341
|
+
})
|
|
253
342
|
);
|
|
254
343
|
}
|
|
255
344
|
portalSummary(userId, connectedDevices) {
|
|
@@ -335,15 +424,33 @@ var RelayStore = class _RelayStore {
|
|
|
335
424
|
createdAt: device.createdAt
|
|
336
425
|
};
|
|
337
426
|
}
|
|
427
|
+
recordShareAccess(share, user) {
|
|
428
|
+
if (share.revokedAt || share.expiresAt && share.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const accessedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
432
|
+
this.sqlite.prepare(
|
|
433
|
+
`
|
|
434
|
+
INSERT INTO relay_share_access_events (
|
|
435
|
+
id, share_id, user_id, username, accessed_at
|
|
436
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
437
|
+
`
|
|
438
|
+
).run(crypto.randomUUID(), share.id, user.id, user.username, accessedAt);
|
|
439
|
+
}
|
|
338
440
|
publicShare(share) {
|
|
339
441
|
const owner = this.getUser(share.ownerUserId);
|
|
340
442
|
const target = this.getUser(share.targetUserId);
|
|
341
443
|
const device = this.getDevice(share.deviceId);
|
|
444
|
+
const accessEvents = this.getShareAccessEvents(share.id);
|
|
445
|
+
const lastAccess = accessEvents[0] ?? null;
|
|
342
446
|
return {
|
|
343
447
|
...share,
|
|
344
448
|
ownerUsername: share.ownerUsername ?? owner?.username ?? "unknown",
|
|
345
449
|
targetUsername: share.targetUsername ?? target?.username ?? "unknown",
|
|
346
|
-
deviceName: share.deviceName ?? device?.name ?? "Remote Codex device"
|
|
450
|
+
deviceName: share.deviceName ?? device?.name ?? "Remote Codex device",
|
|
451
|
+
lastAccessedAt: lastAccess?.accessedAt ?? null,
|
|
452
|
+
lastAccessedByUsername: lastAccess?.username ?? null,
|
|
453
|
+
accessEvents
|
|
347
454
|
};
|
|
348
455
|
}
|
|
349
456
|
migrate() {
|
|
@@ -385,16 +492,34 @@ var RelayStore = class _RelayStore {
|
|
|
385
492
|
device_id TEXT NOT NULL REFERENCES relay_devices(id) ON DELETE CASCADE,
|
|
386
493
|
device_name TEXT,
|
|
387
494
|
thread_id TEXT NOT NULL,
|
|
495
|
+
workspace_id TEXT,
|
|
388
496
|
label TEXT,
|
|
497
|
+
thread_access TEXT NOT NULL DEFAULT 'control',
|
|
498
|
+
workspace_access TEXT NOT NULL DEFAULT 'none',
|
|
389
499
|
created_at TEXT NOT NULL,
|
|
390
|
-
revoked_at TEXT
|
|
500
|
+
revoked_at TEXT,
|
|
501
|
+
expires_at TEXT
|
|
391
502
|
);
|
|
392
503
|
|
|
393
504
|
CREATE INDEX IF NOT EXISTS relay_shares_owner_idx ON relay_shares(owner_user_id);
|
|
394
505
|
CREATE INDEX IF NOT EXISTS relay_shares_target_idx ON relay_shares(target_user_id);
|
|
395
506
|
CREATE INDEX IF NOT EXISTS relay_shares_device_thread_idx ON relay_shares(device_id, thread_id);
|
|
507
|
+
|
|
508
|
+
CREATE TABLE IF NOT EXISTS relay_share_access_events (
|
|
509
|
+
id TEXT PRIMARY KEY,
|
|
510
|
+
share_id TEXT NOT NULL REFERENCES relay_shares(id) ON DELETE CASCADE,
|
|
511
|
+
user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
|
|
512
|
+
username TEXT NOT NULL,
|
|
513
|
+
accessed_at TEXT NOT NULL
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
CREATE INDEX IF NOT EXISTS relay_share_access_events_share_idx ON relay_share_access_events(share_id, accessed_at DESC);
|
|
396
517
|
`);
|
|
397
518
|
this.ensureColumn("relay_devices", "token", "TEXT");
|
|
519
|
+
this.ensureColumn("relay_shares", "workspace_id", "TEXT");
|
|
520
|
+
this.ensureColumn("relay_shares", "thread_access", "TEXT NOT NULL DEFAULT 'control'");
|
|
521
|
+
this.ensureColumn("relay_shares", "workspace_access", "TEXT NOT NULL DEFAULT 'none'");
|
|
522
|
+
this.ensureColumn("relay_shares", "expires_at", "TEXT");
|
|
398
523
|
}
|
|
399
524
|
ensureColumn(table, column, definition) {
|
|
400
525
|
const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all();
|
|
@@ -573,8 +698,9 @@ var RelayStore = class _RelayStore {
|
|
|
573
698
|
`
|
|
574
699
|
INSERT INTO relay_shares (
|
|
575
700
|
id, owner_user_id, owner_username, target_user_id, target_username,
|
|
576
|
-
device_id, device_name, thread_id,
|
|
577
|
-
|
|
701
|
+
device_id, device_name, thread_id, workspace_id, label,
|
|
702
|
+
thread_access, workspace_access, created_at, revoked_at, expires_at
|
|
703
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
578
704
|
`
|
|
579
705
|
).run(
|
|
580
706
|
share.id,
|
|
@@ -585,9 +711,52 @@ var RelayStore = class _RelayStore {
|
|
|
585
711
|
share.deviceId,
|
|
586
712
|
share.deviceName,
|
|
587
713
|
share.threadId,
|
|
714
|
+
share.workspaceId,
|
|
588
715
|
share.label,
|
|
716
|
+
share.threadAccess,
|
|
717
|
+
share.workspaceAccess,
|
|
589
718
|
share.createdAt,
|
|
590
|
-
share.revokedAt
|
|
719
|
+
share.revokedAt,
|
|
720
|
+
share.expiresAt
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
getShareAccessEvents(shareId) {
|
|
724
|
+
return this.sqlite.prepare(
|
|
725
|
+
`
|
|
726
|
+
SELECT * FROM relay_share_access_events
|
|
727
|
+
WHERE share_id = ?
|
|
728
|
+
ORDER BY accessed_at DESC
|
|
729
|
+
LIMIT 8
|
|
730
|
+
`
|
|
731
|
+
).all(shareId).map((row) => ({
|
|
732
|
+
id: row.id,
|
|
733
|
+
shareId: row.share_id,
|
|
734
|
+
userId: row.user_id,
|
|
735
|
+
username: row.username,
|
|
736
|
+
accessedAt: row.accessed_at
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
updateShareRecord(shareId, input) {
|
|
740
|
+
this.sqlite.prepare(
|
|
741
|
+
`
|
|
742
|
+
UPDATE relay_shares
|
|
743
|
+
SET label = ?,
|
|
744
|
+
workspace_id = ?,
|
|
745
|
+
thread_access = ?,
|
|
746
|
+
workspace_access = ?,
|
|
747
|
+
expires_at = ?
|
|
748
|
+
WHERE id = ?
|
|
749
|
+
`
|
|
750
|
+
).run(
|
|
751
|
+
input.label,
|
|
752
|
+
input.workspaceId,
|
|
753
|
+
input.threadAccess,
|
|
754
|
+
input.workspaceAccess,
|
|
755
|
+
input.expiresAt,
|
|
756
|
+
shareId
|
|
757
|
+
);
|
|
758
|
+
return this.rowToShare(
|
|
759
|
+
this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ?").get(shareId)
|
|
591
760
|
);
|
|
592
761
|
}
|
|
593
762
|
getUser(id) {
|
|
@@ -616,10 +785,10 @@ var RelayStore = class _RelayStore {
|
|
|
616
785
|
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
786
|
}
|
|
618
787
|
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));
|
|
788
|
+
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
789
|
}
|
|
621
790
|
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));
|
|
791
|
+
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
792
|
}
|
|
624
793
|
rowToUser(row) {
|
|
625
794
|
if (!row) return null;
|
|
@@ -657,9 +826,16 @@ var RelayStore = class _RelayStore {
|
|
|
657
826
|
deviceId: row.device_id,
|
|
658
827
|
deviceName: row.device_name ?? "Remote Codex device",
|
|
659
828
|
threadId: row.thread_id,
|
|
829
|
+
workspaceId: row.workspace_id ?? null,
|
|
660
830
|
label: row.label,
|
|
831
|
+
threadAccess: normalizeThreadAccess(row.thread_access),
|
|
832
|
+
workspaceAccess: normalizeWorkspaceAccess(row.workspace_access),
|
|
661
833
|
createdAt: row.created_at,
|
|
662
|
-
revokedAt: row.revoked_at
|
|
834
|
+
revokedAt: row.revoked_at,
|
|
835
|
+
expiresAt: row.expires_at ?? null,
|
|
836
|
+
lastAccessedAt: null,
|
|
837
|
+
lastAccessedByUsername: null,
|
|
838
|
+
accessEvents: []
|
|
663
839
|
};
|
|
664
840
|
}
|
|
665
841
|
};
|
|
@@ -675,6 +851,22 @@ var RelayStoreError = class extends Error {
|
|
|
675
851
|
function normalizeUsername(value) {
|
|
676
852
|
return value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
677
853
|
}
|
|
854
|
+
function normalizeThreadAccess(value) {
|
|
855
|
+
return value === "read" ? "read" : "control";
|
|
856
|
+
}
|
|
857
|
+
function normalizeWorkspaceAccess(value) {
|
|
858
|
+
if (value === "read" || value === "write") {
|
|
859
|
+
return value;
|
|
860
|
+
}
|
|
861
|
+
return "none";
|
|
862
|
+
}
|
|
863
|
+
function normalizeExpiresAt(value) {
|
|
864
|
+
if (!value) {
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
const timestamp = Date.parse(value);
|
|
868
|
+
return Number.isNaN(timestamp) ? null : new Date(timestamp).toISOString();
|
|
869
|
+
}
|
|
678
870
|
function hashSecret(secret, salt) {
|
|
679
871
|
return crypto.scryptSync(secret, salt, 32).toString("base64url");
|
|
680
872
|
}
|
|
@@ -700,7 +892,8 @@ function safeEqual(left, right) {
|
|
|
700
892
|
var RELAY_REQUEST_TIMEOUT_MS = 3e4;
|
|
701
893
|
var WEBSOCKET_OPEN = 1;
|
|
702
894
|
var RELAY_COOKIE_NAME = "remote_codex_relay_session";
|
|
703
|
-
var
|
|
895
|
+
var threadAccessSchema = z.enum(["read", "control"]);
|
|
896
|
+
var workspaceAccessSchema = z.enum(["none", "read", "write"]);
|
|
704
897
|
var loginSchema = z.object({
|
|
705
898
|
identifier: z.string().trim().min(1),
|
|
706
899
|
password: z.string().min(1)
|
|
@@ -715,10 +908,25 @@ var createDeviceSchema = z.object({
|
|
|
715
908
|
name: z.string().trim().min(1).max(120)
|
|
716
909
|
});
|
|
717
910
|
var createShareSchema = z.object({
|
|
718
|
-
|
|
911
|
+
targetIdentifier: z.string().trim().min(1).optional(),
|
|
912
|
+
targetUsername: z.string().trim().min(3).optional(),
|
|
719
913
|
deviceId: z.string().uuid(),
|
|
720
914
|
threadId: z.string().trim().min(1),
|
|
721
|
-
|
|
915
|
+
workspaceId: z.string().uuid().nullable().optional(),
|
|
916
|
+
label: z.string().trim().min(1).max(160).nullable().optional(),
|
|
917
|
+
threadAccess: threadAccessSchema.default("control"),
|
|
918
|
+
workspaceAccess: workspaceAccessSchema.default("none"),
|
|
919
|
+
expiresAt: z.string().datetime().nullable().optional()
|
|
920
|
+
}).refine((input) => input.targetIdentifier || input.targetUsername, {
|
|
921
|
+
message: "targetIdentifier is required.",
|
|
922
|
+
path: ["targetIdentifier"]
|
|
923
|
+
});
|
|
924
|
+
var updateShareSchema = z.object({
|
|
925
|
+
workspaceId: z.string().uuid().nullable().optional(),
|
|
926
|
+
label: z.string().trim().min(1).max(160).nullable().optional(),
|
|
927
|
+
threadAccess: threadAccessSchema.optional(),
|
|
928
|
+
workspaceAccess: workspaceAccessSchema.optional(),
|
|
929
|
+
expiresAt: z.string().datetime().nullable().optional()
|
|
722
930
|
});
|
|
723
931
|
var setEnabledSchema = z.object({
|
|
724
932
|
enabled: z.boolean()
|
|
@@ -730,6 +938,11 @@ var updatePasswordSchema = z.object({
|
|
|
730
938
|
currentPassword: z.string().min(1),
|
|
731
939
|
newPassword: z.string().min(8)
|
|
732
940
|
});
|
|
941
|
+
var relayAccessQuerySchema = z.object({
|
|
942
|
+
deviceId: z.string().uuid(),
|
|
943
|
+
threadId: z.string().trim().min(1).optional(),
|
|
944
|
+
workspaceId: z.string().uuid().optional()
|
|
945
|
+
});
|
|
733
946
|
var DEFAULT_WEBVIEW_CORS_ORIGINS = /* @__PURE__ */ new Set([
|
|
734
947
|
"null",
|
|
735
948
|
"capacitor://localhost",
|
|
@@ -749,6 +962,58 @@ var WEBVIEW_CORS_ALLOW_METHODS = [
|
|
|
749
962
|
"DELETE",
|
|
750
963
|
"OPTIONS"
|
|
751
964
|
].join(", ");
|
|
965
|
+
var RELAY_REQUEST_HEADER_BLOCKLIST = /* @__PURE__ */ new Set([
|
|
966
|
+
"authorization",
|
|
967
|
+
"connection",
|
|
968
|
+
"content-length",
|
|
969
|
+
"cookie",
|
|
970
|
+
"expect",
|
|
971
|
+
"forwarded",
|
|
972
|
+
"host",
|
|
973
|
+
"keep-alive",
|
|
974
|
+
"origin",
|
|
975
|
+
"proxy-authenticate",
|
|
976
|
+
"proxy-authorization",
|
|
977
|
+
"proxy-connection",
|
|
978
|
+
"referer",
|
|
979
|
+
"referrer",
|
|
980
|
+
"set-cookie",
|
|
981
|
+
"te",
|
|
982
|
+
"trailer",
|
|
983
|
+
"transfer-encoding",
|
|
984
|
+
"upgrade",
|
|
985
|
+
"via",
|
|
986
|
+
"x-client-ip",
|
|
987
|
+
"x-forwarded-for",
|
|
988
|
+
"x-forwarded-host",
|
|
989
|
+
"x-forwarded-port",
|
|
990
|
+
"x-forwarded-proto",
|
|
991
|
+
"x-forwarded-protocol",
|
|
992
|
+
"x-forwarded-scheme",
|
|
993
|
+
"x-real-ip",
|
|
994
|
+
"x-remote-codex-relay-forwarded"
|
|
995
|
+
]);
|
|
996
|
+
var RELAY_RESPONSE_HEADER_BLOCKLIST = /* @__PURE__ */ new Set([
|
|
997
|
+
"access-control-allow-credentials",
|
|
998
|
+
"access-control-allow-headers",
|
|
999
|
+
"access-control-allow-methods",
|
|
1000
|
+
"access-control-allow-origin",
|
|
1001
|
+
"access-control-expose-headers",
|
|
1002
|
+
"access-control-max-age",
|
|
1003
|
+
"access-control-request-headers",
|
|
1004
|
+
"access-control-request-method",
|
|
1005
|
+
"connection",
|
|
1006
|
+
"content-length",
|
|
1007
|
+
"keep-alive",
|
|
1008
|
+
"location",
|
|
1009
|
+
"proxy-authenticate",
|
|
1010
|
+
"refresh",
|
|
1011
|
+
"set-cookie",
|
|
1012
|
+
"te",
|
|
1013
|
+
"trailer",
|
|
1014
|
+
"transfer-encoding",
|
|
1015
|
+
"upgrade"
|
|
1016
|
+
]);
|
|
752
1017
|
function buildRelayServer(config2, options = {}) {
|
|
753
1018
|
const app2 = Fastify({ logger: false });
|
|
754
1019
|
app2.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) => {
|
|
@@ -846,6 +1111,25 @@ function buildRelayServer(config2, options = {}) {
|
|
|
846
1111
|
}
|
|
847
1112
|
return store.portalSummary(user.id, connectionStatus(state));
|
|
848
1113
|
});
|
|
1114
|
+
app2.get("/relay/access", async (request, reply) => {
|
|
1115
|
+
const user = requireRelayUser(request, reply, store);
|
|
1116
|
+
if (!user) {
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
const query = relayAccessQuerySchema.parse(request.query ?? {});
|
|
1120
|
+
const access = store.effectiveAccess(user.id, query.deviceId, {
|
|
1121
|
+
threadId: query.threadId ?? null,
|
|
1122
|
+
workspaceId: query.workspaceId ?? null
|
|
1123
|
+
});
|
|
1124
|
+
if (!access) {
|
|
1125
|
+
reply.status(403).send({
|
|
1126
|
+
code: "forbidden",
|
|
1127
|
+
message: "Device access is not allowed."
|
|
1128
|
+
});
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
return relayAccessDto(access);
|
|
1132
|
+
});
|
|
849
1133
|
app2.post("/relay/devices", async (request, reply) => {
|
|
850
1134
|
const user = requireRelayUser(request, reply, store);
|
|
851
1135
|
if (!user) {
|
|
@@ -870,12 +1154,25 @@ function buildRelayServer(config2, options = {}) {
|
|
|
870
1154
|
}
|
|
871
1155
|
const body = createShareSchema.parse(request.body ?? {});
|
|
872
1156
|
return store.createShare(user.id, {
|
|
873
|
-
|
|
1157
|
+
targetIdentifier: body.targetIdentifier ?? body.targetUsername,
|
|
874
1158
|
deviceId: body.deviceId,
|
|
875
1159
|
threadId: body.threadId,
|
|
876
|
-
|
|
1160
|
+
workspaceId: body.workspaceId ?? null,
|
|
1161
|
+
label: body.label ?? null,
|
|
1162
|
+
threadAccess: body.threadAccess,
|
|
1163
|
+
workspaceAccess: body.workspaceAccess,
|
|
1164
|
+
expiresAt: body.expiresAt ?? null
|
|
877
1165
|
});
|
|
878
1166
|
});
|
|
1167
|
+
app2.patch("/relay/shares/:shareId", async (request, reply) => {
|
|
1168
|
+
const user = requireRelayUser(request, reply, store);
|
|
1169
|
+
if (!user) {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
const { shareId } = z.object({ shareId: z.string().uuid() }).parse(request.params);
|
|
1173
|
+
const body = updateShareSchema.parse(request.body ?? {});
|
|
1174
|
+
return store.updateShare(user.id, shareId, body);
|
|
1175
|
+
});
|
|
879
1176
|
app2.delete("/relay/shares/:shareId", async (request, reply) => {
|
|
880
1177
|
const user = requireRelayUser(request, reply, store);
|
|
881
1178
|
if (!user) {
|
|
@@ -1059,7 +1356,8 @@ function buildRelayServer(config2, options = {}) {
|
|
|
1059
1356
|
socket.close(1008, "Relay login is required.");
|
|
1060
1357
|
return;
|
|
1061
1358
|
}
|
|
1062
|
-
|
|
1359
|
+
const access = store.effectiveAccess(session.user.id, deviceId, { threadId });
|
|
1360
|
+
if (!access) {
|
|
1063
1361
|
socket.close(1008, "Device access is not allowed.");
|
|
1064
1362
|
return;
|
|
1065
1363
|
}
|
|
@@ -1068,7 +1366,10 @@ function buildRelayServer(config2, options = {}) {
|
|
|
1068
1366
|
socket.close(1013, "No supervisor is connected for this device.");
|
|
1069
1367
|
return;
|
|
1070
1368
|
}
|
|
1071
|
-
|
|
1369
|
+
if (access.kind === "shared") {
|
|
1370
|
+
store.recordShareAccess(access.share, session.user);
|
|
1371
|
+
}
|
|
1372
|
+
connectRelayWebsocket(supervisor, socket, threadId, access);
|
|
1072
1373
|
}
|
|
1073
1374
|
});
|
|
1074
1375
|
realtimeApp.route({
|
|
@@ -1089,11 +1390,19 @@ function buildRelayServer(config2, options = {}) {
|
|
|
1089
1390
|
const threadId = queryString(request.query, "threadId");
|
|
1090
1391
|
const deviceId = firstAccessibleConnectedDevice(state, store, session.user.id, threadId);
|
|
1091
1392
|
const supervisor = deviceId ? state.supervisors.get(deviceId) : null;
|
|
1393
|
+
const access = deviceId ? store.effectiveAccess(session.user.id, deviceId, { threadId }) : null;
|
|
1092
1394
|
if (!deviceId || !supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
|
|
1093
1395
|
socket.close(1013, "No accessible supervisor is connected to this relay.");
|
|
1094
1396
|
return;
|
|
1095
1397
|
}
|
|
1096
|
-
|
|
1398
|
+
if (!access) {
|
|
1399
|
+
socket.close(1008, "Device access is not allowed.");
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (access.kind === "shared") {
|
|
1403
|
+
store.recordShareAccess(access.share, session.user);
|
|
1404
|
+
}
|
|
1405
|
+
connectRelayWebsocket(supervisor, socket, threadId, access);
|
|
1097
1406
|
}
|
|
1098
1407
|
});
|
|
1099
1408
|
});
|
|
@@ -1141,20 +1450,35 @@ function applyWebViewCorsHeaders(reply, origin) {
|
|
|
1141
1450
|
}
|
|
1142
1451
|
async function forwardRelayHttp(input) {
|
|
1143
1452
|
const threadId = threadIdFromPath(input.targetPath);
|
|
1144
|
-
|
|
1453
|
+
const workspaceId = workspaceIdFromPath(input.targetPath);
|
|
1454
|
+
const access = input.store.effectiveAccess(input.user.id, input.deviceId, {
|
|
1455
|
+
threadId,
|
|
1456
|
+
workspaceId
|
|
1457
|
+
});
|
|
1458
|
+
if (!access) {
|
|
1145
1459
|
input.reply.status(403).send({
|
|
1146
1460
|
code: "forbidden",
|
|
1147
1461
|
message: "Device access is not allowed."
|
|
1148
1462
|
});
|
|
1149
1463
|
return;
|
|
1150
1464
|
}
|
|
1151
|
-
if (!
|
|
1465
|
+
if (!isAllowedRelayTarget(input.targetPath)) {
|
|
1466
|
+
input.reply.status(403).send({
|
|
1467
|
+
code: "forbidden",
|
|
1468
|
+
message: "This relay path is not allowed."
|
|
1469
|
+
});
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (!isAllowedForRelayAccess(access, input.request.method, input.targetPath)) {
|
|
1152
1473
|
input.reply.status(403).send({
|
|
1153
1474
|
code: "forbidden",
|
|
1154
1475
|
message: "This shared session does not allow that operation."
|
|
1155
1476
|
});
|
|
1156
1477
|
return;
|
|
1157
1478
|
}
|
|
1479
|
+
if (access.kind === "shared") {
|
|
1480
|
+
input.store.recordShareAccess(access.share, input.user);
|
|
1481
|
+
}
|
|
1158
1482
|
const supervisor = input.state.supervisors.get(input.deviceId);
|
|
1159
1483
|
if (!supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
|
|
1160
1484
|
input.reply.status(503).send({
|
|
@@ -1163,13 +1487,6 @@ async function forwardRelayHttp(input) {
|
|
|
1163
1487
|
});
|
|
1164
1488
|
return;
|
|
1165
1489
|
}
|
|
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
1490
|
try {
|
|
1174
1491
|
const requestId = randomUUID();
|
|
1175
1492
|
const requestBody = relayRequestBody(input.request.body);
|
|
@@ -1206,9 +1523,9 @@ function relayResponseBody(response) {
|
|
|
1206
1523
|
}
|
|
1207
1524
|
return response.body;
|
|
1208
1525
|
}
|
|
1209
|
-
function connectRelayWebsocket(supervisor, socket, threadId) {
|
|
1526
|
+
function connectRelayWebsocket(supervisor, socket, threadId, access) {
|
|
1210
1527
|
const clientId = randomUUID();
|
|
1211
|
-
supervisor.clientSockets.set(clientId, { socket, threadId });
|
|
1528
|
+
supervisor.clientSockets.set(clientId, { socket, threadId, access });
|
|
1212
1529
|
sendToSupervisor(supervisor, {
|
|
1213
1530
|
type: "relay.client.connected",
|
|
1214
1531
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -1221,6 +1538,10 @@ function connectRelayWebsocket(supervisor, socket, threadId) {
|
|
|
1221
1538
|
} catch {
|
|
1222
1539
|
return;
|
|
1223
1540
|
}
|
|
1541
|
+
if (access.kind === "shared" && access.threadAccess !== "control") {
|
|
1542
|
+
socket.close(1008, "Shared read-only session cannot control supervisor.");
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1224
1545
|
sendToSupervisor(supervisor, {
|
|
1225
1546
|
type: "relay.client.message",
|
|
1226
1547
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -1361,6 +1682,15 @@ function connectionStatus(state) {
|
|
|
1361
1682
|
}
|
|
1362
1683
|
return statuses;
|
|
1363
1684
|
}
|
|
1685
|
+
function relayAccessDto(access) {
|
|
1686
|
+
return {
|
|
1687
|
+
kind: access.kind,
|
|
1688
|
+
shareId: access.share?.id ?? null,
|
|
1689
|
+
threadAccess: access.threadAccess,
|
|
1690
|
+
workspaceAccess: access.workspaceAccess,
|
|
1691
|
+
workspaceId: access.workspaceId
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1364
1694
|
function firstAccessibleConnectedDevice(state, store, userId, threadId) {
|
|
1365
1695
|
for (const deviceId of state.supervisors.keys()) {
|
|
1366
1696
|
if (store.canAccessDevice(userId, deviceId, threadId)) {
|
|
@@ -1378,33 +1708,91 @@ function threadIdFromPath(pathValue) {
|
|
|
1378
1708
|
const match = /^\/api\/threads\/([^/?#]+)/.exec(pathname);
|
|
1379
1709
|
return match ? decodeURIComponent(match[1]) : null;
|
|
1380
1710
|
}
|
|
1381
|
-
function
|
|
1382
|
-
|
|
1711
|
+
function workspaceIdFromPath(pathValue) {
|
|
1712
|
+
const pathname = new URL(pathValue, "http://relay.local").pathname;
|
|
1713
|
+
const match = /^\/api\/workspaces\/([^/?#]+)/.exec(pathname);
|
|
1714
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
1715
|
+
}
|
|
1716
|
+
function isAllowedForRelayAccess(access, method, pathValue) {
|
|
1717
|
+
if (access.kind === "owner") {
|
|
1383
1718
|
return true;
|
|
1384
1719
|
}
|
|
1385
1720
|
const pathname = new URL(pathValue, "http://relay.local").pathname;
|
|
1721
|
+
const methodName = method.toUpperCase();
|
|
1386
1722
|
const threadId = threadIdFromPath(pathValue);
|
|
1387
|
-
if (
|
|
1388
|
-
return
|
|
1723
|
+
if (threadId) {
|
|
1724
|
+
return isAllowedSharedThreadPath(access, methodName, pathname, threadId);
|
|
1725
|
+
}
|
|
1726
|
+
const workspaceId = workspaceIdFromPath(pathValue);
|
|
1727
|
+
if (workspaceId) {
|
|
1728
|
+
return isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId);
|
|
1389
1729
|
}
|
|
1390
|
-
|
|
1730
|
+
return false;
|
|
1731
|
+
}
|
|
1732
|
+
function isAllowedSharedThreadPath(access, methodName, pathname, threadId) {
|
|
1733
|
+
if (access.kind !== "shared" || access.share.threadId !== threadId) {
|
|
1391
1734
|
return false;
|
|
1392
1735
|
}
|
|
1393
1736
|
const escapedThreadId = escapeRegExp(encodeURIComponent(threadId));
|
|
1394
|
-
const
|
|
1737
|
+
const readPatterns = [
|
|
1395
1738
|
new RegExp(`^/api/threads/${escapedThreadId}$`),
|
|
1396
1739
|
new RegExp(`^/api/threads/${escapedThreadId}/items/[^/]+/detail$`),
|
|
1397
1740
|
new RegExp(`^/api/threads/${escapedThreadId}/export-turns$`),
|
|
1741
|
+
new RegExp(`^/api/threads/${escapedThreadId}/exports/pdf$`),
|
|
1742
|
+
new RegExp(`^/api/threads/${escapedThreadId}/assets/image$`),
|
|
1398
1743
|
new RegExp(`^/api/threads/${escapedThreadId}/goal$`),
|
|
1399
1744
|
new RegExp(`^/api/threads/${escapedThreadId}/skills$`),
|
|
1400
1745
|
new RegExp(`^/api/threads/${escapedThreadId}/mcp-servers$`),
|
|
1401
|
-
new RegExp(`^/api/threads/${escapedThreadId}/hooks$`)
|
|
1746
|
+
new RegExp(`^/api/threads/${escapedThreadId}/hooks$`)
|
|
1747
|
+
];
|
|
1748
|
+
if (methodName === "GET" && readPatterns.some((pattern) => pattern.test(pathname))) {
|
|
1749
|
+
return true;
|
|
1750
|
+
}
|
|
1751
|
+
if (access.threadAccess !== "control") {
|
|
1752
|
+
return false;
|
|
1753
|
+
}
|
|
1754
|
+
const controlPatterns = [
|
|
1755
|
+
new RegExp(`^/api/threads/${escapedThreadId}/goal$`),
|
|
1402
1756
|
new RegExp(`^/api/threads/${escapedThreadId}/resume$`),
|
|
1403
1757
|
new RegExp(`^/api/threads/${escapedThreadId}/prompt$`),
|
|
1404
1758
|
new RegExp(`^/api/threads/${escapedThreadId}/interrupt$`),
|
|
1405
1759
|
new RegExp(`^/api/threads/${escapedThreadId}/requests/[^/]+/respond$`)
|
|
1406
1760
|
];
|
|
1407
|
-
|
|
1761
|
+
if (methodName === "PATCH") {
|
|
1762
|
+
return new RegExp(`^/api/threads/${escapedThreadId}/goal$`).test(pathname);
|
|
1763
|
+
}
|
|
1764
|
+
if (methodName === "POST") {
|
|
1765
|
+
return controlPatterns.some((pattern) => pattern.test(pathname));
|
|
1766
|
+
}
|
|
1767
|
+
return false;
|
|
1768
|
+
}
|
|
1769
|
+
function isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId) {
|
|
1770
|
+
if (access.kind !== "shared" || access.workspaceAccess === "none" || access.workspaceId !== workspaceId) {
|
|
1771
|
+
return false;
|
|
1772
|
+
}
|
|
1773
|
+
const escapedWorkspaceId = escapeRegExp(encodeURIComponent(workspaceId));
|
|
1774
|
+
const readPatterns = [
|
|
1775
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}$`),
|
|
1776
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/tree$`),
|
|
1777
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/preview$`),
|
|
1778
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/raw$`),
|
|
1779
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/download$`),
|
|
1780
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts$`),
|
|
1781
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts/[^/]+$`),
|
|
1782
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts/[^/]+/download$`)
|
|
1783
|
+
];
|
|
1784
|
+
if (methodName === "GET" && readPatterns.some((pattern) => pattern.test(pathname))) {
|
|
1785
|
+
return true;
|
|
1786
|
+
}
|
|
1787
|
+
if (access.workspaceAccess !== "write") {
|
|
1788
|
+
return false;
|
|
1789
|
+
}
|
|
1790
|
+
const writePatterns = [
|
|
1791
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files$`),
|
|
1792
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/upload$`),
|
|
1793
|
+
new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/move$`)
|
|
1794
|
+
];
|
|
1795
|
+
return ["POST", "PUT", "PATCH", "DELETE"].includes(methodName) && writePatterns.some((pattern) => pattern.test(pathname));
|
|
1408
1796
|
}
|
|
1409
1797
|
function shouldForwardSocketEvent(event, threadId) {
|
|
1410
1798
|
if (!threadId) {
|
|
@@ -1434,7 +1822,7 @@ function relayRequestHeaders(headers) {
|
|
|
1434
1822
|
const output = {};
|
|
1435
1823
|
for (const [name, value] of Object.entries(headers)) {
|
|
1436
1824
|
const lower = name.toLowerCase();
|
|
1437
|
-
if (lower
|
|
1825
|
+
if (RELAY_REQUEST_HEADER_BLOCKLIST.has(lower) || lower.startsWith("x-forwarded-")) {
|
|
1438
1826
|
continue;
|
|
1439
1827
|
}
|
|
1440
1828
|
if (Array.isArray(value)) {
|
|
@@ -1447,7 +1835,7 @@ function relayRequestHeaders(headers) {
|
|
|
1447
1835
|
}
|
|
1448
1836
|
function canForwardResponseHeader(name) {
|
|
1449
1837
|
const lower = name.toLowerCase();
|
|
1450
|
-
return lower
|
|
1838
|
+
return !RELAY_RESPONSE_HEADER_BLOCKLIST.has(lower);
|
|
1451
1839
|
}
|
|
1452
1840
|
function bearerToken(value) {
|
|
1453
1841
|
const match = /^Bearer\s+(.+)$/i.exec(value ?? "");
|