gestalt-mobile 0.20.1 → 0.22.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/dist/client/assets/index-Db9p-xm2.css +1 -0
- package/dist/client/assets/index-NHzJXg1w.js +29 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +2 -0
- package/dist/server/server/composition.contracts.js +142 -6
- package/dist/server/server/composition.js +39 -8
- package/dist/server/server/features/agent-activity/model.js +5 -0
- package/dist/server/server/features/agent-activity/registry.js +12 -1
- package/dist/server/server/features/autopilot/application/service.js +53 -1
- package/dist/server/server/features/files/application/ports.js +6 -0
- package/dist/server/server/features/files/copy-entry/endpoint.js +48 -0
- package/dist/server/server/features/files/delete-entry/endpoint.js +23 -0
- package/dist/server/server/features/files/domain/relative-directory.js +15 -0
- package/dist/server/server/features/files/domain/workspace-directory.js +6 -0
- package/dist/server/server/features/files/list-directory/endpoint.js +49 -0
- package/dist/server/server/features/files/move-entry/endpoint.js +4 -0
- package/dist/server/server/features/files/register-routes.js +19 -0
- package/dist/server/server/features/files/upload-file/endpoint.js +35 -0
- package/dist/server/server/features/sessions/list-sessions/endpoint.js +10 -6
- package/dist/server/server/features/sessions/model/relay-session.js +19 -0
- package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +13 -3
- package/dist/server/server/features/sessions/register-routes.js +1 -0
- package/dist/server/server/features/sessions/start-session/use-case.js +3 -5
- package/dist/server/server/platform/auth/sqlite-authorization-store.js +98 -205
- package/dist/server/server/platform/auth/sqlite.js +54 -0
- package/dist/server/server/platform/codex/activity-facts.js +4 -0
- package/dist/server/server/platform/codex/recent-thread-lister.js +37 -11
- package/dist/server/server/platform/codex/rollout-execution-policy.js +68 -0
- package/dist/server/server/platform/codex/session-runtime.js +76 -17
- package/dist/server/server/platform/filesystem/filesystem-workspace-files.js +559 -0
- package/dist/server/server/platform/persistence/migrate.js +5 -1
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +11 -3
- package/package.json +1 -1
- package/dist/client/assets/index-CnXbn39K.js +0 -29
- package/dist/client/assets/index-DYERmevO.css +0 -1
|
@@ -10,6 +10,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
10
10
|
import { authorizedDeviceId, localOwnerId, passkeyCeremonyId, webAuthnCredentialId, } from '../../features/auth/domain/identifiers.js';
|
|
11
11
|
import { deviceNickname } from '../../features/auth/domain/device-nickname.js';
|
|
12
12
|
import { CeremonyCapacityError } from '../../features/auth/domain/errors.js';
|
|
13
|
+
import { AuthStatementCache, withImmediateTransaction } from './sqlite.js';
|
|
13
14
|
const OWNER_ID = localOwnerId('local-owner');
|
|
14
15
|
const SCHEMA_VERSION = 1;
|
|
15
16
|
const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
@@ -24,6 +25,7 @@ export function authorizationDatabasePath(homeDirectory) {
|
|
|
24
25
|
export class SqliteAuthorizationStore {
|
|
25
26
|
path;
|
|
26
27
|
db;
|
|
28
|
+
statements;
|
|
27
29
|
relyingParty;
|
|
28
30
|
constructor(homeDirectory, relyingParty) {
|
|
29
31
|
this.path = authorizationDatabasePath(homeDirectory);
|
|
@@ -32,6 +34,7 @@ export class SqliteAuthorizationStore {
|
|
|
32
34
|
chmodSync(parent, 0o700);
|
|
33
35
|
this.relyingParty = relyingParty;
|
|
34
36
|
this.db = new DatabaseSync(this.path);
|
|
37
|
+
this.statements = new AuthStatementCache(this.db);
|
|
35
38
|
try {
|
|
36
39
|
// journal_mode can take an exclusive lock. Install the bounded busy handler first so
|
|
37
40
|
// simultaneous first opens wait for the winner instead of failing during startup.
|
|
@@ -62,8 +65,7 @@ export class SqliteAuthorizationStore {
|
|
|
62
65
|
initializeSchemaAttempt() {
|
|
63
66
|
this.db.exec('PRAGMA journal_mode = WAL;');
|
|
64
67
|
this.db.exec('PRAGMA foreign_keys = ON;');
|
|
65
|
-
this.db
|
|
66
|
-
try {
|
|
68
|
+
withImmediateTransaction(this.db, () => {
|
|
67
69
|
this.db
|
|
68
70
|
.exec(`CREATE TABLE IF NOT EXISTS auth_settings (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), schema_version INTEGER NOT NULL, rp_id TEXT NOT NULL, user_handle BLOB NOT NULL);
|
|
69
71
|
CREATE TABLE IF NOT EXISTS auth_devices (id TEXT PRIMARY KEY, credential_id TEXT NOT NULL UNIQUE, public_key BLOB NOT NULL, counter INTEGER NOT NULL, transports_json TEXT NOT NULL, device_type TEXT NOT NULL, backed_up INTEGER NOT NULL CHECK(backed_up IN (0,1)), nickname TEXT NOT NULL, created_at TEXT NOT NULL, last_used_at TEXT, version INTEGER NOT NULL DEFAULT 0);
|
|
@@ -85,186 +87,129 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
|
|
|
85
87
|
.get();
|
|
86
88
|
if (settings && settings.rp_id !== this.relyingParty.rpId && this.deviceCount() > 0)
|
|
87
89
|
throw new Error('WebAuthn RP ID hostname changed while credentials exist');
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
catch (error) {
|
|
91
|
-
try {
|
|
92
|
-
this.db.exec('ROLLBACK');
|
|
93
|
-
}
|
|
94
|
-
catch { }
|
|
95
|
-
throw error;
|
|
96
|
-
}
|
|
90
|
+
});
|
|
97
91
|
}
|
|
98
92
|
initializeOwner(userHandle) {
|
|
99
93
|
if (userHandle.length === 0)
|
|
100
94
|
throw new Error('Local owner handle must not be empty');
|
|
101
|
-
this.db
|
|
102
|
-
|
|
103
|
-
const current = this.db
|
|
95
|
+
return withImmediateTransaction(this.db, () => {
|
|
96
|
+
const current = this.statements
|
|
104
97
|
.prepare('SELECT user_handle, rp_id FROM auth_settings WHERE singleton = 1')
|
|
105
98
|
.get();
|
|
106
99
|
if (!current)
|
|
107
|
-
this.
|
|
100
|
+
this.statements
|
|
108
101
|
.prepare('INSERT INTO auth_settings (singleton, schema_version, rp_id, user_handle) VALUES (1, ?, ?, ?)')
|
|
109
102
|
.run(SCHEMA_VERSION, this.relyingParty.rpId, userHandle);
|
|
110
103
|
else if (current.rp_id !== this.relyingParty.rpId && this.deviceCount() > 0)
|
|
111
104
|
throw new Error('WebAuthn RP ID hostname changed while credentials exist');
|
|
112
105
|
else if (current.rp_id !== this.relyingParty.rpId)
|
|
113
|
-
this.
|
|
106
|
+
this.statements
|
|
114
107
|
.prepare('UPDATE auth_settings SET rp_id = ? WHERE singleton = 1')
|
|
115
108
|
.run(this.relyingParty.rpId);
|
|
116
|
-
this.db.exec('COMMIT');
|
|
117
109
|
return { id: OWNER_ID, userHandle: new Uint8Array(current?.user_handle ?? userHandle) };
|
|
118
|
-
}
|
|
119
|
-
catch (error) {
|
|
120
|
-
this.db.exec('ROLLBACK');
|
|
121
|
-
throw error;
|
|
122
|
-
}
|
|
110
|
+
});
|
|
123
111
|
}
|
|
124
112
|
readOwner() {
|
|
125
|
-
const row = this.
|
|
113
|
+
const row = this.statements
|
|
126
114
|
.prepare('SELECT user_handle FROM auth_settings WHERE singleton = 1')
|
|
127
115
|
.get();
|
|
128
116
|
return row ? { id: OWNER_ID, userHandle: new Uint8Array(row.user_handle) } : null;
|
|
129
117
|
}
|
|
130
118
|
listAuthorizedDevices() {
|
|
131
|
-
return this.
|
|
119
|
+
return this.statements
|
|
120
|
+
.prepare('SELECT * FROM auth_devices ORDER BY created_at, id')
|
|
121
|
+
.all().map(toDevice);
|
|
132
122
|
}
|
|
133
123
|
claimFirstDevice(owner, device) {
|
|
134
124
|
if (owner.id !== OWNER_ID || !this.readOwner())
|
|
135
125
|
throw new Error('Local owner is not initialized');
|
|
136
|
-
this.db
|
|
137
|
-
|
|
138
|
-
if (this.deviceCount()) {
|
|
139
|
-
this.db.exec('COMMIT');
|
|
126
|
+
return withImmediateTransaction(this.db, () => {
|
|
127
|
+
if (this.deviceCount())
|
|
140
128
|
return 'alreadyClaimed';
|
|
141
|
-
}
|
|
142
129
|
this.insertDevice(device);
|
|
143
|
-
this.db.exec('COMMIT');
|
|
144
130
|
return 'claimed';
|
|
145
|
-
}
|
|
146
|
-
catch (error) {
|
|
147
|
-
this.db.exec('ROLLBACK');
|
|
148
|
-
throw error;
|
|
149
|
-
}
|
|
131
|
+
});
|
|
150
132
|
}
|
|
151
133
|
authorizeDevice(device) {
|
|
152
|
-
this.db.exec('BEGIN IMMEDIATE');
|
|
153
134
|
try {
|
|
154
|
-
|
|
155
|
-
this.
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
try {
|
|
135
|
+
return withImmediateTransaction(this.db, () => {
|
|
136
|
+
if (!this.readOwner() || !this.deviceCount())
|
|
137
|
+
return 'notAuthorized';
|
|
159
138
|
this.insertDevice(device);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (String(error).includes('UNIQUE constraint failed: auth_devices.credential_id')) {
|
|
163
|
-
this.db.exec('ROLLBACK');
|
|
164
|
-
return 'duplicateCredential';
|
|
165
|
-
}
|
|
166
|
-
throw error;
|
|
167
|
-
}
|
|
168
|
-
this.db.exec('COMMIT');
|
|
169
|
-
return 'authorized';
|
|
139
|
+
return 'authorized';
|
|
140
|
+
});
|
|
170
141
|
}
|
|
171
142
|
catch (error) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
catch { }
|
|
143
|
+
if (String(error).includes('UNIQUE constraint failed: auth_devices.credential_id'))
|
|
144
|
+
return 'duplicateCredential';
|
|
176
145
|
throw error;
|
|
177
146
|
}
|
|
178
147
|
}
|
|
179
148
|
findDeviceByCredentialId(id) {
|
|
180
|
-
const row = this.
|
|
149
|
+
const row = this.statements
|
|
150
|
+
.prepare('SELECT * FROM auth_devices WHERE credential_id = ?')
|
|
151
|
+
.get(id);
|
|
181
152
|
return row ? toDevice(row) : null;
|
|
182
153
|
}
|
|
183
154
|
findDevice(id) {
|
|
184
|
-
const row = this.
|
|
155
|
+
const row = this.statements.prepare('SELECT * FROM auth_devices WHERE id = ?').get(id);
|
|
185
156
|
return row ? toDevice(row) : null;
|
|
186
157
|
}
|
|
187
158
|
renameDevice(id, expectedVersion, nickname) {
|
|
188
|
-
this.db
|
|
189
|
-
|
|
190
|
-
const result = this.db
|
|
159
|
+
return withImmediateTransaction(this.db, () => {
|
|
160
|
+
const result = this.statements
|
|
191
161
|
.prepare('UPDATE auth_devices SET nickname = ?, version = version + 1 WHERE id = ? AND version = ?')
|
|
192
162
|
.run(nickname, id, expectedVersion);
|
|
193
|
-
if (result.changes)
|
|
194
|
-
this.db.exec('COMMIT');
|
|
163
|
+
if (result.changes)
|
|
195
164
|
return 'renamed';
|
|
196
|
-
}
|
|
197
165
|
const outcome = this.findDevice(id) ? 'stale' : 'notFound';
|
|
198
|
-
this.db.exec('COMMIT');
|
|
199
166
|
return outcome;
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
this.db.exec('ROLLBACK');
|
|
203
|
-
throw error;
|
|
204
|
-
}
|
|
167
|
+
});
|
|
205
168
|
}
|
|
206
169
|
advanceCounter(id, expected, expectedVersion, next, usedAt) {
|
|
207
|
-
const zeroSynced = this.
|
|
170
|
+
const zeroSynced = this.statements
|
|
208
171
|
.prepare("UPDATE auth_devices SET last_used_at = ?, version = version + 1 WHERE id = ? AND counter = ? AND version = ? AND ? = 0 AND device_type = 'multiDevice'")
|
|
209
172
|
.run(usedAt, id, expected, expectedVersion, next).changes === 1;
|
|
210
173
|
return (zeroSynced ||
|
|
211
|
-
this.
|
|
174
|
+
this.statements
|
|
212
175
|
.prepare('UPDATE auth_devices SET counter = ?, last_used_at = ?, version = version + 1 WHERE id = ? AND counter = ? AND version = ? AND ? > counter')
|
|
213
176
|
.run(next, usedAt, id, expected, expectedVersion, next).changes === 1);
|
|
214
177
|
}
|
|
215
178
|
revokeDevice(id, revokedAt) {
|
|
216
|
-
this.db
|
|
217
|
-
|
|
218
|
-
if (!this.findDevice(id)) {
|
|
219
|
-
this.db.exec('COMMIT');
|
|
179
|
+
return withImmediateTransaction(this.db, () => {
|
|
180
|
+
if (!this.findDevice(id))
|
|
220
181
|
return 'notFound';
|
|
221
|
-
|
|
222
|
-
if (this.deviceCount() < 2) {
|
|
223
|
-
this.db.exec('COMMIT');
|
|
182
|
+
if (this.deviceCount() < 2)
|
|
224
183
|
return 'finalDevice';
|
|
225
|
-
|
|
226
|
-
this.db
|
|
184
|
+
this.statements
|
|
227
185
|
.prepare('UPDATE auth_sessions SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL')
|
|
228
186
|
.run(revokedAt, id);
|
|
229
|
-
this.
|
|
230
|
-
this.db.exec('COMMIT');
|
|
187
|
+
this.statements.prepare('DELETE FROM auth_devices WHERE id = ?').run(id);
|
|
231
188
|
return 'revoked';
|
|
232
|
-
}
|
|
233
|
-
catch (error) {
|
|
234
|
-
this.db.exec('ROLLBACK');
|
|
235
|
-
throw error;
|
|
236
|
-
}
|
|
189
|
+
});
|
|
237
190
|
}
|
|
238
191
|
saveCeremony(token, ceremony, now) {
|
|
239
|
-
this.db
|
|
240
|
-
try {
|
|
192
|
+
withImmediateTransaction(this.db, () => {
|
|
241
193
|
// Admission and cleanup share one immediate transaction, so separate
|
|
242
194
|
// processes cannot both observe the same remaining slot.
|
|
243
|
-
this.
|
|
195
|
+
this.statements.prepare('DELETE FROM auth_ceremonies WHERE consumed_at IS NOT NULL').run();
|
|
244
196
|
if (now)
|
|
245
|
-
this.
|
|
246
|
-
const active = this.
|
|
197
|
+
this.statements.prepare('DELETE FROM auth_ceremonies WHERE expires_at <= ?').run(now);
|
|
198
|
+
const active = this.statements
|
|
247
199
|
.prepare('SELECT count(*) AS count FROM auth_ceremonies WHERE consumed_at IS NULL')
|
|
248
200
|
.get();
|
|
249
201
|
if (active.count >= 64)
|
|
250
202
|
throw new CeremonyCapacityError('Ceremony capacity reached');
|
|
251
|
-
this.
|
|
203
|
+
this.statements
|
|
252
204
|
.prepare('INSERT INTO auth_ceremonies (token_hash, purpose, challenge, expected_origin, rp_id, expires_at, ticket_hash) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
253
205
|
.run(hash(token), ceremony.purpose, ceremony.challenge, ceremony.expectedOrigin, ceremony.rpId, ceremony.expiresAt, ceremony.enrollmentTicket ? hash(ceremony.enrollmentTicket) : null);
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
catch (error) {
|
|
257
|
-
this.db.exec('ROLLBACK');
|
|
258
|
-
throw error;
|
|
259
|
-
}
|
|
206
|
+
});
|
|
260
207
|
}
|
|
261
208
|
consumeCeremony(token, now) {
|
|
262
|
-
this.db
|
|
263
|
-
|
|
264
|
-
const row = this.db
|
|
209
|
+
return withImmediateTransaction(this.db, () => {
|
|
210
|
+
const row = this.statements
|
|
265
211
|
.prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ? RETURNING purpose, challenge, expected_origin, rp_id, expires_at')
|
|
266
212
|
.get(now, hash(token), now);
|
|
267
|
-
this.db.exec('COMMIT');
|
|
268
213
|
return row
|
|
269
214
|
? {
|
|
270
215
|
id: passkeyCeremonyId(token),
|
|
@@ -275,14 +220,10 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
|
|
|
275
220
|
expiresAt: row.expires_at,
|
|
276
221
|
}
|
|
277
222
|
: null;
|
|
278
|
-
}
|
|
279
|
-
catch (error) {
|
|
280
|
-
this.db.exec('ROLLBACK');
|
|
281
|
-
throw error;
|
|
282
|
-
}
|
|
223
|
+
});
|
|
283
224
|
}
|
|
284
225
|
readCeremony(token, now) {
|
|
285
|
-
const row = this.
|
|
226
|
+
const row = this.statements
|
|
286
227
|
.prepare('SELECT purpose, challenge, expected_origin, rp_id, expires_at FROM auth_ceremonies WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?')
|
|
287
228
|
.get(hash(token), now);
|
|
288
229
|
return row
|
|
@@ -297,29 +238,23 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
|
|
|
297
238
|
: null;
|
|
298
239
|
}
|
|
299
240
|
saveTicket(token, ticket) {
|
|
300
|
-
this.
|
|
241
|
+
this.statements
|
|
301
242
|
.prepare('INSERT INTO auth_tickets (token_hash, expires_at, consumed_at) VALUES (?, ?, NULL)')
|
|
302
243
|
.run(hash(token), ticket.expiresAt);
|
|
303
244
|
}
|
|
304
245
|
issueEnrollmentTicket(token, creatorSession, expiresAt) {
|
|
305
|
-
this.db
|
|
306
|
-
try {
|
|
246
|
+
withImmediateTransaction(this.db, () => {
|
|
307
247
|
const creator = hash(creatorSession);
|
|
308
|
-
this.
|
|
248
|
+
this.statements
|
|
309
249
|
.prepare('DELETE FROM auth_tickets WHERE creator_session_hash = ? AND consumed_at IS NULL')
|
|
310
250
|
.run(creator);
|
|
311
|
-
this.
|
|
251
|
+
this.statements
|
|
312
252
|
.prepare('INSERT INTO auth_tickets (token_hash, expires_at, consumed_at, creator_session_hash) VALUES (?, ?, NULL, ?)')
|
|
313
253
|
.run(hash(token), expiresAt, creator);
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
catch (error) {
|
|
317
|
-
this.db.exec('ROLLBACK');
|
|
318
|
-
throw error;
|
|
319
|
-
}
|
|
254
|
+
});
|
|
320
255
|
}
|
|
321
256
|
enrollmentTicketStatus(creatorSession, now) {
|
|
322
|
-
const row = this.
|
|
257
|
+
const row = this.statements
|
|
323
258
|
.prepare('SELECT expires_at, consumed_at FROM auth_tickets WHERE creator_session_hash = ? ORDER BY rowid DESC LIMIT 1')
|
|
324
259
|
.get(hash(creatorSession));
|
|
325
260
|
if (!row)
|
|
@@ -329,126 +264,90 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
|
|
|
329
264
|
return row.expires_at > now ? 'pending' : 'expired';
|
|
330
265
|
}
|
|
331
266
|
cancelEnrollmentTicket(creatorSession, now) {
|
|
332
|
-
this.db
|
|
333
|
-
|
|
334
|
-
const result = this.db
|
|
267
|
+
return withImmediateTransaction(this.db, () => {
|
|
268
|
+
const result = this.statements
|
|
335
269
|
.prepare('DELETE FROM auth_tickets WHERE rowid = (SELECT rowid FROM auth_tickets WHERE creator_session_hash = ? AND consumed_at IS NULL AND expires_at > ? ORDER BY rowid DESC LIMIT 1)')
|
|
336
270
|
.run(hash(creatorSession), now);
|
|
337
|
-
this.db.exec('COMMIT');
|
|
338
271
|
return result.changes === 1;
|
|
339
|
-
}
|
|
340
|
-
catch (error) {
|
|
341
|
-
this.db.exec('ROLLBACK');
|
|
342
|
-
throw error;
|
|
343
|
-
}
|
|
272
|
+
});
|
|
344
273
|
}
|
|
345
274
|
consumeTicket(token, now) {
|
|
346
275
|
return this.consume('auth_tickets', token, now);
|
|
347
276
|
}
|
|
348
277
|
ticketAvailable(token, now) {
|
|
349
|
-
return Boolean(this.
|
|
278
|
+
return Boolean(this.statements
|
|
350
279
|
.prepare('SELECT 1 FROM auth_tickets t WHERE t.token_hash = ? AND t.consumed_at IS NULL AND t.expires_at > ? AND (t.creator_session_hash IS NULL OR EXISTS (SELECT 1 FROM auth_sessions s WHERE s.token_hash = t.creator_session_hash AND s.revoked_at IS NULL AND s.expires_at > ?))')
|
|
351
280
|
.get(hash(token), now, now));
|
|
352
281
|
}
|
|
353
282
|
completeRegistration(input) {
|
|
354
283
|
if (input.session.deviceId !== input.device.id)
|
|
355
284
|
throw new Error('AUTHORIZATION_SESSION_DEVICE_MISMATCH');
|
|
356
|
-
this.db.exec('BEGIN IMMEDIATE');
|
|
357
285
|
try {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
this.db.exec('COMMIT');
|
|
371
|
-
return 'ticketUnavailable';
|
|
286
|
+
return withImmediateTransaction(this.db, () => {
|
|
287
|
+
const ceremony = this.statements
|
|
288
|
+
.prepare('SELECT ticket_hash FROM auth_ceremonies WHERE token_hash = ? AND purpose = ? AND consumed_at IS NULL AND expires_at > ?')
|
|
289
|
+
.get(hash(input.ceremony), 'registration', input.now);
|
|
290
|
+
if (!ceremony)
|
|
291
|
+
return 'ceremonyUnavailable';
|
|
292
|
+
if (ceremony.ticket_hash) {
|
|
293
|
+
const result = this.statements
|
|
294
|
+
.prepare('UPDATE auth_tickets SET consumed_at = ? WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ? AND (creator_session_hash IS NULL OR EXISTS (SELECT 1 FROM auth_sessions WHERE token_hash = auth_tickets.creator_session_hash AND revoked_at IS NULL AND expires_at > ?))')
|
|
295
|
+
.run(input.now, ceremony.ticket_hash, input.now, input.now);
|
|
296
|
+
if (result.changes !== 1)
|
|
297
|
+
return 'ticketUnavailable';
|
|
372
298
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
this.db.exec('COMMIT');
|
|
376
|
-
return 'bootstrapAlreadyClaimed';
|
|
377
|
-
}
|
|
378
|
-
try {
|
|
299
|
+
else if (this.deviceCount() !== 0)
|
|
300
|
+
return 'bootstrapAlreadyClaimed';
|
|
379
301
|
this.insertDevice(input.device);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
388
|
-
this.db
|
|
389
|
-
.prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
|
|
390
|
-
.run(hash(input.session.id), input.session.deviceId, input.session.expiresAt);
|
|
391
|
-
this.db
|
|
392
|
-
.prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ?')
|
|
393
|
-
.run(input.now, hash(input.ceremony));
|
|
394
|
-
this.db.exec('COMMIT');
|
|
395
|
-
return 'registered';
|
|
302
|
+
this.statements
|
|
303
|
+
.prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
|
|
304
|
+
.run(hash(input.session.id), input.session.deviceId, input.session.expiresAt);
|
|
305
|
+
this.statements
|
|
306
|
+
.prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ?')
|
|
307
|
+
.run(input.now, hash(input.ceremony));
|
|
308
|
+
return 'registered';
|
|
309
|
+
});
|
|
396
310
|
}
|
|
397
311
|
catch (error) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
catch { }
|
|
312
|
+
if (String(error).includes('UNIQUE constraint failed: auth_devices.credential_id'))
|
|
313
|
+
return 'duplicateCredential';
|
|
402
314
|
throw error;
|
|
403
315
|
}
|
|
404
316
|
}
|
|
405
317
|
completeAuthentication(input) {
|
|
406
318
|
if (input.session.deviceId !== input.device.id)
|
|
407
319
|
throw new Error('AUTHORIZATION_SESSION_DEVICE_MISMATCH');
|
|
408
|
-
this.db
|
|
409
|
-
|
|
410
|
-
const ceremony = this.db
|
|
320
|
+
return withImmediateTransaction(this.db, () => {
|
|
321
|
+
const ceremony = this.statements
|
|
411
322
|
.prepare('SELECT 1 FROM auth_ceremonies WHERE token_hash = ? AND purpose = ? AND consumed_at IS NULL AND expires_at > ?')
|
|
412
323
|
.get(hash(input.ceremony), 'authentication', input.now);
|
|
413
|
-
if (!ceremony)
|
|
414
|
-
this.db.exec('COMMIT');
|
|
324
|
+
if (!ceremony)
|
|
415
325
|
return false;
|
|
416
|
-
}
|
|
417
326
|
const updated = this.advanceCounter(input.device.id, input.device.counter, input.device.version ?? 0, input.nextCounter, input.now);
|
|
418
|
-
if (!updated)
|
|
419
|
-
this.db.exec('COMMIT');
|
|
327
|
+
if (!updated)
|
|
420
328
|
return false;
|
|
421
|
-
|
|
422
|
-
this.db
|
|
329
|
+
this.statements
|
|
423
330
|
.prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
|
|
424
331
|
.run(hash(input.session.id), input.session.deviceId, input.session.expiresAt);
|
|
425
|
-
this.
|
|
332
|
+
this.statements
|
|
426
333
|
.prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ?')
|
|
427
334
|
.run(input.now, hash(input.ceremony));
|
|
428
|
-
this.db.exec('COMMIT');
|
|
429
335
|
return true;
|
|
430
|
-
}
|
|
431
|
-
catch (error) {
|
|
432
|
-
try {
|
|
433
|
-
this.db.exec('ROLLBACK');
|
|
434
|
-
}
|
|
435
|
-
catch { }
|
|
436
|
-
throw error;
|
|
437
|
-
}
|
|
336
|
+
});
|
|
438
337
|
}
|
|
439
338
|
saveSession(token, session) {
|
|
440
|
-
this.
|
|
339
|
+
this.statements
|
|
441
340
|
.prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
|
|
442
341
|
.run(hash(token), session.deviceId, session.expiresAt);
|
|
443
342
|
}
|
|
444
343
|
sessionDevice(token, now) {
|
|
445
|
-
const row = this.
|
|
344
|
+
const row = this.statements
|
|
446
345
|
.prepare('SELECT device_id FROM auth_sessions WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ?')
|
|
447
346
|
.get(hash(token), now);
|
|
448
347
|
return row?.device_id ? authorizedDeviceId(row.device_id) : null;
|
|
449
348
|
}
|
|
450
349
|
revokeSession(token, now) {
|
|
451
|
-
return (this.
|
|
350
|
+
return (this.statements
|
|
452
351
|
.prepare('UPDATE auth_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL')
|
|
453
352
|
.run(now, hash(token)).changes === 1);
|
|
454
353
|
}
|
|
@@ -456,26 +355,20 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
|
|
|
456
355
|
this.db.close();
|
|
457
356
|
}
|
|
458
357
|
insertDevice(device) {
|
|
459
|
-
this.
|
|
358
|
+
this.statements
|
|
460
359
|
.prepare('INSERT INTO auth_devices (id, credential_id, public_key, counter, transports_json, device_type, backed_up, nickname, created_at, last_used_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
461
360
|
.run(device.id, device.credentialId, device.publicKey, device.counter, JSON.stringify(device.transports), device.deviceType, device.backedUp ? 1 : 0, device.nickname, device.createdAt, device.lastUsedAt ?? null, device.version ?? 0);
|
|
462
361
|
}
|
|
463
362
|
consume(table, token, now) {
|
|
464
|
-
this.db
|
|
465
|
-
try {
|
|
363
|
+
return withImmediateTransaction(this.db, () => {
|
|
466
364
|
const changes = this.db
|
|
467
365
|
.prepare(`UPDATE ${table} SET consumed_at = ? WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?`)
|
|
468
366
|
.run(now, hash(token), now).changes;
|
|
469
|
-
this.db.exec('COMMIT');
|
|
470
367
|
return changes === 1;
|
|
471
|
-
}
|
|
472
|
-
catch (error) {
|
|
473
|
-
this.db.exec('ROLLBACK');
|
|
474
|
-
throw error;
|
|
475
|
-
}
|
|
368
|
+
});
|
|
476
369
|
}
|
|
477
370
|
deviceCount() {
|
|
478
|
-
return this.
|
|
371
|
+
return this.statements.prepare('SELECT COUNT(*) AS count FROM auth_devices').get().count;
|
|
479
372
|
}
|
|
480
373
|
}
|
|
481
374
|
function toDevice(row) {
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
export function withImmediateTransaction(db, action) {
|
|
7
|
+
db.exec('BEGIN IMMEDIATE');
|
|
8
|
+
try {
|
|
9
|
+
const result = action();
|
|
10
|
+
db.exec('COMMIT');
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (db.isTransaction) {
|
|
15
|
+
try {
|
|
16
|
+
db.exec('ROLLBACK');
|
|
17
|
+
}
|
|
18
|
+
catch { }
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Auth has existing positional-parameter SQL, while SQLTagStore only accepts
|
|
25
|
+
* tagged-template parameters. This small LRU keeps that SQL unchanged and only
|
|
26
|
+
* receives repeated static statements from the authorization store.
|
|
27
|
+
*/
|
|
28
|
+
export class AuthStatementCache {
|
|
29
|
+
db;
|
|
30
|
+
maxSize;
|
|
31
|
+
statements = new Map();
|
|
32
|
+
constructor(db, maxSize = 32) {
|
|
33
|
+
this.db = db;
|
|
34
|
+
this.maxSize = maxSize;
|
|
35
|
+
if (!Number.isInteger(maxSize) || maxSize < 1)
|
|
36
|
+
throw new RangeError('Statement cache size must be a positive integer');
|
|
37
|
+
}
|
|
38
|
+
prepare(sql) {
|
|
39
|
+
const cached = this.statements.get(sql);
|
|
40
|
+
if (cached) {
|
|
41
|
+
this.statements.delete(sql);
|
|
42
|
+
this.statements.set(sql, cached);
|
|
43
|
+
return cached;
|
|
44
|
+
}
|
|
45
|
+
const statement = this.db.prepare(sql);
|
|
46
|
+
if (this.statements.size === this.maxSize) {
|
|
47
|
+
const oldest = this.statements.keys().next().value;
|
|
48
|
+
if (oldest !== undefined)
|
|
49
|
+
this.statements.delete(oldest);
|
|
50
|
+
}
|
|
51
|
+
this.statements.set(sql, statement);
|
|
52
|
+
return statement;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -95,6 +95,9 @@ export function decodeAgentActivityFacts(sessionId, occurredAt, input) {
|
|
|
95
95
|
: typeof item.status === 'string' && item.status.length <= 64
|
|
96
96
|
? item.status
|
|
97
97
|
: undefined;
|
|
98
|
+
const model = action === 'spawn_agent' && typeof item.model === 'string' && item.model.length <= 256
|
|
99
|
+
? item.model
|
|
100
|
+
: undefined;
|
|
98
101
|
return {
|
|
99
102
|
sessionId,
|
|
100
103
|
occurredAt,
|
|
@@ -105,6 +108,7 @@ export function decodeAgentActivityFacts(sessionId, occurredAt, input) {
|
|
|
105
108
|
? { childStatus: currentStatus ?? legacyStatus }
|
|
106
109
|
: {}),
|
|
107
110
|
...(action ? { collaborationAction: action } : {}),
|
|
111
|
+
...(model ? { childModel: model } : {}),
|
|
108
112
|
};
|
|
109
113
|
};
|
|
110
114
|
return children.length > 0 ? children.map((child) => fact(child)) : [fact()];
|
|
@@ -3,18 +3,36 @@
|
|
|
3
3
|
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
|
+
import { readRolloutExecutionPolicy } from './rollout-execution-policy.js';
|
|
6
7
|
export function createRecentThreadLister(deps) {
|
|
8
|
+
const rolloutPaths = new Map();
|
|
9
|
+
const list = async () => {
|
|
10
|
+
const profiles = (await deps.profiles.list()).filter((profile) => profile.state === 'ok');
|
|
11
|
+
const listed = await Promise.all(profiles.map(async ({ name }) => listProfileThreads(deps.launch, name, deps.root)));
|
|
12
|
+
const byId = new Map();
|
|
13
|
+
for (const thread of listed.flat()) {
|
|
14
|
+
const known = byId.get(thread.id);
|
|
15
|
+
if (!known || (thread.recencyAt ?? 0) > (known.recencyAt ?? 0))
|
|
16
|
+
byId.set(thread.id, thread);
|
|
17
|
+
}
|
|
18
|
+
rolloutPaths.clear();
|
|
19
|
+
return [...byId.values()]
|
|
20
|
+
.sort((a, b) => (b.recencyAt ?? 0) - (a.recencyAt ?? 0))
|
|
21
|
+
.map(({ rolloutPath, ...thread }) => {
|
|
22
|
+
if (rolloutPath)
|
|
23
|
+
rolloutPaths.set(thread.id, rolloutPath);
|
|
24
|
+
return thread;
|
|
25
|
+
});
|
|
26
|
+
};
|
|
7
27
|
return {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
return [...byId.values()].sort((a, b) => (b.recencyAt ?? 0) - (a.recencyAt ?? 0));
|
|
28
|
+
list,
|
|
29
|
+
executionPolicy: async (threadId) => {
|
|
30
|
+
if (!rolloutPaths.has(threadId))
|
|
31
|
+
await list();
|
|
32
|
+
const rolloutPath = rolloutPaths.get(threadId);
|
|
33
|
+
return rolloutPath
|
|
34
|
+
? (deps.readExecutionPolicy ?? readRolloutExecutionPolicy)(rolloutPath)
|
|
35
|
+
: undefined;
|
|
18
36
|
},
|
|
19
37
|
};
|
|
20
38
|
}
|
|
@@ -34,7 +52,15 @@ async function listProfileThreads(launch, profile, cwd) {
|
|
|
34
52
|
return (result.data ?? []).flatMap((thread) => {
|
|
35
53
|
if (typeof thread.id !== 'string' || typeof thread.cwd !== 'string')
|
|
36
54
|
return [];
|
|
37
|
-
return [
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
id: thread.id,
|
|
58
|
+
cwd: thread.cwd,
|
|
59
|
+
profile,
|
|
60
|
+
recencyAt: toRecency(thread.recencyAt),
|
|
61
|
+
...(typeof thread.path === 'string' ? { rolloutPath: thread.path } : {}),
|
|
62
|
+
},
|
|
63
|
+
];
|
|
38
64
|
});
|
|
39
65
|
}
|
|
40
66
|
catch {
|