remote-codex 0.11.30 → 0.11.32

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 (37) hide show
  1. package/README.md +12 -4
  2. package/apps/relay-server/dist/index.js +2956 -164
  3. package/apps/supervisor-api/dist/index.js +521 -64
  4. package/apps/supervisor-web/dist/apple-touch-icon.png +0 -0
  5. package/apps/supervisor-web/dist/assets/index-CGHHTNkM.js +21 -0
  6. package/apps/supervisor-web/dist/assets/index-CdjTdnJt.css +1 -0
  7. package/apps/supervisor-web/dist/assets/thread-ui-B9eC2H4u.js +3677 -0
  8. package/apps/supervisor-web/dist/favicon-16x16.png +0 -0
  9. package/apps/supervisor-web/dist/favicon-32x32.png +0 -0
  10. package/apps/supervisor-web/dist/favicon-48x48.png +0 -0
  11. package/apps/supervisor-web/dist/icon-192.png +0 -0
  12. package/apps/supervisor-web/dist/icon-512.png +0 -0
  13. package/apps/supervisor-web/dist/index.html +10 -4
  14. package/apps/supervisor-web/dist/remote-codex-icon.png +0 -0
  15. package/apps/supervisor-web/dist/site.webmanifest +19 -0
  16. package/bin/remote-codex.mjs +6 -3
  17. package/config/codex-model-pricing.json +44 -0
  18. package/package.json +1 -1
  19. package/packages/agent-runtime/src/model-pricing.ts +66 -6
  20. package/packages/agent-runtime/src/types.ts +13 -0
  21. package/packages/claude/src/runtimeAdapter.test.ts +64 -0
  22. package/packages/claude/src/runtimeAdapter.ts +64 -0
  23. package/packages/codex/src/appServerManager.ts +16 -0
  24. package/packages/codex/src/modelPricing.test.ts +84 -0
  25. package/packages/codex/src/runtimeAdapter.test.ts +44 -0
  26. package/packages/codex/src/runtimeAdapter.ts +66 -0
  27. package/packages/codex/src/types.ts +3 -1
  28. package/packages/db/migrations/0029_thread_turn_delivery.sql +19 -0
  29. package/packages/db/src/repositories.ts +96 -1
  30. package/packages/db/src/schema.ts +20 -0
  31. package/packages/opencode/src/runtimeAdapter.ts +15 -0
  32. package/packages/shared/src/index.ts +203 -7
  33. package/scripts/run-web-service.mjs +2 -2
  34. package/scripts/service-manager.mjs +2 -2
  35. package/apps/supervisor-web/dist/assets/index-BnpZn_3_.js +0 -6
  36. package/apps/supervisor-web/dist/assets/index-CJFMmjP5.css +0 -1
  37. package/apps/supervisor-web/dist/assets/thread-ui-C0VPL4Uk.js +0 -3677
@@ -9,6 +9,7 @@ import fs2 from "fs";
9
9
  import fsp from "fs/promises";
10
10
  import path2 from "path";
11
11
  import { randomUUID } from "crypto";
12
+ import crypto3 from "crypto";
12
13
  import { z } from "zod";
13
14
 
14
15
  // src/request-broker.ts
@@ -59,6 +60,282 @@ var RelayRequestBroker = class {
59
60
  }
60
61
  };
61
62
 
63
+ // src/hosted-sandbox-provider.ts
64
+ var HostedSandboxProviderError = class extends Error {
65
+ constructor(code, message) {
66
+ super(message);
67
+ this.code = code;
68
+ }
69
+ code;
70
+ };
71
+ var DisabledHostedSandboxProvider = class {
72
+ async capability() {
73
+ return {
74
+ provider: "disabled",
75
+ configured: false,
76
+ reachable: false,
77
+ available: false,
78
+ reasonCode: "hosted_sandbox_disabled",
79
+ reason: "Hosted supervisor VMs are not configured on this relay.",
80
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
81
+ };
82
+ }
83
+ inventory() {
84
+ return this.disabled();
85
+ }
86
+ createCredential(_openaiApiKey, _idempotencyKey) {
87
+ return this.disabled();
88
+ }
89
+ createCodexCredential(_files, _idempotencyKey) {
90
+ return this.disabled();
91
+ }
92
+ deleteCredential(_credentialRef, _idempotencyKey) {
93
+ return this.disabled();
94
+ }
95
+ create(_input, _idempotencyKey) {
96
+ return this.disabled();
97
+ }
98
+ status(_id) {
99
+ return this.disabled();
100
+ }
101
+ start(_id, _idempotencyKey) {
102
+ return this.disabled();
103
+ }
104
+ stop(_id, _idempotencyKey) {
105
+ return this.disabled();
106
+ }
107
+ snapshot(_id, _name, _idempotencyKey) {
108
+ return this.disabled();
109
+ }
110
+ delete(_id, _idempotencyKey) {
111
+ return this.disabled();
112
+ }
113
+ provision(_input, _idempotencyKey) {
114
+ return this.disabled();
115
+ }
116
+ readCodexFiles(_id) {
117
+ return this.disabled();
118
+ }
119
+ writeCodexFiles(_id, _files, _idempotencyKey) {
120
+ return this.disabled();
121
+ }
122
+ disabled() {
123
+ return Promise.reject(new Error("Hosted sandbox provider is disabled."));
124
+ }
125
+ };
126
+ var IncusHostedSandboxProvider = class {
127
+ constructor(config2) {
128
+ this.config = config2;
129
+ }
130
+ config;
131
+ async capability(signal) {
132
+ const result = await this.request("/v1/capability", signal ? { signal } : {});
133
+ const available = result.available && result.credentialStoreReady === true;
134
+ return {
135
+ provider: "incus",
136
+ configured: true,
137
+ reachable: true,
138
+ available,
139
+ reasonCode: available ? null : "incus_host_agent_not_ready",
140
+ reason: available ? null : "Incus or encrypted credential storage is not ready.",
141
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
142
+ ...result.limits ? { limits: result.limits } : {},
143
+ ...result.capacity ? { capacity: result.capacity } : {},
144
+ ...result.metrics ? { metrics: result.metrics } : {},
145
+ ...result.alerts ? { alerts: result.alerts } : {}
146
+ };
147
+ }
148
+ inventory() {
149
+ return this.request("/v1/inventory");
150
+ }
151
+ async createCredential(openaiApiKey, idempotencyKey) {
152
+ const result = await this.request(
153
+ "/v1/credentials",
154
+ { method: "POST", idempotencyKey, body: { openaiApiKey } }
155
+ );
156
+ return result.credentialRef;
157
+ }
158
+ async createCodexCredential(files, idempotencyKey) {
159
+ const result = await this.request(
160
+ "/v1/credentials",
161
+ { method: "POST", idempotencyKey, body: { codexFiles: files } }
162
+ );
163
+ return result.credentialRef;
164
+ }
165
+ async deleteCredential(credentialRef, idempotencyKey) {
166
+ await this.request(`/v1/credentials/${encodeURIComponent(credentialRef)}`, {
167
+ method: "DELETE",
168
+ idempotencyKey
169
+ });
170
+ }
171
+ create(input, idempotencyKey) {
172
+ return this.request("/v1/instances", {
173
+ method: "POST",
174
+ idempotencyKey,
175
+ body: input
176
+ });
177
+ }
178
+ status(id) {
179
+ return this.request(
180
+ `/v1/instances/${encodeURIComponent(id)}`
181
+ );
182
+ }
183
+ start(id, idempotencyKey) {
184
+ return this.instanceMutation(id, "start", idempotencyKey);
185
+ }
186
+ stop(id, idempotencyKey) {
187
+ return this.instanceMutation(id, "stop", idempotencyKey);
188
+ }
189
+ async snapshot(id, name, idempotencyKey) {
190
+ await this.request(`/v1/instances/${encodeURIComponent(id)}/snapshots`, {
191
+ method: "POST",
192
+ idempotencyKey,
193
+ body: { name }
194
+ });
195
+ }
196
+ async delete(id, idempotencyKey) {
197
+ await this.request(`/v1/instances/${encodeURIComponent(id)}`, {
198
+ method: "DELETE",
199
+ idempotencyKey
200
+ });
201
+ }
202
+ async provision(input, idempotencyKey) {
203
+ await this.request(
204
+ `/v1/instances/${encodeURIComponent(input.id)}/provision`,
205
+ {
206
+ method: "POST",
207
+ idempotencyKey,
208
+ body: {
209
+ relayServerUrl: input.relayServerUrl,
210
+ relayAgentToken: input.relayAgentToken,
211
+ credentialRef: input.credentialRef,
212
+ codexConfig: input.codexConfig,
213
+ localAdminUsername: input.localAdminUsername ?? "admin"
214
+ }
215
+ }
216
+ );
217
+ }
218
+ readCodexFiles(id) {
219
+ return this.request(
220
+ `/v1/instances/${encodeURIComponent(id)}/backends/codex/files`
221
+ );
222
+ }
223
+ async writeCodexFiles(id, files, idempotencyKey) {
224
+ await this.request(
225
+ `/v1/instances/${encodeURIComponent(id)}/backends/codex/files`,
226
+ { method: "PUT", idempotencyKey, body: files }
227
+ );
228
+ }
229
+ instanceMutation(id, action, idempotencyKey) {
230
+ return this.request(
231
+ `/v1/instances/${encodeURIComponent(id)}/${action}`,
232
+ { method: "POST", idempotencyKey }
233
+ );
234
+ }
235
+ async request(pathname, options = {}) {
236
+ if (!this.config.agentUrl || !this.config.agentToken) {
237
+ throw new Error("Incus host-agent is not configured.");
238
+ }
239
+ const headers = {
240
+ authorization: `Bearer ${this.config.agentToken}`
241
+ };
242
+ if (options.idempotencyKey) {
243
+ headers["idempotency-key"] = options.idempotencyKey;
244
+ }
245
+ if (options.body !== void 0) {
246
+ headers["content-type"] = "application/json";
247
+ }
248
+ const request = {
249
+ method: options.method ?? "GET",
250
+ headers,
251
+ ...options.body === void 0 ? {} : { body: JSON.stringify(options.body) },
252
+ ...options.signal ? { signal: options.signal } : {}
253
+ };
254
+ const response = await fetch(
255
+ `${this.config.agentUrl.replace(/\/$/, "")}${pathname}`,
256
+ request
257
+ );
258
+ if (!response.ok) {
259
+ const payload = await response.json().catch(() => null);
260
+ const detail = typeof payload?.message === "string" && payload.message.trim() ? payload.message.trim() : `Incus host-agent request failed with ${response.status}.`;
261
+ throw new HostedSandboxProviderError(
262
+ typeof payload?.code === "string" ? payload.code : "provider_request_failed",
263
+ detail
264
+ );
265
+ }
266
+ return await response.json();
267
+ }
268
+ };
269
+ function createHostedSandboxProvider(config2) {
270
+ return config2.provider === "incus" ? new IncusHostedSandboxProvider(config2) : new DisabledHostedSandboxProvider();
271
+ }
272
+ var HostedSandboxCapabilityService = class {
273
+ constructor(provider, options) {
274
+ this.provider = provider;
275
+ this.options = options;
276
+ }
277
+ provider;
278
+ options;
279
+ consecutiveFailures = 0;
280
+ circuitOpenedAt = null;
281
+ async read() {
282
+ const now = this.options.now ?? Date.now;
283
+ const failureThreshold = this.options.failureThreshold ?? 2;
284
+ const circuitResetMs = this.options.circuitResetMs ?? 3e4;
285
+ if (this.circuitOpenedAt !== null && now() - this.circuitOpenedAt < circuitResetMs) {
286
+ return unavailableCapability(
287
+ "hosted_provider_circuit_open",
288
+ "Hosted supervisor VM operations are temporarily unavailable after repeated provider failures."
289
+ );
290
+ }
291
+ if (this.circuitOpenedAt !== null) {
292
+ this.circuitOpenedAt = null;
293
+ this.consecutiveFailures = 0;
294
+ }
295
+ const controller = new AbortController();
296
+ let timeout = null;
297
+ try {
298
+ const capability = await Promise.race([
299
+ this.provider.capability(controller.signal),
300
+ new Promise((_resolve, reject) => {
301
+ timeout = setTimeout(() => {
302
+ controller.abort();
303
+ reject(new Error("Hosted sandbox provider request timed out."));
304
+ }, this.options.timeoutMs);
305
+ })
306
+ ]);
307
+ this.consecutiveFailures = 0;
308
+ this.circuitOpenedAt = null;
309
+ return capability;
310
+ } catch (error) {
311
+ this.consecutiveFailures += 1;
312
+ if (this.consecutiveFailures >= failureThreshold) {
313
+ this.circuitOpenedAt = now();
314
+ }
315
+ const timedOut = controller.signal.aborted;
316
+ return unavailableCapability(
317
+ timedOut ? "hosted_provider_timeout" : "hosted_provider_unreachable",
318
+ timedOut ? "The hosted supervisor VM provider did not respond in time." : "The hosted supervisor VM provider could not be reached."
319
+ );
320
+ } finally {
321
+ if (timeout) {
322
+ clearTimeout(timeout);
323
+ }
324
+ }
325
+ }
326
+ };
327
+ function unavailableCapability(reasonCode, reason) {
328
+ return {
329
+ provider: "incus",
330
+ configured: true,
331
+ reachable: false,
332
+ available: false,
333
+ reasonCode,
334
+ reason,
335
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
336
+ };
337
+ }
338
+
62
339
  // src/relay-store.ts
63
340
  import crypto from "crypto";
64
341
  import fs from "fs";
@@ -75,6 +352,7 @@ var RelayStore = class _RelayStore {
75
352
  this.sqlite.pragma("foreign_keys = ON");
76
353
  this.migrate();
77
354
  this.importLegacyJson(legacyJsonPath);
355
+ this.migrateHostedSandboxMembers();
78
356
  this.ensureRegistrationSetting(registrationEnabled);
79
357
  }
80
358
  databasePath;
@@ -105,7 +383,11 @@ var RelayStore = class _RelayStore {
105
383
  }
106
384
  register(input) {
107
385
  if (!this.registrationEnabled()) {
108
- throw new RelayStoreError(403, "forbidden", "Registration is currently disabled.");
386
+ throw new RelayStoreError(
387
+ 403,
388
+ "forbidden",
389
+ "Registration is currently disabled."
390
+ );
109
391
  }
110
392
  const user = this.createStoredUser({
111
393
  email: input.email,
@@ -118,21 +400,41 @@ var RelayStore = class _RelayStore {
118
400
  }
119
401
  requestRegistrationApproval(input) {
120
402
  if (!this.registrationEnabled()) {
121
- throw new RelayStoreError(403, "forbidden", "Registration is currently disabled.");
403
+ throw new RelayStoreError(
404
+ 403,
405
+ "forbidden",
406
+ "Registration is currently disabled."
407
+ );
122
408
  }
123
409
  const email = input.email.trim().toLowerCase();
124
410
  const username = normalizeUsername(input.username);
125
411
  if (!email.includes("@")) {
126
- throw new RelayStoreError(400, "bad_request", "A valid email address is required.");
412
+ throw new RelayStoreError(
413
+ 400,
414
+ "bad_request",
415
+ "A valid email address is required."
416
+ );
127
417
  }
128
418
  if (username.length < 3) {
129
- throw new RelayStoreError(400, "bad_request", "Username must be at least 3 characters.");
419
+ throw new RelayStoreError(
420
+ 400,
421
+ "bad_request",
422
+ "Username must be at least 3 characters."
423
+ );
130
424
  }
131
425
  if (input.password.length < 8) {
132
- throw new RelayStoreError(400, "bad_request", "Password must be at least 8 characters.");
426
+ throw new RelayStoreError(
427
+ 400,
428
+ "bad_request",
429
+ "Password must be at least 8 characters."
430
+ );
133
431
  }
134
432
  if (this.getUserByIdentifier(email) || this.getUserByUsername(username)) {
135
- throw new RelayStoreError(409, "conflict", "A user with that email or username already exists.");
433
+ throw new RelayStoreError(
434
+ 409,
435
+ "conflict",
436
+ "A user with that email or username already exists."
437
+ );
136
438
  }
137
439
  const existing = this.rowToPendingRegistration(
138
440
  this.sqlite.prepare(
@@ -158,19 +460,64 @@ var RelayStore = class _RelayStore {
158
460
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
159
461
  status: "pending",
160
462
  reviewedAt: null,
161
- reviewedByUserId: null
463
+ reviewedByUserId: null,
464
+ provider: input.provider ?? "password",
465
+ providerSubject: input.providerSubject ?? null
162
466
  };
163
467
  this.insertPendingRegistration(record);
164
468
  return this.publicPendingRegistration(record);
165
469
  }
470
+ authenticateExternalIdentity(identity, approvalRequired) {
471
+ const linked = this.sqlite.prepare(
472
+ "SELECT user_id FROM relay_user_identities WHERE provider = ? AND provider_subject = ?"
473
+ ).get(identity.provider, identity.subject);
474
+ if (linked) {
475
+ const user2 = this.requireUser(linked.user_id);
476
+ if (!user2.enabled) throw new RelayStoreError(401, "unauthorized", "This relay account is disabled.");
477
+ return { kind: "login", result: this.createLoginResult(user2) };
478
+ }
479
+ if (!this.registrationEnabled()) {
480
+ throw new RelayStoreError(403, "forbidden", "Registration is currently disabled.");
481
+ }
482
+ if (this.getUserByIdentifier(identity.email)) {
483
+ throw new RelayStoreError(409, "conflict", "An account with this email already exists. Sign in with its current method before linking OAuth.");
484
+ }
485
+ const username = this.availableUsername(identity.username);
486
+ const password = crypto.randomBytes(32).toString("base64url");
487
+ if (approvalRequired) {
488
+ const request = this.requestRegistrationApproval({
489
+ email: identity.email,
490
+ username,
491
+ password,
492
+ provider: identity.provider,
493
+ providerSubject: identity.subject
494
+ });
495
+ return { kind: "pending", request };
496
+ }
497
+ const user = this.createStoredUser({ email: identity.email, username, password, role: "user" });
498
+ const create = this.sqlite.transaction(() => {
499
+ this.insertUser(user);
500
+ this.insertIdentity(user.id, identity.provider, identity.subject, identity.email);
501
+ });
502
+ create();
503
+ return { kind: "login", result: this.createLoginResult(user) };
504
+ }
166
505
  login(input) {
167
506
  const normalizedIdentifier = input.identifier.trim().toLowerCase();
168
507
  const user = this.getUserByIdentifier(normalizedIdentifier);
169
508
  if (!user || !user.enabled) {
170
- throw new RelayStoreError(401, "unauthorized", "Invalid username or password.");
509
+ throw new RelayStoreError(
510
+ 401,
511
+ "unauthorized",
512
+ "Invalid username or password."
513
+ );
171
514
  }
172
515
  if (!verifySecret(input.password, user.passwordSalt, user.passwordHash)) {
173
- throw new RelayStoreError(401, "unauthorized", "Invalid username or password.");
516
+ throw new RelayStoreError(
517
+ 401,
518
+ "unauthorized",
519
+ "Invalid username or password."
520
+ );
174
521
  }
175
522
  return this.createLoginResult(user);
176
523
  }
@@ -210,12 +557,446 @@ var RelayStore = class _RelayStore {
210
557
  token
211
558
  };
212
559
  }
560
+ createHostedSandboxRequested(input) {
561
+ const admin = this.requireUser(input.createdByAdminUserId);
562
+ const assignedUsers = this.requireHostedSandboxMembers(
563
+ input.assignedUserIds
564
+ );
565
+ const sandboxId = crypto.randomUUID();
566
+ const operationId = crypto.randomUUID();
567
+ const now = (/* @__PURE__ */ new Date()).toISOString();
568
+ let deviceResult = null;
569
+ const create = this.sqlite.transaction(() => {
570
+ deviceResult = this.createDevice(admin.id, {
571
+ name: input.deviceName
572
+ });
573
+ this.sqlite.prepare(
574
+ `
575
+ INSERT INTO relay_hosted_sandboxes (
576
+ id, device_id, assigned_user_id, created_by_admin_user_id,
577
+ provider, provider_instance_id, image_version,
578
+ cpu_count, memory_mib, disk_gib, status, credential_ref,
579
+ codex_config_json,
580
+ last_error_code, last_error_message, created_at, updated_at
581
+ ) VALUES (?, ?, ?, ?, 'incus', NULL, ?, ?, ?, ?, 'requested', ?, ?, NULL, NULL, ?, ?)
582
+ `
583
+ ).run(
584
+ sandboxId,
585
+ deviceResult.device.id,
586
+ admin.id,
587
+ input.createdByAdminUserId,
588
+ input.imageVersion,
589
+ input.resources.cpuCount,
590
+ input.resources.memoryMiB,
591
+ input.resources.diskGiB,
592
+ input.credentialRef,
593
+ JSON.stringify(input.codexConfig ?? parseHostedCodexConfig(null)),
594
+ now,
595
+ now
596
+ );
597
+ const insertMember = this.sqlite.prepare(
598
+ `INSERT INTO relay_hosted_sandbox_members
599
+ (sandbox_id, user_id, position, created_at) VALUES (?, ?, ?, ?)`
600
+ );
601
+ assignedUsers.forEach(
602
+ (user, position) => insertMember.run(sandboxId, user.id, position, now)
603
+ );
604
+ this.insertHostedOperation({
605
+ id: operationId,
606
+ sandboxId,
607
+ action: "create",
608
+ status: "pending",
609
+ errorCode: null,
610
+ errorMessage: null,
611
+ createdAt: now,
612
+ updatedAt: now
613
+ });
614
+ });
615
+ create();
616
+ const context = this.getHostedProvisionContext(sandboxId);
617
+ if (!context || !deviceResult) {
618
+ throw new Error("Hosted sandbox transaction did not persist.");
619
+ }
620
+ return {
621
+ sandbox: this.getHostedSandboxDetail(sandboxId),
622
+ operation: this.getHostedOperation(operationId),
623
+ deviceToken: context.deviceToken
624
+ };
625
+ }
626
+ listHostedSandboxes() {
627
+ return this.sqlite.prepare(
628
+ "SELECT * FROM relay_hosted_sandboxes ORDER BY created_at DESC"
629
+ ).all().map((row) => this.rowToHostedSandbox(row));
630
+ }
631
+ setHostedSandboxMembers(id, userIds) {
632
+ if (!this.getHostedSandboxDetail(id)) {
633
+ throw new RelayStoreError(404, "not_found", "Hosted VM was not found.");
634
+ }
635
+ const users = this.requireHostedSandboxMembers(userIds);
636
+ const now = (/* @__PURE__ */ new Date()).toISOString();
637
+ this.sqlite.transaction(() => {
638
+ this.sqlite.prepare(
639
+ "DELETE FROM relay_hosted_sandbox_members WHERE sandbox_id = ?"
640
+ ).run(id);
641
+ const insert = this.sqlite.prepare(
642
+ `INSERT INTO relay_hosted_sandbox_members
643
+ (sandbox_id, user_id, position, created_at) VALUES (?, ?, ?, ?)`
644
+ );
645
+ users.forEach((user, position) => insert.run(id, user.id, position, now));
646
+ this.sqlite.prepare(
647
+ "UPDATE relay_hosted_sandboxes SET updated_at = ? WHERE id = ?"
648
+ ).run(now, id);
649
+ })();
650
+ return this.getHostedSandboxDetail(id);
651
+ }
652
+ setHostedWorkspaceIsolation(id, enabled) {
653
+ const result = this.sqlite.prepare(
654
+ `UPDATE relay_hosted_sandboxes
655
+ SET workspace_isolation_enabled = ?, updated_at = ? WHERE id = ?`
656
+ ).run(enabled ? 1 : 0, (/* @__PURE__ */ new Date()).toISOString(), id);
657
+ if (result.changes === 0) {
658
+ throw new RelayStoreError(404, "not_found", "Hosted VM was not found.");
659
+ }
660
+ return this.getHostedSandboxDetail(id);
661
+ }
662
+ hostedWorkspaceIsolation(deviceId) {
663
+ const row = this.sqlite.prepare(
664
+ `SELECT id, workspace_isolation_enabled
665
+ FROM relay_hosted_sandboxes WHERE device_id = ?`
666
+ ).get(deviceId);
667
+ return row ? { sandboxId: row.id, enabled: Boolean(row.workspace_isolation_enabled) } : null;
668
+ }
669
+ hostedWorkspaceIsolationForUser(deviceId, userId) {
670
+ const row = this.sqlite.prepare(
671
+ `SELECT hs.id, hs.workspace_isolation_enabled
672
+ FROM relay_hosted_sandboxes hs
673
+ JOIN relay_hosted_sandbox_members m ON m.sandbox_id = hs.id
674
+ WHERE hs.device_id = ? AND m.user_id = ?`
675
+ ).get(deviceId, userId);
676
+ return row ? { sandboxId: row.id, enabled: Boolean(row.workspace_isolation_enabled) } : null;
677
+ }
678
+ hostedUserWorkspaceIds(sandboxId, userId) {
679
+ return this.sqlite.prepare(
680
+ `SELECT workspace_id FROM relay_hosted_user_workspaces
681
+ WHERE sandbox_id = ? AND user_id = ? ORDER BY created_at`
682
+ ).all(sandboxId, userId).map((row) => row.workspace_id);
683
+ }
684
+ recordHostedUserWorkspace(sandboxId, userId, workspaceId, initial = false) {
685
+ this.sqlite.prepare(
686
+ `INSERT OR IGNORE INTO relay_hosted_user_workspaces
687
+ (sandbox_id, user_id, workspace_id, initial_workspace, created_at)
688
+ VALUES (?, ?, ?, ?, ?)`
689
+ ).run(sandboxId, userId, workspaceId, initial ? 1 : 0, (/* @__PURE__ */ new Date()).toISOString());
690
+ }
691
+ ownsHostedWorkspace(sandboxId, userId, workspaceId) {
692
+ return Boolean(
693
+ this.sqlite.prepare(
694
+ `SELECT 1 FROM relay_hosted_user_workspaces
695
+ WHERE sandbox_id = ? AND user_id = ? AND workspace_id = ?`
696
+ ).get(sandboxId, userId, workspaceId)
697
+ );
698
+ }
699
+ recordHostedUserThread(sandboxId, userId, threadId, workspaceId) {
700
+ this.sqlite.prepare(
701
+ `INSERT OR IGNORE INTO relay_hosted_user_threads
702
+ (sandbox_id, user_id, thread_id, workspace_id, created_at)
703
+ VALUES (?, ?, ?, ?, ?)`
704
+ ).run(sandboxId, userId, threadId, workspaceId, (/* @__PURE__ */ new Date()).toISOString());
705
+ }
706
+ ownsHostedThread(sandboxId, userId, threadId) {
707
+ return Boolean(
708
+ this.sqlite.prepare(
709
+ `SELECT 1 FROM relay_hosted_user_threads
710
+ WHERE sandbox_id = ? AND user_id = ? AND thread_id = ?`
711
+ ).get(sandboxId, userId, threadId)
712
+ );
713
+ }
714
+ listHostedProviderRecords() {
715
+ return this.sqlite.prepare(
716
+ "SELECT id, credential_ref FROM relay_hosted_sandboxes ORDER BY id"
717
+ ).all().map((row) => ({ id: row.id, credentialRef: row.credential_ref }));
718
+ }
719
+ getHostedSandboxDetail(id) {
720
+ const row = this.sqlite.prepare("SELECT * FROM relay_hosted_sandboxes WHERE id = ?").get(id);
721
+ if (!row) {
722
+ return null;
723
+ }
724
+ return {
725
+ ...this.rowToHostedSandbox(row),
726
+ operations: this.getHostedOperations(id)
727
+ };
728
+ }
729
+ getHostedProvisionContext(id) {
730
+ const row = this.sqlite.prepare(
731
+ `
732
+ SELECT hs.*, d.token AS device_token
733
+ FROM relay_hosted_sandboxes hs
734
+ JOIN relay_devices d ON d.id = hs.device_id
735
+ WHERE hs.id = ?
736
+ `
737
+ ).get(id);
738
+ if (!row || !row.device_token) {
739
+ return null;
740
+ }
741
+ return {
742
+ sandbox: this.rowToHostedSandbox(row),
743
+ deviceToken: row.device_token,
744
+ credentialRef: row.credential_ref,
745
+ codexConfig: parseHostedCodexConfig(row.codex_config_json)
746
+ };
747
+ }
748
+ listHostedSandboxesNeedingReconciliation() {
749
+ return this.sqlite.prepare(
750
+ `
751
+ SELECT id FROM relay_hosted_sandboxes
752
+ WHERE status IN ('requested', 'creating', 'starting', 'provisioning')
753
+ ORDER BY created_at ASC
754
+ `
755
+ ).all().map((row) => row.id);
756
+ }
757
+ updateHostedSandboxStatus(id, status, options = {}) {
758
+ const result = this.sqlite.prepare(
759
+ `
760
+ UPDATE relay_hosted_sandboxes
761
+ SET status = ?,
762
+ provider_instance_id = COALESCE(?, provider_instance_id),
763
+ last_error_code = ?,
764
+ last_error_message = ?,
765
+ running_since = CASE
766
+ WHEN ? = 'stopped' THEN NULL
767
+ WHEN ? = 'starting' AND running_since IS NULL THEN ?
768
+ ELSE running_since
769
+ END,
770
+ updated_at = ?
771
+ WHERE id = ?
772
+ `
773
+ ).run(
774
+ status,
775
+ options.providerInstanceId ?? null,
776
+ options.errorCode ?? null,
777
+ options.errorMessage ?? null,
778
+ status,
779
+ status,
780
+ (/* @__PURE__ */ new Date()).toISOString(),
781
+ (/* @__PURE__ */ new Date()).toISOString(),
782
+ id
783
+ );
784
+ if (result.changes < 1) {
785
+ throw new RelayStoreError(
786
+ 404,
787
+ "not_found",
788
+ "Hosted sandbox was not found."
789
+ );
790
+ }
791
+ }
792
+ updateHostedOperation(id, status, error) {
793
+ const result = this.sqlite.prepare(
794
+ `
795
+ UPDATE relay_hosted_operations
796
+ SET status = ?, error_code = ?, error_message = ?, updated_at = ?
797
+ WHERE id = ?
798
+ `
799
+ ).run(
800
+ status,
801
+ error?.code ?? null,
802
+ error?.message ?? null,
803
+ (/* @__PURE__ */ new Date()).toISOString(),
804
+ id
805
+ );
806
+ if (result.changes < 1) {
807
+ throw new RelayStoreError(
808
+ 404,
809
+ "not_found",
810
+ "Hosted operation was not found."
811
+ );
812
+ }
813
+ }
814
+ createHostedOperation(sandboxId, action) {
815
+ if (!this.getHostedSandboxDetail(sandboxId)) {
816
+ throw new RelayStoreError(
817
+ 404,
818
+ "not_found",
819
+ "Hosted sandbox was not found."
820
+ );
821
+ }
822
+ const now = (/* @__PURE__ */ new Date()).toISOString();
823
+ const operation = {
824
+ id: crypto.randomUUID(),
825
+ sandboxId,
826
+ action,
827
+ status: "pending",
828
+ errorCode: null,
829
+ errorMessage: null,
830
+ createdAt: now,
831
+ updatedAt: now
832
+ };
833
+ this.insertHostedOperation(operation);
834
+ return operation;
835
+ }
836
+ markHostedDeviceOnline(deviceId) {
837
+ this.sqlite.prepare(
838
+ `
839
+ UPDATE relay_hosted_sandboxes
840
+ SET status = 'online', last_error_code = NULL,
841
+ last_error_message = NULL,
842
+ running_since = COALESCE(running_since, ?), updated_at = ?
843
+ WHERE device_id = ? AND status != 'deleting'
844
+ `
845
+ ).run((/* @__PURE__ */ new Date()).toISOString(), (/* @__PURE__ */ new Date()).toISOString(), deviceId);
846
+ }
847
+ getHostedSandboxByDeviceId(deviceId) {
848
+ const row = this.sqlite.prepare("SELECT * FROM relay_hosted_sandboxes WHERE device_id = ?").get(deviceId);
849
+ return row ? this.rowToHostedSandbox(row) : null;
850
+ }
851
+ recordHostedUserActivity(deviceId, idleTimeoutMs) {
852
+ const row = this.sqlite.prepare("SELECT * FROM relay_hosted_sandboxes WHERE device_id = ?").get(deviceId);
853
+ if (!row) return null;
854
+ const now = /* @__PURE__ */ new Date();
855
+ const deadline = row.active_turn_count === 0 ? new Date(now.getTime() + idleTimeoutMs).toISOString() : null;
856
+ this.sqlite.prepare(
857
+ `
858
+ UPDATE relay_hosted_sandboxes
859
+ SET last_user_activity_at = ?, idle_deadline_at = ?,
860
+ lifecycle_generation = lifecycle_generation + 1, updated_at = ?
861
+ WHERE id = ?
862
+ `
863
+ ).run(now.toISOString(), deadline, now.toISOString(), row.id);
864
+ return this.getHostedSandboxDetail(row.id);
865
+ }
866
+ recordHostedTurnActivity(input) {
867
+ const row = this.sqlite.prepare("SELECT * FROM relay_hosted_sandboxes WHERE device_id = ?").get(input.deviceId);
868
+ if (!row) return null;
869
+ const update = this.sqlite.transaction(() => {
870
+ if (input.kind === "turn_started") {
871
+ this.sqlite.prepare(
872
+ `
873
+ INSERT OR IGNORE INTO relay_hosted_active_turns (
874
+ sandbox_id, thread_id, turn_id, started_at
875
+ ) VALUES (?, ?, ?, ?)
876
+ `
877
+ ).run(row.id, input.threadId, input.turnId, (/* @__PURE__ */ new Date()).toISOString());
878
+ } else {
879
+ this.sqlite.prepare(
880
+ `
881
+ DELETE FROM relay_hosted_active_turns
882
+ WHERE sandbox_id = ? AND thread_id = ? AND turn_id = ?
883
+ `
884
+ ).run(row.id, input.threadId, input.turnId);
885
+ }
886
+ const active = this.sqlite.prepare(
887
+ "SELECT COUNT(*) AS count FROM relay_hosted_active_turns WHERE sandbox_id = ?"
888
+ ).get(row.id);
889
+ const now = /* @__PURE__ */ new Date();
890
+ const deadline = active.count === 0 ? new Date(now.getTime() + input.idleTimeoutMs).toISOString() : null;
891
+ this.sqlite.prepare(
892
+ `
893
+ UPDATE relay_hosted_sandboxes
894
+ SET active_turn_count = ?, idle_deadline_at = ?,
895
+ lifecycle_generation = lifecycle_generation + 1, updated_at = ?
896
+ WHERE id = ?
897
+ `
898
+ ).run(active.count, deadline, now.toISOString(), row.id);
899
+ });
900
+ update();
901
+ return this.getHostedSandboxDetail(row.id);
902
+ }
903
+ armHostedIdleDeadline(id, idleTimeoutMs) {
904
+ const now = /* @__PURE__ */ new Date();
905
+ const current = this.getHostedSandboxDetail(id);
906
+ if (!current || current.activeTurnCount > 0) {
907
+ return current;
908
+ }
909
+ const activityDeadline = current.lastUserActivityAt ? new Date(
910
+ new Date(current.lastUserActivityAt).getTime() + idleTimeoutMs
911
+ ).toISOString() : null;
912
+ const deadline = activityDeadline ? current.idleDeadlineAt && current.idleDeadlineAt < activityDeadline ? current.idleDeadlineAt : activityDeadline : current.idleDeadlineAt ?? new Date(now.getTime() + idleTimeoutMs).toISOString();
913
+ if (deadline === current.idleDeadlineAt) {
914
+ return current;
915
+ }
916
+ this.sqlite.prepare(
917
+ `
918
+ UPDATE relay_hosted_sandboxes
919
+ SET idle_deadline_at = ?, lifecycle_generation = lifecycle_generation + 1,
920
+ updated_at = ?
921
+ WHERE id = ? AND active_turn_count = 0
922
+ `
923
+ ).run(deadline, now.toISOString(), id);
924
+ return this.getHostedSandboxDetail(id);
925
+ }
926
+ listHostedIdleDeadlines() {
927
+ return this.sqlite.prepare(
928
+ `
929
+ SELECT id, idle_deadline_at, lifecycle_generation
930
+ FROM relay_hosted_sandboxes
931
+ WHERE idle_deadline_at IS NOT NULL AND active_turn_count = 0
932
+ AND status = 'online'
933
+ `
934
+ ).all().map((row) => ({
935
+ id: row.id,
936
+ deadlineAt: row.idle_deadline_at,
937
+ generation: row.lifecycle_generation
938
+ }));
939
+ }
940
+ claimHostedIdleStop(id, generation, now) {
941
+ const result = this.sqlite.prepare(
942
+ `
943
+ UPDATE relay_hosted_sandboxes
944
+ SET status = 'stopping', idle_deadline_at = NULL,
945
+ lifecycle_generation = lifecycle_generation + 1, updated_at = ?
946
+ WHERE id = ? AND lifecycle_generation = ? AND active_turn_count = 0
947
+ AND status = 'online' AND idle_deadline_at <= ?
948
+ `
949
+ ).run(now.toISOString(), id, generation, now.toISOString());
950
+ return result.changes === 1;
951
+ }
213
952
  deleteDevice(userId, deviceId) {
953
+ const hosted = this.sqlite.prepare("SELECT id FROM relay_hosted_sandboxes WHERE device_id = ?").get(deviceId);
954
+ if (hosted) {
955
+ throw new RelayStoreError(
956
+ 409,
957
+ "conflict",
958
+ "Hosted devices must be deleted from the Hosted supervisor VM admin panel."
959
+ );
960
+ }
214
961
  const result = this.sqlite.prepare("DELETE FROM relay_devices WHERE id = ? AND owner_user_id = ?").run(deviceId, userId);
215
962
  if (result.changes < 1) {
216
963
  throw new RelayStoreError(404, "not_found", "Device was not found.");
217
964
  }
218
965
  }
966
+ deleteHostedSandboxRecord(id) {
967
+ const context = this.getHostedProvisionContext(id);
968
+ if (!context) {
969
+ throw new RelayStoreError(
970
+ 404,
971
+ "not_found",
972
+ "Hosted sandbox was not found."
973
+ );
974
+ }
975
+ const remove = this.sqlite.transaction(() => {
976
+ this.sqlite.prepare("DELETE FROM relay_hosted_sandboxes WHERE id = ?").run(id);
977
+ this.sqlite.prepare("DELETE FROM relay_devices WHERE id = ?").run(context.sandbox.deviceId);
978
+ });
979
+ remove();
980
+ return {
981
+ id,
982
+ deviceId: context.sandbox.deviceId,
983
+ credentialRef: context.credentialRef
984
+ };
985
+ }
986
+ replaceHostedCredentialRef(id, credentialRef) {
987
+ const context = this.getHostedProvisionContext(id);
988
+ if (!context) {
989
+ throw new RelayStoreError(
990
+ 404,
991
+ "not_found",
992
+ "Hosted sandbox was not found."
993
+ );
994
+ }
995
+ this.sqlite.prepare(
996
+ "UPDATE relay_hosted_sandboxes SET credential_ref = ?, updated_at = ? WHERE id = ?"
997
+ ).run(credentialRef, (/* @__PURE__ */ new Date()).toISOString(), id);
998
+ return context.credentialRef;
999
+ }
219
1000
  verifyDeviceToken(token) {
220
1001
  if (!token) {
221
1002
  return null;
@@ -236,7 +1017,11 @@ var RelayStore = class _RelayStore {
236
1017
  throw new RelayStoreError(404, "not_found", "Target user was not found.");
237
1018
  }
238
1019
  if (target.id === ownerUserId) {
239
- throw new RelayStoreError(400, "bad_request", "You cannot share a session with yourself.");
1020
+ throw new RelayStoreError(
1021
+ 400,
1022
+ "bad_request",
1023
+ "You cannot share a session with yourself."
1024
+ );
240
1025
  }
241
1026
  const existing = this.rowToShare(
242
1027
  this.sqlite.prepare(
@@ -295,16 +1080,28 @@ var RelayStore = class _RelayStore {
295
1080
  throw new RelayStoreError(404, "not_found", "Target user was not found.");
296
1081
  }
297
1082
  if (target.id === ownerUserId) {
298
- throw new RelayStoreError(400, "bad_request", "You cannot share access with yourself.");
1083
+ throw new RelayStoreError(
1084
+ 400,
1085
+ "bad_request",
1086
+ "You cannot share access with yourself."
1087
+ );
299
1088
  }
300
1089
  const scope = normalizeShareScope(input.scope);
301
1090
  const threadId = scope === "thread" ? input.threadId?.trim() || null : null;
302
1091
  const workspaceId = scope === "workspace" ? input.workspaceId?.trim() || null : null;
303
1092
  if (scope === "thread" && !threadId) {
304
- throw new RelayStoreError(400, "bad_request", "threadId is required for thread grants.");
1093
+ throw new RelayStoreError(
1094
+ 400,
1095
+ "bad_request",
1096
+ "threadId is required for thread grants."
1097
+ );
305
1098
  }
306
1099
  if (scope === "workspace" && !workspaceId) {
307
- throw new RelayStoreError(400, "bad_request", "workspaceId is required for workspace grants.");
1100
+ throw new RelayStoreError(
1101
+ 400,
1102
+ "bad_request",
1103
+ "workspaceId is required for workspace grants."
1104
+ );
308
1105
  }
309
1106
  const existing = this.rowToGrant(
310
1107
  this.sqlite.prepare(
@@ -318,7 +1115,14 @@ var RelayStore = class _RelayStore {
318
1115
  AND COALESCE(workspace_id, '') = COALESCE(?, '')
319
1116
  AND revoked_at IS NULL
320
1117
  `
321
- ).get(ownerUserId, target.id, input.deviceId, scope, threadId, workspaceId)
1118
+ ).get(
1119
+ ownerUserId,
1120
+ target.id,
1121
+ input.deviceId,
1122
+ scope,
1123
+ threadId,
1124
+ workspaceId
1125
+ )
322
1126
  );
323
1127
  if (existing) {
324
1128
  return this.updateGrantRecord(existing.id, {
@@ -362,7 +1166,9 @@ var RelayStore = class _RelayStore {
362
1166
  }
363
1167
  updateShare(userId, shareId, input) {
364
1168
  const share = this.rowToShare(
365
- this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL").get(shareId, userId)
1169
+ this.sqlite.prepare(
1170
+ "SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL"
1171
+ ).get(shareId, userId)
366
1172
  );
367
1173
  if (!share) {
368
1174
  throw new RelayStoreError(404, "not_found", "Share was not found.");
@@ -379,7 +1185,9 @@ var RelayStore = class _RelayStore {
379
1185
  }
380
1186
  revokeShare(userId, shareId) {
381
1187
  const share = this.rowToShare(
382
- this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ?").get(shareId, userId)
1188
+ this.sqlite.prepare(
1189
+ "SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ?"
1190
+ ).get(shareId, userId)
383
1191
  );
384
1192
  if (!share) {
385
1193
  throw new RelayStoreError(404, "not_found", "Share was not found.");
@@ -390,7 +1198,9 @@ var RelayStore = class _RelayStore {
390
1198
  }
391
1199
  updateGrant(userId, grantId, input) {
392
1200
  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)
1201
+ this.sqlite.prepare(
1202
+ "SELECT * FROM relay_access_grants WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL"
1203
+ ).get(grantId, userId)
394
1204
  );
395
1205
  if (!grant) {
396
1206
  throw new RelayStoreError(404, "not_found", "Grant was not found.");
@@ -409,7 +1219,9 @@ var RelayStore = class _RelayStore {
409
1219
  }
410
1220
  revokeGrant(userId, grantId) {
411
1221
  const grant = this.rowToGrant(
412
- this.sqlite.prepare("SELECT * FROM relay_access_grants WHERE id = ? AND owner_user_id = ?").get(grantId, userId)
1222
+ this.sqlite.prepare(
1223
+ "SELECT * FROM relay_access_grants WHERE id = ? AND owner_user_id = ?"
1224
+ ).get(grantId, userId)
413
1225
  );
414
1226
  if (!grant) {
415
1227
  throw new RelayStoreError(404, "not_found", "Grant was not found.");
@@ -433,6 +1245,25 @@ var RelayStore = class _RelayStore {
433
1245
  canCreateThreads: true
434
1246
  };
435
1247
  }
1248
+ const hostedMember = this.sqlite.prepare(
1249
+ `SELECT 1
1250
+ FROM relay_hosted_sandbox_members m
1251
+ JOIN relay_hosted_sandboxes hs ON hs.id = m.sandbox_id
1252
+ WHERE m.user_id = ? AND hs.device_id = ?`
1253
+ ).get(userId, deviceId);
1254
+ if (hostedMember) {
1255
+ return {
1256
+ kind: "owner",
1257
+ share: null,
1258
+ grant: null,
1259
+ scope: "owner",
1260
+ threadAccess: "control",
1261
+ workspaceAccess: "write",
1262
+ workspaceId: null,
1263
+ workspaceScope: null,
1264
+ canCreateThreads: true
1265
+ };
1266
+ }
436
1267
  const now = (/* @__PURE__ */ new Date()).toISOString();
437
1268
  if (scope.threadId) {
438
1269
  const share = this.rowToShare(
@@ -515,23 +1346,34 @@ var RelayStore = class _RelayStore {
515
1346
  }
516
1347
  portalSummary(userId, connectedDevices) {
517
1348
  const user = this.requireUser(userId);
518
- const devices = this.getDevicesByOwner(userId);
1349
+ const devices = [
1350
+ ...this.getDevicesByOwner(userId),
1351
+ ...this.getHostedDevicesByMember(userId)
1352
+ ].filter(
1353
+ (device, index, all) => all.findIndex((item) => item.id === device.id) === index
1354
+ );
519
1355
  const sharedWithMe = this.getSharesByTarget(userId);
520
1356
  const sharedByMe = this.getSharesByOwner(userId);
521
1357
  const grantsWithMe = this.getGrantsByTarget(userId);
522
1358
  const grantsByMe = this.getGrantsByOwner(userId);
523
1359
  return {
524
1360
  user: this.publicUser(user),
525
- devices: devices.map((device) => this.publicDevice(device, connectedDevices.get(device.id) ?? null)),
1361
+ devices: devices.map(
1362
+ (device) => this.publicDevice(device, connectedDevices.get(device.id) ?? null)
1363
+ ),
526
1364
  sharedWithMe: sharedWithMe.map((share) => this.publicShare(share)),
527
1365
  sharedByMe: sharedByMe.map((share) => this.publicShare(share)),
528
1366
  sharedDevicesWithMe: grantsWithMe.filter((grant) => grant.scope === "device").map((grant) => this.publicGrant(grant)),
529
1367
  sharedThreadsWithMe: [
530
- ...sharedWithMe.map((share) => this.publicGrant(this.grantFromShare(share))),
1368
+ ...sharedWithMe.map(
1369
+ (share) => this.publicGrant(this.grantFromShare(share))
1370
+ ),
531
1371
  ...grantsWithMe.filter((grant) => grant.scope !== "device").map((grant) => this.publicGrant(grant))
532
1372
  ],
533
1373
  grantsByMe: [
534
- ...sharedByMe.map((share) => this.publicGrant(this.grantFromShare(share))),
1374
+ ...sharedByMe.map(
1375
+ (share) => this.publicGrant(this.grantFromShare(share))
1376
+ ),
535
1377
  ...grantsByMe.map((grant) => this.publicGrant(grant))
536
1378
  ]
537
1379
  };
@@ -540,17 +1382,39 @@ var RelayStore = class _RelayStore {
540
1382
  return this.getSharesByTarget(userId).filter((share) => share.deviceId === deviceId).map((share) => this.publicShare(share));
541
1383
  }
542
1384
  adminSummary(connectedDevices, options = {}) {
543
- const conversationWindowDays = normalizeConversationWindowDays(options.conversationWindowDays);
1385
+ const conversationWindowDays = normalizeConversationWindowDays(
1386
+ options.conversationWindowDays
1387
+ );
544
1388
  const users = this.getUsers();
545
1389
  const devices = this.getDevices();
546
1390
  const deviceCounts = /* @__PURE__ */ new Map();
1391
+ const hostedDeviceIds = new Set(
1392
+ this.sqlite.prepare("SELECT device_id FROM relay_hosted_sandboxes").all().map((row) => row.device_id)
1393
+ );
547
1394
  for (const device of devices) {
548
- deviceCounts.set(device.ownerUserId, (deviceCounts.get(device.ownerUserId) ?? 0) + 1);
1395
+ if (hostedDeviceIds.has(device.id)) continue;
1396
+ deviceCounts.set(
1397
+ device.ownerUserId,
1398
+ (deviceCounts.get(device.ownerUserId) ?? 0) + 1
1399
+ );
1400
+ }
1401
+ const hostedMemberships = this.sqlite.prepare("SELECT user_id FROM relay_hosted_sandbox_members").all();
1402
+ for (const membership of hostedMemberships) {
1403
+ deviceCounts.set(
1404
+ membership.user_id,
1405
+ (deviceCounts.get(membership.user_id) ?? 0) + 1
1406
+ );
549
1407
  }
550
- const conversationCounts = this.conversationCountsByUser(conversationWindowDays);
1408
+ const conversationCounts = this.conversationCountsByUser(
1409
+ conversationWindowDays
1410
+ );
551
1411
  return {
552
1412
  users: users.map(
553
- (user) => this.publicAdminUser(user, deviceCounts.get(user.id) ?? 0, conversationCounts.get(user.id) ?? 0)
1413
+ (user) => this.publicAdminUser(
1414
+ user,
1415
+ deviceCounts.get(user.id) ?? 0,
1416
+ conversationCounts.get(user.id) ?? 0
1417
+ )
554
1418
  ),
555
1419
  devices: devices.map((device) => {
556
1420
  const owner = users.find((user) => user.id === device.ownerUserId);
@@ -561,7 +1425,9 @@ var RelayStore = class _RelayStore {
561
1425
  options.metadata
562
1426
  );
563
1427
  }),
564
- shares: this.getShares({ includeRevoked: true }).map((share) => this.publicShare(share)),
1428
+ shares: this.getShares({ includeRevoked: true }).map(
1429
+ (share) => this.publicShare(share)
1430
+ ),
565
1431
  pendingRegistrations: this.pendingRegistrations(),
566
1432
  settings: this.registrationSettings(),
567
1433
  conversationWindowDays,
@@ -576,7 +1442,13 @@ var RelayStore = class _RelayStore {
576
1442
  return {
577
1443
  enabled: this.registrationEnabled(),
578
1444
  registrationPassword: this.getSetting("registrationPassword"),
579
- approvalRequired: this.getSetting("registrationApprovalRequired") === "true"
1445
+ approvalRequired: this.getSetting("registrationApprovalRequired") === "true",
1446
+ googleAuthEnabled: this.getSetting("googleAuthEnabled") === "true",
1447
+ githubAuthEnabled: this.getSetting("githubAuthEnabled") === "true",
1448
+ emailVerificationEnabled: this.getSetting("emailVerificationEnabled") === "true",
1449
+ googleAuthAvailable: false,
1450
+ githubAuthAvailable: false,
1451
+ emailVerificationAvailable: false
580
1452
  };
581
1453
  }
582
1454
  updateRegistrationSettings(input) {
@@ -586,7 +1458,11 @@ var RelayStore = class _RelayStore {
586
1458
  if (input.registrationPassword !== void 0) {
587
1459
  const password = input.registrationPassword?.trim() || null;
588
1460
  if (password !== null && password.length < 8) {
589
- throw new RelayStoreError(400, "bad_request", "Registration password must be at least 8 characters.");
1461
+ throw new RelayStoreError(
1462
+ 400,
1463
+ "bad_request",
1464
+ "Registration password must be at least 8 characters."
1465
+ );
590
1466
  }
591
1467
  if (password === null) {
592
1468
  this.deleteSetting("registrationPassword");
@@ -595,10 +1471,21 @@ var RelayStore = class _RelayStore {
595
1471
  }
596
1472
  }
597
1473
  if (input.approvalRequired !== void 0) {
598
- this.setSetting("registrationApprovalRequired", input.approvalRequired ? "true" : "false");
1474
+ this.setSetting(
1475
+ "registrationApprovalRequired",
1476
+ input.approvalRequired ? "true" : "false"
1477
+ );
1478
+ }
1479
+ for (const key of ["googleAuthEnabled", "githubAuthEnabled", "emailVerificationEnabled"]) {
1480
+ if (input[key] !== void 0) this.setSetting(key, input[key] ? "true" : "false");
599
1481
  }
600
1482
  return this.registrationSettings();
601
1483
  }
1484
+ ensureAuthSettings(input) {
1485
+ if (this.getSetting("googleAuthEnabled") === null) this.setSetting("googleAuthEnabled", input.google ? "true" : "false");
1486
+ if (this.getSetting("githubAuthEnabled") === null) this.setSetting("githubAuthEnabled", input.github ? "true" : "false");
1487
+ if (this.getSetting("emailVerificationEnabled") === null) this.setSetting("emailVerificationEnabled", "false");
1488
+ }
602
1489
  ensureRegistrationPassword(password) {
603
1490
  if (!password || this.getSetting("registrationPassword") !== null) {
604
1491
  return;
@@ -620,6 +1507,9 @@ var RelayStore = class _RelayStore {
620
1507
  };
621
1508
  const approve = this.sqlite.transaction(() => {
622
1509
  this.insertUser(user);
1510
+ if (record.providerSubject && record.provider !== "password") {
1511
+ this.insertIdentity(user.id, record.provider, record.providerSubject, record.email);
1512
+ }
623
1513
  this.sqlite.prepare(
624
1514
  `
625
1515
  UPDATE relay_pending_registrations
@@ -664,7 +1554,11 @@ var RelayStore = class _RelayStore {
664
1554
  setUserEnabled(userId, enabled) {
665
1555
  const user = this.requireUser(userId);
666
1556
  if (user.role === "admin" && !enabled) {
667
- throw new RelayStoreError(400, "bad_request", "The admin user cannot be disabled.");
1557
+ throw new RelayStoreError(
1558
+ 400,
1559
+ "bad_request",
1560
+ "The admin user cannot be disabled."
1561
+ );
668
1562
  }
669
1563
  this.sqlite.prepare("UPDATE relay_users SET enabled = ? WHERE id = ?").run(enabled ? 1 : 0, userId);
670
1564
  return this.publicUser({ ...user, enabled });
@@ -672,21 +1566,51 @@ var RelayStore = class _RelayStore {
672
1566
  deleteUser(userId) {
673
1567
  const user = this.requireUser(userId);
674
1568
  if (user.role === "admin") {
675
- throw new RelayStoreError(400, "bad_request", "The admin user cannot be deleted.");
1569
+ throw new RelayStoreError(
1570
+ 400,
1571
+ "bad_request",
1572
+ "The admin user cannot be deleted."
1573
+ );
1574
+ }
1575
+ const soleMembership = this.sqlite.prepare(
1576
+ `SELECT hs.id
1577
+ FROM relay_hosted_sandbox_members m
1578
+ JOIN relay_hosted_sandboxes hs ON hs.id = m.sandbox_id
1579
+ WHERE m.user_id = ?
1580
+ AND (SELECT COUNT(*) FROM relay_hosted_sandbox_members all_members
1581
+ WHERE all_members.sandbox_id = m.sandbox_id) = 1
1582
+ LIMIT 1`
1583
+ ).get(userId);
1584
+ if (soleMembership) {
1585
+ throw new RelayStoreError(
1586
+ 409,
1587
+ "conflict",
1588
+ "Reassign or delete the user's hosted VM before deleting this account."
1589
+ );
676
1590
  }
677
1591
  this.sqlite.prepare("DELETE FROM relay_users WHERE id = ?").run(userId);
678
1592
  }
679
1593
  adminResetUserPassword(userId, password) {
680
1594
  const user = this.requireUser(userId);
681
1595
  if (user.role === "admin") {
682
- throw new RelayStoreError(400, "bad_request", "The admin user password cannot be reset here.");
1596
+ throw new RelayStoreError(
1597
+ 400,
1598
+ "bad_request",
1599
+ "The admin user password cannot be reset here."
1600
+ );
683
1601
  }
684
1602
  if (password.length < 8) {
685
- throw new RelayStoreError(400, "bad_request", "Password must be at least 8 characters.");
1603
+ throw new RelayStoreError(
1604
+ 400,
1605
+ "bad_request",
1606
+ "Password must be at least 8 characters."
1607
+ );
686
1608
  }
687
1609
  const passwordSalt = crypto.randomBytes(16).toString("base64url");
688
1610
  const passwordHash = hashSecret(password, passwordSalt);
689
- this.sqlite.prepare("UPDATE relay_users SET password_salt = ?, password_hash = ? WHERE id = ?").run(passwordSalt, passwordHash, user.id);
1611
+ this.sqlite.prepare(
1612
+ "UPDATE relay_users SET password_salt = ?, password_hash = ? WHERE id = ?"
1613
+ ).run(passwordSalt, passwordHash, user.id);
690
1614
  return this.publicUser({
691
1615
  ...user,
692
1616
  passwordSalt,
@@ -697,11 +1621,19 @@ var RelayStore = class _RelayStore {
697
1621
  const user = this.requireUser(userId);
698
1622
  const username = input.username !== void 0 ? normalizeUsername(input.username) : user.username;
699
1623
  if (username.length < 3) {
700
- throw new RelayStoreError(400, "bad_request", "Username must be at least 3 characters.");
1624
+ throw new RelayStoreError(
1625
+ 400,
1626
+ "bad_request",
1627
+ "Username must be at least 3 characters."
1628
+ );
701
1629
  }
702
1630
  const existingUsername = this.getUserByUsername(username);
703
1631
  if (existingUsername && existingUsername.id !== user.id) {
704
- throw new RelayStoreError(409, "conflict", "A user with that username already exists.");
1632
+ throw new RelayStoreError(
1633
+ 409,
1634
+ "conflict",
1635
+ "A user with that username already exists."
1636
+ );
705
1637
  }
706
1638
  this.sqlite.prepare("UPDATE relay_users SET username = ? WHERE id = ?").run(username, user.id);
707
1639
  return this.publicUser({ ...user, username });
@@ -709,14 +1641,24 @@ var RelayStore = class _RelayStore {
709
1641
  updatePassword(userId, input) {
710
1642
  const user = this.requireUser(userId);
711
1643
  if (!verifySecret(input.currentPassword, user.passwordSalt, user.passwordHash)) {
712
- throw new RelayStoreError(403, "forbidden", "Current password is incorrect.");
1644
+ throw new RelayStoreError(
1645
+ 403,
1646
+ "forbidden",
1647
+ "Current password is incorrect."
1648
+ );
713
1649
  }
714
1650
  if (input.newPassword.length < 8) {
715
- throw new RelayStoreError(400, "bad_request", "Password must be at least 8 characters.");
1651
+ throw new RelayStoreError(
1652
+ 400,
1653
+ "bad_request",
1654
+ "Password must be at least 8 characters."
1655
+ );
716
1656
  }
717
1657
  const passwordSalt = crypto.randomBytes(16).toString("base64url");
718
1658
  const passwordHash = hashSecret(input.newPassword, passwordSalt);
719
- this.sqlite.prepare("UPDATE relay_users SET password_salt = ?, password_hash = ? WHERE id = ?").run(passwordSalt, passwordHash, user.id);
1659
+ this.sqlite.prepare(
1660
+ "UPDATE relay_users SET password_salt = ?, password_hash = ? WHERE id = ?"
1661
+ ).run(passwordSalt, passwordHash, user.id);
720
1662
  return this.publicUser({
721
1663
  ...user,
722
1664
  passwordSalt,
@@ -731,16 +1673,25 @@ var RelayStore = class _RelayStore {
731
1673
  };
732
1674
  }
733
1675
  publicDevice(device, status) {
1676
+ const hosted = this.sqlite.prepare(
1677
+ `
1678
+ SELECT status, active_turn_count, idle_deadline_at
1679
+ FROM relay_hosted_sandboxes WHERE device_id = ?
1680
+ `
1681
+ ).get(device.id);
734
1682
  return {
735
1683
  id: device.id,
736
1684
  ownerUserId: device.ownerUserId,
737
1685
  name: device.name,
738
- token: device.token,
1686
+ token: hosted ? null : device.token,
739
1687
  tokenPreview: device.tokenPreview,
740
1688
  connected: Boolean(status?.connected),
741
1689
  connectedAt: status?.connectedAt ?? null,
742
1690
  lastHeartbeatAt: status?.lastHeartbeatAt ?? null,
743
- createdAt: device.createdAt
1691
+ createdAt: device.createdAt,
1692
+ hostedStatus: hosted?.status ?? null,
1693
+ hostedActiveTurnCount: hosted?.active_turn_count ?? 0,
1694
+ hostedIdleDeadlineAt: hosted?.idle_deadline_at ?? null
744
1695
  };
745
1696
  }
746
1697
  publicAdminUser(user, deviceCount, conversationCount) {
@@ -772,7 +1723,14 @@ var RelayStore = class _RelayStore {
772
1723
  id, share_id, user_id, username, kind, accessed_at
773
1724
  ) VALUES (?, ?, ?, ?, ?, ?)
774
1725
  `
775
- ).run(crypto.randomUUID(), share.id, user.id, user.username, kind, accessedAt);
1726
+ ).run(
1727
+ crypto.randomUUID(),
1728
+ share.id,
1729
+ user.id,
1730
+ user.username,
1731
+ kind,
1732
+ accessedAt
1733
+ );
776
1734
  }
777
1735
  recordGrantAccess(grant, user, kind = "access") {
778
1736
  if (grant.revokedAt || grant.expiresAt && grant.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
@@ -785,7 +1743,14 @@ var RelayStore = class _RelayStore {
785
1743
  id, grant_id, user_id, username, kind, accessed_at
786
1744
  ) VALUES (?, ?, ?, ?, ?, ?)
787
1745
  `
788
- ).run(crypto.randomUUID(), grant.id, user.id, user.username, kind, accessedAt);
1746
+ ).run(
1747
+ crypto.randomUUID(),
1748
+ grant.id,
1749
+ user.id,
1750
+ user.username,
1751
+ kind,
1752
+ accessedAt
1753
+ );
789
1754
  }
790
1755
  publicShare(share) {
791
1756
  const owner = this.getUser(share.ownerUserId);
@@ -850,6 +1815,97 @@ var RelayStore = class _RelayStore {
850
1815
 
851
1816
  CREATE INDEX IF NOT EXISTS relay_devices_owner_idx ON relay_devices(owner_user_id);
852
1817
 
1818
+ CREATE TABLE IF NOT EXISTS relay_hosted_sandboxes (
1819
+ id TEXT PRIMARY KEY,
1820
+ device_id TEXT NOT NULL UNIQUE REFERENCES relay_devices(id) ON DELETE CASCADE,
1821
+ assigned_user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE RESTRICT,
1822
+ created_by_admin_user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE RESTRICT,
1823
+ provider TEXT NOT NULL CHECK (provider IN ('incus')),
1824
+ provider_instance_id TEXT,
1825
+ image_version TEXT NOT NULL,
1826
+ cpu_count INTEGER NOT NULL,
1827
+ memory_mib INTEGER NOT NULL,
1828
+ disk_gib INTEGER NOT NULL,
1829
+ status TEXT NOT NULL CHECK (status IN (
1830
+ 'requested', 'creating', 'starting', 'provisioning', 'stopped',
1831
+ 'online', 'stopping', 'error', 'deleting'
1832
+ )),
1833
+ credential_ref TEXT NOT NULL,
1834
+ codex_config_json TEXT,
1835
+ last_error_code TEXT,
1836
+ last_error_message TEXT,
1837
+ active_turn_count INTEGER NOT NULL DEFAULT 0,
1838
+ last_user_activity_at TEXT,
1839
+ idle_deadline_at TEXT,
1840
+ lifecycle_generation INTEGER NOT NULL DEFAULT 0,
1841
+ created_at TEXT NOT NULL,
1842
+ updated_at TEXT NOT NULL
1843
+ );
1844
+
1845
+ CREATE INDEX IF NOT EXISTS relay_hosted_sandboxes_assigned_idx
1846
+ ON relay_hosted_sandboxes(assigned_user_id, created_at DESC);
1847
+ CREATE INDEX IF NOT EXISTS relay_hosted_sandboxes_status_idx
1848
+ ON relay_hosted_sandboxes(status, updated_at);
1849
+
1850
+ CREATE TABLE IF NOT EXISTS relay_hosted_sandbox_members (
1851
+ sandbox_id TEXT NOT NULL REFERENCES relay_hosted_sandboxes(id) ON DELETE CASCADE,
1852
+ user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
1853
+ position INTEGER NOT NULL,
1854
+ created_at TEXT NOT NULL,
1855
+ PRIMARY KEY (sandbox_id, user_id)
1856
+ );
1857
+
1858
+ CREATE INDEX IF NOT EXISTS relay_hosted_sandbox_members_user_idx
1859
+ ON relay_hosted_sandbox_members(user_id, sandbox_id);
1860
+
1861
+ CREATE TABLE IF NOT EXISTS relay_hosted_user_workspaces (
1862
+ sandbox_id TEXT NOT NULL REFERENCES relay_hosted_sandboxes(id) ON DELETE CASCADE,
1863
+ user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
1864
+ workspace_id TEXT NOT NULL,
1865
+ initial_workspace INTEGER NOT NULL DEFAULT 0,
1866
+ created_at TEXT NOT NULL,
1867
+ PRIMARY KEY (sandbox_id, workspace_id)
1868
+ );
1869
+
1870
+ CREATE INDEX IF NOT EXISTS relay_hosted_user_workspaces_user_idx
1871
+ ON relay_hosted_user_workspaces(sandbox_id, user_id, created_at);
1872
+
1873
+ CREATE TABLE IF NOT EXISTS relay_hosted_user_threads (
1874
+ sandbox_id TEXT NOT NULL REFERENCES relay_hosted_sandboxes(id) ON DELETE CASCADE,
1875
+ user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
1876
+ thread_id TEXT NOT NULL,
1877
+ workspace_id TEXT NOT NULL,
1878
+ created_at TEXT NOT NULL,
1879
+ PRIMARY KEY (sandbox_id, thread_id)
1880
+ );
1881
+
1882
+ CREATE INDEX IF NOT EXISTS relay_hosted_user_threads_user_idx
1883
+ ON relay_hosted_user_threads(sandbox_id, user_id, created_at);
1884
+
1885
+ CREATE TABLE IF NOT EXISTS relay_hosted_operations (
1886
+ id TEXT PRIMARY KEY,
1887
+ sandbox_id TEXT NOT NULL REFERENCES relay_hosted_sandboxes(id) ON DELETE CASCADE,
1888
+ action TEXT NOT NULL CHECK (action IN (
1889
+ 'create', 'start', 'stop', 'snapshot', 'delete', 'rotate_credential'
1890
+ )),
1891
+ status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'succeeded', 'failed')),
1892
+ error_code TEXT,
1893
+ error_message TEXT,
1894
+ created_at TEXT NOT NULL,
1895
+ updated_at TEXT NOT NULL
1896
+ );
1897
+
1898
+ CREATE INDEX IF NOT EXISTS relay_hosted_operations_sandbox_idx
1899
+ ON relay_hosted_operations(sandbox_id, created_at DESC);
1900
+
1901
+ CREATE TABLE IF NOT EXISTS relay_hosted_active_turns (
1902
+ sandbox_id TEXT NOT NULL REFERENCES relay_hosted_sandboxes(id) ON DELETE CASCADE,
1903
+ thread_id TEXT NOT NULL,
1904
+ turn_id TEXT NOT NULL,
1905
+ started_at TEXT NOT NULL,
1906
+ PRIMARY KEY (sandbox_id, thread_id, turn_id)
1907
+ );
1908
+
853
1909
  CREATE TABLE IF NOT EXISTS relay_shares (
854
1910
  id TEXT PRIMARY KEY,
855
1911
  owner_user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
@@ -947,21 +2003,84 @@ var RelayStore = class _RelayStore {
947
2003
  reviewed_by_user_id TEXT
948
2004
  );
949
2005
 
2006
+ CREATE TABLE IF NOT EXISTS relay_user_identities (
2007
+ id TEXT PRIMARY KEY,
2008
+ user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
2009
+ provider TEXT NOT NULL CHECK (provider IN ('google', 'github')),
2010
+ provider_subject TEXT NOT NULL,
2011
+ provider_email TEXT NOT NULL,
2012
+ created_at TEXT NOT NULL,
2013
+ UNIQUE(provider, provider_subject)
2014
+ );
2015
+
950
2016
  CREATE INDEX IF NOT EXISTS relay_pending_registrations_status_idx ON relay_pending_registrations(status, created_at DESC);
951
2017
  `);
952
2018
  this.ensureColumn("relay_users", "last_seen_at", "TEXT");
2019
+ this.ensureColumn("relay_pending_registrations", "provider", "TEXT NOT NULL DEFAULT 'password'");
2020
+ this.ensureColumn("relay_pending_registrations", "provider_subject", "TEXT");
953
2021
  this.ensureColumn("relay_devices", "token", "TEXT");
2022
+ this.ensureColumn(
2023
+ "relay_hosted_sandboxes",
2024
+ "active_turn_count",
2025
+ "INTEGER NOT NULL DEFAULT 0"
2026
+ );
2027
+ this.ensureColumn(
2028
+ "relay_hosted_sandboxes",
2029
+ "last_user_activity_at",
2030
+ "TEXT"
2031
+ );
2032
+ this.ensureColumn("relay_hosted_sandboxes", "idle_deadline_at", "TEXT");
2033
+ this.ensureColumn(
2034
+ "relay_hosted_sandboxes",
2035
+ "lifecycle_generation",
2036
+ "INTEGER NOT NULL DEFAULT 0"
2037
+ );
2038
+ this.ensureColumn("relay_hosted_sandboxes", "codex_config_json", "TEXT");
2039
+ this.ensureColumn(
2040
+ "relay_hosted_sandboxes",
2041
+ "workspace_isolation_enabled",
2042
+ "INTEGER NOT NULL DEFAULT 0"
2043
+ );
2044
+ this.ensureColumn("relay_hosted_sandboxes", "running_since", "TEXT");
954
2045
  this.ensureColumn("relay_shares", "thread_title", "TEXT");
955
2046
  this.ensureColumn("relay_shares", "workspace_id", "TEXT");
956
2047
  this.ensureColumn("relay_shares", "workspace_label", "TEXT");
957
- this.ensureColumn("relay_shares", "thread_access", "TEXT NOT NULL DEFAULT 'control'");
958
- this.ensureColumn("relay_shares", "workspace_access", "TEXT NOT NULL DEFAULT 'none'");
2048
+ this.ensureColumn(
2049
+ "relay_shares",
2050
+ "thread_access",
2051
+ "TEXT NOT NULL DEFAULT 'control'"
2052
+ );
2053
+ this.ensureColumn(
2054
+ "relay_shares",
2055
+ "workspace_access",
2056
+ "TEXT NOT NULL DEFAULT 'none'"
2057
+ );
959
2058
  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'");
2059
+ this.ensureColumn(
2060
+ "relay_access_grants",
2061
+ "workspace_scope",
2062
+ "TEXT NOT NULL DEFAULT 'all'"
2063
+ );
2064
+ this.ensureColumn(
2065
+ "relay_access_grants",
2066
+ "workspace_ids",
2067
+ "TEXT NOT NULL DEFAULT '[]'"
2068
+ );
2069
+ this.ensureColumn(
2070
+ "relay_access_grants",
2071
+ "can_create_threads",
2072
+ "INTEGER NOT NULL DEFAULT 0"
2073
+ );
2074
+ this.ensureColumn(
2075
+ "relay_share_access_events",
2076
+ "kind",
2077
+ "TEXT NOT NULL DEFAULT 'access'"
2078
+ );
2079
+ this.ensureColumn(
2080
+ "relay_access_grant_events",
2081
+ "kind",
2082
+ "TEXT NOT NULL DEFAULT 'access'"
2083
+ );
965
2084
  }
966
2085
  ensureColumn(table, column, definition) {
967
2086
  const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all();
@@ -970,6 +2089,59 @@ var RelayStore = class _RelayStore {
970
2089
  }
971
2090
  this.sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
972
2091
  }
2092
+ migrateHostedSandboxMembers() {
2093
+ this.sqlite.transaction(() => {
2094
+ const legacyRows = this.sqlite.prepare(
2095
+ `SELECT id, device_id, assigned_user_id, created_by_admin_user_id, created_at
2096
+ FROM relay_hosted_sandboxes`
2097
+ ).all();
2098
+ const insertMember = this.sqlite.prepare(
2099
+ `INSERT OR IGNORE INTO relay_hosted_sandbox_members
2100
+ (sandbox_id, user_id, position, created_at) VALUES (?, ?, 0, ?)`
2101
+ );
2102
+ const updateSandbox = this.sqlite.prepare(
2103
+ `UPDATE relay_hosted_sandboxes SET assigned_user_id = ? WHERE id = ?`
2104
+ );
2105
+ const updateDevice = this.sqlite.prepare(
2106
+ `UPDATE relay_devices SET owner_user_id = ? WHERE id = ?`
2107
+ );
2108
+ for (const row of legacyRows) {
2109
+ if (row.assigned_user_id !== row.created_by_admin_user_id) {
2110
+ insertMember.run(row.id, row.assigned_user_id, row.created_at);
2111
+ }
2112
+ updateSandbox.run(row.created_by_admin_user_id, row.id);
2113
+ updateDevice.run(row.created_by_admin_user_id, row.device_id);
2114
+ }
2115
+ })();
2116
+ }
2117
+ requireHostedSandboxMembers(userIds) {
2118
+ const uniqueIds = [...new Set(userIds)];
2119
+ if (uniqueIds.length < 1 || uniqueIds.length > 20) {
2120
+ throw new RelayStoreError(
2121
+ 400,
2122
+ "bad_request",
2123
+ "A hosted VM requires between 1 and 20 assigned users."
2124
+ );
2125
+ }
2126
+ if (uniqueIds.length !== userIds.length) {
2127
+ throw new RelayStoreError(
2128
+ 400,
2129
+ "bad_request",
2130
+ "Assigned users must be unique."
2131
+ );
2132
+ }
2133
+ return uniqueIds.map((userId) => {
2134
+ const user = this.requireUser(userId);
2135
+ if (user.role !== "user" || !user.enabled) {
2136
+ throw new RelayStoreError(
2137
+ 400,
2138
+ "bad_request",
2139
+ "Hosted VMs can only be assigned to enabled user accounts."
2140
+ );
2141
+ }
2142
+ return user;
2143
+ });
2144
+ }
973
2145
  importLegacyJson(legacyJsonPath) {
974
2146
  if (!legacyJsonPath || !fs.existsSync(legacyJsonPath)) {
975
2147
  return;
@@ -979,7 +2151,9 @@ var RelayStore = class _RelayStore {
979
2151
  if (existingUsers.count > 0 || imported === legacyJsonPath) {
980
2152
  return;
981
2153
  }
982
- const parsed = JSON.parse(fs.readFileSync(legacyJsonPath, "utf8"));
2154
+ const parsed = JSON.parse(
2155
+ fs.readFileSync(legacyJsonPath, "utf8")
2156
+ );
983
2157
  const data = {
984
2158
  registrationEnabled: typeof parsed.registrationEnabled === "boolean" ? parsed.registrationEnabled : true,
985
2159
  users: Array.isArray(parsed.users) ? parsed.users : [],
@@ -987,7 +2161,10 @@ var RelayStore = class _RelayStore {
987
2161
  shares: Array.isArray(parsed.shares) ? parsed.shares : []
988
2162
  };
989
2163
  const importData = this.sqlite.transaction(() => {
990
- this.setSetting("registrationEnabled", data.registrationEnabled ? "true" : "false");
2164
+ this.setSetting(
2165
+ "registrationEnabled",
2166
+ data.registrationEnabled ? "true" : "false"
2167
+ );
991
2168
  for (const user of data.users) {
992
2169
  this.insertUser(user);
993
2170
  }
@@ -1003,7 +2180,10 @@ var RelayStore = class _RelayStore {
1003
2180
  }
1004
2181
  ensureRegistrationSetting(registrationEnabled) {
1005
2182
  if (this.getSetting("registrationEnabled") === null) {
1006
- this.setSetting("registrationEnabled", registrationEnabled ? "true" : "false");
2183
+ this.setSetting(
2184
+ "registrationEnabled",
2185
+ registrationEnabled ? "true" : "false"
2186
+ );
1007
2187
  }
1008
2188
  }
1009
2189
  registrationEnabled() {
@@ -1014,7 +2194,9 @@ var RelayStore = class _RelayStore {
1014
2194
  return row?.value ?? null;
1015
2195
  }
1016
2196
  setSetting(key, value) {
1017
- this.sqlite.prepare("INSERT INTO relay_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
2197
+ this.sqlite.prepare(
2198
+ "INSERT INTO relay_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
2199
+ ).run(key, value);
1018
2200
  }
1019
2201
  deleteSetting(key) {
1020
2202
  this.sqlite.prepare("DELETE FROM relay_settings WHERE key = ?").run(key);
@@ -1023,16 +2205,32 @@ var RelayStore = class _RelayStore {
1023
2205
  const email = input.email.trim().toLowerCase();
1024
2206
  const username = normalizeUsername(input.username);
1025
2207
  if (!email.includes("@")) {
1026
- throw new RelayStoreError(400, "bad_request", "A valid email address is required.");
2208
+ throw new RelayStoreError(
2209
+ 400,
2210
+ "bad_request",
2211
+ "A valid email address is required."
2212
+ );
1027
2213
  }
1028
2214
  if (username.length < 3) {
1029
- throw new RelayStoreError(400, "bad_request", "Username must be at least 3 characters.");
2215
+ throw new RelayStoreError(
2216
+ 400,
2217
+ "bad_request",
2218
+ "Username must be at least 3 characters."
2219
+ );
1030
2220
  }
1031
2221
  if (input.password.length < 8) {
1032
- throw new RelayStoreError(400, "bad_request", "Password must be at least 8 characters.");
2222
+ throw new RelayStoreError(
2223
+ 400,
2224
+ "bad_request",
2225
+ "Password must be at least 8 characters."
2226
+ );
1033
2227
  }
1034
2228
  if (this.getUserByIdentifier(email) || this.getUserByUsername(username)) {
1035
- throw new RelayStoreError(409, "conflict", "A user with that email or username already exists.");
2229
+ throw new RelayStoreError(
2230
+ 409,
2231
+ "conflict",
2232
+ "A user with that email or username already exists."
2233
+ );
1036
2234
  }
1037
2235
  const passwordSalt = crypto.randomBytes(16).toString("base64url");
1038
2236
  return {
@@ -1064,7 +2262,9 @@ var RelayStore = class _RelayStore {
1064
2262
  expiresAt: Date.now() + SESSION_TTL_MS,
1065
2263
  nonce: crypto.randomBytes(16).toString("base64url")
1066
2264
  };
1067
- const payloadText = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
2265
+ const payloadText = Buffer.from(JSON.stringify(payload), "utf8").toString(
2266
+ "base64url"
2267
+ );
1068
2268
  const signature = crypto.createHmac("sha256", this.sessionSecret).update(payloadText).digest("base64url");
1069
2269
  return `${payloadText}.${signature}`;
1070
2270
  }
@@ -1078,7 +2278,9 @@ var RelayStore = class _RelayStore {
1078
2278
  return null;
1079
2279
  }
1080
2280
  try {
1081
- const payload = JSON.parse(Buffer.from(payloadText, "base64url").toString("utf8"));
2281
+ const payload = JSON.parse(
2282
+ Buffer.from(payloadText, "base64url").toString("utf8")
2283
+ );
1082
2284
  if (typeof payload?.userId !== "string" || typeof payload?.expiresAt !== "number" || typeof payload?.nonce !== "string" || payload.expiresAt <= Date.now()) {
1083
2285
  return null;
1084
2286
  }
@@ -1140,6 +2342,34 @@ var RelayStore = class _RelayStore {
1140
2342
  device.createdAt
1141
2343
  );
1142
2344
  }
2345
+ insertHostedOperation(operation) {
2346
+ this.sqlite.prepare(
2347
+ `
2348
+ INSERT INTO relay_hosted_operations (
2349
+ id, sandbox_id, action, status, error_code, error_message,
2350
+ created_at, updated_at
2351
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2352
+ `
2353
+ ).run(
2354
+ operation.id,
2355
+ operation.sandboxId,
2356
+ operation.action,
2357
+ operation.status,
2358
+ operation.errorCode,
2359
+ operation.errorMessage,
2360
+ operation.createdAt,
2361
+ operation.updatedAt
2362
+ );
2363
+ }
2364
+ getHostedOperation(id) {
2365
+ const row = this.sqlite.prepare("SELECT * FROM relay_hosted_operations WHERE id = ?").get(id);
2366
+ return row ? this.rowToHostedOperation(row) : null;
2367
+ }
2368
+ getHostedOperations(sandboxId) {
2369
+ return this.sqlite.prepare(
2370
+ "SELECT * FROM relay_hosted_operations WHERE sandbox_id = ? ORDER BY created_at DESC"
2371
+ ).all(sandboxId).map((row) => this.rowToHostedOperation(row));
2372
+ }
1143
2373
  insertShare(share) {
1144
2374
  this.sqlite.prepare(
1145
2375
  `
@@ -1308,7 +2538,9 @@ var RelayStore = class _RelayStore {
1308
2538
  );
1309
2539
  }
1310
2540
  getUser(id) {
1311
- return this.rowToUser(this.sqlite.prepare("SELECT * FROM relay_users WHERE id = ?").get(id));
2541
+ return this.rowToUser(
2542
+ this.sqlite.prepare("SELECT * FROM relay_users WHERE id = ?").get(id)
2543
+ );
1312
2544
  }
1313
2545
  getUserByIdentifier(identifier) {
1314
2546
  return this.rowToUser(
@@ -1324,19 +2556,37 @@ var RelayStore = class _RelayStore {
1324
2556
  return this.sqlite.prepare("SELECT * FROM relay_users ORDER BY created_at ASC").all().map((row) => this.rowToUser(row)).filter((user) => Boolean(user));
1325
2557
  }
1326
2558
  getDevice(id) {
1327
- return this.rowToDevice(this.sqlite.prepare("SELECT * FROM relay_devices WHERE id = ?").get(id));
2559
+ return this.rowToDevice(
2560
+ this.sqlite.prepare("SELECT * FROM relay_devices WHERE id = ?").get(id)
2561
+ );
1328
2562
  }
1329
2563
  getDevices() {
1330
2564
  return this.sqlite.prepare("SELECT * FROM relay_devices ORDER BY created_at ASC").all().map((row) => this.rowToDevice(row)).filter((device) => Boolean(device));
1331
2565
  }
1332
2566
  getDevicesByOwner(ownerUserId) {
1333
- 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));
2567
+ return this.sqlite.prepare(
2568
+ "SELECT * FROM relay_devices WHERE owner_user_id = ? ORDER BY created_at ASC"
2569
+ ).all(ownerUserId).map((row) => this.rowToDevice(row)).filter((device) => Boolean(device));
2570
+ }
2571
+ getHostedDevicesByMember(userId) {
2572
+ return this.sqlite.prepare(
2573
+ `SELECT d.*
2574
+ FROM relay_hosted_sandbox_members m
2575
+ JOIN relay_hosted_sandboxes hs ON hs.id = m.sandbox_id
2576
+ JOIN relay_devices d ON d.id = hs.device_id
2577
+ WHERE m.user_id = ?
2578
+ ORDER BY d.created_at ASC`
2579
+ ).all(userId).map((row) => this.rowToDevice(row)).filter((device) => Boolean(device));
1334
2580
  }
1335
2581
  getSharesByOwner(ownerUserId) {
1336
- 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));
2582
+ return this.sqlite.prepare(
2583
+ "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"
2584
+ ).all(ownerUserId, (/* @__PURE__ */ new Date()).toISOString()).map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
1337
2585
  }
1338
2586
  getSharesByTarget(targetUserId) {
1339
- 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));
2587
+ return this.sqlite.prepare(
2588
+ "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"
2589
+ ).all(targetUserId, (/* @__PURE__ */ new Date()).toISOString()).map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
1340
2590
  }
1341
2591
  getShares(options = {}) {
1342
2592
  const sql = options.includeRevoked ? "SELECT * FROM relay_shares ORDER BY created_at DESC" : "SELECT * FROM relay_shares WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC";
@@ -1344,10 +2594,14 @@ var RelayStore = class _RelayStore {
1344
2594
  return rows.map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
1345
2595
  }
1346
2596
  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));
2597
+ return this.sqlite.prepare(
2598
+ "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"
2599
+ ).all(ownerUserId, (/* @__PURE__ */ new Date()).toISOString()).map((row) => this.rowToGrant(row)).filter((grant) => Boolean(grant));
1348
2600
  }
1349
2601
  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));
2602
+ return this.sqlite.prepare(
2603
+ "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"
2604
+ ).all(targetUserId, (/* @__PURE__ */ new Date()).toISOString()).map((row) => this.rowToGrant(row)).filter((grant) => Boolean(grant));
1351
2605
  }
1352
2606
  findBestGrant(userId, deviceId, scope) {
1353
2607
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1372,7 +2626,9 @@ var RelayStore = class _RelayStore {
1372
2626
  `
1373
2627
  ).all(userId, deviceId, now);
1374
2628
  const grants = rows.map((row) => this.rowToGrant(row)).filter((grant) => Boolean(grant));
1375
- const matchingGrants = grants.filter((grant) => this.grantMatchesScope(grant, scope));
2629
+ const matchingGrants = grants.filter(
2630
+ (grant) => this.grantMatchesScope(grant, scope)
2631
+ );
1376
2632
  return matchingGrants.length > 0 ? mergeMatchingGrants(matchingGrants, scope) : null;
1377
2633
  }
1378
2634
  grantMatchesScope(grant, scope) {
@@ -1445,7 +2701,9 @@ var RelayStore = class _RelayStore {
1445
2701
  };
1446
2702
  }
1447
2703
  conversationCountsByUser(days) {
1448
- const since = new Date(Date.now() - days * 24 * 60 * 60 * 1e3).toISOString();
2704
+ const since = new Date(
2705
+ Date.now() - days * 24 * 60 * 60 * 1e3
2706
+ ).toISOString();
1449
2707
  const rows = this.sqlite.prepare(
1450
2708
  `
1451
2709
  SELECT user_id, COUNT(*) AS count
@@ -1467,13 +2725,23 @@ var RelayStore = class _RelayStore {
1467
2725
  }
1468
2726
  requirePendingRegistration(id) {
1469
2727
  const record = this.rowToPendingRegistration(
1470
- this.sqlite.prepare("SELECT * FROM relay_pending_registrations WHERE id = ? AND status = 'pending'").get(id)
2728
+ this.sqlite.prepare(
2729
+ "SELECT * FROM relay_pending_registrations WHERE id = ? AND status = 'pending'"
2730
+ ).get(id)
1471
2731
  );
1472
2732
  if (!record) {
1473
- throw new RelayStoreError(404, "not_found", "Pending registration was not found.");
2733
+ throw new RelayStoreError(
2734
+ 404,
2735
+ "not_found",
2736
+ "Pending registration was not found."
2737
+ );
1474
2738
  }
1475
2739
  if (this.getUserByIdentifier(record.email) || this.getUserByUsername(record.username)) {
1476
- throw new RelayStoreError(409, "conflict", "A user with that email or username already exists.");
2740
+ throw new RelayStoreError(
2741
+ 409,
2742
+ "conflict",
2743
+ "A user with that email or username already exists."
2744
+ );
1477
2745
  }
1478
2746
  return record;
1479
2747
  }
@@ -1482,7 +2750,8 @@ var RelayStore = class _RelayStore {
1482
2750
  id: record.id,
1483
2751
  email: record.email,
1484
2752
  username: record.username,
1485
- createdAt: record.createdAt
2753
+ createdAt: record.createdAt,
2754
+ provider: record.provider
1486
2755
  };
1487
2756
  }
1488
2757
  insertPendingRegistration(record) {
@@ -1490,8 +2759,8 @@ var RelayStore = class _RelayStore {
1490
2759
  `
1491
2760
  INSERT INTO relay_pending_registrations (
1492
2761
  id, email, username, password_salt, password_hash,
1493
- created_at, status, reviewed_at, reviewed_by_user_id
1494
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
2762
+ created_at, status, reviewed_at, reviewed_by_user_id, provider, provider_subject
2763
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1495
2764
  `
1496
2765
  ).run(
1497
2766
  record.id,
@@ -1502,9 +2771,32 @@ var RelayStore = class _RelayStore {
1502
2771
  record.createdAt,
1503
2772
  record.status,
1504
2773
  record.reviewedAt,
1505
- record.reviewedByUserId
2774
+ record.reviewedByUserId,
2775
+ record.provider,
2776
+ record.providerSubject
1506
2777
  );
1507
2778
  }
2779
+ insertIdentity(userId, provider, subject, email) {
2780
+ this.sqlite.prepare(`
2781
+ INSERT INTO relay_user_identities (
2782
+ id, user_id, provider, provider_subject, provider_email, created_at
2783
+ ) VALUES (?, ?, ?, ?, ?, ?)
2784
+ `).run(crypto.randomUUID(), userId, provider, subject, email, (/* @__PURE__ */ new Date()).toISOString());
2785
+ }
2786
+ availableUsername(input) {
2787
+ const base = normalizeUsername(input).slice(0, 48) || "user";
2788
+ let candidate = base.length >= 3 ? base : `${base}user`;
2789
+ let suffix = 1;
2790
+ const isTaken = (username) => Boolean(
2791
+ this.getUserByUsername(username) || this.sqlite.prepare(
2792
+ "SELECT 1 FROM relay_pending_registrations WHERE username = ? AND status = 'pending' LIMIT 1"
2793
+ ).get(username)
2794
+ );
2795
+ while (isTaken(candidate)) {
2796
+ candidate = `${base.slice(0, 42)}-${suffix++}`;
2797
+ }
2798
+ return candidate;
2799
+ }
1508
2800
  rowToUser(row) {
1509
2801
  if (!row) return null;
1510
2802
  return {
@@ -1531,6 +2823,60 @@ var RelayStore = class _RelayStore {
1531
2823
  createdAt: row.created_at
1532
2824
  };
1533
2825
  }
2826
+ rowToHostedSandbox(row) {
2827
+ const assignedUsers = this.sqlite.prepare(
2828
+ `SELECT u.*
2829
+ FROM relay_hosted_sandbox_members m
2830
+ JOIN relay_users u ON u.id = m.user_id
2831
+ WHERE m.sandbox_id = ?
2832
+ ORDER BY m.position ASC, m.created_at ASC`
2833
+ ).all(row.id).map((userRow) => this.rowToUser(userRow)).filter((user) => Boolean(user)).map((user) => ({
2834
+ userId: user.id,
2835
+ username: user.username,
2836
+ email: user.email
2837
+ }));
2838
+ const primaryUser = assignedUsers[0];
2839
+ const device = this.getDevice(row.device_id);
2840
+ return {
2841
+ id: row.id,
2842
+ deviceId: row.device_id,
2843
+ deviceName: device?.name ?? "Hosted supervisor VM",
2844
+ assignedUserId: primaryUser?.userId ?? row.assigned_user_id,
2845
+ assignedUsername: primaryUser?.username ?? "unknown",
2846
+ assignedUsers,
2847
+ workspaceIsolationEnabled: Boolean(row.workspace_isolation_enabled),
2848
+ createdByAdminUserId: row.created_by_admin_user_id,
2849
+ provider: "incus",
2850
+ providerInstanceId: row.provider_instance_id,
2851
+ imageVersion: row.image_version,
2852
+ resources: {
2853
+ cpuCount: row.cpu_count,
2854
+ memoryMiB: row.memory_mib,
2855
+ diskGiB: row.disk_gib
2856
+ },
2857
+ status: row.status,
2858
+ lastErrorCode: row.last_error_code,
2859
+ lastErrorMessage: row.last_error_message,
2860
+ activeTurnCount: row.active_turn_count,
2861
+ lastUserActivityAt: row.last_user_activity_at,
2862
+ idleDeadlineAt: row.idle_deadline_at,
2863
+ runningSince: row.running_since,
2864
+ createdAt: row.created_at,
2865
+ updatedAt: row.updated_at
2866
+ };
2867
+ }
2868
+ rowToHostedOperation(row) {
2869
+ return {
2870
+ id: row.id,
2871
+ sandboxId: row.sandbox_id,
2872
+ action: row.action,
2873
+ status: row.status,
2874
+ errorCode: row.error_code,
2875
+ errorMessage: row.error_message,
2876
+ createdAt: row.created_at,
2877
+ updatedAt: row.updated_at
2878
+ };
2879
+ }
1534
2880
  rowToShare(row) {
1535
2881
  if (!row) return null;
1536
2882
  return {
@@ -1596,10 +2942,32 @@ var RelayStore = class _RelayStore {
1596
2942
  createdAt: row.created_at,
1597
2943
  status: row.status,
1598
2944
  reviewedAt: row.reviewed_at ?? null,
1599
- reviewedByUserId: row.reviewed_by_user_id ?? null
2945
+ reviewedByUserId: row.reviewed_by_user_id ?? null,
2946
+ provider: row.provider ?? "password",
2947
+ providerSubject: row.provider_subject ?? null
1600
2948
  };
1601
2949
  }
1602
2950
  };
2951
+ function parseHostedCodexConfig(value) {
2952
+ const fallback = {
2953
+ modelProvider: "OpenAI",
2954
+ model: "gpt-5.4",
2955
+ reviewModel: "gpt-5.4",
2956
+ reasoningEffort: "medium",
2957
+ baseUrl: "https://api.openai.com/v1",
2958
+ wireApi: "responses",
2959
+ requiresOpenaiAuth: true,
2960
+ disableResponseStorage: true,
2961
+ networkAccess: "enabled",
2962
+ goals: true
2963
+ };
2964
+ if (!value) return fallback;
2965
+ try {
2966
+ return { ...fallback, ...JSON.parse(value) };
2967
+ } catch {
2968
+ return fallback;
2969
+ }
2970
+ }
1603
2971
  var RelayStoreError = class extends Error {
1604
2972
  constructor(statusCode, code, message) {
1605
2973
  super(message);
@@ -1634,7 +3002,9 @@ function normalizeWorkspaceIds(values) {
1634
3002
  if (!Array.isArray(values)) {
1635
3003
  return [];
1636
3004
  }
1637
- return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).sort();
3005
+ return Array.from(
3006
+ new Set(values.map((value) => value.trim()).filter(Boolean))
3007
+ ).sort();
1638
3008
  }
1639
3009
  function mergeMatchingGrants(grants, scope) {
1640
3010
  const sorted = [...grants].sort(compareGrantCapability);
@@ -1710,7 +3080,9 @@ function parseWorkspaceIds(value) {
1710
3080
  }
1711
3081
  try {
1712
3082
  const parsed = JSON.parse(value);
1713
- return normalizeWorkspaceIds(Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []);
3083
+ return normalizeWorkspaceIds(
3084
+ Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []
3085
+ );
1714
3086
  } catch {
1715
3087
  return [];
1716
3088
  }
@@ -1755,10 +3127,554 @@ function safeEqual(left, right) {
1755
3127
  return crypto.timingSafeEqual(leftBuffer, rightBuffer);
1756
3128
  }
1757
3129
 
3130
+ // src/hosted-sandbox-reconciler.ts
3131
+ var HostedSandboxReconciler = class {
3132
+ constructor(store, provider, config2) {
3133
+ this.store = store;
3134
+ this.provider = provider;
3135
+ this.config = config2;
3136
+ }
3137
+ store;
3138
+ provider;
3139
+ config;
3140
+ latest = emptyReport();
3141
+ timer = null;
3142
+ inFlight = null;
3143
+ start() {
3144
+ if (this.timer || this.config.provider === "disabled") return;
3145
+ void this.run();
3146
+ this.timer = setInterval(
3147
+ () => void this.run(),
3148
+ this.config.reconcileIntervalMs
3149
+ );
3150
+ this.timer.unref?.();
3151
+ }
3152
+ close() {
3153
+ if (this.timer) clearInterval(this.timer);
3154
+ this.timer = null;
3155
+ }
3156
+ read() {
3157
+ return this.latest;
3158
+ }
3159
+ run() {
3160
+ if (this.inFlight) return this.inFlight;
3161
+ this.inFlight = this.performAudit().finally(() => {
3162
+ this.inFlight = null;
3163
+ });
3164
+ return this.inFlight;
3165
+ }
3166
+ async deleteOrphanInstance(id) {
3167
+ const report = await this.run();
3168
+ if (!report.orphanInstances.some((instance) => instance.id === id)) {
3169
+ throw new RelayStoreError(
3170
+ 409,
3171
+ "conflict",
3172
+ "The instance is no longer an orphan."
3173
+ );
3174
+ }
3175
+ await this.provider.delete(id, `relay-orphan-instance-delete-${id}`);
3176
+ return this.performAudit();
3177
+ }
3178
+ async deleteOrphanCredential(credentialRef) {
3179
+ const report = await this.run();
3180
+ if (!report.orphanCredentials.some(
3181
+ (credential) => credential.credentialRef === credentialRef
3182
+ )) {
3183
+ throw new RelayStoreError(
3184
+ 409,
3185
+ "conflict",
3186
+ "The credential is no longer an orphan."
3187
+ );
3188
+ }
3189
+ await this.provider.deleteCredential(
3190
+ credentialRef,
3191
+ `relay-orphan-credential-delete-${credentialRef}`
3192
+ );
3193
+ return this.performAudit();
3194
+ }
3195
+ async performAudit() {
3196
+ try {
3197
+ const expected = this.store.listHostedProviderRecords();
3198
+ const inventory = await this.provider.inventory();
3199
+ const expectedIds = new Set(expected.map((record) => record.id));
3200
+ const expectedCredentials = new Set(
3201
+ expected.map((record) => record.credentialRef)
3202
+ );
3203
+ const instanceIds = new Set(
3204
+ inventory.instances.map((instance) => instance.id)
3205
+ );
3206
+ const credentialRefs = new Set(
3207
+ inventory.credentials.map((credential) => credential.credentialRef)
3208
+ );
3209
+ const orphanInstances = inventory.instances.filter(
3210
+ (instance) => !expectedIds.has(instance.id)
3211
+ );
3212
+ const orphanCredentials = inventory.credentials.filter(
3213
+ (credential) => !expectedCredentials.has(credential.credentialRef)
3214
+ );
3215
+ const missingInstanceSandboxIds = expected.filter((record) => !instanceIds.has(record.id)).map((record) => record.id);
3216
+ const missingCredentialSandboxIds = expected.filter((record) => !credentialRefs.has(record.credentialRef)).map((record) => record.id);
3217
+ const hasIssues = orphanInstances.length > 0 || orphanCredentials.length > 0 || missingInstanceSandboxIds.length > 0 || missingCredentialSandboxIds.length > 0;
3218
+ this.latest = {
3219
+ status: hasIssues ? "issues" : "healthy",
3220
+ checkedAt: inventory.checkedAt,
3221
+ errorCode: null,
3222
+ missingInstanceSandboxIds,
3223
+ missingCredentialSandboxIds,
3224
+ orphanInstances,
3225
+ orphanCredentials,
3226
+ orphanSnapshotCount: orphanInstances.reduce(
3227
+ (count, instance) => count + instance.snapshots.length,
3228
+ 0
3229
+ )
3230
+ };
3231
+ } catch {
3232
+ this.latest = {
3233
+ ...emptyReport(),
3234
+ status: "unavailable",
3235
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
3236
+ errorCode: "hosted_inventory_unavailable"
3237
+ };
3238
+ }
3239
+ return this.latest;
3240
+ }
3241
+ };
3242
+ function emptyReport() {
3243
+ return {
3244
+ status: "never_run",
3245
+ checkedAt: null,
3246
+ errorCode: null,
3247
+ missingInstanceSandboxIds: [],
3248
+ missingCredentialSandboxIds: [],
3249
+ orphanInstances: [],
3250
+ orphanCredentials: [],
3251
+ orphanSnapshotCount: 0
3252
+ };
3253
+ }
3254
+
3255
+ // src/hosted-sandbox-service.ts
3256
+ import crypto2 from "crypto";
3257
+ var HostedSandboxService = class {
3258
+ constructor(store, provider, config2) {
3259
+ this.store = store;
3260
+ this.provider = provider;
3261
+ this.config = config2;
3262
+ }
3263
+ store;
3264
+ provider;
3265
+ config;
3266
+ running = /* @__PURE__ */ new Map();
3267
+ idleTimers = /* @__PURE__ */ new Map();
3268
+ list() {
3269
+ return this.store.listHostedSandboxes();
3270
+ }
3271
+ detail(id) {
3272
+ const detail = this.store.getHostedSandboxDetail(id);
3273
+ if (!detail) {
3274
+ throw new RelayStoreError(
3275
+ 404,
3276
+ "not_found",
3277
+ "Hosted sandbox was not found."
3278
+ );
3279
+ }
3280
+ return detail;
3281
+ }
3282
+ async create(input) {
3283
+ this.requireConfiguredRelayUrl();
3284
+ const requestId = crypto2.randomUUID();
3285
+ const credentialRef = await this.provider.createCodexCredential(
3286
+ input.codexFiles,
3287
+ `relay-credential-${requestId}`
3288
+ );
3289
+ try {
3290
+ const created = this.store.createHostedSandboxRequested({
3291
+ createdByAdminUserId: input.createdByAdminUserId,
3292
+ assignedUserIds: input.assignedUserIds,
3293
+ deviceName: input.deviceName,
3294
+ imageVersion: input.imageVersion,
3295
+ resources: input.resources,
3296
+ credentialRef
3297
+ });
3298
+ this.schedule(created.sandbox.id, created.operation.id);
3299
+ return {
3300
+ sandbox: created.sandbox,
3301
+ operation: created.operation
3302
+ };
3303
+ } catch (error) {
3304
+ await this.provider.deleteCredential(
3305
+ credentialRef,
3306
+ `relay-credential-compensate-${requestId}`
3307
+ ).catch(() => void 0);
3308
+ throw error;
3309
+ }
3310
+ }
3311
+ updateMembers(id, userIds) {
3312
+ return this.store.setHostedSandboxMembers(id, userIds);
3313
+ }
3314
+ readCodexFiles(id) {
3315
+ this.detail(id);
3316
+ return this.provider.readCodexFiles(id);
3317
+ }
3318
+ async writeCodexFiles(id, files) {
3319
+ this.detail(id);
3320
+ await this.provider.writeCodexFiles(
3321
+ id,
3322
+ files,
3323
+ `relay-codex-files-${crypto2.randomUUID()}`
3324
+ );
3325
+ return { updated: true };
3326
+ }
3327
+ retry(id) {
3328
+ this.detail(id);
3329
+ const operation = this.store.createHostedOperation(id, "create");
3330
+ this.store.updateHostedSandboxStatus(id, "requested");
3331
+ this.schedule(id, operation.id);
3332
+ return operation;
3333
+ }
3334
+ start(id) {
3335
+ const detail = this.detail(id);
3336
+ this.store.recordHostedUserActivity(
3337
+ detail.deviceId,
3338
+ this.config.idleTimeoutMs
3339
+ );
3340
+ const operation = this.store.createHostedOperation(id, "start");
3341
+ this.scheduleLifecycle(id, operation, async () => {
3342
+ this.store.updateHostedSandboxStatus(id, "starting");
3343
+ await this.provider.start(
3344
+ id,
3345
+ `relay-sandbox-start-action-${operation.id}`
3346
+ );
3347
+ this.store.updateHostedSandboxStatus(id, "starting");
3348
+ });
3349
+ return operation;
3350
+ }
3351
+ stop(id) {
3352
+ this.detail(id);
3353
+ const operation = this.store.createHostedOperation(id, "stop");
3354
+ this.scheduleLifecycle(id, operation, async () => {
3355
+ this.store.updateHostedSandboxStatus(id, "stopping");
3356
+ await this.provider.stop(id, `relay-sandbox-stop-action-${operation.id}`);
3357
+ this.store.updateHostedSandboxStatus(id, "stopped");
3358
+ });
3359
+ return operation;
3360
+ }
3361
+ snapshot(id, name) {
3362
+ this.detail(id);
3363
+ const operation = this.store.createHostedOperation(id, "snapshot");
3364
+ this.scheduleLifecycle(
3365
+ id,
3366
+ operation,
3367
+ () => this.provider.snapshot(
3368
+ id,
3369
+ name,
3370
+ `relay-sandbox-snapshot-${operation.id}`
3371
+ )
3372
+ );
3373
+ return operation;
3374
+ }
3375
+ delete(id) {
3376
+ const context = this.store.getHostedProvisionContext(id);
3377
+ if (!context) {
3378
+ throw new RelayStoreError(
3379
+ 404,
3380
+ "not_found",
3381
+ "Hosted sandbox was not found."
3382
+ );
3383
+ }
3384
+ const operation = this.store.createHostedOperation(id, "delete");
3385
+ this.scheduleLifecycle(
3386
+ id,
3387
+ operation,
3388
+ async () => {
3389
+ this.store.updateHostedSandboxStatus(id, "deleting");
3390
+ await this.provider.delete(id, `relay-sandbox-delete-${operation.id}`);
3391
+ await this.provider.deleteCredential(
3392
+ context.credentialRef,
3393
+ `relay-credential-delete-${operation.id}`
3394
+ );
3395
+ this.store.updateHostedOperation(operation.id, "succeeded");
3396
+ this.store.deleteHostedSandboxRecord(id);
3397
+ },
3398
+ { operationCompletesInside: true }
3399
+ );
3400
+ return operation;
3401
+ }
3402
+ async rotateCredential(id, openaiApiKey) {
3403
+ const context = this.store.getHostedProvisionContext(id);
3404
+ if (!context) {
3405
+ throw new RelayStoreError(
3406
+ 404,
3407
+ "not_found",
3408
+ "Hosted sandbox was not found."
3409
+ );
3410
+ }
3411
+ if (this.running.has(id)) {
3412
+ throw new RelayStoreError(
3413
+ 409,
3414
+ "conflict",
3415
+ "Another Hosted supervisor VM operation is already running."
3416
+ );
3417
+ }
3418
+ const relayServerUrl = this.requireConfiguredRelayUrl();
3419
+ const operation = this.store.createHostedOperation(id, "rotate_credential");
3420
+ let credentialRef;
3421
+ try {
3422
+ credentialRef = await this.provider.createCredential(
3423
+ openaiApiKey,
3424
+ `relay-credential-rotate-${operation.id}`
3425
+ );
3426
+ } catch {
3427
+ this.store.updateHostedOperation(operation.id, "failed", {
3428
+ code: "hosted_sandbox_rotate_credential_failed",
3429
+ message: "Hosted supervisor VM credential rotation failed."
3430
+ });
3431
+ throw new RelayStoreError(
3432
+ 502,
3433
+ "service_unavailable",
3434
+ "Hosted supervisor VM credential rotation failed."
3435
+ );
3436
+ }
3437
+ this.scheduleLifecycle(id, operation, async () => {
3438
+ await this.provider.provision(
3439
+ {
3440
+ id,
3441
+ relayServerUrl,
3442
+ relayAgentToken: context.deviceToken,
3443
+ credentialRef,
3444
+ codexConfig: context.codexConfig
3445
+ },
3446
+ `relay-sandbox-reprovision-${operation.id}`
3447
+ );
3448
+ const previousRef = this.store.replaceHostedCredentialRef(
3449
+ id,
3450
+ credentialRef
3451
+ );
3452
+ await this.provider.deleteCredential(
3453
+ previousRef,
3454
+ `relay-credential-retire-${operation.id}`
3455
+ );
3456
+ });
3457
+ return operation;
3458
+ }
3459
+ reconcilePending() {
3460
+ for (const id of this.store.listHostedSandboxesNeedingReconciliation()) {
3461
+ const detail = this.store.getHostedSandboxDetail(id);
3462
+ const operation = detail?.operations.find((candidate) => candidate.action === "create") ?? this.store.createHostedOperation(id, "create");
3463
+ this.schedule(id, operation.id);
3464
+ }
3465
+ this.restoreIdleTimers();
3466
+ }
3467
+ close() {
3468
+ for (const timer of this.idleTimers.values()) {
3469
+ clearTimeout(timer);
3470
+ }
3471
+ this.idleTimers.clear();
3472
+ }
3473
+ markOnline(deviceId) {
3474
+ this.store.markHostedDeviceOnline(deviceId);
3475
+ const sandbox = this.store.getHostedSandboxByDeviceId(deviceId);
3476
+ if (!sandbox) return;
3477
+ const armed = this.store.armHostedIdleDeadline(
3478
+ sandbox.id,
3479
+ this.config.idleTimeoutMs
3480
+ );
3481
+ if (armed?.idleDeadlineAt) {
3482
+ this.scheduleIdleTimer(armed.id, armed.idleDeadlineAt);
3483
+ }
3484
+ }
3485
+ recordUserActivity(deviceId) {
3486
+ const sandbox = this.store.recordHostedUserActivity(
3487
+ deviceId,
3488
+ this.config.idleTimeoutMs
3489
+ );
3490
+ if (!sandbox) return { hosted: false, waking: false };
3491
+ if (sandbox.idleDeadlineAt && sandbox.status === "online") {
3492
+ this.scheduleIdleTimer(sandbox.id, sandbox.idleDeadlineAt);
3493
+ }
3494
+ if (sandbox.status === "stopped") {
3495
+ this.start(sandbox.id);
3496
+ return { hosted: true, waking: true };
3497
+ }
3498
+ return {
3499
+ hosted: true,
3500
+ waking: ["requested", "creating", "starting", "provisioning"].includes(
3501
+ sandbox.status
3502
+ )
3503
+ };
3504
+ }
3505
+ wakeIfStopped(deviceId) {
3506
+ const sandbox = this.store.getHostedSandboxByDeviceId(deviceId);
3507
+ if (!sandbox) return { hosted: false, waking: false };
3508
+ if (sandbox.status === "stopped") {
3509
+ this.start(sandbox.id);
3510
+ return { hosted: true, waking: true };
3511
+ }
3512
+ return {
3513
+ hosted: true,
3514
+ waking: ["requested", "creating", "starting", "provisioning"].includes(
3515
+ sandbox.status
3516
+ )
3517
+ };
3518
+ }
3519
+ recordTurnActivity(input) {
3520
+ const sandbox = this.store.recordHostedTurnActivity({
3521
+ ...input,
3522
+ idleTimeoutMs: this.config.idleTimeoutMs
3523
+ });
3524
+ if (!sandbox) return;
3525
+ if (sandbox.activeTurnCount > 0) {
3526
+ this.clearIdleTimer(sandbox.id);
3527
+ } else if (sandbox.idleDeadlineAt && sandbox.status === "online") {
3528
+ this.scheduleIdleTimer(sandbox.id, sandbox.idleDeadlineAt);
3529
+ }
3530
+ }
3531
+ schedule(sandboxId, operationId) {
3532
+ if (this.running.has(sandboxId)) {
3533
+ return;
3534
+ }
3535
+ const promise = this.runCreateSaga(sandboxId, operationId).finally(() => {
3536
+ this.running.delete(sandboxId);
3537
+ });
3538
+ this.running.set(sandboxId, promise);
3539
+ }
3540
+ scheduleLifecycle(sandboxId, operation, action, options = {}) {
3541
+ if (this.running.has(sandboxId)) {
3542
+ throw new RelayStoreError(
3543
+ 409,
3544
+ "conflict",
3545
+ "Another Hosted supervisor VM operation is already running."
3546
+ );
3547
+ }
3548
+ const promise = (async () => {
3549
+ try {
3550
+ this.store.updateHostedOperation(operation.id, "running");
3551
+ await action();
3552
+ if (!options.operationCompletesInside) {
3553
+ this.store.updateHostedOperation(operation.id, "succeeded");
3554
+ }
3555
+ } catch {
3556
+ const error = {
3557
+ code: `hosted_sandbox_${operation.action}_failed`,
3558
+ message: `Hosted supervisor VM ${operation.action} failed.`
3559
+ };
3560
+ this.store.updateHostedOperation(operation.id, "failed", error);
3561
+ this.store.updateHostedSandboxStatus(sandboxId, "error", {
3562
+ errorCode: error.code,
3563
+ errorMessage: error.message
3564
+ });
3565
+ }
3566
+ })().finally(() => this.running.delete(sandboxId));
3567
+ this.running.set(sandboxId, promise);
3568
+ }
3569
+ restoreIdleTimers() {
3570
+ for (const candidate of this.store.listHostedIdleDeadlines()) {
3571
+ const normalized = this.store.armHostedIdleDeadline(
3572
+ candidate.id,
3573
+ this.config.idleTimeoutMs
3574
+ );
3575
+ if (normalized?.idleDeadlineAt && normalized.status === "online") {
3576
+ this.scheduleIdleTimer(normalized.id, normalized.idleDeadlineAt);
3577
+ }
3578
+ }
3579
+ }
3580
+ scheduleIdleTimer(sandboxId, deadlineAt) {
3581
+ this.clearIdleTimer(sandboxId);
3582
+ const delay = Math.max(0, new Date(deadlineAt).getTime() - Date.now());
3583
+ const timer = setTimeout(() => {
3584
+ this.idleTimers.delete(sandboxId);
3585
+ void this.handleIdleDeadline(sandboxId);
3586
+ }, delay);
3587
+ timer.unref?.();
3588
+ this.idleTimers.set(sandboxId, timer);
3589
+ }
3590
+ clearIdleTimer(sandboxId) {
3591
+ const timer = this.idleTimers.get(sandboxId);
3592
+ if (timer) clearTimeout(timer);
3593
+ this.idleTimers.delete(sandboxId);
3594
+ }
3595
+ async handleIdleDeadline(sandboxId) {
3596
+ if (this.running.has(sandboxId)) return;
3597
+ const candidate = this.store.listHostedIdleDeadlines().find((entry) => entry.id === sandboxId);
3598
+ if (!candidate || !this.store.claimHostedIdleStop(
3599
+ sandboxId,
3600
+ candidate.generation,
3601
+ /* @__PURE__ */ new Date()
3602
+ )) {
3603
+ return;
3604
+ }
3605
+ const operation = this.store.createHostedOperation(sandboxId, "stop");
3606
+ this.scheduleLifecycle(sandboxId, operation, async () => {
3607
+ await this.provider.stop(
3608
+ sandboxId,
3609
+ `relay-sandbox-idle-stop-${operation.id}`
3610
+ );
3611
+ this.store.updateHostedSandboxStatus(sandboxId, "stopped");
3612
+ });
3613
+ }
3614
+ async runCreateSaga(sandboxId, operationId) {
3615
+ try {
3616
+ const context = this.store.getHostedProvisionContext(sandboxId);
3617
+ if (!context) {
3618
+ throw new Error("Hosted sandbox provision context is unavailable.");
3619
+ }
3620
+ const relayServerUrl = this.requireConfiguredRelayUrl();
3621
+ this.store.updateHostedOperation(operationId, "running");
3622
+ this.store.updateHostedSandboxStatus(sandboxId, "creating");
3623
+ const instance = await this.provider.create(
3624
+ {
3625
+ id: sandboxId,
3626
+ imageVersion: context.sandbox.imageVersion,
3627
+ resources: context.sandbox.resources
3628
+ },
3629
+ `relay-sandbox-create-${sandboxId}`
3630
+ );
3631
+ this.store.updateHostedSandboxStatus(sandboxId, "starting", {
3632
+ providerInstanceId: instance.name
3633
+ });
3634
+ await this.provider.start(sandboxId, `relay-sandbox-start-${sandboxId}`);
3635
+ this.store.updateHostedSandboxStatus(sandboxId, "provisioning");
3636
+ await this.provider.provision(
3637
+ {
3638
+ id: sandboxId,
3639
+ relayServerUrl,
3640
+ relayAgentToken: context.deviceToken,
3641
+ credentialRef: context.credentialRef,
3642
+ codexConfig: context.codexConfig
3643
+ },
3644
+ `relay-sandbox-provision-${sandboxId}`
3645
+ );
3646
+ this.store.updateHostedOperation(operationId, "succeeded");
3647
+ this.store.updateHostedSandboxStatus(sandboxId, "starting");
3648
+ } catch (caught) {
3649
+ const detail = caught instanceof HostedSandboxProviderError && caught.code === "running_instance_limit_reached" ? caught.message : null;
3650
+ const error = {
3651
+ code: "hosted_sandbox_create_failed",
3652
+ message: detail ? `Hosted supervisor VM creation failed: ${detail}` : "Hosted supervisor VM creation failed."
3653
+ };
3654
+ this.store.updateHostedOperation(operationId, "failed", error);
3655
+ this.store.updateHostedSandboxStatus(sandboxId, "error", {
3656
+ errorCode: error.code,
3657
+ errorMessage: error.message
3658
+ });
3659
+ }
3660
+ }
3661
+ requireConfiguredRelayUrl() {
3662
+ if (!this.config.relayServerUrl) {
3663
+ throw new RelayStoreError(
3664
+ 503,
3665
+ "service_unavailable",
3666
+ "Hosted supervisor VM relay URL is not configured."
3667
+ );
3668
+ }
3669
+ return this.config.relayServerUrl;
3670
+ }
3671
+ };
3672
+
1758
3673
  // src/app.ts
1759
3674
  var RELAY_REQUEST_TIMEOUT_MS = 3e4;
1760
3675
  var RELAY_PORTAL_METADATA_TIMEOUT_MS = 900;
1761
3676
  var WEBSOCKET_OPEN = 1;
3677
+ var hostedBootstrapPromises = /* @__PURE__ */ new Map();
1762
3678
  var RELAY_COOKIE_NAME = "remote_codex_relay_session";
1763
3679
  var threadAccessSchema = z.enum(["read", "control"]);
1764
3680
  var workspaceAccessSchema = z.enum(["none", "read", "write"]);
@@ -1777,6 +3693,62 @@ var registerSchema = z.object({
1777
3693
  var createDeviceSchema = z.object({
1778
3694
  name: z.string().trim().min(1).max(120)
1779
3695
  });
3696
+ var hostedCodexConfigSchema = z.object({
3697
+ modelProvider: z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,31}$/),
3698
+ model: z.string().trim().min(1).max(120),
3699
+ reviewModel: z.string().trim().min(1).max(120),
3700
+ reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]),
3701
+ baseUrl: z.string().url().refine((value) => value.startsWith("https://"), "HTTPS is required."),
3702
+ wireApi: z.literal("responses"),
3703
+ requiresOpenaiAuth: z.boolean(),
3704
+ disableResponseStorage: z.boolean(),
3705
+ networkAccess: z.enum(["enabled", "disabled"]),
3706
+ goals: z.boolean()
3707
+ }).strict().default({
3708
+ modelProvider: "OpenAI",
3709
+ model: "gpt-5.4",
3710
+ reviewModel: "gpt-5.4",
3711
+ reasoningEffort: "medium",
3712
+ baseUrl: "https://api.openai.com/v1",
3713
+ wireApi: "responses",
3714
+ requiresOpenaiAuth: true,
3715
+ disableResponseStorage: true,
3716
+ networkAccess: "enabled",
3717
+ goals: true
3718
+ });
3719
+ var createHostedSandboxSchema = z.object({
3720
+ assignedUserIds: z.array(z.string().uuid()).min(1).max(20),
3721
+ deviceName: z.string().trim().min(1).max(120),
3722
+ imageVersion: z.enum([
3723
+ "ubuntu-24.04-v1",
3724
+ "ubuntu-24.04-v2",
3725
+ "ubuntu-24.04-v3",
3726
+ "ubuntu-24.04-v4",
3727
+ "ubuntu-24.04-v5"
3728
+ ]),
3729
+ resources: z.object({
3730
+ cpuCount: z.number().int().min(1).max(2),
3731
+ memoryMiB: z.number().int().min(1024).max(2048),
3732
+ diskGiB: z.number().int().min(10).max(12)
3733
+ }),
3734
+ backends: z.array(z.literal("codex")).length(1),
3735
+ codexFiles: z.object({
3736
+ configToml: z.string().min(1).max(128 * 1024),
3737
+ authJson: z.string().min(2).max(128 * 1024)
3738
+ })
3739
+ }).strict();
3740
+ var updateHostedSandboxMembersSchema = z.object({
3741
+ assignedUserIds: z.array(z.string().uuid()).min(1).max(20)
3742
+ }).strict();
3743
+ var updateHostedSandboxSettingsSchema = z.object({ workspaceIsolationEnabled: z.boolean() }).strict();
3744
+ var hostedSnapshotSchema = z.object({
3745
+ name: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$/)
3746
+ });
3747
+ var rotateHostedCredentialSchema = z.object({ openaiApiKey: z.string().min(20).max(512) }).strict();
3748
+ var hostedCodexFilesSchema = z.object({
3749
+ configToml: z.string().min(1).max(128 * 1024),
3750
+ authJson: z.string().min(2).max(128 * 1024)
3751
+ }).strict();
1780
3752
  var createShareSchema = z.object({
1781
3753
  targetIdentifier: z.string().trim().min(1).optional(),
1782
3754
  targetUsername: z.string().trim().min(3).optional(),
@@ -1818,10 +3790,13 @@ var createGrantSchema = z.object({
1818
3790
  }).refine((input) => input.scope !== "thread" || Boolean(input.threadId), {
1819
3791
  message: "threadId is required for thread grants.",
1820
3792
  path: ["threadId"]
1821
- }).refine((input) => input.scope !== "workspace" || Boolean(input.workspaceId), {
1822
- message: "workspaceId is required for workspace grants.",
1823
- path: ["workspaceId"]
1824
- });
3793
+ }).refine(
3794
+ (input) => input.scope !== "workspace" || Boolean(input.workspaceId),
3795
+ {
3796
+ message: "workspaceId is required for workspace grants.",
3797
+ path: ["workspaceId"]
3798
+ }
3799
+ );
1825
3800
  var updateGrantSchema = z.object({
1826
3801
  workspaceId: z.string().uuid().nullable().optional(),
1827
3802
  workspaceScope: workspaceScopeSchema.optional(),
@@ -1844,7 +3819,10 @@ var adminQuerySchema = z.object({
1844
3819
  var updateRegistrationSettingsSchema = z.object({
1845
3820
  enabled: z.boolean().optional(),
1846
3821
  registrationPassword: z.string().nullable().optional(),
1847
- approvalRequired: z.boolean().optional()
3822
+ approvalRequired: z.boolean().optional(),
3823
+ googleAuthEnabled: z.boolean().optional(),
3824
+ githubAuthEnabled: z.boolean().optional(),
3825
+ emailVerificationEnabled: z.boolean().optional()
1848
3826
  });
1849
3827
  var updateAccountSchema = z.object({
1850
3828
  username: z.string().trim().min(3).optional()
@@ -1865,10 +3843,7 @@ var DEFAULT_WEBVIEW_CORS_ORIGINS = /* @__PURE__ */ new Set([
1865
3843
  "http://localhost",
1866
3844
  "https://localhost"
1867
3845
  ]);
1868
- var WEBVIEW_CORS_ALLOW_HEADERS = [
1869
- "authorization",
1870
- "content-type"
1871
- ].join(", ");
3846
+ var WEBVIEW_CORS_ALLOW_HEADERS = ["authorization", "content-type"].join(", ");
1872
3847
  var WEBVIEW_CORS_ALLOW_METHODS = [
1873
3848
  "GET",
1874
3849
  "POST",
@@ -1931,9 +3906,13 @@ var RELAY_RESPONSE_HEADER_BLOCKLIST = /* @__PURE__ */ new Set([
1931
3906
  ]);
1932
3907
  function buildRelayServer(config2, options = {}) {
1933
3908
  const app2 = Fastify({ logger: false });
1934
- app2.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) => {
1935
- done(null, body);
1936
- });
3909
+ app2.addContentTypeParser(
3910
+ "*",
3911
+ { parseAs: "buffer" },
3912
+ (_request, body, done) => {
3913
+ done(null, body);
3914
+ }
3915
+ );
1937
3916
  const store = RelayStore.fromDataDir(
1938
3917
  config2.dataDir,
1939
3918
  config2.sessionSecret,
@@ -1943,6 +3922,12 @@ function buildRelayServer(config2, options = {}) {
1943
3922
  store.setRegistrationEnabled(config2.registrationEnabled);
1944
3923
  }
1945
3924
  store.ensureRegistrationPassword(config2.registrationPassword);
3925
+ const googleAvailable = Boolean(config2.googleOAuthClientId && config2.googleOAuthClientSecret);
3926
+ const githubAvailable = Boolean(config2.githubOAuthClientId && config2.githubOAuthClientSecret);
3927
+ store.ensureAuthSettings({
3928
+ google: googleAvailable && config2.googleOAuthEnabled !== false,
3929
+ github: githubAvailable && config2.githubOAuthEnabled !== false
3930
+ });
1946
3931
  store.seedAdmin({
1947
3932
  username: config2.adminUsername,
1948
3933
  email: config2.adminEmail,
@@ -1951,7 +3936,28 @@ function buildRelayServer(config2, options = {}) {
1951
3936
  const state = {
1952
3937
  supervisors: /* @__PURE__ */ new Map()
1953
3938
  };
1954
- const allowedWebViewCorsOrigins = webViewCorsOrigins(options.env ?? process.env);
3939
+ const hostedSandboxProvider = options.hostedSandboxProvider ?? createHostedSandboxProvider(config2.hostedSandbox);
3940
+ const hostedSandboxCapability = new HostedSandboxCapabilityService(
3941
+ hostedSandboxProvider,
3942
+ { timeoutMs: config2.hostedSandbox.requestTimeoutMs }
3943
+ );
3944
+ const hostedSandboxService = new HostedSandboxService(
3945
+ store,
3946
+ hostedSandboxProvider,
3947
+ config2.hostedSandbox
3948
+ );
3949
+ const hostedSandboxReconciler = new HostedSandboxReconciler(
3950
+ store,
3951
+ hostedSandboxProvider,
3952
+ config2.hostedSandbox
3953
+ );
3954
+ const allowedWebViewCorsOrigins = webViewCorsOrigins(
3955
+ options.env ?? process.env
3956
+ );
3957
+ app2.addHook("onReady", () => {
3958
+ queueMicrotask(() => hostedSandboxService.reconcilePending());
3959
+ queueMicrotask(() => hostedSandboxReconciler.start());
3960
+ });
1955
3961
  app2.addHook("onRequest", async (request, reply) => {
1956
3962
  if (!allowedWebViewCorsOrigins) {
1957
3963
  return;
@@ -1975,8 +3981,55 @@ function buildRelayServer(config2, options = {}) {
1975
3981
  supervisorCount: state.supervisors.size
1976
3982
  };
1977
3983
  });
3984
+ const authSettings = () => ({
3985
+ ...store.registrationSettings(),
3986
+ googleAuthAvailable: googleAvailable,
3987
+ githubAuthAvailable: githubAvailable,
3988
+ emailVerificationAvailable: config2.emailVerificationConfigured
3989
+ });
1978
3990
  app2.get("/relay/auth/session", async (request) => {
1979
- return store.verifySession(readRelaySessionToken(request));
3991
+ return { ...verifyRelayRequestSession(request, store), registrationSettings: authSettings() };
3992
+ });
3993
+ app2.get("/relay/auth/oauth/:provider/start", async (request, reply) => {
3994
+ const { provider } = z.object({ provider: z.enum(["google", "github"]) }).parse(request.params);
3995
+ const settings = authSettings();
3996
+ if (provider === "google" ? !settings.googleAuthEnabled || !googleAvailable : !settings.githubAuthEnabled || !githubAvailable) {
3997
+ reply.status(403).send({ code: "forbidden", message: `${provider === "google" ? "Google" : "GitHub"} authentication is disabled.` });
3998
+ return;
3999
+ }
4000
+ const state2 = signOAuthState(provider, config2.sessionSecret);
4001
+ const callback = oauthCallbackUrl(request, config2, provider);
4002
+ const url = provider === "google" ? new URL("https://accounts.google.com/o/oauth2/v2/auth") : new URL("https://github.com/login/oauth/authorize");
4003
+ url.searchParams.set("client_id", provider === "google" ? config2.googleOAuthClientId : config2.githubOAuthClientId);
4004
+ url.searchParams.set("redirect_uri", callback);
4005
+ url.searchParams.set("response_type", "code");
4006
+ url.searchParams.set("state", state2);
4007
+ url.searchParams.set("scope", provider === "google" ? "openid email profile" : "read:user user:email");
4008
+ if (provider === "google") url.searchParams.set("prompt", "select_account");
4009
+ reply.redirect(url.toString());
4010
+ });
4011
+ app2.get("/relay/auth/oauth/:provider/callback", async (request, reply) => {
4012
+ const { provider } = z.object({ provider: z.enum(["google", "github"]) }).parse(request.params);
4013
+ const query = z.object({ code: z.string().min(1), state: z.string().min(1) }).parse(request.query);
4014
+ if (!verifyOAuthState(query.state, provider, config2.sessionSecret)) {
4015
+ reply.redirect("/relay-portal?oauthError=OAuth%20request%20expired%20or%20was%20invalid.");
4016
+ return;
4017
+ }
4018
+ try {
4019
+ const identity = provider === "google" ? await fetchGoogleIdentity(query.code, oauthCallbackUrl(request, config2, provider), config2) : await fetchGitHubIdentity(query.code, oauthCallbackUrl(request, config2, provider), config2);
4020
+ const outcome = store.authenticateExternalIdentity(identity, store.registrationSettings().approvalRequired);
4021
+ if (outcome.kind === "pending") {
4022
+ reply.redirect("/relay-portal?oauthPending=1");
4023
+ return;
4024
+ }
4025
+ store.recordUserSeen(outcome.result.session.user.id);
4026
+ attachRelayCookie(reply, outcome.result.token);
4027
+ reply.redirect("/relay-portal");
4028
+ } catch (error) {
4029
+ request.log.error(error);
4030
+ const message = error instanceof RelayStoreError ? error.message : "OAuth authentication failed.";
4031
+ reply.redirect(`/relay-portal?oauthError=${encodeURIComponent(message)}`);
4032
+ }
1980
4033
  });
1981
4034
  app2.post("/relay/auth/register", async (request, reply) => {
1982
4035
  const body = registerSchema.parse(request.body ?? {});
@@ -2039,7 +4092,11 @@ function buildRelayServer(config2, options = {}) {
2039
4092
  if (!user) {
2040
4093
  return;
2041
4094
  }
2042
- return enrichPortalSummary(store.portalSummary(user.id, connectionStatus(state)), state, store);
4095
+ return enrichPortalSummary(
4096
+ store.portalSummary(user.id, connectionStatus(state)),
4097
+ state,
4098
+ store
4099
+ );
2043
4100
  });
2044
4101
  app2.get("/relay/access", async (request, reply) => {
2045
4102
  const user = requireRelayUser(request, reply, store);
@@ -2158,22 +4215,228 @@ function buildRelayServer(config2, options = {}) {
2158
4215
  const baseSummary = store.adminSummary(connectionStatus(state), {
2159
4216
  ...query.days !== void 0 ? { conversationWindowDays: query.days } : {}
2160
4217
  });
2161
- return enrichAdminSummary(baseSummary, state, store, query.days);
4218
+ const summary = await enrichAdminSummary(baseSummary, state, store, query.days);
4219
+ return { ...summary, settings: authSettings() };
4220
+ });
4221
+ app2.get(
4222
+ "/relay/admin/hosted-sandboxes/capability",
4223
+ async (request, reply) => {
4224
+ const user = requireRelayUser(request, reply, store, { admin: true });
4225
+ if (!user) {
4226
+ return;
4227
+ }
4228
+ return hostedSandboxCapability.read();
4229
+ }
4230
+ );
4231
+ app2.get("/relay/admin/hosted-sandboxes", async (request, reply) => {
4232
+ const user = requireRelayUser(request, reply, store, { admin: true });
4233
+ if (!user) {
4234
+ return;
4235
+ }
4236
+ return { sandboxes: hostedSandboxService.list() };
4237
+ });
4238
+ app2.get(
4239
+ "/relay/admin/hosted-sandboxes/reconciliation",
4240
+ async (request, reply) => {
4241
+ const user = requireRelayUser(request, reply, store, { admin: true });
4242
+ if (!user) return;
4243
+ return hostedSandboxReconciler.read();
4244
+ }
4245
+ );
4246
+ app2.post(
4247
+ "/relay/admin/hosted-sandboxes/reconciliation/run",
4248
+ async (request, reply) => {
4249
+ const user = requireRelayUser(request, reply, store, { admin: true });
4250
+ if (!user) return;
4251
+ return hostedSandboxReconciler.run();
4252
+ }
4253
+ );
4254
+ app2.delete(
4255
+ "/relay/admin/hosted-sandboxes/reconciliation/orphan-instances/:sandboxId",
4256
+ async (request, reply) => {
4257
+ const user = requireRelayUser(request, reply, store, { admin: true });
4258
+ if (!user) return;
4259
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4260
+ return hostedSandboxReconciler.deleteOrphanInstance(sandboxId);
4261
+ }
4262
+ );
4263
+ app2.delete(
4264
+ "/relay/admin/hosted-sandboxes/reconciliation/orphan-credentials/:credentialRef",
4265
+ async (request, reply) => {
4266
+ const user = requireRelayUser(request, reply, store, { admin: true });
4267
+ if (!user) return;
4268
+ const { credentialRef } = z.object({
4269
+ credentialRef: z.string().regex(/^rcc_[A-Za-z0-9_-]{32}$/)
4270
+ }).parse(request.params);
4271
+ return hostedSandboxReconciler.deleteOrphanCredential(credentialRef);
4272
+ }
4273
+ );
4274
+ app2.get(
4275
+ "/relay/admin/hosted-sandboxes/:sandboxId",
4276
+ async (request, reply) => {
4277
+ const user = requireRelayUser(request, reply, store, { admin: true });
4278
+ if (!user) {
4279
+ return;
4280
+ }
4281
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4282
+ return hostedSandboxService.detail(sandboxId);
4283
+ }
4284
+ );
4285
+ app2.post("/relay/admin/hosted-sandboxes", async (request, reply) => {
4286
+ const user = requireRelayUser(request, reply, store, { admin: true });
4287
+ if (!user) {
4288
+ return;
4289
+ }
4290
+ const body = createHostedSandboxSchema.parse(request.body ?? {});
4291
+ const result = await hostedSandboxService.create({
4292
+ createdByAdminUserId: user.id,
4293
+ ...body
4294
+ });
4295
+ return reply.code(202).send(result);
2162
4296
  });
4297
+ app2.put(
4298
+ "/relay/admin/hosted-sandboxes/:sandboxId/members",
4299
+ async (request, reply) => {
4300
+ const user = requireRelayUser(request, reply, store, { admin: true });
4301
+ if (!user) {
4302
+ return;
4303
+ }
4304
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4305
+ const body = updateHostedSandboxMembersSchema.parse(request.body ?? {});
4306
+ return hostedSandboxService.updateMembers(
4307
+ sandboxId,
4308
+ body.assignedUserIds
4309
+ );
4310
+ }
4311
+ );
4312
+ app2.patch(
4313
+ "/relay/admin/hosted-sandboxes/:sandboxId/settings",
4314
+ async (request, reply) => {
4315
+ const user = requireRelayUser(request, reply, store, { admin: true });
4316
+ if (!user) return;
4317
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4318
+ const body = updateHostedSandboxSettingsSchema.parse(request.body ?? {});
4319
+ return store.setHostedWorkspaceIsolation(
4320
+ sandboxId,
4321
+ body.workspaceIsolationEnabled
4322
+ );
4323
+ }
4324
+ );
4325
+ app2.post(
4326
+ "/relay/admin/hosted-sandboxes/:sandboxId/retry",
4327
+ async (request, reply) => {
4328
+ const user = requireRelayUser(request, reply, store, { admin: true });
4329
+ if (!user) {
4330
+ return;
4331
+ }
4332
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4333
+ return reply.code(202).send({
4334
+ sandbox: hostedSandboxService.detail(sandboxId),
4335
+ operation: hostedSandboxService.retry(sandboxId)
4336
+ });
4337
+ }
4338
+ );
4339
+ app2.post(
4340
+ "/relay/admin/hosted-sandboxes/:sandboxId/start",
4341
+ async (request, reply) => {
4342
+ const user = requireRelayUser(request, reply, store, { admin: true });
4343
+ if (!user) return;
4344
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4345
+ return reply.code(202).send({
4346
+ operation: hostedSandboxService.start(sandboxId)
4347
+ });
4348
+ }
4349
+ );
4350
+ app2.post(
4351
+ "/relay/admin/hosted-sandboxes/:sandboxId/stop",
4352
+ async (request, reply) => {
4353
+ const user = requireRelayUser(request, reply, store, { admin: true });
4354
+ if (!user) return;
4355
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4356
+ return reply.code(202).send({
4357
+ operation: hostedSandboxService.stop(sandboxId)
4358
+ });
4359
+ }
4360
+ );
4361
+ app2.post(
4362
+ "/relay/admin/hosted-sandboxes/:sandboxId/snapshots",
4363
+ async (request, reply) => {
4364
+ const user = requireRelayUser(request, reply, store, { admin: true });
4365
+ if (!user) return;
4366
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4367
+ const { name } = hostedSnapshotSchema.parse(request.body ?? {});
4368
+ return reply.code(202).send({
4369
+ operation: hostedSandboxService.snapshot(sandboxId, name)
4370
+ });
4371
+ }
4372
+ );
4373
+ app2.delete(
4374
+ "/relay/admin/hosted-sandboxes/:sandboxId",
4375
+ async (request, reply) => {
4376
+ const user = requireRelayUser(request, reply, store, { admin: true });
4377
+ if (!user) return;
4378
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4379
+ return reply.code(202).send({
4380
+ operation: hostedSandboxService.delete(sandboxId)
4381
+ });
4382
+ }
4383
+ );
4384
+ app2.post(
4385
+ "/relay/admin/hosted-sandboxes/:sandboxId/rotate-credential",
4386
+ async (request, reply) => {
4387
+ const user = requireRelayUser(request, reply, store, { admin: true });
4388
+ if (!user) return;
4389
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4390
+ const { openaiApiKey } = rotateHostedCredentialSchema.parse(
4391
+ request.body ?? {}
4392
+ );
4393
+ return reply.code(202).send({
4394
+ operation: await hostedSandboxService.rotateCredential(
4395
+ sandboxId,
4396
+ openaiApiKey
4397
+ )
4398
+ });
4399
+ }
4400
+ );
4401
+ app2.get(
4402
+ "/relay/admin/hosted-sandboxes/:sandboxId/backends/codex/files",
4403
+ async (request, reply) => {
4404
+ const user = requireRelayUser(request, reply, store, { admin: true });
4405
+ if (!user) return;
4406
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4407
+ return hostedSandboxService.readCodexFiles(sandboxId);
4408
+ }
4409
+ );
4410
+ app2.put(
4411
+ "/relay/admin/hosted-sandboxes/:sandboxId/backends/codex/files",
4412
+ async (request, reply) => {
4413
+ const user = requireRelayUser(request, reply, store, { admin: true });
4414
+ if (!user) return;
4415
+ const { sandboxId } = z.object({ sandboxId: z.string().uuid() }).parse(request.params);
4416
+ const body = hostedCodexFilesSchema.parse(request.body ?? {});
4417
+ return hostedSandboxService.writeCodexFiles(sandboxId, body);
4418
+ }
4419
+ );
2163
4420
  app2.patch("/relay/admin/settings/registration", async (request, reply) => {
2164
4421
  const user = requireRelayUser(request, reply, store, { admin: true });
2165
4422
  if (!user) {
2166
4423
  return;
2167
4424
  }
2168
4425
  const body = updateRegistrationSettingsSchema.parse(request.body ?? {});
4426
+ if (body.googleAuthEnabled && !googleAvailable) throw new RelayStoreError(400, "bad_request", "Google OAuth credentials are not configured.");
4427
+ if (body.githubAuthEnabled && !githubAvailable) throw new RelayStoreError(400, "bad_request", "GitHub OAuth credentials are not configured.");
4428
+ if (body.emailVerificationEnabled && !config2.emailVerificationConfigured) throw new RelayStoreError(400, "bad_request", "Email verification is not configured.");
2169
4429
  const settings = store.updateRegistrationSettings({
2170
4430
  ...body.enabled !== void 0 ? { enabled: body.enabled } : {},
2171
4431
  ...body.registrationPassword !== void 0 ? { registrationPassword: body.registrationPassword } : {},
2172
- ...body.approvalRequired !== void 0 ? { approvalRequired: body.approvalRequired } : {}
4432
+ ...body.approvalRequired !== void 0 ? { approvalRequired: body.approvalRequired } : {},
4433
+ ...body.googleAuthEnabled !== void 0 ? { googleAuthEnabled: body.googleAuthEnabled } : {},
4434
+ ...body.githubAuthEnabled !== void 0 ? { githubAuthEnabled: body.githubAuthEnabled } : {},
4435
+ ...body.emailVerificationEnabled !== void 0 ? { emailVerificationEnabled: body.emailVerificationEnabled } : {}
2173
4436
  });
2174
4437
  return {
2175
4438
  registrationEnabled: settings.enabled,
2176
- settings
4439
+ settings: { ...settings, googleAuthAvailable: googleAvailable, githubAuthAvailable: githubAvailable, emailVerificationAvailable: config2.emailVerificationConfigured }
2177
4440
  };
2178
4441
  });
2179
4442
  app2.patch("/relay/admin/users/:userId", async (request, reply) => {
@@ -2194,31 +4457,40 @@ function buildRelayServer(config2, options = {}) {
2194
4457
  store.deleteUser(userId);
2195
4458
  return { id: userId };
2196
4459
  });
2197
- app2.post("/relay/admin/users/:userId/reset-password", async (request, reply) => {
2198
- const user = requireRelayUser(request, reply, store, { admin: true });
2199
- if (!user) {
2200
- return;
4460
+ app2.post(
4461
+ "/relay/admin/users/:userId/reset-password",
4462
+ async (request, reply) => {
4463
+ const user = requireRelayUser(request, reply, store, { admin: true });
4464
+ if (!user) {
4465
+ return;
4466
+ }
4467
+ const { userId } = z.object({ userId: z.string().uuid() }).parse(request.params);
4468
+ const body = adminResetPasswordSchema.parse(request.body ?? {});
4469
+ return store.adminResetUserPassword(userId, body.password);
2201
4470
  }
2202
- const { userId } = z.object({ userId: z.string().uuid() }).parse(request.params);
2203
- const body = adminResetPasswordSchema.parse(request.body ?? {});
2204
- return store.adminResetUserPassword(userId, body.password);
2205
- });
2206
- app2.post("/relay/admin/registrations/:requestId/approve", async (request, reply) => {
2207
- const user = requireRelayUser(request, reply, store, { admin: true });
2208
- if (!user) {
2209
- return;
4471
+ );
4472
+ app2.post(
4473
+ "/relay/admin/registrations/:requestId/approve",
4474
+ async (request, reply) => {
4475
+ const user = requireRelayUser(request, reply, store, { admin: true });
4476
+ if (!user) {
4477
+ return;
4478
+ }
4479
+ const { requestId } = z.object({ requestId: z.string().uuid() }).parse(request.params);
4480
+ return store.approvePendingRegistration(user.id, requestId);
2210
4481
  }
2211
- const { requestId } = z.object({ requestId: z.string().uuid() }).parse(request.params);
2212
- return store.approvePendingRegistration(user.id, requestId);
2213
- });
2214
- app2.post("/relay/admin/registrations/:requestId/reject", async (request, reply) => {
2215
- const user = requireRelayUser(request, reply, store, { admin: true });
2216
- if (!user) {
2217
- return;
4482
+ );
4483
+ app2.post(
4484
+ "/relay/admin/registrations/:requestId/reject",
4485
+ async (request, reply) => {
4486
+ const user = requireRelayUser(request, reply, store, { admin: true });
4487
+ if (!user) {
4488
+ return;
4489
+ }
4490
+ const { requestId } = z.object({ requestId: z.string().uuid() }).parse(request.params);
4491
+ return store.rejectPendingRegistration(user.id, requestId);
2218
4492
  }
2219
- const { requestId } = z.object({ requestId: z.string().uuid() }).parse(request.params);
2220
- return store.rejectPendingRegistration(user.id, requestId);
2221
- });
4493
+ );
2222
4494
  app2.all("/relay/devices/:deviceId/api/*", async (request, reply) => {
2223
4495
  const user = requireRelayUser(request, reply, store);
2224
4496
  if (!user) {
@@ -2231,6 +4503,7 @@ function buildRelayServer(config2, options = {}) {
2231
4503
  reply,
2232
4504
  state,
2233
4505
  store,
4506
+ hostedSandboxService,
2234
4507
  user,
2235
4508
  deviceId,
2236
4509
  targetPath
@@ -2247,6 +4520,7 @@ function buildRelayServer(config2, options = {}) {
2247
4520
  reply,
2248
4521
  state,
2249
4522
  store,
4523
+ hostedSandboxService,
2250
4524
  user,
2251
4525
  deviceId,
2252
4526
  targetPath: "/healthz"
@@ -2271,6 +4545,7 @@ function buildRelayServer(config2, options = {}) {
2271
4545
  reply,
2272
4546
  state,
2273
4547
  store,
4548
+ hostedSandboxService,
2274
4549
  user,
2275
4550
  deviceId,
2276
4551
  targetPath
@@ -2298,12 +4573,16 @@ function buildRelayServer(config2, options = {}) {
2298
4573
  }
2299
4574
  const connectedAt = (/* @__PURE__ */ new Date()).toISOString();
2300
4575
  const existing = state.supervisors.get(deviceId);
2301
- existing?.socket.send(JSON.stringify({
2302
- type: "relay.connected",
2303
- timestamp: connectedAt,
2304
- deviceId
2305
- }));
2306
- existing?.clientSockets.forEach((clientConnection) => clientConnection.socket.close());
4576
+ existing?.socket.send(
4577
+ JSON.stringify({
4578
+ type: "relay.connected",
4579
+ timestamp: connectedAt,
4580
+ deviceId
4581
+ })
4582
+ );
4583
+ existing?.clientSockets.forEach(
4584
+ (clientConnection) => clientConnection.socket.close()
4585
+ );
2307
4586
  const connection = {
2308
4587
  deviceId,
2309
4588
  socket,
@@ -2315,6 +4594,7 @@ function buildRelayServer(config2, options = {}) {
2315
4594
  ipAddress: relayClientIp(request)
2316
4595
  };
2317
4596
  state.supervisors.set(deviceId, connection);
4597
+ hostedSandboxService.markOnline(deviceId);
2318
4598
  socket.send(
2319
4599
  JSON.stringify({
2320
4600
  type: "relay.connected",
@@ -2325,7 +4605,9 @@ function buildRelayServer(config2, options = {}) {
2325
4605
  socket.on("message", (rawMessage) => {
2326
4606
  let parsed;
2327
4607
  try {
2328
- parsed = JSON.parse(rawMessage.toString());
4608
+ parsed = JSON.parse(
4609
+ rawMessage.toString()
4610
+ );
2329
4611
  } catch {
2330
4612
  return;
2331
4613
  }
@@ -2333,21 +4615,54 @@ function buildRelayServer(config2, options = {}) {
2333
4615
  connection.lastHeartbeatAt = parsed.timestamp;
2334
4616
  return;
2335
4617
  }
4618
+ if (parsed.type === "relay.activity") {
4619
+ hostedSandboxService.recordTurnActivity({
4620
+ deviceId,
4621
+ threadId: parsed.payload.threadId,
4622
+ turnId: parsed.payload.turnId,
4623
+ kind: parsed.payload.kind
4624
+ });
4625
+ return;
4626
+ }
2336
4627
  if (parsed.type === "relay.server.message") {
2337
- const clientConnection = connection.clientSockets.get(parsed.clientId);
4628
+ const clientConnection = connection.clientSockets.get(
4629
+ parsed.clientId
4630
+ );
4631
+ const eventThreadId = threadIdFromSocketPayload(parsed.payload);
2338
4632
  if (clientConnection) {
2339
- const eventThreadId = threadIdFromSocketPayload(parsed.payload);
2340
- const freshAccess = store.effectiveAccess(clientConnection.user.id, clientConnection.deviceId, {
2341
- threadId: clientConnection.threadId ?? eventThreadId
2342
- });
4633
+ const freshAccess = store.effectiveAccess(
4634
+ clientConnection.user.id,
4635
+ clientConnection.deviceId,
4636
+ {
4637
+ threadId: clientConnection.threadId ?? eventThreadId
4638
+ }
4639
+ );
2343
4640
  if (!freshAccess) {
2344
4641
  connection.clientSockets.delete(parsed.clientId);
2345
- clientConnection.socket.close(1008, "Shared access is no longer allowed.");
4642
+ clientConnection.socket.close(
4643
+ 1008,
4644
+ "Shared access is no longer allowed."
4645
+ );
2346
4646
  return;
2347
4647
  }
2348
4648
  clientConnection.access = freshAccess;
4649
+ const isolation = store.hostedWorkspaceIsolationForUser(
4650
+ clientConnection.deviceId,
4651
+ clientConnection.user.id
4652
+ );
4653
+ const connectionControlEvent = parsed.payload.type === "supervisor.connected" || parsed.payload.type === "supervisor.pong";
4654
+ if (isolation?.enabled && !connectionControlEvent && (!eventThreadId || !store.ownsHostedThread(
4655
+ isolation.sandboxId,
4656
+ clientConnection.user.id,
4657
+ eventThreadId
4658
+ ))) {
4659
+ return;
4660
+ }
2349
4661
  }
2350
- if (clientConnection && clientConnection.socket.readyState === WEBSOCKET_OPEN && shouldForwardSocketEvent(parsed.payload, clientConnection.threadId)) {
4662
+ if (clientConnection && clientConnection.socket.readyState === WEBSOCKET_OPEN && shouldForwardSocketEvent(
4663
+ parsed.payload,
4664
+ clientConnection.threadId
4665
+ )) {
2351
4666
  clientConnection.socket.send(JSON.stringify(parsed.payload));
2352
4667
  }
2353
4668
  return;
@@ -2358,7 +4673,9 @@ function buildRelayServer(config2, options = {}) {
2358
4673
  if (state.supervisors.get(deviceId)?.socket === socket) {
2359
4674
  state.supervisors.delete(deviceId);
2360
4675
  }
2361
- connection.requestBroker.rejectAll(new Error("Supervisor tunnel closed."));
4676
+ connection.requestBroker.rejectAll(
4677
+ new Error("Supervisor tunnel closed.")
4678
+ );
2362
4679
  for (const [clientId, clientConnection] of connection.clientSockets) {
2363
4680
  connection.clientSockets.delete(clientId);
2364
4681
  clientConnection.socket.close();
@@ -2376,27 +4693,55 @@ function buildRelayServer(config2, options = {}) {
2376
4693
  });
2377
4694
  },
2378
4695
  wsHandler: (socket, request) => {
2379
- const session = store.verifySession(readRelaySessionToken(request));
4696
+ const session = verifyRelayRequestSession(request, store);
2380
4697
  const deviceId = pathParam(request.params, "deviceId");
2381
4698
  const threadId = queryString(request.query, "threadId");
2382
4699
  if (!session.authenticated || !session.user || !deviceId) {
2383
4700
  socket.close(1008, "Relay login is required.");
2384
4701
  return;
2385
4702
  }
2386
- const access = store.effectiveAccess(session.user.id, deviceId, { threadId });
4703
+ const access = store.effectiveAccess(session.user.id, deviceId, {
4704
+ threadId
4705
+ });
2387
4706
  if (!access) {
2388
4707
  socket.close(1008, "Device access is not allowed.");
2389
4708
  return;
2390
4709
  }
4710
+ const isolation = store.hostedWorkspaceIsolationForUser(
4711
+ deviceId,
4712
+ session.user.id
4713
+ );
4714
+ if (isolation?.enabled && threadId && !store.ownsHostedThread(isolation.sandboxId, session.user.id, threadId)) {
4715
+ socket.close(1008, "This thread belongs to another VM user.");
4716
+ return;
4717
+ }
4718
+ const lifecycle = hostedSandboxService.wakeIfStopped(deviceId);
4719
+ if (lifecycle.waking) {
4720
+ socket.close(1013, "Hosted supervisor VM is starting.");
4721
+ return;
4722
+ }
2391
4723
  const supervisor = state.supervisors.get(deviceId);
2392
4724
  if (!supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
2393
4725
  socket.close(1013, "No supervisor is connected for this device.");
2394
4726
  return;
2395
4727
  }
2396
4728
  if (access.kind === "shared") {
2397
- recordRelayAccess(store, access, session.user, threadId ? "open_thread" : "open_device");
4729
+ recordRelayAccess(
4730
+ store,
4731
+ access,
4732
+ session.user,
4733
+ threadId ? "open_thread" : "open_device"
4734
+ );
2398
4735
  }
2399
- connectRelayWebsocket(supervisor, socket, store, session.user, deviceId, threadId, access);
4736
+ connectRelayWebsocket(
4737
+ supervisor,
4738
+ socket,
4739
+ store,
4740
+ session.user,
4741
+ deviceId,
4742
+ threadId,
4743
+ access
4744
+ );
2400
4745
  }
2401
4746
  });
2402
4747
  realtimeApp.route({
@@ -2409,17 +4754,25 @@ function buildRelayServer(config2, options = {}) {
2409
4754
  });
2410
4755
  },
2411
4756
  wsHandler: (socket, request) => {
2412
- const session = store.verifySession(readRelaySessionToken(request));
4757
+ const session = verifyRelayRequestSession(request, store);
2413
4758
  if (!session.authenticated || !session.user) {
2414
4759
  socket.close(1008, "Relay login is required.");
2415
4760
  return;
2416
4761
  }
2417
4762
  const threadId = queryString(request.query, "threadId");
2418
- const deviceId = firstAccessibleConnectedDevice(state, store, session.user.id, threadId);
4763
+ const deviceId = firstAccessibleConnectedDevice(
4764
+ state,
4765
+ store,
4766
+ session.user.id,
4767
+ threadId
4768
+ );
2419
4769
  const supervisor = deviceId ? state.supervisors.get(deviceId) : null;
2420
4770
  const access = deviceId ? store.effectiveAccess(session.user.id, deviceId, { threadId }) : null;
2421
4771
  if (!deviceId || !supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
2422
- socket.close(1013, "No accessible supervisor is connected to this relay.");
4772
+ socket.close(
4773
+ 1013,
4774
+ "No accessible supervisor is connected to this relay."
4775
+ );
2423
4776
  return;
2424
4777
  }
2425
4778
  if (!access) {
@@ -2427,15 +4780,32 @@ function buildRelayServer(config2, options = {}) {
2427
4780
  return;
2428
4781
  }
2429
4782
  if (access.kind === "shared") {
2430
- recordRelayAccess(store, access, session.user, threadId ? "open_thread" : "open_device");
4783
+ recordRelayAccess(
4784
+ store,
4785
+ access,
4786
+ session.user,
4787
+ threadId ? "open_thread" : "open_device"
4788
+ );
2431
4789
  }
2432
- connectRelayWebsocket(supervisor, socket, store, session.user, deviceId, threadId, access);
4790
+ connectRelayWebsocket(
4791
+ supervisor,
4792
+ socket,
4793
+ store,
4794
+ session.user,
4795
+ deviceId,
4796
+ threadId,
4797
+ access
4798
+ );
2433
4799
  }
2434
4800
  });
2435
4801
  });
2436
4802
  if (config2.webDistDir) {
2437
4803
  registerRelayWebApp(app2, config2.webDistDir);
2438
4804
  }
4805
+ app2.addHook("onClose", () => {
4806
+ hostedSandboxService.close();
4807
+ hostedSandboxReconciler.close();
4808
+ });
2439
4809
  app2.setErrorHandler((error, _request, reply) => {
2440
4810
  if (error instanceof RelayStoreError) {
2441
4811
  reply.status(error.statusCode).send({
@@ -2445,9 +4815,12 @@ function buildRelayServer(config2, options = {}) {
2445
4815
  return;
2446
4816
  }
2447
4817
  if (error instanceof z.ZodError) {
4818
+ const firstIssue = error.issues[0];
4819
+ const field = firstIssue?.path[0];
4820
+ const message = field === "password" ? "Password must be at least 8 characters." : field === "username" ? "Username must be at least 3 characters." : field === "email" ? "Enter a valid email address." : "The request payload is invalid.";
2448
4821
  reply.status(400).send({
2449
4822
  code: "bad_request",
2450
- message: "The request payload is invalid.",
4823
+ message,
2451
4824
  details: {
2452
4825
  issues: error.issues
2453
4826
  }
@@ -2466,7 +4839,9 @@ function webViewCorsOrigins(env) {
2466
4839
  return null;
2467
4840
  }
2468
4841
  const configured = env.REMOTE_CODEX_WEBVIEW_CORS_ORIGINS?.split(",").map((origin) => origin.trim()).filter(Boolean);
2469
- return new Set(configured?.length ? configured : DEFAULT_WEBVIEW_CORS_ORIGINS);
4842
+ return new Set(
4843
+ configured?.length ? configured : DEFAULT_WEBVIEW_CORS_ORIGINS
4844
+ );
2470
4845
  }
2471
4846
  function applyWebViewCorsHeaders(reply, origin) {
2472
4847
  reply.header("access-control-allow-origin", origin);
@@ -2549,6 +4924,18 @@ async function forwardRelayHttp(input) {
2549
4924
  workspaceId: conversationEvent.workspaceId
2550
4925
  });
2551
4926
  }
4927
+ const lifecycle = isHostedUserActivityRequest(
4928
+ input.request.method,
4929
+ input.targetPath
4930
+ ) ? input.hostedSandboxService.recordUserActivity(input.deviceId) : input.hostedSandboxService.wakeIfStopped(input.deviceId);
4931
+ if (lifecycle.waking) {
4932
+ input.reply.status(503).send({
4933
+ code: "service_unavailable",
4934
+ message: "Hosted supervisor VM is starting. Retry shortly.",
4935
+ details: { reason: "hosted_sandbox_starting" }
4936
+ });
4937
+ return;
4938
+ }
2552
4939
  const supervisor = input.state.supervisors.get(input.deviceId);
2553
4940
  if (!supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
2554
4941
  input.reply.status(503).send({
@@ -2558,6 +4945,64 @@ async function forwardRelayHttp(input) {
2558
4945
  return;
2559
4946
  }
2560
4947
  try {
4948
+ const isolation = input.store.hostedWorkspaceIsolationForUser(
4949
+ input.deviceId,
4950
+ input.user.id
4951
+ );
4952
+ if (isolation?.enabled) {
4953
+ if (input.request.method.toUpperCase() === "GET" && (targetUrl.pathname === "/api/workspaces" || targetUrl.pathname === "/api/threads")) {
4954
+ await ensureHostedUserBootstrap({
4955
+ store: input.store,
4956
+ supervisor,
4957
+ deviceId: input.deviceId,
4958
+ sandboxId: isolation.sandboxId,
4959
+ user: input.user
4960
+ });
4961
+ }
4962
+ if (workspaceId && !input.store.ownsHostedWorkspace(
4963
+ isolation.sandboxId,
4964
+ input.user.id,
4965
+ workspaceId
4966
+ )) {
4967
+ input.reply.status(403).send({
4968
+ code: "forbidden",
4969
+ message: "This workspace belongs to another VM user."
4970
+ });
4971
+ return;
4972
+ }
4973
+ if (threadId && !input.store.ownsHostedThread(
4974
+ isolation.sandboxId,
4975
+ input.user.id,
4976
+ threadId
4977
+ )) {
4978
+ input.reply.status(403).send({
4979
+ code: "forbidden",
4980
+ message: "This thread belongs to another VM user."
4981
+ });
4982
+ return;
4983
+ }
4984
+ if (input.request.method.toUpperCase() === "POST" && targetUrl.pathname === "/api/threads/start") {
4985
+ const requestedWorkspaceId = isObject(input.request.body) && typeof input.request.body.workspaceId === "string" ? input.request.body.workspaceId : null;
4986
+ if (!requestedWorkspaceId || !input.store.ownsHostedWorkspace(
4987
+ isolation.sandboxId,
4988
+ input.user.id,
4989
+ requestedWorkspaceId
4990
+ )) {
4991
+ input.reply.status(403).send({
4992
+ code: "forbidden",
4993
+ message: "Create threads only in your own workspace."
4994
+ });
4995
+ return;
4996
+ }
4997
+ }
4998
+ if (input.request.method.toUpperCase() === "POST" && targetUrl.pathname === "/api/threads/import") {
4999
+ input.reply.status(403).send({
5000
+ code: "forbidden",
5001
+ message: "Thread import is unavailable while user workspace isolation is enabled."
5002
+ });
5003
+ return;
5004
+ }
5005
+ }
2561
5006
  const requestId = randomUUID();
2562
5007
  const requestBody = relayRequestBody(input.request.body);
2563
5008
  const response = await supervisor.requestBroker.forward(supervisor.socket, {
@@ -2578,6 +5023,18 @@ async function forwardRelayHttp(input) {
2578
5023
  input.reply.header(name, value);
2579
5024
  }
2580
5025
  }
5026
+ if (isolation?.enabled && response.statusCode >= 200 && response.statusCode < 300) {
5027
+ const transformed = transformHostedIsolatedResponse({
5028
+ store: input.store,
5029
+ sandboxId: isolation.sandboxId,
5030
+ userId: input.user.id,
5031
+ method: input.request.method,
5032
+ pathname: targetUrl.pathname,
5033
+ response
5034
+ });
5035
+ input.reply.status(response.statusCode).send(transformed);
5036
+ return;
5037
+ }
2581
5038
  input.reply.status(response.statusCode).send(relayResponseBody(response));
2582
5039
  } catch (error) {
2583
5040
  const message = error instanceof Error ? error.message : "Relay request failed.";
@@ -2609,7 +5066,162 @@ async function forwardSharedThreadList(input) {
2609
5066
  return isObject(thread) ? thread : null;
2610
5067
  })
2611
5068
  );
2612
- input.reply.send(threads.filter((thread) => Boolean(thread)));
5069
+ input.reply.send(
5070
+ threads.filter(
5071
+ (thread) => Boolean(thread)
5072
+ )
5073
+ );
5074
+ }
5075
+ async function ensureHostedUserBootstrap(input) {
5076
+ if (input.store.hostedUserWorkspaceIds(input.sandboxId, input.user.id).length) {
5077
+ return;
5078
+ }
5079
+ const key = `${input.sandboxId}:${input.user.id}`;
5080
+ const existing = hostedBootstrapPromises.get(key);
5081
+ if (existing) return existing;
5082
+ const pending = (async () => {
5083
+ const slug = input.user.username.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "user";
5084
+ const directory = `${slug}-${input.user.id.slice(0, 8)}`;
5085
+ const absoluteDirectory = `/home/remote-codex/workspaces/${directory}`;
5086
+ const label = `${input.user.username}'s workspace`;
5087
+ const current = await forwardSupervisorCommandJson(
5088
+ input.supervisor,
5089
+ input.deviceId,
5090
+ "GET",
5091
+ "/api/workspaces"
5092
+ );
5093
+ const currentWorkspaces = Array.isArray(current) ? current : [];
5094
+ let workspace = currentWorkspaces.find(
5095
+ (candidate) => isObject(candidate) && typeof candidate.absPath === "string" && candidate.absPath === absoluteDirectory
5096
+ );
5097
+ if (!workspace) {
5098
+ workspace = await forwardSupervisorCommandJson(
5099
+ input.supervisor,
5100
+ input.deviceId,
5101
+ "POST",
5102
+ "/api/workspaces",
5103
+ { absPath: absoluteDirectory, label }
5104
+ );
5105
+ }
5106
+ const workspaceId = stringField(workspace, "id");
5107
+ if (!workspaceId) throw new Error("Initial workspace creation returned no id.");
5108
+ const thread = await forwardSupervisorCommandJson(
5109
+ input.supervisor,
5110
+ input.deviceId,
5111
+ "POST",
5112
+ "/api/threads/start",
5113
+ {
5114
+ workspaceId,
5115
+ title: "Getting started",
5116
+ provider: "codex",
5117
+ model: "gpt-5.6-sol",
5118
+ reasoningEffort: "low",
5119
+ approvalMode: "yolo"
5120
+ }
5121
+ );
5122
+ const threadId = stringField(thread, "id");
5123
+ if (!threadId) throw new Error("Initial thread creation returned no id.");
5124
+ input.store.recordHostedUserWorkspace(
5125
+ input.sandboxId,
5126
+ input.user.id,
5127
+ workspaceId,
5128
+ true
5129
+ );
5130
+ input.store.recordHostedUserThread(
5131
+ input.sandboxId,
5132
+ input.user.id,
5133
+ threadId,
5134
+ workspaceId
5135
+ );
5136
+ })().finally(() => hostedBootstrapPromises.delete(key));
5137
+ hostedBootstrapPromises.set(key, pending);
5138
+ return pending;
5139
+ }
5140
+ async function forwardSupervisorCommandJson(supervisor, deviceId, method, path4, body) {
5141
+ const response = await supervisor.requestBroker.forward(supervisor.socket, {
5142
+ type: "relay.request",
5143
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5144
+ requestId: randomUUID(),
5145
+ deviceId,
5146
+ payload: {
5147
+ method,
5148
+ path: path4,
5149
+ headers: body === void 0 ? {} : { "content-type": "application/json" },
5150
+ body: body === void 0 ? null : JSON.stringify(body)
5151
+ }
5152
+ });
5153
+ if (response.statusCode < 200 || response.statusCode >= 300) {
5154
+ let detail = relayJsonBody(response);
5155
+ try {
5156
+ const payload = JSON.parse(detail);
5157
+ detail = isObject(payload) && typeof payload.message === "string" ? payload.message : detail;
5158
+ } catch {
5159
+ }
5160
+ throw new Error(
5161
+ `Supervisor bootstrap request failed with ${response.statusCode}: ${detail.slice(0, 300)}`
5162
+ );
5163
+ }
5164
+ return JSON.parse(relayJsonBody(response));
5165
+ }
5166
+ function transformHostedIsolatedResponse(input) {
5167
+ const raw = relayJsonBody(input.response);
5168
+ let payload;
5169
+ try {
5170
+ payload = JSON.parse(raw);
5171
+ } catch {
5172
+ return relayResponseBody(input.response);
5173
+ }
5174
+ const method = input.method.toUpperCase();
5175
+ if (method === "GET" && input.pathname === "/api/workspaces" && Array.isArray(payload)) {
5176
+ const owned = new Set(
5177
+ input.store.hostedUserWorkspaceIds(input.sandboxId, input.userId)
5178
+ );
5179
+ return payload.filter(
5180
+ (workspace) => isObject(workspace) && owned.has(stringField(workspace, "id") ?? "")
5181
+ );
5182
+ }
5183
+ if (method === "POST" && input.pathname === "/api/workspaces" && isObject(payload)) {
5184
+ const workspaceId = stringField(payload, "id");
5185
+ if (workspaceId) {
5186
+ input.store.recordHostedUserWorkspace(
5187
+ input.sandboxId,
5188
+ input.userId,
5189
+ workspaceId
5190
+ );
5191
+ }
5192
+ return payload;
5193
+ }
5194
+ if (method === "GET" && input.pathname === "/api/threads" && Array.isArray(payload)) {
5195
+ const ownedWorkspaces = new Set(
5196
+ input.store.hostedUserWorkspaceIds(input.sandboxId, input.userId)
5197
+ );
5198
+ return payload.filter((thread) => {
5199
+ if (!isObject(thread)) return false;
5200
+ const threadId = stringField(thread, "id");
5201
+ const workspaceId = stringField(thread, "workspaceId");
5202
+ if (!threadId || !workspaceId || !ownedWorkspaces.has(workspaceId)) return false;
5203
+ input.store.recordHostedUserThread(
5204
+ input.sandboxId,
5205
+ input.userId,
5206
+ threadId,
5207
+ workspaceId
5208
+ );
5209
+ return true;
5210
+ });
5211
+ }
5212
+ if (method === "POST" && input.pathname === "/api/threads/start" && isObject(payload)) {
5213
+ const threadId = stringField(payload, "id");
5214
+ const workspaceId = stringField(payload, "workspaceId");
5215
+ if (threadId && workspaceId) {
5216
+ input.store.recordHostedUserThread(
5217
+ input.sandboxId,
5218
+ input.userId,
5219
+ threadId,
5220
+ workspaceId
5221
+ );
5222
+ }
5223
+ }
5224
+ return payload;
2613
5225
  }
2614
5226
  function relayResponseBody(response) {
2615
5227
  if (response.bodyEncoding === "base64") {
@@ -2619,7 +5231,13 @@ function relayResponseBody(response) {
2619
5231
  }
2620
5232
  function connectRelayWebsocket(supervisor, socket, store, user, deviceId, threadId, access) {
2621
5233
  const clientId = randomUUID();
2622
- supervisor.clientSockets.set(clientId, { socket, threadId, deviceId, user, access });
5234
+ supervisor.clientSockets.set(clientId, {
5235
+ socket,
5236
+ threadId,
5237
+ deviceId,
5238
+ user,
5239
+ access
5240
+ });
2623
5241
  sendToSupervisor(supervisor, {
2624
5242
  type: "relay.client.connected",
2625
5243
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2711,7 +5329,9 @@ function registerRelayWebApp(app2, distDirInput) {
2711
5329
  return reply.send(payload);
2712
5330
  }
2713
5331
  const stat = await fsp.stat(assetPath);
2714
- reply.header("cache-control", "public, max-age=31536000, immutable").header("content-length", stat.size).type(mimeTypes.get(path2.extname(assetPath).toLowerCase()) ?? "application/octet-stream");
5332
+ reply.header("cache-control", "public, max-age=31536000, immutable").header("content-length", stat.size).type(
5333
+ mimeTypes.get(path2.extname(assetPath).toLowerCase()) ?? "application/octet-stream"
5334
+ );
2715
5335
  return reply.send(fs2.createReadStream(assetPath));
2716
5336
  });
2717
5337
  }
@@ -2747,7 +5367,7 @@ async function resolveAssetPath(distDir, indexFile, pathname) {
2747
5367
  return indexFile;
2748
5368
  }
2749
5369
  function requireRelayUser(request, reply, store, options = {}) {
2750
- const session = store.verifySession(readRelaySessionToken(request));
5370
+ const session = verifyRelayRequestSession(request, store);
2751
5371
  if (!session.authenticated || !session.user) {
2752
5372
  reply.status(401).send({
2753
5373
  code: "unauthorized",
@@ -2773,8 +5393,19 @@ function requireRelayUser(request, reply, store, options = {}) {
2773
5393
  request.relayUser = session.user;
2774
5394
  return session.user;
2775
5395
  }
2776
- function readRelaySessionToken(request) {
2777
- return bearerToken(request.headers.authorization) ?? queryToken(request.query, "relaySession") ?? queryToken(request.query, "token") ?? readCookie(request.headers.cookie, RELAY_COOKIE_NAME);
5396
+ function verifyRelayRequestSession(request, store) {
5397
+ const candidates = [
5398
+ bearerToken(request.headers.authorization),
5399
+ queryToken(request.query, "relaySession"),
5400
+ queryToken(request.query, "token"),
5401
+ readCookie(request.headers.cookie, RELAY_COOKIE_NAME)
5402
+ ];
5403
+ for (const token of candidates) {
5404
+ if (!token) continue;
5405
+ const session = store.verifySession(token);
5406
+ if (session.authenticated) return session;
5407
+ }
5408
+ return store.emptySession();
2778
5409
  }
2779
5410
  function attachRelayCookie(reply, token) {
2780
5411
  reply.header(
@@ -2788,6 +5419,66 @@ function clearRelayCookie(reply) {
2788
5419
  `${RELAY_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`
2789
5420
  );
2790
5421
  }
5422
+ function oauthCallbackUrl(request, config2, provider) {
5423
+ const configured = config2.publicBaseUrl?.replace(/^ws:/, "http:").replace(/^wss:/, "https:");
5424
+ const forwardedProto = String(request.headers["x-forwarded-proto"] ?? "").split(",")[0]?.trim();
5425
+ const protocol = forwardedProto || request.protocol;
5426
+ const host = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:8788").split(",")[0]?.trim();
5427
+ const base = configured || `${protocol}://${host}`;
5428
+ return `${base}/relay/auth/oauth/${provider}/callback`;
5429
+ }
5430
+ function signOAuthState(provider, secret) {
5431
+ const payload = Buffer.from(JSON.stringify({ provider, expiresAt: Date.now() + 10 * 6e4, nonce: crypto3.randomBytes(18).toString("base64url") })).toString("base64url");
5432
+ const signature = crypto3.createHmac("sha256", secret).update(payload).digest("base64url");
5433
+ return `${payload}.${signature}`;
5434
+ }
5435
+ function verifyOAuthState(state, provider, secret) {
5436
+ const [payload, signature] = state.split(".");
5437
+ if (!payload || !signature) return false;
5438
+ const expected = crypto3.createHmac("sha256", secret).update(payload).digest();
5439
+ const actual = Buffer.from(signature, "base64url");
5440
+ if (actual.length !== expected.length || !crypto3.timingSafeEqual(actual, expected)) return false;
5441
+ try {
5442
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
5443
+ return parsed.provider === provider && typeof parsed.expiresAt === "number" && parsed.expiresAt > Date.now();
5444
+ } catch {
5445
+ return false;
5446
+ }
5447
+ }
5448
+ async function fetchGoogleIdentity(code, redirectUri, config2) {
5449
+ const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
5450
+ method: "POST",
5451
+ headers: { "content-type": "application/x-www-form-urlencoded" },
5452
+ body: new URLSearchParams({ code, client_id: config2.googleOAuthClientId, client_secret: config2.googleOAuthClientSecret, redirect_uri: redirectUri, grant_type: "authorization_code" })
5453
+ });
5454
+ if (!tokenResponse.ok) throw new Error("Google token exchange failed.");
5455
+ const tokens = await tokenResponse.json();
5456
+ const response = await fetch("https://openidconnect.googleapis.com/v1/userinfo", { headers: { authorization: `Bearer ${tokens.access_token}` } });
5457
+ if (!response.ok) throw new Error("Google profile lookup failed.");
5458
+ const profile = await response.json();
5459
+ if (!profile.sub || !profile.email || !profile.email_verified) throw new Error("Google did not provide a verified email address.");
5460
+ return { provider: "google", subject: profile.sub, email: profile.email.toLowerCase(), username: profile.email.split("@")[0] || profile.name || "google-user" };
5461
+ }
5462
+ async function fetchGitHubIdentity(code, redirectUri, config2) {
5463
+ const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
5464
+ method: "POST",
5465
+ headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
5466
+ body: new URLSearchParams({ code, client_id: config2.githubOAuthClientId, client_secret: config2.githubOAuthClientSecret, redirect_uri: redirectUri })
5467
+ });
5468
+ if (!tokenResponse.ok) throw new Error("GitHub token exchange failed.");
5469
+ const tokens = await tokenResponse.json();
5470
+ const headers = { accept: "application/vnd.github+json", authorization: `Bearer ${tokens.access_token}`, "user-agent": "remote-codex-relay" };
5471
+ const [userResponse, emailsResponse] = await Promise.all([
5472
+ fetch("https://api.github.com/user", { headers }),
5473
+ fetch("https://api.github.com/user/emails", { headers })
5474
+ ]);
5475
+ if (!userResponse.ok || !emailsResponse.ok) throw new Error("GitHub profile lookup failed.");
5476
+ const user = await userResponse.json();
5477
+ const emails = await emailsResponse.json();
5478
+ const email = emails.find((item) => item.primary && item.verified)?.email ?? emails.find((item) => item.verified)?.email;
5479
+ if (!user.id || !user.login || !email) throw new Error("GitHub did not provide a verified email address.");
5480
+ return { provider: "github", subject: String(user.id), email: email.toLowerCase(), username: user.login };
5481
+ }
2791
5482
  function connectionStatus(state) {
2792
5483
  const statuses = /* @__PURE__ */ new Map();
2793
5484
  for (const [deviceId, supervisor] of state.supervisors) {
@@ -2814,7 +5505,9 @@ async function enrichAdminSummary(summary, state, store, conversationWindowDays)
2814
5505
  fetchRelayThreads(supervisor, device.id)
2815
5506
  ]);
2816
5507
  workspacesByDeviceId.set(device.id, workspaces);
2817
- const workspaceLabelById = new Map(workspaces.map((workspace) => [workspace.id, workspace.label]));
5508
+ const workspaceLabelById = new Map(
5509
+ workspaces.map((workspace) => [workspace.id, workspace.label])
5510
+ );
2818
5511
  threadsByDeviceId.set(
2819
5512
  device.id,
2820
5513
  threads.map((thread) => ({
@@ -2845,7 +5538,11 @@ async function enrichAdminSummary(summary, state, store, conversationWindowDays)
2845
5538
  };
2846
5539
  }
2847
5540
  async function fetchRelayWorkspaces(supervisor, deviceId) {
2848
- const payload = await forwardSupervisorJson(supervisor, deviceId, "/api/workspaces");
5541
+ const payload = await forwardSupervisorJson(
5542
+ supervisor,
5543
+ deviceId,
5544
+ "/api/workspaces"
5545
+ );
2849
5546
  const rows = Array.isArray(payload) ? payload : [];
2850
5547
  const workspaces = [];
2851
5548
  for (const workspace of rows.filter(isObject)) {
@@ -2863,7 +5560,11 @@ async function fetchRelayWorkspaces(supervisor, deviceId) {
2863
5560
  return workspaces.slice(0, 50);
2864
5561
  }
2865
5562
  async function fetchRelayThreads(supervisor, deviceId) {
2866
- const payload = await forwardSupervisorJson(supervisor, deviceId, "/api/threads");
5563
+ const payload = await forwardSupervisorJson(
5564
+ supervisor,
5565
+ deviceId,
5566
+ "/api/threads"
5567
+ );
2867
5568
  const rows = Array.isArray(payload) ? payload : [];
2868
5569
  const threads = [];
2869
5570
  for (const thread of rows.filter(isObject)) {
@@ -2896,7 +5597,11 @@ async function enrichPortalSummary(portal, state, store) {
2896
5597
  const threadCacheKey = `${share.deviceId}:${share.threadId}`;
2897
5598
  let threadTitlePromise = threadCache.get(threadCacheKey);
2898
5599
  if (!threadTitlePromise) {
2899
- threadTitlePromise = fetchRelayThreadTitle(supervisor, share.deviceId, share.threadId);
5600
+ threadTitlePromise = fetchRelayThreadTitle(
5601
+ supervisor,
5602
+ share.deviceId,
5603
+ share.threadId
5604
+ );
2900
5605
  threadCache.set(threadCacheKey, threadTitlePromise);
2901
5606
  }
2902
5607
  let workspaceLabelPromise = Promise.resolve(null);
@@ -2950,7 +5655,11 @@ async function fetchRelayThreadTitle(supervisor, deviceId, threadId) {
2950
5655
  return stringField(thread, "title");
2951
5656
  }
2952
5657
  async function fetchRelayWorkspaceLabel(supervisor, deviceId, workspaceId) {
2953
- const payload = await forwardSupervisorJson(supervisor, deviceId, `/api/workspaces/${encodeURIComponent(workspaceId)}`);
5658
+ const payload = await forwardSupervisorJson(
5659
+ supervisor,
5660
+ deviceId,
5661
+ `/api/workspaces/${encodeURIComponent(workspaceId)}`
5662
+ );
2954
5663
  return stringField(payload, "label");
2955
5664
  }
2956
5665
  function stableShareThreadTitle(share) {
@@ -3049,7 +5758,7 @@ function isAllowedSharedRuntimeMetadataRequest(method, pathname) {
3049
5758
  if (pathname === "/api/plugins") {
3050
5759
  return true;
3051
5760
  }
3052
- return /^\/api\/agent-runtimes\/[^/]+\/(?:status|models)$/.test(pathname);
5761
+ return /^\/api\/agent-runtimes\/[^/]+\/(?:status|models|subscription-usage)$/.test(pathname);
3053
5762
  }
3054
5763
  function threadIdFromPath(pathValue) {
3055
5764
  const pathname = new URL(pathValue, "http://relay.local").pathname;
@@ -3081,6 +5790,13 @@ function conversationEventFromRequest(method, pathValue, body) {
3081
5790
  }
3082
5791
  return null;
3083
5792
  }
5793
+ function isHostedUserActivityRequest(method, pathValue) {
5794
+ const methodName = method.toUpperCase();
5795
+ if (["GET", "HEAD", "OPTIONS"].includes(methodName)) {
5796
+ return false;
5797
+ }
5798
+ return new URL(pathValue, "http://relay.local").pathname.startsWith("/api/");
5799
+ }
3084
5800
  function relayAccessEventKindFromRequest(method, pathValue) {
3085
5801
  const methodName = method.toUpperCase();
3086
5802
  const pathname = new URL(pathValue, "http://relay.local").pathname;
@@ -3096,7 +5812,9 @@ function relayAccessEventKindFromRequest(method, pathValue) {
3096
5812
  if (methodName === "GET" && /^\/api\/workspaces\/[^/]+$/.test(pathname)) {
3097
5813
  return "open_device";
3098
5814
  }
3099
- if (methodName === "GET" && /^\/api\/workspaces\/[^/]+\/(?:files\/(?:tree|preview|raw|download)|artifacts(?:\/[^/]+(?:\/download)?)?)$/.test(pathname)) {
5815
+ if (methodName === "GET" && /^\/api\/workspaces\/[^/]+\/(?:files\/(?:tree|preview|raw|download)|artifacts(?:\/[^/]+(?:\/download)?)?)$/.test(
5816
+ pathname
5817
+ )) {
3100
5818
  return "read_workspace_file";
3101
5819
  }
3102
5820
  if (["POST", "PUT", "PATCH", "DELETE"].includes(methodName) && /^\/api\/workspaces\/[^/]+\/files(?:\/(?:upload|move))?$/.test(pathname)) {
@@ -3135,7 +5853,12 @@ function isAllowedForRelayAccess(access, method, pathValue) {
3135
5853
  }
3136
5854
  const workspaceId = workspaceIdFromPath(pathValue);
3137
5855
  if (workspaceId) {
3138
- return isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId);
5856
+ return isAllowedSharedWorkspacePath(
5857
+ access,
5858
+ methodName,
5859
+ pathname,
5860
+ workspaceId
5861
+ );
3139
5862
  }
3140
5863
  if (access.scope === "device") {
3141
5864
  if (methodName === "GET" && (pathname === "/api/threads" || pathname === "/api/workspaces")) {
@@ -3220,7 +5943,9 @@ function isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId)
3220
5943
  new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/download$`),
3221
5944
  new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts$`),
3222
5945
  new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts/[^/]+$`),
3223
- new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts/[^/]+/download$`)
5946
+ new RegExp(
5947
+ `^/api/workspaces/${escapedWorkspaceId}/artifacts/[^/]+/download$`
5948
+ )
3224
5949
  ];
3225
5950
  if (methodName === "GET" && readPatterns.some((pattern) => pattern.test(pathname))) {
3226
5951
  return true;
@@ -3349,7 +6074,23 @@ var envSchema = z2.object({
3349
6074
  REMOTE_CODEX_RELAY_SESSION_SECRET: z2.string().min(16).optional(),
3350
6075
  REMOTE_CODEX_RELAY_REGISTRATION_ENABLED: z2.string().optional(),
3351
6076
  REMOTE_CODEX_RELAY_REGISTRATION_PASSWORD: z2.string().min(8).optional(),
3352
- REMOTE_CODEX_RELAY_WEB_DIST_DIR: z2.string().min(1).optional()
6077
+ REMOTE_CODEX_PUBLIC_BASE_URL: z2.string().url().optional(),
6078
+ REMOTE_CODEX_GOOGLE_OAUTH_CLIENT_ID: z2.string().min(1).optional(),
6079
+ REMOTE_CODEX_GOOGLE_OAUTH_CLIENT_SECRET: z2.string().min(1).optional(),
6080
+ REMOTE_CODEX_GOOGLE_OAUTH_ENABLED: z2.string().optional(),
6081
+ REMOTE_CODEX_GITHUB_OAUTH_CLIENT_ID: z2.string().min(1).optional(),
6082
+ REMOTE_CODEX_GITHUB_OAUTH_CLIENT_SECRET: z2.string().min(1).optional(),
6083
+ REMOTE_CODEX_GITHUB_OAUTH_ENABLED: z2.string().optional(),
6084
+ REMOTE_CODEX_EMAIL_VERIFICATION_SECRET: z2.string().min(16).optional(),
6085
+ REMOTE_CODEX_POSTMARK_SERVER_TOKEN: z2.string().min(1).optional(),
6086
+ REMOTE_CODEX_RELAY_WEB_DIST_DIR: z2.string().min(1).optional(),
6087
+ REMOTE_CODEX_HOSTED_SANDBOX_PROVIDER: z2.enum(["disabled", "incus"]).optional(),
6088
+ REMOTE_CODEX_INCUS_HOST_AGENT_URL: z2.string().url().optional(),
6089
+ REMOTE_CODEX_INCUS_HOST_AGENT_TOKEN: z2.string().min(16).optional(),
6090
+ REMOTE_CODEX_HOSTED_RELAY_SERVER_URL: z2.string().url().refine((value) => value.startsWith("ws://") || value.startsWith("wss://")).optional(),
6091
+ REMOTE_CODEX_INCUS_HOST_AGENT_TIMEOUT_MS: z2.coerce.number().int().positive().max(3e4).optional(),
6092
+ REMOTE_CODEX_HOSTED_IDLE_TIMEOUT_MS: z2.coerce.number().int().positive().max(24 * 60 * 6e4).optional(),
6093
+ REMOTE_CODEX_HOSTED_RECONCILE_INTERVAL_MS: z2.coerce.number().int().min(1e4).max(24 * 60 * 6e4).optional()
3353
6094
  });
3354
6095
  function optionalNonEmpty(value) {
3355
6096
  const normalized = value?.trim();
@@ -3369,7 +6110,9 @@ function normalizeOptionalEnv(env) {
3369
6110
  env.REMOTE_CODEX_RELAY_CLIENT_TOKEN
3370
6111
  ),
3371
6112
  REMOTE_CODEX_ADMIN_EMAIL: optionalNonEmpty(env.REMOTE_CODEX_ADMIN_EMAIL),
3372
- REMOTE_CODEX_RELAY_DATA_DIR: optionalNonEmpty(env.REMOTE_CODEX_RELAY_DATA_DIR),
6113
+ REMOTE_CODEX_RELAY_DATA_DIR: optionalNonEmpty(
6114
+ env.REMOTE_CODEX_RELAY_DATA_DIR
6115
+ ),
3373
6116
  REMOTE_CODEX_RELAY_SESSION_SECRET: optionalNonEmpty(
3374
6117
  env.REMOTE_CODEX_RELAY_SESSION_SECRET
3375
6118
  ),
@@ -3379,8 +6122,38 @@ function normalizeOptionalEnv(env) {
3379
6122
  REMOTE_CODEX_RELAY_REGISTRATION_PASSWORD: optionalNonEmpty(
3380
6123
  env.REMOTE_CODEX_RELAY_REGISTRATION_PASSWORD
3381
6124
  ),
6125
+ REMOTE_CODEX_PUBLIC_BASE_URL: optionalNonEmpty(env.REMOTE_CODEX_PUBLIC_BASE_URL),
6126
+ REMOTE_CODEX_GOOGLE_OAUTH_CLIENT_ID: optionalNonEmpty(env.REMOTE_CODEX_GOOGLE_OAUTH_CLIENT_ID),
6127
+ REMOTE_CODEX_GOOGLE_OAUTH_CLIENT_SECRET: optionalNonEmpty(env.REMOTE_CODEX_GOOGLE_OAUTH_CLIENT_SECRET),
6128
+ REMOTE_CODEX_GOOGLE_OAUTH_ENABLED: optionalNonEmpty(env.REMOTE_CODEX_GOOGLE_OAUTH_ENABLED),
6129
+ REMOTE_CODEX_GITHUB_OAUTH_CLIENT_ID: optionalNonEmpty(env.REMOTE_CODEX_GITHUB_OAUTH_CLIENT_ID),
6130
+ REMOTE_CODEX_GITHUB_OAUTH_CLIENT_SECRET: optionalNonEmpty(env.REMOTE_CODEX_GITHUB_OAUTH_CLIENT_SECRET),
6131
+ REMOTE_CODEX_GITHUB_OAUTH_ENABLED: optionalNonEmpty(env.REMOTE_CODEX_GITHUB_OAUTH_ENABLED),
6132
+ REMOTE_CODEX_EMAIL_VERIFICATION_SECRET: optionalNonEmpty(env.REMOTE_CODEX_EMAIL_VERIFICATION_SECRET),
6133
+ REMOTE_CODEX_POSTMARK_SERVER_TOKEN: optionalNonEmpty(env.REMOTE_CODEX_POSTMARK_SERVER_TOKEN),
3382
6134
  REMOTE_CODEX_RELAY_WEB_DIST_DIR: optionalNonEmpty(
3383
6135
  env.REMOTE_CODEX_RELAY_WEB_DIST_DIR
6136
+ ),
6137
+ REMOTE_CODEX_HOSTED_SANDBOX_PROVIDER: optionalNonEmpty(
6138
+ env.REMOTE_CODEX_HOSTED_SANDBOX_PROVIDER
6139
+ ),
6140
+ REMOTE_CODEX_INCUS_HOST_AGENT_URL: optionalNonEmpty(
6141
+ env.REMOTE_CODEX_INCUS_HOST_AGENT_URL
6142
+ ),
6143
+ REMOTE_CODEX_INCUS_HOST_AGENT_TOKEN: optionalNonEmpty(
6144
+ env.REMOTE_CODEX_INCUS_HOST_AGENT_TOKEN
6145
+ ),
6146
+ REMOTE_CODEX_HOSTED_RELAY_SERVER_URL: optionalNonEmpty(
6147
+ env.REMOTE_CODEX_HOSTED_RELAY_SERVER_URL
6148
+ ),
6149
+ REMOTE_CODEX_INCUS_HOST_AGENT_TIMEOUT_MS: optionalNonEmpty(
6150
+ env.REMOTE_CODEX_INCUS_HOST_AGENT_TIMEOUT_MS
6151
+ ),
6152
+ REMOTE_CODEX_HOSTED_IDLE_TIMEOUT_MS: optionalNonEmpty(
6153
+ env.REMOTE_CODEX_HOSTED_IDLE_TIMEOUT_MS
6154
+ ),
6155
+ REMOTE_CODEX_HOSTED_RECONCILE_INTERVAL_MS: optionalNonEmpty(
6156
+ env.REMOTE_CODEX_HOSTED_RECONCILE_INTERVAL_MS
3384
6157
  )
3385
6158
  };
3386
6159
  }
@@ -3401,7 +6174,26 @@ function loadRelayServerConfig(env = process.env) {
3401
6174
  ),
3402
6175
  registrationEnabledConfigured: parsed.REMOTE_CODEX_RELAY_REGISTRATION_ENABLED !== void 0,
3403
6176
  registrationPassword: parsed.REMOTE_CODEX_RELAY_REGISTRATION_PASSWORD ?? null,
3404
- webDistDir: parsed.REMOTE_CODEX_RELAY_WEB_DIST_DIR ?? defaultRelayWebDistDir()
6177
+ publicBaseUrl: parsed.REMOTE_CODEX_PUBLIC_BASE_URL?.replace(/\/$/, "") ?? null,
6178
+ googleOAuthClientId: parsed.REMOTE_CODEX_GOOGLE_OAUTH_CLIENT_ID ?? null,
6179
+ googleOAuthClientSecret: parsed.REMOTE_CODEX_GOOGLE_OAUTH_CLIENT_SECRET ?? null,
6180
+ googleOAuthEnabled: parsed.REMOTE_CODEX_GOOGLE_OAUTH_ENABLED !== "false",
6181
+ githubOAuthClientId: parsed.REMOTE_CODEX_GITHUB_OAUTH_CLIENT_ID ?? null,
6182
+ githubOAuthClientSecret: parsed.REMOTE_CODEX_GITHUB_OAUTH_CLIENT_SECRET ?? null,
6183
+ githubOAuthEnabled: parsed.REMOTE_CODEX_GITHUB_OAUTH_ENABLED !== "false",
6184
+ emailVerificationConfigured: Boolean(
6185
+ parsed.REMOTE_CODEX_EMAIL_VERIFICATION_SECRET && parsed.REMOTE_CODEX_POSTMARK_SERVER_TOKEN
6186
+ ),
6187
+ webDistDir: parsed.REMOTE_CODEX_RELAY_WEB_DIST_DIR ?? defaultRelayWebDistDir(),
6188
+ hostedSandbox: {
6189
+ provider: parsed.REMOTE_CODEX_HOSTED_SANDBOX_PROVIDER ?? "disabled",
6190
+ agentUrl: parsed.REMOTE_CODEX_INCUS_HOST_AGENT_URL ?? null,
6191
+ agentToken: parsed.REMOTE_CODEX_INCUS_HOST_AGENT_TOKEN ?? null,
6192
+ relayServerUrl: parsed.REMOTE_CODEX_HOSTED_RELAY_SERVER_URL ?? null,
6193
+ requestTimeoutMs: parsed.REMOTE_CODEX_INCUS_HOST_AGENT_TIMEOUT_MS ?? 1500,
6194
+ idleTimeoutMs: parsed.REMOTE_CODEX_HOSTED_IDLE_TIMEOUT_MS ?? 30 * 6e4,
6195
+ reconcileIntervalMs: parsed.REMOTE_CODEX_HOSTED_RECONCILE_INTERVAL_MS ?? 5 * 6e4
6196
+ }
3405
6197
  };
3406
6198
  }
3407
6199
  function defaultRelayWebDistDir() {