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.
- package/README.md +211 -110
- package/apps/relay-server/dist/index.js +295 -51
- package/apps/supervisor-web/dist/assets/index-CtcCwgIc.js +5 -0
- package/apps/supervisor-web/dist/assets/index-PMKHoC-h.css +1 -0
- package/apps/supervisor-web/dist/assets/{thread-ui-CDgAOcDh.js → thread-ui-jJyFqIuV.js} +69 -35
- package/apps/supervisor-web/dist/index.html +3 -3
- package/package.json +2 -1
- package/packages/shared/src/index.ts +26 -0
- package/apps/supervisor-web/dist/assets/index-BfspE5mQ.js +0 -5
- package/apps/supervisor-web/dist/assets/index-BmBS1Wzk.css +0 -1
|
@@ -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.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
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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.
|
|
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)
|
|
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,
|
|
577
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1031
|
+
targetIdentifier: body.targetIdentifier ?? body.targetUsername,
|
|
874
1032
|
deviceId: body.deviceId,
|
|
875
1033
|
threadId: body.threadId,
|
|
876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
1382
|
-
|
|
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 (
|
|
1388
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|