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,38 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { createInterface } from 'node:readline';
7
+ export const CODEX_THREAD_NOT_FOUND = 'CODEX_THREAD_NOT_FOUND';
8
+ export const CODEX_JSON_RPC_ERROR = 'CODEX_JSON_RPC_ERROR';
9
+ /** A bounded representation of an app-server JSON-RPC failure. */
10
+ export class CodexJsonRpcError extends Error {
11
+ code;
12
+ kind;
13
+ constructor(code, message, kind = classifyCodexJsonRpcError(code, message)) {
14
+ super(boundMessage(message));
15
+ this.code = code;
16
+ this.kind = kind;
17
+ this.name = 'CodexJsonRpcError';
18
+ }
19
+ }
20
+ /**
21
+ * Codex 0.146 reports a rollout removed by an upgrade as -32600. Keep the
22
+ * message match deliberately narrow: other invalid-request responses must not
23
+ * be treated as permission to replace a durable thread.
24
+ */
25
+ export function isMissingCodexThreadRollout(error) {
26
+ return error instanceof CodexJsonRpcError && error.kind === CODEX_THREAD_NOT_FOUND;
27
+ }
28
+ function classifyCodexJsonRpcError(code, message) {
29
+ return code === -32600 && /^no rollout found for thread id\b/i.test(message)
30
+ ? CODEX_THREAD_NOT_FOUND
31
+ : CODEX_JSON_RPC_ERROR;
32
+ }
33
+ function boundMessage(message) {
34
+ return message
35
+ .replace(/((?:authorization|token|api[_ -]?key|password)\s*[:=]\s*)\S+/gi, '$1[REDACTED]')
36
+ .replace(/(authentication failed:\s*).*/i, '$1[REDACTED]')
37
+ .slice(0, 256);
38
+ }
7
39
  export class JsonRpcClient {
8
40
  output;
9
41
  sequence = 0;
@@ -59,8 +91,10 @@ export class JsonRpcClient {
59
91
  if (!pending)
60
92
  return;
61
93
  this.pending.delete(message.id);
62
- if (message.error)
63
- pending.reject(message.error);
94
+ if (message.error) {
95
+ const error = message.error;
96
+ pending.reject(new CodexJsonRpcError(typeof error.code === 'number' ? error.code : undefined, typeof error.message === 'string' ? error.message : 'JSON_RPC_ERROR'));
97
+ }
64
98
  else
65
99
  pending.resolve(message.result);
66
100
  }
@@ -6,6 +6,7 @@
6
6
  import { RelaySession, } from '../../features/sessions/model/relay-session.js';
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import { createPlanMeasurementSnapshot, } from '../../features/plans/application/measurement-snapshot.js';
9
+ import { canRebindMissingRollout, rebindMissingRollout, } from '../../features/sessions/restore-session/use-case.js';
9
10
  import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
10
11
  export class CodexSessionRuntime {
11
12
  launch;
@@ -43,22 +44,13 @@ export class CodexSessionRuntime {
43
44
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
44
45
  capabilities: { experimentalApi: true },
45
46
  });
46
- const result = (await process.rpc.request('thread/start', {
47
- cwd: session.workspacePath,
48
- approvalPolicy: settings.approvalPolicy ?? 'on-request',
49
- dynamicTools: [gestaltQuizDynamicTool],
50
- ...(settings.model ? { model: settings.model } : {}),
51
- ...(settings.sandbox ? { sandbox: settings.sandbox } : {}),
52
- }));
53
- if (!result.thread?.id)
54
- throw new Error('CODEX_THREAD_ID_MISSING');
47
+ const startedThreadId = await this.startThread(process, session, settings);
55
48
  this.processes.set(session.id, process);
56
- this.threadIds.set(session.id, result.thread.id);
57
- return RelaySession.rehydrate(session).bindThread(result.thread.id, now).snapshot;
49
+ this.threadIds.set(session.id, startedThreadId);
50
+ return RelaySession.rehydrate(session).bindThread(startedThreadId, now).snapshot;
58
51
  }
59
52
  catch (error) {
60
- process.close();
61
- this.releasePlanStatus(session.id);
53
+ this.discardProcess(session.id, process);
62
54
  throw error;
63
55
  }
64
56
  }
@@ -68,6 +60,8 @@ export class CodexSessionRuntime {
68
60
  this.processes.get(sessionId)?.close();
69
61
  this.processes.delete(sessionId);
70
62
  this.threadIds.delete(sessionId);
63
+ this.clearPendingRequests(sessionId);
64
+ this.planMeasurementTokens.delete(sessionId);
71
65
  this.releasePlanStatus(sessionId);
72
66
  }
73
67
  async release(sessionId) {
@@ -156,6 +150,9 @@ export class CodexSessionRuntime {
156
150
  };
157
151
  }
158
152
  async restore(session, now) {
153
+ return (await this.restoreWithOutcome(session, now)).session;
154
+ }
155
+ async restoreWithOutcome(session, now) {
159
156
  if (!session.threadId)
160
157
  throw new Error('CODEX_THREAD_ID_MISSING');
161
158
  const process = await this.launchForSession(session);
@@ -167,18 +164,31 @@ export class CodexSessionRuntime {
167
164
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
168
165
  capabilities: { experimentalApi: true },
169
166
  });
170
- await process.rpc.request('thread/resume', {
171
- threadId: session.threadId,
172
- cwd: session.workspacePath,
173
- dynamicTools: [gestaltQuizDynamicTool],
174
- });
167
+ let result;
168
+ try {
169
+ await process.rpc.request('thread/resume', {
170
+ threadId: session.threadId,
171
+ cwd: session.workspacePath,
172
+ dynamicTools: [gestaltQuizDynamicTool],
173
+ });
174
+ result = {
175
+ session: RelaySession.rehydrate(session).restore(now).snapshot,
176
+ historyUnavailable: false,
177
+ replacementCreated: false,
178
+ };
179
+ }
180
+ catch (error) {
181
+ if (!canRebindMissingRollout(session, error))
182
+ throw error;
183
+ const replacementThreadId = await this.startThread(process, session, { model: session.model });
184
+ result = rebindMissingRollout(session, error, replacementThreadId, now);
185
+ }
175
186
  this.processes.set(session.id, process);
176
- this.threadIds.set(session.id, session.threadId);
177
- return RelaySession.rehydrate(session).restore(now).snapshot;
187
+ this.threadIds.set(session.id, result.session.threadId);
188
+ return result;
178
189
  }
179
190
  catch (error) {
180
- process.close();
181
- this.releasePlanStatus(session.id);
191
+ this.discardProcess(session.id, process);
182
192
  throw error;
183
193
  }
184
194
  }
@@ -192,12 +202,44 @@ export class CodexSessionRuntime {
192
202
  this.exitUnsubscribers.set(sessionId, process.onExit?.(() => {
193
203
  this.processes.delete(sessionId);
194
204
  this.threadIds.delete(sessionId);
205
+ this.clearPendingRequests(sessionId);
195
206
  this.planMeasurementTokens.delete(sessionId);
196
207
  this.exitUnsubscribers.delete(sessionId);
197
208
  this.releasePlanStatus(sessionId);
198
209
  this.onProcessExit?.(sessionId);
199
210
  }) ?? (() => { }));
200
211
  }
212
+ async startThread(process, session, settings = {}) {
213
+ const result = (await process.rpc.request('thread/start', this.threadStartParams(session, settings)));
214
+ if (typeof result.thread?.id !== 'string' || !result.thread.id) {
215
+ throw new Error('CODEX_THREAD_ID_MISSING');
216
+ }
217
+ return result.thread.id;
218
+ }
219
+ threadStartParams(session, settings) {
220
+ return {
221
+ cwd: session.workspacePath,
222
+ approvalPolicy: settings.approvalPolicy ?? 'on-request',
223
+ dynamicTools: [gestaltQuizDynamicTool],
224
+ ...(settings.model ? { model: settings.model } : {}),
225
+ ...(settings.sandbox ? { sandbox: settings.sandbox } : {}),
226
+ };
227
+ }
228
+ discardProcess(sessionId, process) {
229
+ this.exitUnsubscribers.get(sessionId)?.();
230
+ this.exitUnsubscribers.delete(sessionId);
231
+ process.close();
232
+ this.clearPendingRequests(sessionId);
233
+ this.planMeasurementTokens.delete(sessionId);
234
+ this.releasePlanStatus(sessionId);
235
+ }
236
+ clearPendingRequests(sessionId) {
237
+ const prefix = `${sessionId}:`;
238
+ for (const key of this.pendingRequests.keys()) {
239
+ if (key.startsWith(prefix))
240
+ this.pendingRequests.delete(key);
241
+ }
242
+ }
201
243
  async launchForSession(session) {
202
244
  const lease = this.planStatusSource
203
245
  ? await this.planStatusSource.open({ id: session.id, workspacePath: session.workspacePath }, (update) => this.onPlanStatus?.(session.id, update))
@@ -3,12 +3,16 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- export function installWebSocketHeartbeat(socket, intervalMs = 25_000) {
6
+ export function installWebSocketHeartbeat(socket, intervalMs = 25_000, isAuthorized = () => true) {
7
7
  let alive = true;
8
8
  socket.on('pong', () => {
9
9
  alive = true;
10
10
  });
11
11
  const interval = setInterval(() => {
12
+ if (!isAuthorized()) {
13
+ socket.terminate();
14
+ return;
15
+ }
12
16
  if (!alive) {
13
17
  socket.terminate();
14
18
  return;
@@ -0,0 +1,60 @@
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
+ import { parseAuthorizationSessionId } from '../../features/auth/domain/identifiers.js';
7
+ import { problem } from './problem.js';
8
+ const publicApi = new Set([
9
+ 'GET /api/auth/status',
10
+ 'POST /api/auth/login/options',
11
+ 'POST /api/auth/login/verify',
12
+ 'POST /api/auth/logout',
13
+ 'POST /api/auth/register/options',
14
+ 'POST /api/auth/register/verify',
15
+ ]);
16
+ export function registerAuthorizationBoundary(app, deps) {
17
+ app.addHook('onRequest', async (request, reply) => {
18
+ if (!request.url.startsWith('/api/'))
19
+ return;
20
+ if (isUnsafe(request.method) && request.headers.origin !== deps.publicOrigin)
21
+ return reply
22
+ .code(403)
23
+ .type('application/problem+json')
24
+ .send(problem('ORIGIN_NOT_ALLOWED', 403, 'The request origin is not allowed.'));
25
+ if (publicApi.has(`${request.method} ${request.routeOptions.url}`))
26
+ return;
27
+ if (authorizationSessionDevice(request.headers.cookie, deps) !== null)
28
+ return;
29
+ return reply
30
+ .code(401)
31
+ .type('application/problem+json')
32
+ .send(problem('AUTH_REQUIRED', 401, 'Authentication is required.'));
33
+ });
34
+ }
35
+ /** Shared by HTTP and raw WebSocket upgrades; the opaque cookie is never logged. */
36
+ export function authorizationSessionDevice(cookieHeader, deps) {
37
+ const token = cookieValue(cookieHeader, 'gestalt_mobile_session');
38
+ const session = parseAuthorizationSessionId(token);
39
+ return session === null
40
+ ? null
41
+ : deps.repository.sessionDevice(session, deps.clock.now().toISOString());
42
+ }
43
+ function cookieValue(header, name) {
44
+ if (!header)
45
+ return undefined;
46
+ let found;
47
+ for (const part of header.split(';')) {
48
+ const [key, ...value] = part.trim().split('=');
49
+ if (key !== name)
50
+ continue;
51
+ const candidate = value.join('=');
52
+ if (!candidate || found !== undefined)
53
+ return undefined;
54
+ found = candidate;
55
+ }
56
+ return found;
57
+ }
58
+ function isUnsafe(method) {
59
+ return method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';
60
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { chmod, mkdir, readFile, readdir, rename, rm, stat, watch, writeFile, realpath, } from 'node:fs/promises';
7
7
  import { createHash, randomUUID } from 'node:crypto';
8
+ import { isPlanSignalReason } from '../../../shared/contracts/plan-signal.js';
8
9
  import { basename, dirname, join } from 'node:path';
9
10
  import { parseSupervisedPlan } from '../../features/plans/application/parse-supervised-plan.js';
10
11
  import { isPlanPathWithinWorkspace } from '../../features/plans/application/parse-supervised-plan.js';
@@ -49,13 +50,21 @@ export class FilesystemPlanStatusSource {
49
50
  async remove(sessionId, identity) {
50
51
  const statusPath = this.activeStatusPaths.get(sessionId);
51
52
  const signal = statusPath ? await this.readStatusForRollback(statusPath) : undefined;
52
- if (statusPath)
53
- await this.statusRemovalFilesystem.rm(statusPath, { force: true });
53
+ if (statusPath) {
54
+ try {
55
+ await this.statusRemovalFilesystem.rm(statusPath, { force: true });
56
+ }
57
+ catch (error) {
58
+ this.leases.get(sessionId)?.allowRepeatedUpdate();
59
+ throw error;
60
+ }
61
+ }
54
62
  try {
55
63
  if (identity)
56
64
  await this.dismiss(sessionId, identity);
57
65
  }
58
66
  catch (error) {
67
+ this.leases.get(sessionId)?.allowRepeatedUpdate();
59
68
  if (signal !== undefined && statusPath)
60
69
  await this.restoreStatus(statusPath, signal);
61
70
  throw error;
@@ -146,6 +155,7 @@ class ActiveLease {
146
155
  debounce;
147
156
  closed = false;
148
157
  activeStatusPath;
158
+ lastEmittedUpdate;
149
159
  constructor(statusDirectory, sessionId, workspacePath, listener, planReadFilesystem, isDismissed, onActiveStatusPath, onClose) {
150
160
  this.statusDirectory = statusDirectory;
151
161
  this.sessionId = sessionId;
@@ -163,7 +173,7 @@ class ActiveLease {
163
173
  void this.consumeChanges();
164
174
  }
165
175
  catch {
166
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
176
+ this.emitUnavailable();
167
177
  }
168
178
  }
169
179
  close() {
@@ -179,6 +189,9 @@ class ActiveLease {
179
189
  if (this.activeStatusPath)
180
190
  await rm(this.activeStatusPath, { force: true });
181
191
  }
192
+ allowRepeatedUpdate() {
193
+ this.lastEmittedUpdate = undefined;
194
+ }
182
195
  async consumeChanges() {
183
196
  try {
184
197
  for await (const event of this.watcher) {
@@ -196,7 +209,7 @@ class ActiveLease {
196
209
  }
197
210
  catch {
198
211
  if (!this.closed)
199
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
212
+ this.emitUnavailable();
200
213
  }
201
214
  }
202
215
  pendingStatusPath;
@@ -232,7 +245,7 @@ class ActiveLease {
232
245
  }
233
246
  catch {
234
247
  if (!this.closed)
235
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
248
+ this.emitUnavailable();
236
249
  }
237
250
  }
238
251
  async refresh(statusPath, parsedSignal) {
@@ -259,13 +272,18 @@ class ActiveLease {
259
272
  const previousStatusPath = this.activeStatusPath;
260
273
  this.activeStatusPath = statusPath;
261
274
  this.onActiveStatusPath(statusPath);
262
- this.listener({ kind: 'updated', plan: result.plan, identity, planPath, reason: signal.reason });
275
+ const update = { kind: 'updated', plan: result.plan, identity, planPath, reason: signal.reason };
276
+ const serializedUpdate = JSON.stringify(update);
277
+ if (serializedUpdate !== this.lastEmittedUpdate) {
278
+ this.lastEmittedUpdate = serializedUpdate;
279
+ this.listener(update);
280
+ }
263
281
  if (previousStatusPath && previousStatusPath !== statusPath)
264
282
  await rm(previousStatusPath, { force: true }).catch(() => { });
265
283
  }
266
284
  }
267
285
  else if (!this.closed) {
268
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
286
+ this.emitUnavailable();
269
287
  }
270
288
  }
271
289
  catch (error) {
@@ -274,9 +292,13 @@ class ActiveLease {
274
292
  statusPath !== this.activeStatusPath)
275
293
  return;
276
294
  if (!this.closed)
277
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
295
+ this.emitUnavailable();
278
296
  }
279
297
  }
298
+ emitUnavailable() {
299
+ this.lastEmittedUpdate = undefined;
300
+ this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
301
+ }
280
302
  }
281
303
  function parseSignal(source) {
282
304
  try {
@@ -298,9 +320,6 @@ function parseSignal(source) {
298
320
  return null;
299
321
  }
300
322
  }
301
- function isPlanSignalReason(value) {
302
- return value === 'authoring-start' || value === 'work-start' || value === 'checkpoint' || value === 'update';
303
- }
304
323
  function isRfc3339Utc(value) {
305
324
  return (typeof value === 'string' &&
306
325
  /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) &&
@@ -0,0 +1,17 @@
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
+ /** Every reason published by the bundled org-plan helper. */
7
+ export const planSignalReasons = [
8
+ 'authoring-start',
9
+ 'work-start',
10
+ 'checkpoint',
11
+ 'update',
12
+ 'supervision-start',
13
+ 'resync',
14
+ ];
15
+ export function isPlanSignalReason(value) {
16
+ return typeof value === 'string' && planSignalReasons.includes(value);
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "scripts": {
35
35
  "dev": "node scripts/dev.mjs",
36
- "dev:server": "tsx watch src/server/main.ts -- --cwd ..",
36
+ "dev:server": "tsx watch src/server/main.ts -- --cwd .. --public-origin http://localhost:5173",
37
37
  "start": "node dist/server/server/main.js",
38
38
  "dev:client": "vite",
39
39
  "build": "npm run build:server && npm run build:client",
@@ -45,6 +45,7 @@
45
45
  "format": "prettier --write .",
46
46
  "format:check": "prettier --check .",
47
47
  "test": "vitest run",
48
+ "test:open-profile-smoke": "tsx scripts/open-profile-smoke.ts",
48
49
  "test:watch": "vitest",
49
50
  "test:package": "node scripts/smoke-packed-cli.mjs",
50
51
  "test:e2e": "playwright test",
@@ -54,8 +55,12 @@
54
55
  "license:apply": "node scripts/apply-license-headers.mjs"
55
56
  },
56
57
  "dependencies": {
58
+ "@fastify/cookie": "11.1.2",
57
59
  "@fastify/static": "^10.1.0",
60
+ "@simplewebauthn/browser": "13.3.0",
61
+ "@simplewebauthn/server": "13.3.2",
58
62
  "fastify": "^5.10.0",
63
+ "qrcode": "^1.5.4",
59
64
  "ws": "^8.21.0",
60
65
  "yaml": "^2.9.0",
61
66
  "zod": "^4.4.3"
@@ -65,6 +70,7 @@
65
70
  "@sveltejs/vite-plugin-svelte": "^7.2.0",
66
71
  "@testing-library/svelte": "^5.4.2",
67
72
  "@types/node": "^24.13.3",
73
+ "@types/qrcode": "^1.5.6",
68
74
  "@types/ws": "^8.18.1",
69
75
  "eslint": "^10.7.0",
70
76
  "jsdom": "^29.1.1",