gestalt-mobile 0.9.0 → 0.11.0

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 (40) hide show
  1. package/README.md +83 -14
  2. package/dist/client/assets/{index-DrULMr7l.css → index-Bkm5GDCs.css} +1 -1
  3. package/dist/client/assets/index-DviggEIS.js +19 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/server/app.js +52 -3
  6. package/dist/server/server/cli.js +6 -0
  7. package/dist/server/server/composition.js +212 -150
  8. package/dist/server/server/config.js +64 -2
  9. package/dist/server/server/features/auth/application/ports.js +8 -0
  10. package/dist/server/server/features/auth/devices/application.js +26 -0
  11. package/dist/server/server/features/auth/devices/list/endpoint.js +12 -0
  12. package/dist/server/server/features/auth/devices/rename/endpoint.js +37 -0
  13. package/dist/server/server/features/auth/devices/revoke/endpoint.js +48 -0
  14. package/dist/server/server/features/auth/domain/authorization.js +101 -0
  15. package/dist/server/server/features/auth/domain/device-nickname.js +13 -0
  16. package/dist/server/server/features/auth/domain/errors.js +7 -0
  17. package/dist/server/server/features/auth/domain/identifiers.js +45 -0
  18. package/dist/server/server/features/auth/enrollment-tickets/cancel/endpoint.js +14 -0
  19. package/dist/server/server/features/auth/enrollment-tickets/endpoint.js +17 -0
  20. package/dist/server/server/features/auth/enrollment-tickets/status/endpoint.js +13 -0
  21. package/dist/server/server/features/auth/login/options/endpoint.js +44 -0
  22. package/dist/server/server/features/auth/login/verify/endpoint.js +88 -0
  23. package/dist/server/server/features/auth/logout/endpoint.js +16 -0
  24. package/dist/server/server/features/auth/register/options/endpoint.js +34 -0
  25. package/dist/server/server/features/auth/register/verify/endpoint.js +56 -0
  26. package/dist/server/server/features/auth/status/endpoint.js +24 -0
  27. package/dist/server/server/features/sessions/get-history/endpoint.js +23 -1
  28. package/dist/server/server/features/sessions/restore-session/endpoint.js +24 -4
  29. package/dist/server/server/features/sessions/restore-session/use-case.js +20 -0
  30. package/dist/server/server/features/sessions/session-events/endpoint.js +7 -2
  31. package/dist/server/server/platform/auth/simple-webauthn-adapter.js +75 -0
  32. package/dist/server/server/platform/auth/sqlite-authorization-store.js +478 -0
  33. package/dist/server/server/platform/codex/json-rpc-client.js +36 -2
  34. package/dist/server/server/platform/codex/session-runtime.js +64 -22
  35. package/dist/server/server/platform/events/websocket-heartbeat.js +5 -1
  36. package/dist/server/server/platform/http/authorization-boundary.js +60 -0
  37. package/dist/server/server/platform/plans/filesystem-plan-status-source.js +30 -11
  38. package/dist/server/shared/contracts/plan-signal.js +17 -0
  39. package/package.json +8 -2
  40. package/dist/client/assets/index-D7-LoAAR.js +0 -12
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import fastifyStatic from '@fastify/static';
7
+ import fastifyCookie from '@fastify/cookie';
7
8
  import fastify, {} from 'fastify';
8
9
  import { registerGetHealth } from './features/health/get-health/endpoint.js';
9
10
  import { registerGetBootstrap } from './features/catalog/get-bootstrap/endpoint.js';
@@ -33,22 +34,60 @@ import { registerGetPlan } from './features/plans/get-plan/endpoint.js';
33
34
  import { registerClosePlan } from './features/plans/close-plan/endpoint.js';
34
35
  import { registerGetPlanMeasurement } from './features/plans/get-measurement/endpoint.js';
35
36
  import { registerProblemHandler } from './platform/http/problem-handler.js';
37
+ import { authorizationSessionDevice, registerAuthorizationBoundary, } from './platform/http/authorization-boundary.js';
36
38
  import { registerListAvailableSkills, } from './features/skills/list-available/endpoint.js';
37
39
  import { registerListSkillProfiles, } from './features/skills/list-profiles/endpoint.js';
38
40
  import { registerReplaceSkillProfile, } from './features/skills/replace-profile/endpoint.js';
39
41
  import { registerDeleteSkillProfile, } from './features/skills/delete-profile/endpoint.js';
42
+ import { registerRegistrationOptions } from './features/auth/register/options/endpoint.js';
43
+ import { registerRegistrationVerification } from './features/auth/register/verify/endpoint.js';
44
+ import { registerLoginOptions } from './features/auth/login/options/endpoint.js';
45
+ import { registerLoginVerification } from './features/auth/login/verify/endpoint.js';
46
+ import { registerAuthStatus, registerDisabledAuthStatus, } from './features/auth/status/endpoint.js';
47
+ import { registerLogout } from './features/auth/logout/endpoint.js';
48
+ import { registerListAuthorizedDevices } from './features/auth/devices/list/endpoint.js';
49
+ import { registerRenameAuthorizedDevice } from './features/auth/devices/rename/endpoint.js';
50
+ import { registerRevokeAuthorizedDevice } from './features/auth/devices/revoke/endpoint.js';
51
+ import { registerCreateEnrollmentTicket } from './features/auth/enrollment-tickets/endpoint.js';
52
+ import { registerEnrollmentTicketStatus } from './features/auth/enrollment-tickets/status/endpoint.js';
53
+ import { registerCancelEnrollmentTicket } from './features/auth/enrollment-tickets/cancel/endpoint.js';
40
54
  export async function buildApp(deps) {
41
55
  const app = fastify({ logger: false });
56
+ await app.register(fastifyCookie);
42
57
  if (deps.staticDir)
43
58
  await app.register(fastifyStatic, { root: deps.staticDir });
59
+ if (deps.auth)
60
+ registerAuthorizationBoundary(app, {
61
+ repository: deps.auth.repository,
62
+ clock: deps.auth.clock,
63
+ publicOrigin: deps.auth.relyingParty.publicOrigin,
64
+ });
44
65
  registerGetHealth(app, deps.health);
66
+ if (deps.auth) {
67
+ registerRegistrationOptions(app, deps.auth);
68
+ registerRegistrationVerification(app, deps.auth);
69
+ registerLoginOptions(app, deps.auth);
70
+ registerLoginVerification(app, deps.auth);
71
+ registerAuthStatus(app, deps.auth);
72
+ registerLogout(app, deps.auth);
73
+ registerListAuthorizedDevices(app, deps.auth);
74
+ registerRenameAuthorizedDevice(app, deps.auth);
75
+ registerRevokeAuthorizedDevice(app, deps.auth);
76
+ registerCreateEnrollmentTicket(app, deps.auth);
77
+ registerEnrollmentTicketStatus(app, deps.auth);
78
+ registerCancelEnrollmentTicket(app, deps.auth);
79
+ }
80
+ else if (deps.passkeyAuthDisabled)
81
+ registerDisabledAuthStatus(app);
45
82
  if (deps.bootstrap)
46
83
  registerGetBootstrap(app, deps.bootstrap);
47
84
  if (deps.recentThreads)
48
85
  registerListRecentThreads(app, {
49
86
  ...deps.recentThreads,
50
87
  metadata: (threadId) => {
51
- const session = deps.sessionRoutes?.list?.().find((candidate) => candidate.threadId === threadId);
88
+ const session = deps.sessionRoutes
89
+ ?.list?.()
90
+ .find((candidate) => candidate.threadId === threadId);
52
91
  if (!session)
53
92
  return null;
54
93
  return {
@@ -56,7 +95,9 @@ export async function buildApp(deps) {
56
95
  ...(session.effectiveSkillSelection?.selectedProfileName === undefined
57
96
  ? {}
58
97
  : { skillProfile: session.effectiveSkillSelection.selectedProfileName }),
59
- ...(session.lastOrgPlan === undefined ? {} : { orgPlanFilename: session.lastOrgPlan.filename }),
98
+ ...(session.lastOrgPlan === undefined
99
+ ? {}
100
+ : { orgPlanFilename: session.lastOrgPlan.filename }),
60
101
  };
61
102
  },
62
103
  });
@@ -138,7 +179,15 @@ export async function buildApp(deps) {
138
179
  });
139
180
  }
140
181
  if (deps.sessionEvents)
141
- registerSessionEvents(app, deps.sessionEvents);
182
+ registerSessionEvents(app, {
183
+ ...deps.sessionEvents,
184
+ ...(deps.auth
185
+ ? {
186
+ publicOrigin: deps.auth.relyingParty.publicOrigin,
187
+ authorized: (cookieHeader) => authorizationSessionDevice(cookieHeader, deps.auth) !== null,
188
+ }
189
+ : {}),
190
+ });
142
191
  if (deps.planRoutes) {
143
192
  registerGetPlan(app, deps.planRoutes);
144
193
  registerClosePlan(app, deps.planRoutes);
@@ -21,6 +21,8 @@ Options:
21
21
  --cwd <path> Workspace root (default: current directory)
22
22
  --host <address> Listen address (default: 127.0.0.1)
23
23
  --port <number> Listen port (default: 3000)
24
+ --public-origin <origin> Exact HTTPS browser origin for passkeys (localhost may use HTTP)
25
+ --disable-passkey-auth Disable passkey access control (unsafe on shared or network hosts)
24
26
  --data-dir <path> Directory for persistent application data
25
27
  --skills <profile> Use a global skill profile for every session
26
28
  --skills list List saved global skill profiles without starting the server
@@ -135,6 +137,8 @@ export async function runCli(dependencies = {}) {
135
137
  if (invocation.command === 'list')
136
138
  return listSkillProfiles(skillProfiles, stdout, stderr);
137
139
  const config = invocation.config;
140
+ if (!config.passkeyAuthEnabled)
141
+ stderr.write('WARNING: Passkey access control is disabled; anyone who can reach this server has full access.\n');
138
142
  let explicitSkillProfile;
139
143
  try {
140
144
  explicitSkillProfile = config.skillsProfile
@@ -152,6 +156,8 @@ export async function runCli(dependencies = {}) {
152
156
  const app = await (dependencies.compose ?? composeRelayApp)({
153
157
  root: config.root,
154
158
  dataDir: config.dataDir,
159
+ relyingParty: config.relyingParty,
160
+ passkeyAuthEnabled: config.passkeyAuthEnabled,
155
161
  staticDir: packagedClientDir(moduleUrl),
156
162
  profiles: new CodexProfileCatalog(),
157
163
  installedCodexVersion: await (dependencies.probeCodexVersion ?? probeCodexVersion)(),
@@ -3,7 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- import { randomUUID } from 'node:crypto';
6
+ import { randomBytes, randomUUID } from 'node:crypto';
7
7
  import { existsSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { basename, dirname, join, resolve } from 'node:path';
@@ -17,6 +17,9 @@ import { CodexSessionRuntime } from './platform/codex/session-runtime.js';
17
17
  import { normalizeCodexNotification } from './platform/codex/normalizer.js';
18
18
  import { migrate } from './platform/persistence/migrate.js';
19
19
  import { openRelayDatabase } from './platform/persistence/sqlite.js';
20
+ import { SqliteAuthorizationStore } from './platform/auth/sqlite-authorization-store.js';
21
+ import { SimpleWebAuthnAdapter } from './platform/auth/simple-webauthn-adapter.js';
22
+ import { authorizationSessionId, authorizedDeviceId } from './features/auth/domain/identifiers.js';
20
23
  import { SqliteSessionRepository } from './platform/persistence/sqlite-session-repository.js';
21
24
  import { SqliteEventJournal } from './platform/persistence/sqlite-event-journal.js';
22
25
  import { SqlitePendingInteractionStore } from './platform/persistence/sqlite-pending-interaction-store.js';
@@ -40,14 +43,30 @@ import { SupervisedPlanRegistry } from './features/plans/application/supervised-
40
43
  import { FilesystemPlanStatusSource } from './platform/plans/filesystem-plan-status-source.js';
41
44
  import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-command.js';
42
45
  import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
46
+ import { createRelyingPartyConfig, } from './config.js';
43
47
  const generatedProtocolVersion = 'codex-cli 0.144.3';
44
48
  export async function composeRelayApp(options) {
49
+ const passkeyAuthEnabled = options.passkeyAuthEnabled ?? true;
50
+ const relyingParty = createRelyingPartyConfig(options.relyingParty.publicOrigin);
51
+ if (relyingParty.rpId !== options.relyingParty.rpId ||
52
+ relyingParty.rpName !== options.relyingParty.rpName)
53
+ throw new Error('Invalid WebAuthn relying-party configuration');
54
+ const authorizationRandom = options.authorizationRandomBytes ?? randomBytes;
55
+ const ownerHandle = passkeyAuthEnabled ? authorizationRandom(32) : undefined;
56
+ if (ownerHandle && ownerHandle.length !== 32)
57
+ throw new Error('Authorization randomness must return exactly 32 bytes');
45
58
  const root = resolve(options.root);
46
59
  const databasePath = options.dataDir
47
60
  ? join(resolve(options.dataDir), 'relay.sqlite')
48
61
  : resolveStateDatabasePath(root, process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'));
49
62
  const database = openRelayDatabase(databasePath);
50
- migrate(database);
63
+ try {
64
+ migrate(database);
65
+ }
66
+ catch (error) {
67
+ database.close();
68
+ throw error;
69
+ }
51
70
  const sessions = new SqliteSessionRepository(database);
52
71
  const journal = new SqliteEventJournal(database);
53
72
  const interactions = new SqlitePendingInteractionStore(database);
@@ -158,164 +177,206 @@ export async function composeRelayApp(options) {
158
177
  });
159
178
  recoverExitedSession = (sessionId) => supervisor.recover(sessionId);
160
179
  }
161
- const app = await buildApp({
162
- health: {
163
- async read() {
164
- return {
165
- status: protocol.compatible ? 'ok' : 'degraded',
166
- version: '0.1.0',
167
- codex: {
168
- installedVersion: options.installedCodexVersion,
169
- protocolVersion: generatedProtocolVersion,
170
- compatible: protocol.compatible,
180
+ let authorization;
181
+ if (passkeyAuthEnabled) {
182
+ try {
183
+ authorization = new SqliteAuthorizationStore(options.homeDirectory ?? homedir(), relyingParty);
184
+ authorization.initializeOwner(ownerHandle);
185
+ }
186
+ catch (error) {
187
+ database.close();
188
+ throw error;
189
+ }
190
+ }
191
+ let app;
192
+ try {
193
+ app = await buildApp({
194
+ ...(authorization ? { auth: {
195
+ repository: authorization,
196
+ clock: { now: options.authorizationClock ?? (() => new Date()) },
197
+ random: {
198
+ bytes: (length) => {
199
+ const value = authorizationRandom(length);
200
+ if (length !== 32 || value.length !== 32)
201
+ throw new Error('Authorization randomness must return exactly 32 bytes');
202
+ return value;
203
+ },
171
204
  },
172
- };
173
- },
174
- },
175
- skills: {
176
- workspaces,
177
- profiles: options.profiles,
178
- catalog: editorSkillCatalog,
179
- selections: skillProfiles,
180
- listGlobalProfileNames: () => skillProfiles.listGlobalProfileNames(),
181
- readGlobalProfile: (name) => skillProfiles.readGlobalProfile(name),
182
- replaceGlobalProfile: (profile) => skillProfiles.replaceGlobalProfile(profile),
183
- deleteGlobalProfile: (name) => skillProfiles.deleteGlobalProfile(name),
184
- profilePath: (name) => skillProfiles.globalProfilePath(name),
185
- },
186
- logger: console,
187
- staticDir: options.staticDir,
188
- recentThreads,
189
- bootstrap: {
190
- workspaces,
191
- profiles: options.profiles,
192
- models,
193
- sessions: {
194
- list: () => sessions.list().map((session) => withPendingInteractions(session)),
195
- },
196
- protocolCompatible: protocol.compatible,
197
- },
198
- sessionRoutes: {
199
- createId: randomUUID,
200
- now: () => new Date().toISOString(),
201
- save: saveSession,
202
- find: (id) => withPendingInteractions(sessions.find(id)),
203
- list: () => sessions.list().map((session) => withPendingInteractions(session)),
204
- workspaces,
205
- profiles: options.profiles,
206
- skillProfiles,
207
- skillCatalog,
208
- defaultSkillProfile: options.explicitSkillProfile,
209
- activate: runtime
210
- ? async (session, settings) => runtime.start(session, new Date().toISOString(), settings)
211
- : undefined,
212
- startTurn: runtime
213
- ? async (session, text) => runtime.startTurn(session, text, new Date().toISOString())
214
- : undefined,
215
- onTurnStarted: (session) => planMeasurementRefresh?.refreshNow(session.id),
216
- models,
217
- readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
218
- currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
219
- interruptTurn: runtime
220
- ? (session, turnId) => runtime.interruptTurn(session, turnId)
221
- : undefined,
222
- restore: runtime
223
- ? (session) => runtime.restore(session, new Date().toISOString())
224
- : undefined,
225
- promoteRecent: runtime
226
- ? (thread) => promoteRecentThread(thread, {
227
- createId: randomUUID,
228
- now: () => new Date().toISOString(),
229
- list: () => sessions.list(),
230
- save: saveSession,
231
- restore: (session) => runtime.restore(session, new Date().toISOString()),
232
- })
233
- : undefined,
234
- release: (session) => RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot,
235
- remove: (id) => sessions.remove(id),
236
- idempotency,
237
- close: runtime
238
- ? (id) => {
239
- planMeasurementRefresh?.stop(id);
240
- return runtime.release(id);
241
- }
242
- : undefined,
243
- replyInteraction: runtime
244
- ? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value)
245
- : undefined,
246
- interactionResolved: (sessionId, requestId, occurredAt) => {
247
- events.publish(journal.append(sessionId, 'interaction.resolved', { requestId }, occurredAt));
205
+ identifiers: {
206
+ sessionId: () => {
207
+ const value = authorizationRandom(32);
208
+ if (value.length !== 32)
209
+ throw new Error('Authorization randomness must return exactly 32 bytes');
210
+ return authorizationSessionId(Buffer.from(value).toString('base64url'));
211
+ },
212
+ deviceId: () => authorizedDeviceId(randomUUID()),
213
+ },
214
+ webauthn: options.authorizationWebauthn ?? new SimpleWebAuthnAdapter(),
215
+ relyingParty,
216
+ } } : { passkeyAuthDisabled: true }),
217
+ health: {
218
+ async read() {
219
+ return {
220
+ status: protocol.compatible ? 'ok' : 'degraded',
221
+ version: '0.1.0',
222
+ codex: {
223
+ installedVersion: options.installedCodexVersion,
224
+ protocolVersion: generatedProtocolVersion,
225
+ compatible: protocol.compatible,
226
+ },
227
+ };
228
+ },
248
229
  },
249
- },
250
- sessionEvents: {
251
- exists: (id) => sessions.find(id) !== null,
252
- since: (id, after) => journal.since(id, after),
253
- subscribe: (id, listener) => events.subscribe(id, listener),
254
- },
255
- planRoutes: {
256
- exists: (id) => sessions.find(id) !== null,
257
- find: (id) => supervisedPlans.find(id),
258
- removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
259
- clear: (id) => supervisedPlans.clear(id),
260
- closed: (id) => {
261
- planMeasurementRefresh?.stop(id);
262
- const occurredAt = new Date().toISOString();
263
- events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
230
+ skills: {
231
+ workspaces,
232
+ profiles: options.profiles,
233
+ catalog: editorSkillCatalog,
234
+ selections: skillProfiles,
235
+ listGlobalProfileNames: () => skillProfiles.listGlobalProfileNames(),
236
+ readGlobalProfile: (name) => skillProfiles.readGlobalProfile(name),
237
+ replaceGlobalProfile: (profile) => skillProfiles.replaceGlobalProfile(profile),
238
+ deleteGlobalProfile: (name) => skillProfiles.deleteGlobalProfile(name),
239
+ profilePath: (name) => skillProfiles.globalProfilePath(name),
264
240
  },
265
- },
266
- ...(runtime
267
- ? {
268
- planMeasurementRoutes: {
269
- exists: (id) => sessions.find(id) !== null,
270
- authorize: (id, authorization) => runtime.authorizePlanMeasurement(id, authorization),
271
- read: async (id) => {
272
- const session = sessions.find(id);
273
- if (!session)
274
- throw new Error('CODEX_SESSION_NOT_RUNNING');
275
- return runtime.readPlanMeasurement(session);
276
- },
241
+ logger: console,
242
+ staticDir: options.staticDir,
243
+ recentThreads,
244
+ bootstrap: {
245
+ workspaces,
246
+ profiles: options.profiles,
247
+ models,
248
+ sessions: {
249
+ list: () => sessions.list().map((session) => withPendingInteractions(session)),
277
250
  },
278
- }
279
- : {}),
280
- interactions: {
281
- resolve: (sessionId, requestId, resolvedAt) => interactions.resolve(sessionId, requestId, resolvedAt),
282
- validate: (sessionId, requestId, value) => {
283
- const interaction = interactions.find(sessionId, requestId);
284
- if (!interaction)
285
- return false;
286
- if (interaction.kind === 'quiz')
287
- return isValidQuizInteractionResponse(interaction.payload, value);
288
- return isValidInteractionResponse(interaction.kind, value);
251
+ protocolCompatible: protocol.compatible,
289
252
  },
290
- },
291
- gitSummary: {
292
- workspaces,
293
- inspect: async (path) => {
294
- const summary = await gitSummaries.inspect(path);
295
- const fetchedAt = gitFetches.lastSuccessfulAt(path);
296
- return { ...summary, fetchedAt: fetchedAt ? new Date(fetchedAt).toISOString() : null };
253
+ sessionRoutes: {
254
+ createId: randomUUID,
255
+ now: () => new Date().toISOString(),
256
+ save: saveSession,
257
+ find: (id) => withPendingInteractions(sessions.find(id)),
258
+ list: () => sessions.list().map((session) => withPendingInteractions(session)),
259
+ workspaces,
260
+ profiles: options.profiles,
261
+ skillProfiles,
262
+ skillCatalog,
263
+ defaultSkillProfile: options.explicitSkillProfile,
264
+ activate: runtime
265
+ ? async (session, settings) => runtime.start(session, new Date().toISOString(), settings)
266
+ : undefined,
267
+ startTurn: runtime
268
+ ? async (session, text) => runtime.startTurn(session, text, new Date().toISOString())
269
+ : undefined,
270
+ onTurnStarted: (session) => planMeasurementRefresh?.refreshNow(session.id),
271
+ models,
272
+ readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
273
+ currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
274
+ interruptTurn: runtime
275
+ ? (session, turnId) => runtime.interruptTurn(session, turnId)
276
+ : undefined,
277
+ restore: runtime
278
+ ? (session) => runtime.restoreWithOutcome(session, new Date().toISOString())
279
+ : undefined,
280
+ promoteRecent: runtime
281
+ ? (thread) => promoteRecentThread(thread, {
282
+ createId: randomUUID,
283
+ now: () => new Date().toISOString(),
284
+ list: () => sessions.list(),
285
+ save: saveSession,
286
+ restore: (session) => runtime.restore(session, new Date().toISOString()),
287
+ })
288
+ : undefined,
289
+ release: (session) => RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot,
290
+ remove: (id) => sessions.remove(id),
291
+ idempotency,
292
+ close: runtime
293
+ ? (id) => {
294
+ planMeasurementRefresh?.stop(id);
295
+ return runtime.release(id);
296
+ }
297
+ : undefined,
298
+ replyInteraction: runtime
299
+ ? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value)
300
+ : undefined,
301
+ interactionResolved: (sessionId, requestId, occurredAt) => {
302
+ events.publish(journal.append(sessionId, 'interaction.resolved', { requestId }, occurredAt));
303
+ },
297
304
  },
298
- inspectForPush: inspectGit,
299
- push: async (path, upstream) => {
300
- await pushUpstream(path, upstream);
301
- gitSummaries.invalidate(path);
305
+ sessionEvents: {
306
+ exists: (id) => sessions.find(id) !== null,
307
+ since: (id, after) => journal.since(id, after),
308
+ subscribe: (id, listener) => events.subscribe(id, listener),
302
309
  },
303
- refresh: async (path) => {
304
- await gitFetches.refresh(path);
305
- gitSummaries.invalidate(path);
310
+ planRoutes: {
311
+ exists: (id) => sessions.find(id) !== null,
312
+ find: (id) => supervisedPlans.find(id),
313
+ removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
314
+ clear: (id) => supervisedPlans.clear(id),
315
+ closed: (id) => {
316
+ planMeasurementRefresh?.stop(id);
317
+ const occurredAt = new Date().toISOString();
318
+ events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
319
+ },
306
320
  },
307
- pull: async (path) => {
308
- await pullRebase(path);
309
- gitSummaries.invalidate(path);
321
+ ...(runtime
322
+ ? {
323
+ planMeasurementRoutes: {
324
+ exists: (id) => sessions.find(id) !== null,
325
+ authorize: (id, authorization) => runtime.authorizePlanMeasurement(id, authorization),
326
+ read: async (id) => {
327
+ const session = sessions.find(id);
328
+ if (!session)
329
+ throw new Error('CODEX_SESSION_NOT_RUNNING');
330
+ return runtime.readPlanMeasurement(session);
331
+ },
332
+ },
333
+ }
334
+ : {}),
335
+ interactions: {
336
+ resolve: (sessionId, requestId, resolvedAt) => interactions.resolve(sessionId, requestId, resolvedAt),
337
+ validate: (sessionId, requestId, value) => {
338
+ const interaction = interactions.find(sessionId, requestId);
339
+ if (!interaction)
340
+ return false;
341
+ if (interaction.kind === 'quiz')
342
+ return isValidQuizInteractionResponse(interaction.payload, value);
343
+ return isValidInteractionResponse(interaction.kind, value);
344
+ },
310
345
  },
311
- checkout: async (path, branch) => {
312
- await checkoutBranch(path, branch);
313
- gitSummaries.invalidate(path);
346
+ gitSummary: {
347
+ workspaces,
348
+ inspect: async (path) => {
349
+ const summary = await gitSummaries.inspect(path);
350
+ const fetchedAt = gitFetches.lastSuccessfulAt(path);
351
+ return { ...summary, fetchedAt: fetchedAt ? new Date(fetchedAt).toISOString() : null };
352
+ },
353
+ inspectForPush: inspectGit,
354
+ push: async (path, upstream) => {
355
+ await pushUpstream(path, upstream);
356
+ gitSummaries.invalidate(path);
357
+ },
358
+ refresh: async (path) => {
359
+ await gitFetches.refresh(path);
360
+ gitSummaries.invalidate(path);
361
+ },
362
+ pull: async (path) => {
363
+ await pullRebase(path);
364
+ gitSummaries.invalidate(path);
365
+ },
366
+ checkout: async (path, branch) => {
367
+ await checkoutBranch(path, branch);
368
+ gitSummaries.invalidate(path);
369
+ },
370
+ clone: cloneRepository,
371
+ idempotency,
314
372
  },
315
- clone: cloneRepository,
316
- idempotency,
317
- },
318
- });
373
+ });
374
+ }
375
+ catch (error) {
376
+ authorization?.close();
377
+ database.close();
378
+ throw error;
379
+ }
319
380
  const restoreActiveSessions = async () => {
320
381
  if (!runtime)
321
382
  return;
@@ -341,6 +402,7 @@ export async function composeRelayApp(options) {
341
402
  runtime?.stopAll();
342
403
  planStatusSource.closeAll();
343
404
  database.close();
405
+ authorization?.close();
344
406
  });
345
407
  return app;
346
408
  }
@@ -4,16 +4,32 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { resolve } from 'node:path';
7
+ import { isIP } from 'node:net';
7
8
  export class CliUsageError extends Error {
8
9
  exitCode = 2;
9
10
  }
10
- const optionNames = new Set(['--cwd', '--host', '--port', '--data-dir', '--skills']);
11
+ const optionNames = new Set([
12
+ '--cwd',
13
+ '--host',
14
+ '--port',
15
+ '--data-dir',
16
+ '--public-origin',
17
+ '--skills',
18
+ ]);
19
+ const flagNames = new Set(['--disable-passkey-auth']);
11
20
  export function parseConfig(args, cwd = process.cwd()) {
12
21
  const values = new Map();
22
+ const flags = new Set();
13
23
  for (let index = 0; index < args.length; index += 1) {
14
24
  const option = args[index];
15
25
  if (!option.startsWith('--'))
16
26
  throw new CliUsageError(`Unexpected argument: ${option}`);
27
+ if (flagNames.has(option)) {
28
+ if (flags.has(option))
29
+ throw new CliUsageError(`Duplicate option: ${option}`);
30
+ flags.add(option);
31
+ continue;
32
+ }
17
33
  if (!optionNames.has(option))
18
34
  throw new CliUsageError(`Unknown option: ${option}`);
19
35
  if (values.has(option))
@@ -28,11 +44,57 @@ export function parseConfig(args, cwd = process.cwd()) {
28
44
  const port = Number(portValue);
29
45
  if (!Number.isInteger(port) || port < 1 || port > 65535)
30
46
  throw new CliUsageError(`Invalid --port: ${portValue}`);
47
+ const host = values.get('--host') ?? '127.0.0.1';
48
+ const passkeyAuthEnabled = !flags.has('--disable-passkey-auth');
49
+ const publicOrigin = values.has('--public-origin')
50
+ ? normalizePublicOrigin(values.get('--public-origin'))
51
+ : defaultPublicOrigin(host, port, passkeyAuthEnabled);
31
52
  return {
32
- host: values.get('--host') ?? '127.0.0.1',
53
+ host,
33
54
  port,
55
+ passkeyAuthEnabled,
56
+ relyingParty: createRelyingPartyConfig(publicOrigin),
34
57
  root: resolve(cwd, values.get('--cwd') ?? '.'),
35
58
  dataDir: values.get('--data-dir'),
36
59
  skillsProfile: values.get('--skills'),
37
60
  };
38
61
  }
62
+ export function normalizePublicOrigin(value) {
63
+ if (!/^[a-z][a-z\d+.-]*:\/\/[^/?#\s\\\\]+\/?$/i.test(value))
64
+ throw new CliUsageError('--public-origin must be a bare origin without a path, query, or fragment');
65
+ let url;
66
+ try {
67
+ url = new URL(value);
68
+ }
69
+ catch {
70
+ throw new CliUsageError(`Invalid --public-origin: ${value}`);
71
+ }
72
+ if (url.username || url.password)
73
+ throw new CliUsageError('--public-origin must not include credentials');
74
+ if (url.pathname !== '/' || url.search || url.hash)
75
+ throw new CliUsageError('--public-origin must be a bare origin without a path, query, or fragment');
76
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
77
+ if (isIP(hostname))
78
+ throw new CliUsageError('--public-origin must use a hostname, not an IP address');
79
+ if (url.protocol === 'http:' && url.hostname === 'localhost')
80
+ return url.origin;
81
+ if (url.protocol !== 'https:')
82
+ throw new CliUsageError('--public-origin must use HTTPS, except for http://localhost');
83
+ return url.origin;
84
+ }
85
+ export function createRelyingPartyConfig(publicOrigin) {
86
+ const normalizedOrigin = normalizePublicOrigin(publicOrigin);
87
+ return {
88
+ publicOrigin: normalizedOrigin,
89
+ rpId: new URL(normalizedOrigin).hostname,
90
+ rpName: 'Gestalt Mobile',
91
+ };
92
+ }
93
+ function defaultPublicOrigin(host, port, passkeyAuthEnabled) {
94
+ if (passkeyAuthEnabled && !isLoopbackHost(host))
95
+ throw new CliUsageError('--public-origin is required when --host is not a loopback address');
96
+ return `http://localhost:${port}`;
97
+ }
98
+ function isLoopbackHost(host) {
99
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
100
+ }
@@ -0,0 +1,8 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ /** Expected proof failure; adapters must translate library-specific failures to this type. */
7
+ export class PasskeyVerificationError extends Error {
8
+ }