gestalt-mobile 0.10.0 → 0.12.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.
- package/README.md +64 -14
- package/dist/client/assets/index-B7KwEjTE.js +19 -0
- package/dist/client/assets/index-CMs5ZzxJ.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +58 -3
- package/dist/server/server/cli.js +6 -0
- package/dist/server/server/composition.js +221 -150
- package/dist/server/server/config.js +64 -2
- package/dist/server/server/features/auth/application/ports.js +8 -0
- package/dist/server/server/features/auth/devices/application.js +26 -0
- package/dist/server/server/features/auth/devices/list/endpoint.js +12 -0
- package/dist/server/server/features/auth/devices/rename/endpoint.js +37 -0
- package/dist/server/server/features/auth/devices/revoke/endpoint.js +48 -0
- package/dist/server/server/features/auth/domain/authorization.js +101 -0
- package/dist/server/server/features/auth/domain/device-nickname.js +13 -0
- package/dist/server/server/features/auth/domain/errors.js +7 -0
- package/dist/server/server/features/auth/domain/identifiers.js +45 -0
- package/dist/server/server/features/auth/enrollment-tickets/cancel/endpoint.js +14 -0
- package/dist/server/server/features/auth/enrollment-tickets/endpoint.js +17 -0
- package/dist/server/server/features/auth/enrollment-tickets/status/endpoint.js +13 -0
- package/dist/server/server/features/auth/login/options/endpoint.js +44 -0
- package/dist/server/server/features/auth/login/verify/endpoint.js +88 -0
- package/dist/server/server/features/auth/logout/endpoint.js +16 -0
- package/dist/server/server/features/auth/register/options/endpoint.js +34 -0
- package/dist/server/server/features/auth/register/verify/endpoint.js +56 -0
- package/dist/server/server/features/auth/status/endpoint.js +24 -0
- package/dist/server/server/features/plans/application/native-plan-alignment.js +39 -0
- package/dist/server/server/features/plans/domain/workspace-plan-catalog.js +6 -0
- package/dist/server/server/features/plans/get-workspace-plan/endpoint.js +23 -0
- package/dist/server/server/features/plans/list-workspace-plans/endpoint.js +18 -0
- package/dist/server/server/features/sessions/session-events/endpoint.js +7 -2
- package/dist/server/server/platform/auth/simple-webauthn-adapter.js +75 -0
- package/dist/server/server/platform/auth/sqlite-authorization-store.js +478 -0
- package/dist/server/server/platform/codex/session-runtime.js +28 -0
- package/dist/server/server/platform/codex/thread-plan-name.js +23 -0
- package/dist/server/server/platform/events/websocket-heartbeat.js +5 -1
- package/dist/server/server/platform/http/authorization-boundary.js +60 -0
- package/dist/server/server/platform/plans/filesystem-plan-status-source.js +29 -8
- package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +113 -0
- package/package.json +7 -2
- package/dist/client/assets/index-Cjxvsp9k.js +0 -12
- package/dist/client/assets/index-DrULMr7l.css +0 -1
|
@@ -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';
|
|
@@ -37,23 +40,43 @@ import { CodexSkillCatalog } from './platform/skills/codex-skill-catalog.js';
|
|
|
37
40
|
import { CachedSkillCatalog } from './platform/skills/cached-skill-catalog.js';
|
|
38
41
|
import { compileSkillOverride } from './features/skills/model/skill-profile.js';
|
|
39
42
|
import { SupervisedPlanRegistry } from './features/plans/application/supervised-plan-registry.js';
|
|
43
|
+
import { NativePlanAlignmentRegistry } from './features/plans/application/native-plan-alignment.js';
|
|
40
44
|
import { FilesystemPlanStatusSource } from './platform/plans/filesystem-plan-status-source.js';
|
|
45
|
+
import { FilesystemWorkspacePlanCatalog } from './platform/plans/filesystem-workspace-plan-catalog.js';
|
|
41
46
|
import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-command.js';
|
|
42
47
|
import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
|
|
48
|
+
import { createRelyingPartyConfig, } from './config.js';
|
|
43
49
|
const generatedProtocolVersion = 'codex-cli 0.144.3';
|
|
44
50
|
export async function composeRelayApp(options) {
|
|
51
|
+
const passkeyAuthEnabled = options.passkeyAuthEnabled ?? true;
|
|
52
|
+
const relyingParty = createRelyingPartyConfig(options.relyingParty.publicOrigin);
|
|
53
|
+
if (relyingParty.rpId !== options.relyingParty.rpId ||
|
|
54
|
+
relyingParty.rpName !== options.relyingParty.rpName)
|
|
55
|
+
throw new Error('Invalid WebAuthn relying-party configuration');
|
|
56
|
+
const authorizationRandom = options.authorizationRandomBytes ?? randomBytes;
|
|
57
|
+
const ownerHandle = passkeyAuthEnabled ? authorizationRandom(32) : undefined;
|
|
58
|
+
if (ownerHandle && ownerHandle.length !== 32)
|
|
59
|
+
throw new Error('Authorization randomness must return exactly 32 bytes');
|
|
45
60
|
const root = resolve(options.root);
|
|
46
61
|
const databasePath = options.dataDir
|
|
47
62
|
? join(resolve(options.dataDir), 'relay.sqlite')
|
|
48
63
|
: resolveStateDatabasePath(root, process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'));
|
|
49
64
|
const database = openRelayDatabase(databasePath);
|
|
50
|
-
|
|
65
|
+
try {
|
|
66
|
+
migrate(database);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
database.close();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
51
72
|
const sessions = new SqliteSessionRepository(database);
|
|
52
73
|
const journal = new SqliteEventJournal(database);
|
|
53
74
|
const interactions = new SqlitePendingInteractionStore(database);
|
|
54
75
|
const idempotency = new SqliteIdempotencyStore(database);
|
|
55
76
|
const supervisedPlans = new SupervisedPlanRegistry();
|
|
77
|
+
const nativePlanAlignment = new NativePlanAlignmentRegistry();
|
|
56
78
|
const planStatusSource = new FilesystemPlanStatusSource(join(dirname(databasePath), 'plans'));
|
|
79
|
+
const workspacePlanCatalog = new FilesystemWorkspacePlanCatalog();
|
|
57
80
|
const planMeasurementHelperPath = options.planMeasurementHelperPath ?? process.env.GESTALT_MOBILE_ORG_PLAN_HELPER;
|
|
58
81
|
const withPendingInteractions = (session) => (session ? { ...session, pendingInteractions: interactions.list(session.id) } : null);
|
|
59
82
|
const events = new SessionEventBus();
|
|
@@ -91,6 +114,8 @@ export async function composeRelayApp(options) {
|
|
|
91
114
|
const runtime = options.startAppServers
|
|
92
115
|
? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification) => {
|
|
93
116
|
const occurredAt = new Date().toISOString();
|
|
117
|
+
if (notification.method === 'turn/plan/updated')
|
|
118
|
+
nativePlanAlignment.observe(sessionId, sessions.find(sessionId)?.threadId ?? null, notification.params);
|
|
94
119
|
const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, sessions.find(sessionId)?.workspacePath);
|
|
95
120
|
if (!normalized)
|
|
96
121
|
return;
|
|
@@ -116,6 +141,8 @@ export async function composeRelayApp(options) {
|
|
|
116
141
|
supervisedPlans.accept(sessionId, update);
|
|
117
142
|
planMeasurementRefresh?.accept(sessionId, update);
|
|
118
143
|
if (update.kind === 'updated') {
|
|
144
|
+
nativePlanAlignment.replace(sessionId, update.plan);
|
|
145
|
+
void runtime?.syncThreadPlanName(sessionId, update.plan);
|
|
119
146
|
const occurredAt = new Date().toISOString();
|
|
120
147
|
const session = sessions.find(sessionId);
|
|
121
148
|
if (session) {
|
|
@@ -158,164 +185,207 @@ export async function composeRelayApp(options) {
|
|
|
158
185
|
});
|
|
159
186
|
recoverExitedSession = (sessionId) => supervisor.recover(sessionId);
|
|
160
187
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
188
|
+
let authorization;
|
|
189
|
+
if (passkeyAuthEnabled) {
|
|
190
|
+
try {
|
|
191
|
+
authorization = new SqliteAuthorizationStore(options.homeDirectory ?? homedir(), relyingParty);
|
|
192
|
+
authorization.initializeOwner(ownerHandle);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
database.close();
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
let app;
|
|
200
|
+
try {
|
|
201
|
+
app = await buildApp({
|
|
202
|
+
...(authorization ? { auth: {
|
|
203
|
+
repository: authorization,
|
|
204
|
+
clock: { now: options.authorizationClock ?? (() => new Date()) },
|
|
205
|
+
random: {
|
|
206
|
+
bytes: (length) => {
|
|
207
|
+
const value = authorizationRandom(length);
|
|
208
|
+
if (length !== 32 || value.length !== 32)
|
|
209
|
+
throw new Error('Authorization randomness must return exactly 32 bytes');
|
|
210
|
+
return value;
|
|
211
|
+
},
|
|
171
212
|
},
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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.restoreWithOutcome(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));
|
|
213
|
+
identifiers: {
|
|
214
|
+
sessionId: () => {
|
|
215
|
+
const value = authorizationRandom(32);
|
|
216
|
+
if (value.length !== 32)
|
|
217
|
+
throw new Error('Authorization randomness must return exactly 32 bytes');
|
|
218
|
+
return authorizationSessionId(Buffer.from(value).toString('base64url'));
|
|
219
|
+
},
|
|
220
|
+
deviceId: () => authorizedDeviceId(randomUUID()),
|
|
221
|
+
},
|
|
222
|
+
webauthn: options.authorizationWebauthn ?? new SimpleWebAuthnAdapter(),
|
|
223
|
+
relyingParty,
|
|
224
|
+
} } : { passkeyAuthDisabled: true }),
|
|
225
|
+
health: {
|
|
226
|
+
async read() {
|
|
227
|
+
return {
|
|
228
|
+
status: protocol.compatible ? 'ok' : 'degraded',
|
|
229
|
+
version: '0.1.0',
|
|
230
|
+
codex: {
|
|
231
|
+
installedVersion: options.installedCodexVersion,
|
|
232
|
+
protocolVersion: generatedProtocolVersion,
|
|
233
|
+
compatible: protocol.compatible,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
},
|
|
248
237
|
},
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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));
|
|
238
|
+
skills: {
|
|
239
|
+
workspaces,
|
|
240
|
+
profiles: options.profiles,
|
|
241
|
+
catalog: editorSkillCatalog,
|
|
242
|
+
selections: skillProfiles,
|
|
243
|
+
listGlobalProfileNames: () => skillProfiles.listGlobalProfileNames(),
|
|
244
|
+
readGlobalProfile: (name) => skillProfiles.readGlobalProfile(name),
|
|
245
|
+
replaceGlobalProfile: (profile) => skillProfiles.replaceGlobalProfile(profile),
|
|
246
|
+
deleteGlobalProfile: (name) => skillProfiles.deleteGlobalProfile(name),
|
|
247
|
+
profilePath: (name) => skillProfiles.globalProfilePath(name),
|
|
264
248
|
},
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
275
|
-
return runtime.readPlanMeasurement(session);
|
|
276
|
-
},
|
|
249
|
+
logger: console,
|
|
250
|
+
staticDir: options.staticDir,
|
|
251
|
+
recentThreads,
|
|
252
|
+
bootstrap: {
|
|
253
|
+
workspaces,
|
|
254
|
+
profiles: options.profiles,
|
|
255
|
+
models,
|
|
256
|
+
sessions: {
|
|
257
|
+
list: () => sessions.list().map((session) => withPendingInteractions(session)),
|
|
277
258
|
},
|
|
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);
|
|
259
|
+
protocolCompatible: protocol.compatible,
|
|
289
260
|
},
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
261
|
+
sessionRoutes: {
|
|
262
|
+
createId: randomUUID,
|
|
263
|
+
now: () => new Date().toISOString(),
|
|
264
|
+
save: saveSession,
|
|
265
|
+
find: (id) => withPendingInteractions(sessions.find(id)),
|
|
266
|
+
list: () => sessions.list().map((session) => withPendingInteractions(session)),
|
|
267
|
+
workspaces,
|
|
268
|
+
profiles: options.profiles,
|
|
269
|
+
skillProfiles,
|
|
270
|
+
skillCatalog,
|
|
271
|
+
defaultSkillProfile: options.explicitSkillProfile,
|
|
272
|
+
activate: runtime
|
|
273
|
+
? async (session, settings) => runtime.start(session, new Date().toISOString(), settings)
|
|
274
|
+
: undefined,
|
|
275
|
+
startTurn: runtime
|
|
276
|
+
? async (session, text) => runtime.startTurn(session, text, new Date().toISOString())
|
|
277
|
+
: undefined,
|
|
278
|
+
onTurnStarted: (session) => planMeasurementRefresh?.refreshNow(session.id),
|
|
279
|
+
models,
|
|
280
|
+
readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
|
|
281
|
+
currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
|
|
282
|
+
interruptTurn: runtime
|
|
283
|
+
? (session, turnId) => runtime.interruptTurn(session, turnId)
|
|
284
|
+
: undefined,
|
|
285
|
+
restore: runtime
|
|
286
|
+
? (session) => runtime.restoreWithOutcome(session, new Date().toISOString())
|
|
287
|
+
: undefined,
|
|
288
|
+
promoteRecent: runtime
|
|
289
|
+
? (thread) => promoteRecentThread(thread, {
|
|
290
|
+
createId: randomUUID,
|
|
291
|
+
now: () => new Date().toISOString(),
|
|
292
|
+
list: () => sessions.list(),
|
|
293
|
+
save: saveSession,
|
|
294
|
+
restore: (session) => runtime.restore(session, new Date().toISOString()),
|
|
295
|
+
})
|
|
296
|
+
: undefined,
|
|
297
|
+
release: (session) => RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot,
|
|
298
|
+
remove: (id) => sessions.remove(id),
|
|
299
|
+
idempotency,
|
|
300
|
+
close: runtime
|
|
301
|
+
? (id) => {
|
|
302
|
+
planMeasurementRefresh?.stop(id);
|
|
303
|
+
return runtime.release(id);
|
|
304
|
+
}
|
|
305
|
+
: undefined,
|
|
306
|
+
replyInteraction: runtime
|
|
307
|
+
? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value)
|
|
308
|
+
: undefined,
|
|
309
|
+
interactionResolved: (sessionId, requestId, occurredAt) => {
|
|
310
|
+
events.publish(journal.append(sessionId, 'interaction.resolved', { requestId }, occurredAt));
|
|
311
|
+
},
|
|
297
312
|
},
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
313
|
+
sessionEvents: {
|
|
314
|
+
exists: (id) => sessions.find(id) !== null,
|
|
315
|
+
since: (id, after) => journal.since(id, after),
|
|
316
|
+
subscribe: (id, listener) => events.subscribe(id, listener),
|
|
302
317
|
},
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
318
|
+
planRoutes: {
|
|
319
|
+
exists: (id) => sessions.find(id) !== null,
|
|
320
|
+
find: (id) => supervisedPlans.find(id),
|
|
321
|
+
removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
|
|
322
|
+
clear: (id) => supervisedPlans.clear(id),
|
|
323
|
+
closed: (id) => {
|
|
324
|
+
planMeasurementRefresh?.stop(id);
|
|
325
|
+
const occurredAt = new Date().toISOString();
|
|
326
|
+
events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
|
|
327
|
+
},
|
|
306
328
|
},
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
329
|
+
workspacePlanRoutes: { workspaces, plans: workspacePlanCatalog },
|
|
330
|
+
...(runtime
|
|
331
|
+
? {
|
|
332
|
+
planMeasurementRoutes: {
|
|
333
|
+
exists: (id) => sessions.find(id) !== null,
|
|
334
|
+
authorize: (id, authorization) => runtime.authorizePlanMeasurement(id, authorization),
|
|
335
|
+
read: async (id) => {
|
|
336
|
+
const session = sessions.find(id);
|
|
337
|
+
if (!session)
|
|
338
|
+
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
339
|
+
return runtime.readPlanMeasurement(session);
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
: {}),
|
|
344
|
+
interactions: {
|
|
345
|
+
resolve: (sessionId, requestId, resolvedAt) => interactions.resolve(sessionId, requestId, resolvedAt),
|
|
346
|
+
validate: (sessionId, requestId, value) => {
|
|
347
|
+
const interaction = interactions.find(sessionId, requestId);
|
|
348
|
+
if (!interaction)
|
|
349
|
+
return false;
|
|
350
|
+
if (interaction.kind === 'quiz')
|
|
351
|
+
return isValidQuizInteractionResponse(interaction.payload, value);
|
|
352
|
+
return isValidInteractionResponse(interaction.kind, value);
|
|
353
|
+
},
|
|
310
354
|
},
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
355
|
+
gitSummary: {
|
|
356
|
+
workspaces,
|
|
357
|
+
inspect: async (path) => {
|
|
358
|
+
const summary = await gitSummaries.inspect(path);
|
|
359
|
+
const fetchedAt = gitFetches.lastSuccessfulAt(path);
|
|
360
|
+
return { ...summary, fetchedAt: fetchedAt ? new Date(fetchedAt).toISOString() : null };
|
|
361
|
+
},
|
|
362
|
+
inspectForPush: inspectGit,
|
|
363
|
+
push: async (path, upstream) => {
|
|
364
|
+
await pushUpstream(path, upstream);
|
|
365
|
+
gitSummaries.invalidate(path);
|
|
366
|
+
},
|
|
367
|
+
refresh: async (path) => {
|
|
368
|
+
await gitFetches.refresh(path);
|
|
369
|
+
gitSummaries.invalidate(path);
|
|
370
|
+
},
|
|
371
|
+
pull: async (path) => {
|
|
372
|
+
await pullRebase(path);
|
|
373
|
+
gitSummaries.invalidate(path);
|
|
374
|
+
},
|
|
375
|
+
checkout: async (path, branch) => {
|
|
376
|
+
await checkoutBranch(path, branch);
|
|
377
|
+
gitSummaries.invalidate(path);
|
|
378
|
+
},
|
|
379
|
+
clone: cloneRepository,
|
|
380
|
+
idempotency,
|
|
314
381
|
},
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
authorization?.close();
|
|
386
|
+
database.close();
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
319
389
|
const restoreActiveSessions = async () => {
|
|
320
390
|
if (!runtime)
|
|
321
391
|
return;
|
|
@@ -341,6 +411,7 @@ export async function composeRelayApp(options) {
|
|
|
341
411
|
runtime?.stopAll();
|
|
342
412
|
planStatusSource.closeAll();
|
|
343
413
|
database.close();
|
|
414
|
+
authorization?.close();
|
|
344
415
|
});
|
|
345
416
|
return app;
|
|
346
417
|
}
|
|
@@ -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([
|
|
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
|
|
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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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 { deviceNickname } from '../domain/device-nickname.js';
|
|
7
|
+
/** Maps a credential to deliberately non-WebAuthn management metadata. */
|
|
8
|
+
export function listAuthorizedDeviceMetadata(repository, currentDeviceId) {
|
|
9
|
+
return repository.listAuthorizedDevices().map((device) => ({
|
|
10
|
+
id: device.id,
|
|
11
|
+
nickname: device.nickname,
|
|
12
|
+
createdAt: device.createdAt,
|
|
13
|
+
...(device.lastUsedAt === undefined ? {} : { lastUsedAt: device.lastUsedAt }),
|
|
14
|
+
current: device.id === currentDeviceId,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
export function renameAuthorizedDevice(repository, id, nickname) {
|
|
18
|
+
const validated = deviceNickname(nickname);
|
|
19
|
+
const current = repository.findDevice(id);
|
|
20
|
+
if (!current)
|
|
21
|
+
return 'notFound';
|
|
22
|
+
return repository.renameDevice(id, current.version ?? 0, validated);
|
|
23
|
+
}
|
|
24
|
+
export function revokeAuthorizedDevice(repository, id, revokedAt) {
|
|
25
|
+
return repository.revokeDevice(id, revokedAt);
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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 { authorizationSessionDevice } from '../../../../platform/http/authorization-boundary.js';
|
|
7
|
+
import { listAuthorizedDeviceMetadata } from '../application.js';
|
|
8
|
+
export function registerListAuthorizedDevices(app, deps) {
|
|
9
|
+
app.get('/api/auth/devices', async (request) => ({
|
|
10
|
+
devices: listAuthorizedDeviceMetadata(deps.repository, authorizationSessionDevice(request.headers.cookie, deps)),
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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 { z } from 'zod';
|
|
7
|
+
import { AuthorizationDomainError } from '../../domain/errors.js';
|
|
8
|
+
import { authorizedDeviceId } from '../../domain/identifiers.js';
|
|
9
|
+
import { problem } from '../../../../platform/http/problem.js';
|
|
10
|
+
import { renameAuthorizedDevice } from '../application.js';
|
|
11
|
+
const parameters = z.object({ deviceId: z.string().min(1).max(200) }).strict();
|
|
12
|
+
const requestBody = z.object({ nickname: z.string() }).strict();
|
|
13
|
+
export function registerRenameAuthorizedDevice(app, deps) {
|
|
14
|
+
app.patch('/api/auth/devices/:deviceId', async (request, reply) => {
|
|
15
|
+
const params = parameters.safeParse(request.params);
|
|
16
|
+
const body = requestBody.safeParse(request.body);
|
|
17
|
+
if (!params.success || !body.success)
|
|
18
|
+
return unavailable(reply, 400, 'INVALID_DEVICE_REQUEST');
|
|
19
|
+
try {
|
|
20
|
+
const outcome = renameAuthorizedDevice(deps.repository, authorizedDeviceId(params.data.deviceId), body.data.nickname);
|
|
21
|
+
if (outcome === 'renamed')
|
|
22
|
+
return reply.code(204).send();
|
|
23
|
+
return unavailable(reply, outcome === 'stale' ? 409 : 404, 'DEVICE_NOT_AVAILABLE');
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error instanceof AuthorizationDomainError)
|
|
27
|
+
return unavailable(reply, 400, 'INVALID_DEVICE_REQUEST');
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function unavailable(reply, status, code) {
|
|
33
|
+
return reply
|
|
34
|
+
.code(status)
|
|
35
|
+
.type('application/problem+json')
|
|
36
|
+
.send(problem(code, status, 'The requested device is not available.'));
|
|
37
|
+
}
|