@north-light/sdk 0.0.0 → 0.3.415

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 (121) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +187 -2
  3. package/dist/client.d.ts +176 -0
  4. package/dist/client.js +582 -0
  5. package/dist/crtr.d.ts +24 -0
  6. package/dist/crtr.js +50 -0
  7. package/dist/env-command.d.ts +15 -0
  8. package/dist/env-command.js +138 -0
  9. package/dist/error-codes.d.ts +7 -0
  10. package/dist/error-codes.js +43 -0
  11. package/dist/errors.d.ts +72 -0
  12. package/dist/errors.js +101 -0
  13. package/dist/index.d.ts +41 -0
  14. package/dist/index.js +17 -0
  15. package/dist/keygen-cli.d.ts +2 -0
  16. package/dist/keygen-cli.js +11 -0
  17. package/dist/keygen-command.d.ts +6 -0
  18. package/dist/keygen-command.js +131 -0
  19. package/dist/oauth/index.d.ts +235 -0
  20. package/dist/oauth/index.js +621 -0
  21. package/dist/oauth/keygen.d.ts +18 -0
  22. package/dist/oauth/keygen.js +46 -0
  23. package/dist/oauth/refusal.d.ts +32 -0
  24. package/dist/oauth/refusal.js +17 -0
  25. package/dist/resources/activity.d.ts +22 -0
  26. package/dist/resources/activity.js +43 -0
  27. package/dist/resources/attachments.d.ts +15 -0
  28. package/dist/resources/attachments.js +13 -0
  29. package/dist/resources/bash.d.ts +9 -0
  30. package/dist/resources/bash.js +15 -0
  31. package/dist/resources/canvas/history.d.ts +11 -0
  32. package/dist/resources/canvas/history.js +20 -0
  33. package/dist/resources/canvas.d.ts +19 -0
  34. package/dist/resources/canvas.js +42 -0
  35. package/dist/resources/crons.d.ts +15 -0
  36. package/dist/resources/crons.js +33 -0
  37. package/dist/resources/custom-objects.d.ts +20 -0
  38. package/dist/resources/custom-objects.js +84 -0
  39. package/dist/resources/files.d.ts +34 -0
  40. package/dist/resources/files.js +34 -0
  41. package/dist/resources/follower.d.ts +71 -0
  42. package/dist/resources/follower.js +202 -0
  43. package/dist/resources/forward.d.ts +7 -0
  44. package/dist/resources/forward.js +67 -0
  45. package/dist/resources/grant-watcher.d.ts +57 -0
  46. package/dist/resources/grant-watcher.js +100 -0
  47. package/dist/resources/human/inbox.d.ts +14 -0
  48. package/dist/resources/human/inbox.js +30 -0
  49. package/dist/resources/human/requests.d.ts +13 -0
  50. package/dist/resources/human/requests.js +26 -0
  51. package/dist/resources/human.d.ts +8 -0
  52. package/dist/resources/human.js +10 -0
  53. package/dist/resources/identifiers.d.ts +8 -0
  54. package/dist/resources/identifiers.js +33 -0
  55. package/dist/resources/memory.d.ts +69 -0
  56. package/dist/resources/memory.js +30 -0
  57. package/dist/resources/models/config.d.ts +8 -0
  58. package/dist/resources/models/config.js +9 -0
  59. package/dist/resources/models/credentials.d.ts +10 -0
  60. package/dist/resources/models/credentials.js +17 -0
  61. package/dist/resources/models.d.ts +8 -0
  62. package/dist/resources/models.js +10 -0
  63. package/dist/resources/node-stream.d.ts +34 -0
  64. package/dist/resources/node-stream.js +176 -0
  65. package/dist/resources/nodes/jobs.d.ts +9 -0
  66. package/dist/resources/nodes/jobs.js +14 -0
  67. package/dist/resources/nodes/result.d.ts +8 -0
  68. package/dist/resources/nodes/result.js +11 -0
  69. package/dist/resources/nodes/worktree.d.ts +9 -0
  70. package/dist/resources/nodes/worktree.js +14 -0
  71. package/dist/resources/nodes.d.ts +23 -0
  72. package/dist/resources/nodes.js +47 -0
  73. package/dist/resources/providers.d.ts +51 -0
  74. package/dist/resources/providers.js +15 -0
  75. package/dist/resources/questions.d.ts +49 -0
  76. package/dist/resources/questions.js +21 -0
  77. package/dist/resources/request.d.ts +12 -0
  78. package/dist/resources/request.js +50 -0
  79. package/dist/resources/run-notice.d.ts +34 -0
  80. package/dist/resources/run-notice.js +67 -0
  81. package/dist/resources/run-reply.d.ts +79 -0
  82. package/dist/resources/run-reply.js +93 -0
  83. package/dist/resources/run-stream.d.ts +65 -0
  84. package/dist/resources/run-stream.js +243 -0
  85. package/dist/resources/run-watcher.d.ts +98 -0
  86. package/dist/resources/run-watcher.js +162 -0
  87. package/dist/resources/runs.d.ts +199 -0
  88. package/dist/resources/runs.js +228 -0
  89. package/dist/resources/shares.d.ts +40 -0
  90. package/dist/resources/shares.js +19 -0
  91. package/dist/resources/structured-requests.d.ts +14 -0
  92. package/dist/resources/structured-requests.js +19 -0
  93. package/dist/resources/uploads.d.ts +27 -0
  94. package/dist/resources/uploads.js +11 -0
  95. package/dist/schema.d.ts +7 -0
  96. package/dist/schema.js +10 -0
  97. package/dist/stores/postgres.d.ts +32 -0
  98. package/dist/stores/postgres.js +92 -0
  99. package/dist/testing/clock.d.ts +7 -0
  100. package/dist/testing/clock.js +12 -0
  101. package/dist/testing/cloud.d.ts +228 -0
  102. package/dist/testing/cloud.js +914 -0
  103. package/dist/testing/conformance/index.d.ts +2 -0
  104. package/dist/testing/conformance/index.js +3 -0
  105. package/dist/testing/conformance/keyed-write.d.ts +43 -0
  106. package/dist/testing/conformance/keyed-write.js +124 -0
  107. package/dist/testing/index.d.ts +7 -0
  108. package/dist/testing/index.js +4 -0
  109. package/dist/testing/notices.d.ts +76 -0
  110. package/dist/testing/notices.js +209 -0
  111. package/dist/testing/run.d.ts +174 -0
  112. package/dist/testing/run.js +429 -0
  113. package/dist/testing/users.d.ts +127 -0
  114. package/dist/testing/users.js +78 -0
  115. package/dist/testing/wire.d.ts +62 -0
  116. package/dist/testing/wire.js +148 -0
  117. package/dist/types.d.ts +88 -0
  118. package/dist/types.js +1 -0
  119. package/dist/version.d.ts +2 -0
  120. package/dist/version.js +3 -0
  121. package/package.json +60 -5
@@ -0,0 +1,914 @@
1
+ // FakeCloud: a fake crouter directory and runtime behind one `fetch`, for testing an app's real `OAuth2Client` and
2
+ // `Crouter`. Routing, the call logs, intercepts, users and every runtime operation an app may call.
3
+ import { RUNTIME_VERSION_HEADER, minimumRuntimeRefusal, MINIMUM_RUNTIME_VERSION_HEADER } from '@north-light/api';
4
+ import { covers } from '@north-light/identity';
5
+ import { createHash } from 'node:crypto';
6
+ import { posix } from 'node:path';
7
+ import { exportJWK, generateKeyPair } from 'jose';
8
+ import { Crouter } from '../client.js';
9
+ import { MemoryConnectionStore, OAuth2Client } from '../oauth/index.js';
10
+ import { SDK_VERSION } from '../version.js';
11
+ import { RunNotices, SigningKey } from './notices.js';
12
+ import { FakeRun } from './run.js';
13
+ import { UserState, normalizeScopes } from './users.js';
14
+ import { FakeRefused, decodeUnsigned, envelopeBody, fakeError, integerParam, invalid, isFakeError, jsonAnswer, notFound, objectBody, refuse, requestedScopes, sha256Hex, unsignedJwt, waitSeconds, } from './wire.js';
15
+ const ROUTES = [
16
+ ['GET', /^\/v1\/grant$/, 'grant.get'],
17
+ ['POST', /^\/v1\/runs$/, 'run.start'],
18
+ ['GET', /^\/v1\/runs$/, 'run.list'],
19
+ ['GET', /^\/v1\/runs\/([^/]+)$/, 'run.get'],
20
+ ['DELETE', /^\/v1\/runs\/([^/]+)$/, 'run.delete'],
21
+ ['POST', /^\/v1\/runs\/([^/]+)\/messages$/, 'run.message'],
22
+ ['GET', /^\/v1\/runs\/([^/]+)\/events$/, 'run.events'],
23
+ ['POST', /^\/v1\/runs\/([^/]+)\/interrupt$/, 'run.interrupt'],
24
+ ['POST', /^\/v1\/runs\/([^/]+)\/cancel$/, 'run.cancel'],
25
+ ['POST', /^\/v1\/runs\/([^/]+)\/rename$/, 'run.rename'],
26
+ ['GET', /^\/v1\/runs\/([^/]+)\/trace$/, 'trace.get'],
27
+ ['GET', /^\/v1\/questions\/([^/]+)$/, 'question.get'],
28
+ ['POST', /^\/v1\/questions\/([^/]+)\/answer$/, 'question.answer'],
29
+ ['GET', /^\/v1\/memory$/, 'memory.list'],
30
+ ['GET', /^\/v1\/memory\/document$/, 'memory.get'],
31
+ ['GET', /^\/v1\/providers\/tools$/, 'provider.tools'],
32
+ ['GET', /^\/v1\/providers\/([^/]+)\/tools$/, 'provider.tools'],
33
+ ['POST', /^\/v1\/provider\/call$/, 'provider.call'],
34
+ ['GET', /^\/v1\/profiles$/, 'profile.list'],
35
+ ];
36
+ const DIRECTORY_OPS = new Set(['directory.discovery', 'directory.token', 'directory.jwks']);
37
+ const RUN_OPS = new Set(['run.get', 'run.delete', 'run.message', 'run.events', 'run.interrupt', 'run.cancel', 'run.rename', 'trace.get']);
38
+ const ACT_OPS = new Set(['run.start', 'run.message']);
39
+ /** The served memory body cap (`MEMORY_BODY_MAX_BYTES`, src/daemon/api/handlers/memory.ts). */
40
+ const MEMORY_BODY_MAX_BYTES = 1024 * 1024;
41
+ const MEMORY_NAME = /^[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*$/;
42
+ const PROVIDER_ID = /^[A-Za-z0-9_-]{1,64}$/;
43
+ const unreachable = () => new TypeError('fetch failed');
44
+ const aborted = (signal) => signal.reason ?? new DOMException('This operation was aborted', 'AbortError');
45
+ /** Race `promise` with `signal`. */
46
+ function withSignal(promise, signal) {
47
+ if (!signal)
48
+ return promise;
49
+ if (signal.aborted)
50
+ return Promise.reject(aborted(signal));
51
+ return new Promise((resolve, reject) => {
52
+ const onAbort = () => reject(aborted(signal));
53
+ signal.addEventListener('abort', onAbort, { once: true });
54
+ promise.then((value) => { signal.removeEventListener('abort', onAbort); resolve(value); }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); });
55
+ });
56
+ }
57
+ /** A fake crouter cloud: the directory (discovery, refresh, JWKS) and each person's runtime, behind `fetch`. */
58
+ export class FakeCloud {
59
+ /** Pass to `OAuth2Client` (and `verifyRunNotice`) as `fetch`. Unique per cloud. */
60
+ fetch;
61
+ issuer;
62
+ clientId;
63
+ /** Every runtime request, in order. */
64
+ calls = [];
65
+ /** Every directory request, in order. */
66
+ directoryCalls = [];
67
+ /** Every run-notice attempt, in order. */
68
+ notices;
69
+ /** What responders threw. */
70
+ responderErrors = [];
71
+ runs;
72
+ providers;
73
+ options;
74
+ clock;
75
+ users = new Map();
76
+ handles = new Map();
77
+ allRuns = [];
78
+ providerDefs = new Map();
79
+ intercepts = [];
80
+ gates = new Set();
81
+ responder = null;
82
+ key = new SigningKey();
83
+ runNotices;
84
+ host;
85
+ /** Superseded refresh tokens → owner, for `refresh_token_reused`. */
86
+ superseded = new Map();
87
+ tokenSerial = 0;
88
+ refreshSerial = 0;
89
+ runSerial = 0;
90
+ constructor(options) {
91
+ if (!options || typeof options.clientId !== 'string' || !options.clientId)
92
+ throw new TypeError('FakeCloud needs a clientId');
93
+ this.options = options;
94
+ this.clock = options.clock ?? { now: () => Date.now() };
95
+ this.clientId = options.clientId;
96
+ this.issuer = (options.issuer ?? 'https://directory.test').replace(/\/$/, '');
97
+ this.runNotices = new RunNotices(options.runNotice, this.key, {
98
+ issuer: this.issuer, clientId: this.clientId, runtimeId: options.runtimeId ?? 'runtime:test', runtimeClientId: options.runtimeClientId ?? 'runtime-client',
99
+ }, (userId) => this.users.get(userId)?.active === true, this.clock);
100
+ this.notices = this.runNotices.records;
101
+ const grantee = `app:${this.clientId}`;
102
+ this.host = {
103
+ grantee,
104
+ streamIdleMs: options.streamIdleMs ?? 60_000,
105
+ now: () => this.clock.now(),
106
+ grantActive: (userId) => this.users.get(userId)?.active === true,
107
+ appended: (run) => this.runNotices.appended(run),
108
+ streamOpened: (run) => this.runNotices.streamOpened(run),
109
+ deleted: (run) => {
110
+ const user = this.users.get(run.userId);
111
+ if (user)
112
+ for (const [key, entry] of user.idempotency)
113
+ if (entry.runId === run.id)
114
+ user.idempotency.delete(key);
115
+ this.runNotices.forget(run);
116
+ },
117
+ };
118
+ const allRuns = this.allRuns;
119
+ this.runs = { get all() { return allRuns; }, respond: (fn) => { this.responder = fn; } };
120
+ this.providers = { define: (provider, definition) => {
121
+ if (!PROVIDER_ID.test(provider) || provider === 'crtr')
122
+ throw new TypeError(`invalid provider id ${provider}`);
123
+ this.providerDefs.set(provider, definition);
124
+ } };
125
+ this.fetch = (input, init) => this.route(input, init);
126
+ }
127
+ /** A `FakeError` envelope for intercepts, `failNext` and provider handlers. `retryable` defaults to `status === 503`. */
128
+ static error(input) {
129
+ return fakeError(input);
130
+ }
131
+ /** The `directory.token` subset of `directoryCalls`. */
132
+ get refreshes() { return this.directoryCalls.filter((call) => call.op === 'directory.token'); }
133
+ /** That user's handle, created on first use. */
134
+ user(userId) {
135
+ let handle = this.handles.get(userId);
136
+ if (handle)
137
+ return handle;
138
+ const state = this.state(userId);
139
+ const cloud = this;
140
+ const runs = { get all() { return state.runs; }, respond: (fn) => { state.responder = fn; } };
141
+ handle = {
142
+ id: userId,
143
+ grant: {
144
+ get state() { return state.dto(); },
145
+ set(patch) { state.apply(patch); state.rev++; state.wakeHolds(); },
146
+ setup(provider, setupState, options = {}) {
147
+ const dependency = {
148
+ state: setupState, ...(options.message === undefined ? {} : { message: options.message }),
149
+ next: options.next ?? `${cloud.issuer}/connect/setup/${provider}?client_id=${cloud.clientId}`,
150
+ };
151
+ state.dependencies = { ...state.dependencies, [provider]: dependency };
152
+ state.wakeHolds();
153
+ },
154
+ sync() { },
155
+ revoke(code = 'grant_revoked') { state.revoke(code); },
156
+ restore(patch = {}) {
157
+ state.apply(patch);
158
+ state.active = true;
159
+ state.refusal = null;
160
+ state.rev++;
161
+ state.wakeHolds();
162
+ },
163
+ },
164
+ refuse(code) {
165
+ state.refusal = code === 'ok' ? null : code;
166
+ if (code !== 'ok')
167
+ state.minSerial = cloud.tokenSerial + 1;
168
+ },
169
+ expireTokens() { state.minSerial = cloud.tokenSerial + 1; },
170
+ get reads() { return cloud.calls.filter((call) => call.userId === userId && call.op === 'grant.get'); },
171
+ get holding() { return state.holds.size; },
172
+ runs,
173
+ memory: {
174
+ put(doc) {
175
+ if (!MEMORY_NAME.test(doc.name))
176
+ throw new TypeError(`invalid memory document name ${doc.name}`);
177
+ state.putMemory(doc);
178
+ },
179
+ remove(name) { state.memory.delete(name); },
180
+ },
181
+ };
182
+ this.handles.set(userId, handle);
183
+ return handle;
184
+ }
185
+ /**
186
+ * Make `userId`'s grant active and save a stored connection for them in `store`, with its access token expired so
187
+ * the first call refreshes through the directory. Default scopes: `crtr:llm`, `crtr:act`.
188
+ */
189
+ async connect(store, userId, options = {}) {
190
+ const handle = this.user(userId);
191
+ const state = this.state(userId);
192
+ this.activateState(state, options);
193
+ state.refreshToken ??= this.newRefreshToken(userId);
194
+ await store.save({ userId, runtimeUrl: this.runtimeUrlFor(userId), refreshToken: state.refreshToken, grantId: state.grantId, accessToken: '', accessTokenExpiresAt: 0 });
195
+ return handle;
196
+ }
197
+ activateState(state, options) {
198
+ if (!state.active) {
199
+ state.active = true;
200
+ state.refusal = null;
201
+ state.rev++;
202
+ state.resolvedScopes = normalizeScopes(options.scopes ?? ['crtr:llm', 'crtr:act']);
203
+ if (options.dependencies)
204
+ state.dependencies = { ...options.dependencies };
205
+ state.wakeHolds();
206
+ }
207
+ else if (options.scopes || options.dependencies) {
208
+ state.apply({ ...(options.scopes ? { resolved_scopes: options.scopes } : {}), ...(options.dependencies ? { dependencies: options.dependencies } : {}) });
209
+ state.rev++;
210
+ state.wakeHolds();
211
+ }
212
+ }
213
+ /**
214
+ * Make `userId`'s grant active with no stored connection, for an app that mints a per-user runtime token through
215
+ * the directory's `client_credentials` grant. Default scopes: `crtr:llm`, `crtr:act`.
216
+ */
217
+ activate(userId, options = {}) {
218
+ const handle = this.user(userId);
219
+ const state = this.state(userId);
220
+ this.activateState(state, options);
221
+ state.refreshToken ??= this.newRefreshToken(userId);
222
+ return handle;
223
+ }
224
+ /**
225
+ * A real `Crouter` on `userId`'s runtime with a fresh access token and no refresh: refusals never reach it. For
226
+ * code whose production signature takes a `Crouter`; an app that goes through `OAuth2Client` tests through it.
227
+ */
228
+ crouter(userId) {
229
+ const state = this.users.get(userId);
230
+ if (!state?.refreshToken)
231
+ throw new Error(`${userId} is not connected: call cloud.connect first`);
232
+ return new Crouter({ baseURL: this.runtimeUrlFor(userId), token: this.accessToken(state), fetch: this.fetch });
233
+ }
234
+ /** Run `fn` before `op`. `times` defaults to Infinity. `user` filters runtime operations and `directory.token`. */
235
+ intercept(op, fn, options = {}) {
236
+ if (options.user !== undefined && (op === 'directory.discovery' || op === 'directory.jwks'))
237
+ throw new TypeError(`${op} carries no user; drop the user filter`);
238
+ const entry = { op, fn, user: options.user, remaining: options.times ?? Infinity };
239
+ this.intercepts.push(entry);
240
+ return () => { this.intercepts = this.intercepts.filter((other) => other !== entry); };
241
+ }
242
+ /** Answer the next `times` (default 1) matching requests with `answer`. */
243
+ failNext(op, answer, options = {}) {
244
+ if (DIRECTORY_OPS.has(op) && isFakeError(answer))
245
+ throw new TypeError(`${op} cannot answer an envelope: use a status or 'unreachable'${op === 'directory.token' ? ', and user.refuse for refusals' : ''}`);
246
+ this.intercept(op, () => answer, { ...options, times: options.times ?? 1 });
247
+ }
248
+ /** Hold every matching request until `release()`, which frees them all and removes the gate. */
249
+ gate(op, options = {}) {
250
+ let open;
251
+ const opened = new Promise((resolve) => { open = resolve; });
252
+ const remove = this.intercept(op, () => opened, options);
253
+ const release = () => { remove(); this.gates.delete(release); open(); };
254
+ this.gates.add(release);
255
+ return release;
256
+ }
257
+ /** Clear runs, calls, intercepts (gates are released), responders, memory, idempotency keys and notices. Grants
258
+ * and refresh-token chains stay, so stored connections remain valid. */
259
+ reset() {
260
+ for (const release of [...this.gates])
261
+ release();
262
+ this.intercepts = [];
263
+ for (const run of this.allRuns)
264
+ run.delete();
265
+ this.allRuns.length = 0;
266
+ for (const user of this.users.values()) {
267
+ user.runs.length = 0;
268
+ user.responder = null;
269
+ user.memory.clear();
270
+ user.idempotency.clear();
271
+ }
272
+ this.responder = null;
273
+ this.calls.length = 0;
274
+ this.directoryCalls.length = 0;
275
+ this.responderErrors.length = 0;
276
+ this.runNotices.reset();
277
+ }
278
+ /** The exact error the SDK raises when the directory refuses a refresh with `code` (`refusalOf(e)` is set). */
279
+ async refusalError(code) {
280
+ const probe = new FakeCloud({ clientId: this.clientId, issuer: this.issuer, runtimeUrl: this.runtimeUrlFor('user:refused') });
281
+ const store = new MemoryConnectionStore();
282
+ const user = await probe.connect(store, 'user:refused');
283
+ user.refuse(code);
284
+ const { privateKey } = await generateKeyPair('ES256', { extractable: true });
285
+ const oauth = new OAuth2Client({
286
+ clientId: this.clientId, privateKey: await exportJWK(privateKey), redirectUri: 'https://app.test/callback',
287
+ issuer: this.issuer, store, fetch: probe.fetch,
288
+ });
289
+ try {
290
+ await (await oauth.client('user:refused')).grant.get();
291
+ }
292
+ catch (error) {
293
+ return error;
294
+ }
295
+ throw new Error(`refusalError: the refresh was not refused with ${code}`);
296
+ }
297
+ // ---- routing ---------------------------------------------------------------------------------------------------
298
+ state(userId) {
299
+ let state = this.users.get(userId);
300
+ if (!state) {
301
+ state = new UserState(userId, this.clientId, (user) => { for (const run of user.runs)
302
+ if (!run.deleted)
303
+ run.revokeStreams(); }, this.clock);
304
+ this.users.set(userId, state);
305
+ }
306
+ return state;
307
+ }
308
+ runtimeUrlFor(userId) {
309
+ const option = this.options.runtimeUrl ?? 'https://runtime.test';
310
+ return (typeof option === 'function' ? option(userId) : option).replace(/\/$/, '');
311
+ }
312
+ isRuntimeOrigin(origin, sub) {
313
+ const option = this.options.runtimeUrl ?? 'https://runtime.test';
314
+ if (typeof option === 'string')
315
+ return new URL(option).origin === origin;
316
+ if (sub !== null && new URL(option(sub)).origin === origin)
317
+ return true;
318
+ return [...this.users.keys()].some((id) => new URL(option(id)).origin === origin);
319
+ }
320
+ headers() { return { [RUNTIME_VERSION_HEADER]: this.options.runtimeVersion ?? SDK_VERSION }; }
321
+ async route(input, init) {
322
+ const request = new Request(input, init);
323
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : null);
324
+ if (signal?.aborted)
325
+ throw aborted(signal);
326
+ const url = new URL(request.url);
327
+ if (url.href.startsWith(`${this.issuer}/`))
328
+ return this.directory(url, request, signal);
329
+ const claims = decodeUnsigned((request.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, ''));
330
+ const sub = typeof claims?.['sub'] === 'string' ? claims['sub'] : null;
331
+ if (!this.isRuntimeOrigin(url.origin, sub))
332
+ throw new Error(`FakeCloud: no fake serves ${request.method} ${url.href}`);
333
+ return this.runtime(url, request, signal, claims, sub);
334
+ }
335
+ async runIntercepts(op, call, signal) {
336
+ for (const entry of [...this.intercepts]) {
337
+ const matches = entry.op === op || (entry.op === '*' && !DIRECTORY_OPS.has(op));
338
+ if (!matches || (entry.user !== undefined && entry.user !== call.userId) || entry.remaining <= 0)
339
+ continue;
340
+ entry.remaining--;
341
+ if (entry.remaining <= 0)
342
+ this.intercepts = this.intercepts.filter((other) => other !== entry);
343
+ const answer = await withSignal(Promise.resolve(entry.fn(call)), signal);
344
+ if (answer !== undefined)
345
+ return answer;
346
+ }
347
+ return undefined;
348
+ }
349
+ // ---- directory ---------------------------------------------------------------------------------------------------
350
+ async directory(url, request, signal) {
351
+ const path = url.href.slice(this.issuer.length);
352
+ let op;
353
+ if (request.method === 'GET' && path === '/.well-known/openid-configuration')
354
+ op = 'directory.discovery';
355
+ else if (request.method === 'POST' && path === '/token')
356
+ op = 'directory.token';
357
+ else if (request.method === 'GET' && path === '/.well-known/jwks.json')
358
+ op = 'directory.jwks';
359
+ else
360
+ throw new Error(`FakeCloud: the fake directory does not serve ${request.method} ${url.href}`);
361
+ const params = op === 'directory.token' ? new URLSearchParams(await request.text()) : null;
362
+ const presented = params?.get('refresh_token') ?? '';
363
+ const credentials = params?.get('grant_type') === 'client_credentials';
364
+ const owner = op === 'directory.token' && credentials ? params.get('sub') || null
365
+ : op === 'directory.token' ? [...this.users.values()].find((user) => user.refreshToken === presented)?.id ?? this.superseded.get(presented) ?? null : null;
366
+ const call = { op, userId: owner, answer: 'ok', at: this.clock.now() };
367
+ this.directoryCalls.push(call);
368
+ const scripted = await this.runIntercepts(op, call, signal);
369
+ if (scripted !== undefined) {
370
+ if (isFakeError(scripted))
371
+ throw new TypeError(`${op} cannot answer an envelope: use a status or 'unreachable'`);
372
+ call.answer = scripted;
373
+ if (scripted === 'unreachable')
374
+ throw unreachable();
375
+ return Response.json(op === 'directory.token' ? { error: 'temporarily_unavailable' } : {}, { status: scripted });
376
+ }
377
+ if (op === 'directory.discovery') {
378
+ const template = this.options.accountAppUriTemplate === undefined ? `${this.issuer}/account/apps/{client_id}` : this.options.accountAppUriTemplate;
379
+ return Response.json({
380
+ issuer: this.issuer, authorization_endpoint: `${this.issuer}/authorize`, token_endpoint: `${this.issuer}/token`,
381
+ jwks_uri: `${this.issuer}/.well-known/jwks.json`, token_endpoint_auth_methods_supported: ['private_key_jwt'],
382
+ ...(template === null ? {} : { account_app_uri_template: template }),
383
+ });
384
+ }
385
+ if (op === 'directory.jwks')
386
+ return Response.json(await this.key.jwks());
387
+ return credentials ? this.mint(params, call) : this.refresh(params, presented, call);
388
+ }
389
+ /** `grant_type=client_credentials` + `sub`: the directory's per-user runtime-token mint. The client assertion is not verified. */
390
+ mint(params, call) {
391
+ const sub = params.get('sub');
392
+ if (!sub) {
393
+ call.answer = 400;
394
+ return Response.json({ error: 'unauthorized_client' }, { status: 400 });
395
+ }
396
+ const user = this.users.get(sub);
397
+ const refused = (description, answer) => { call.answer = answer; return Response.json({ error: 'invalid_grant', error_description: description }, { status: 400 }); };
398
+ if (user?.refusal)
399
+ return refused(user.refusal, user.refusal);
400
+ if (!user?.active)
401
+ return refused('no active grant', 'grant_revoked');
402
+ return Response.json({ access_token: this.accessToken(user, 600), token_type: 'Bearer', expires_in: 600, runtimeUrl: this.runtimeUrlFor(user.id) });
403
+ }
404
+ refresh(params, presented, call) {
405
+ const refused = (code) => { call.answer = code; return Response.json({ error: 'invalid_grant', error_description: code }, { status: 400 }); };
406
+ if (params.get('grant_type') !== 'refresh_token') {
407
+ call.answer = 400;
408
+ return Response.json({ error: 'unsupported_grant_type' }, { status: 400 });
409
+ }
410
+ if (call.userId === null)
411
+ return refused('grant_revoked');
412
+ const user = this.state(call.userId);
413
+ if (user.refreshToken !== presented)
414
+ return refused('refresh_token_reused');
415
+ if (user.refusal)
416
+ return refused(user.refusal);
417
+ this.superseded.set(presented, user.id);
418
+ user.refreshToken = this.newRefreshToken(user.id);
419
+ return Response.json({
420
+ access_token: this.accessToken(user), token_type: 'Bearer', expires_in: 3600,
421
+ refresh_token: user.refreshToken, runtimeUrl: this.runtimeUrlFor(user.id),
422
+ });
423
+ }
424
+ newRefreshToken(userId) { return `rt-${++this.refreshSerial}-${userId}`; }
425
+ accessToken(user, ttlSeconds = 3600) {
426
+ const now = Math.floor(this.clock.now() / 1000);
427
+ return unsignedJwt({ sub: user.id, grant: user.grantId, exp: now + ttlSeconds, iat: now, rev: user.rev, serial: ++this.tokenSerial });
428
+ }
429
+ // ---- runtime -----------------------------------------------------------------------------------------------------
430
+ async runtime(url, request, signal, claims, sub) {
431
+ const route = ROUTES.find(([method, pattern]) => method === request.method && pattern.test(url.pathname));
432
+ if (!route)
433
+ return jsonAnswer(envelopeBody(fakeError({ status: 404, code: 'not_found', message: 'unknown operation' })), 404, this.headers());
434
+ const [, pattern, op] = route;
435
+ const segment = pattern.exec(url.pathname)?.[1];
436
+ const id = segment === undefined ? undefined : decodeURIComponent(segment);
437
+ let body = null;
438
+ let badJson = false;
439
+ if (request.method === 'POST') {
440
+ const text = await request.text();
441
+ if (text) {
442
+ try {
443
+ body = JSON.parse(text);
444
+ }
445
+ catch {
446
+ badJson = true;
447
+ }
448
+ }
449
+ }
450
+ const user = sub !== null ? this.users.get(sub) : undefined;
451
+ const runId = RUN_OPS.has(op) ? id : undefined;
452
+ const questionId = op === 'question.get' || op === 'question.answer' ? id : null;
453
+ const questionRun = questionId === null ? undefined : user?.runs.find((run) => !run.deleted && run.questions.has(questionId));
454
+ const call = {
455
+ userId: user ? user.id : null, op, runId: runId ?? questionRun?.id ?? null, questionId, method: request.method, path: url.pathname,
456
+ query: Object.fromEntries(url.searchParams), body, idempotencyKey: request.headers.get('idempotency-key'), status: null, at: this.clock.now(),
457
+ };
458
+ this.calls.push(call);
459
+ const answer = (status, payload) => { call.status = status; return jsonAnswer(payload, status, this.headers()); };
460
+ const fail = (error) => answer(error.status, envelopeBody(error, runId));
461
+ const scripted = await this.runIntercepts(op, call, signal);
462
+ if (scripted !== undefined) {
463
+ if (scripted === 'unreachable')
464
+ throw unreachable();
465
+ if (typeof scripted === 'number')
466
+ return fail(fakeError({ status: scripted, code: scripted >= 500 ? 'server_error' : 'invalid_request', message: `scripted ${scripted}`, retryable: false }));
467
+ return fail(scripted);
468
+ }
469
+ const minimum = minimumRuntimeRefusal(request.headers.get(MINIMUM_RUNTIME_VERSION_HEADER) ?? undefined, this.options.runtimeVersion ?? SDK_VERSION);
470
+ if (minimum) {
471
+ const error = fakeError({ status: minimum.status, code: minimum.code, message: minimum.message, ...(minimum.status === 426 ? { details: minimum.details } : { param: minimum.param }), origin: minimum.status === 426 ? 'router' : 'runtime', retryable: false });
472
+ call.status = error.status;
473
+ // A router refusal names no runtime release.
474
+ return error.status === 426 ? jsonAnswer(envelopeBody(error), 426, {}) : fail(error);
475
+ }
476
+ try {
477
+ if (!user?.refreshToken || claims === null)
478
+ throw refuse(401, 'unauthorized', 'a directory access token is required');
479
+ if (typeof claims['serial'] !== 'number' || claims['serial'] < user.minSerial)
480
+ throw refuse(401, 'unauthorized', 'invalid directory access token');
481
+ if (!user.active)
482
+ throw refuse(401, 'grant_revoked', 'grant is not active');
483
+ if (ACT_OPS.has(op) && !user.scopeStrings().some((scope) => covers(scope, 'crtr:act')))
484
+ throw refuse(403, 'scope_missing', 'missing scope crtr:act');
485
+ if (badJson)
486
+ throw invalid('invalid JSON body');
487
+ if (op === 'run.events') {
488
+ const run = this.ownedRun(user, id);
489
+ const raw = request.headers.get('last-event-id') ?? url.searchParams.get('after');
490
+ const after = integerParam(raw, 'after', 0, Number.MAX_SAFE_INTEGER, 0);
491
+ if (after > run.latestSequence)
492
+ throw invalid('cursor is beyond the run', 'after');
493
+ const earliest = run.earliestSequence;
494
+ if (after < run.latestSequence && (earliest === null || after < earliest - 1)) {
495
+ throw refuse(409, 'stream_gap', 'events have expired', { details: { earliest_sequence: earliest, latest_sequence: run.latestSequence } });
496
+ }
497
+ call.status = 200;
498
+ return new Response(run.openStream(after, signal), { status: 200, headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', ...this.headers() } });
499
+ }
500
+ const result = await this.handle(op, user, id, url.searchParams, body, call.idempotencyKey, signal, claims);
501
+ return answer(result.status, result.body);
502
+ }
503
+ catch (error) {
504
+ if (error instanceof FakeRefused)
505
+ return fail(error.error);
506
+ if (isFakeError(error))
507
+ return fail(error);
508
+ throw error;
509
+ }
510
+ }
511
+ ownedRun(user, id) {
512
+ const run = user.runs.find((candidate) => candidate.id === id);
513
+ if (!run || run.deleted)
514
+ throw notFound();
515
+ return run;
516
+ }
517
+ respond(run, kind, messages) {
518
+ const responder = this.users.get(run.userId)?.responder ?? this.responder;
519
+ if (!responder)
520
+ return;
521
+ setImmediate(() => {
522
+ try {
523
+ Promise.resolve(responder({ run, kind, messages })).catch((error) => { this.responderErrors.push(error); });
524
+ }
525
+ catch (error) {
526
+ this.responderErrors.push(error);
527
+ }
528
+ });
529
+ }
530
+ idempotencyKey(raw) {
531
+ if (raw === null)
532
+ return null;
533
+ if (raw.length < 1 || raw.length > 255)
534
+ throw invalid('Idempotency-Key must be 1 to 255 characters');
535
+ return raw;
536
+ }
537
+ async handle(op, user, id, query, body, rawKey, signal, claims) {
538
+ switch (op) {
539
+ case 'grant.get': return this.grantGet(user, query, signal, claims);
540
+ case 'run.start': return this.runStart(user, body, rawKey);
541
+ case 'run.list': {
542
+ const status = query.get('status');
543
+ if (status && !['running', 'waiting_on_user', 'idle', 'settled'].includes(status))
544
+ throw invalid('invalid status', 'status');
545
+ let after;
546
+ const rawAfter = query.get('after');
547
+ if (rawAfter) {
548
+ try {
549
+ const parsed = JSON.parse(Buffer.from(rawAfter, 'base64url').toString());
550
+ if (typeof parsed.activity !== 'string' || typeof parsed.id !== 'string')
551
+ throw new Error('invalid');
552
+ after = { activity: parsed.activity, id: parsed.id };
553
+ }
554
+ catch {
555
+ throw invalid('invalid list cursor', 'after');
556
+ }
557
+ }
558
+ const limit = integerParam(query.get('limit'), 'limit', 1, 100, 50);
559
+ const rows = user.runs.filter((run) => !run.deleted)
560
+ .sort((a, b) => (a.lastActivityAt === b.lastActivityAt ? (a.id < b.id ? 1 : -1) : a.lastActivityAt < b.lastActivityAt ? 1 : -1))
561
+ .filter((run) => !after || run.lastActivityAt < after.activity || (run.lastActivityAt === after.activity && run.id < after.id))
562
+ .filter((run) => !status || run.status === status);
563
+ const page = rows.slice(0, limit);
564
+ const last = page.at(-1);
565
+ return { status: 200, body: { runs: page.map((run) => run.toObject()), next: rows.length > limit && last ? Buffer.from(JSON.stringify({ activity: last.lastActivityAt, id: last.id })).toString('base64url') : null } };
566
+ }
567
+ case 'run.get': {
568
+ const wait = waitSeconds(query);
569
+ const run = this.ownedRun(user, id);
570
+ await run.waitResting(wait, signal);
571
+ return { status: 200, body: this.ownedRun(user, id).toObject() };
572
+ }
573
+ case 'run.message': return this.runMessage(user, this.ownedRun(user, id), body, rawKey);
574
+ case 'run.interrupt': {
575
+ const run = this.ownedRun(user, id);
576
+ const params = objectBody(body ?? {});
577
+ if (params['children'] !== undefined && params['children'] !== 'stop' && params['children'] !== 'continue')
578
+ throw invalid('invalid children', 'children');
579
+ return { status: 200, body: run.toObject() };
580
+ }
581
+ case 'run.cancel': {
582
+ const run = this.ownedRun(user, id);
583
+ const params = objectBody(body ?? {});
584
+ if (params['keep_schedules'] !== undefined && typeof params['keep_schedules'] !== 'boolean')
585
+ throw invalid('invalid keep_schedules', 'keep_schedules');
586
+ if (!run.outcome)
587
+ run.settle({ failure: 'canceled' });
588
+ return { status: 200, body: run.toObject() };
589
+ }
590
+ case 'run.rename': {
591
+ const run = this.ownedRun(user, id);
592
+ const params = objectBody(body);
593
+ for (const field of Object.keys(params))
594
+ if (field !== 'name')
595
+ throw invalid(`unknown field ${field}`, field);
596
+ const name = params['name'];
597
+ if (typeof name !== 'string' || !name.trim() || name.length > 200)
598
+ throw invalid('name must be a non-empty string of at most 200 characters', 'name');
599
+ run.name = name.trim();
600
+ return { status: 200, body: run.toObject() };
601
+ }
602
+ case 'run.delete': {
603
+ const run = this.ownedRun(user, id);
604
+ run.delete();
605
+ return { status: 200, body: { run_id: run.id, uploads_pending: 0 } };
606
+ }
607
+ case 'trace.get': {
608
+ const format = query.get('format');
609
+ if (format && format !== 'entries')
610
+ throw invalid('unknown trace format', 'format');
611
+ const run = this.ownedRun(user, id);
612
+ return { status: 200, body: { run_id: run.id, latest_sequence: run.latestSequence, nodes: [] } };
613
+ }
614
+ case 'question.get': {
615
+ const { run, question } = this.question(user, id);
616
+ return { status: 200, body: run.questionObject(question) };
617
+ }
618
+ case 'question.answer': {
619
+ const { run, question } = this.question(user, id);
620
+ const current = run.questionObject(question);
621
+ if (current['status'] !== 'open')
622
+ throw refuse(409, 'run_settled', `question ${question} is already ${String(current['status'])}`, { details: { state: current['status'] } });
623
+ const responses = body && typeof body === 'object' && !Array.isArray(body) ? body['responses'] : undefined;
624
+ if (!responses || typeof responses !== 'object' || Array.isArray(responses))
625
+ throw invalid('responses must be an object keyed by slot id', 'responses');
626
+ run.answer(question, responses);
627
+ return { status: 200, body: { question_id: question, status: 'answered', answer: responses } };
628
+ }
629
+ case 'memory.list': return { status: 200, body: this.memoryList(user, query) };
630
+ case 'memory.get': {
631
+ this.memoryStore(query);
632
+ const name = query.get('name');
633
+ if (name === null)
634
+ throw invalid('name is required', 'name');
635
+ if (!MEMORY_NAME.test(name))
636
+ throw invalid('name must be segments of letters, digits, `_` or `-` joined by `/`', 'name');
637
+ const doc = user.memory.get(name);
638
+ if (!doc || !this.memoryVisible(user, doc))
639
+ throw refuse(404, 'not_found', 'no memory document by that name');
640
+ // The served body is capped at 1 MiB on a whole UTF-8 character (`memoryGet`); `size` stays the full length.
641
+ const full = Buffer.from(doc.body, 'utf8');
642
+ let end = Math.min(full.length, MEMORY_BODY_MAX_BYTES);
643
+ while (end < full.length && end > 0 && (full[end] & 0xc0) === 0x80)
644
+ end--;
645
+ return { status: 200, body: { ...memoryItem(doc), body: full.subarray(0, end).toString('utf8'), size: full.length, truncated: end < full.length, grantee: null } };
646
+ }
647
+ case 'provider.tools': return this.providerTools(user, id);
648
+ case 'provider.call': return this.providerCall(user, body, signal);
649
+ case 'profile.list': return { status: 200, body: { profiles: [{ profile_id: 'default', name: 'default', grantee: this.host.grantee, default_kind: 'general', default: true }] } };
650
+ default: throw notFound('unknown operation');
651
+ }
652
+ }
653
+ async grantGet(user, query, signal, claims) {
654
+ const wait = waitSeconds(query);
655
+ const since = query.get('since');
656
+ if (since !== null && !/^[A-Za-z0-9_-]{1,64}$/.test(since))
657
+ throw invalid('invalid since', 'since');
658
+ if (wait && since !== null && user.version() === since) {
659
+ await new Promise((resolve, reject) => {
660
+ const hold = { wake: () => { if (user.version() !== since)
661
+ finish(); } };
662
+ const finish = () => { clearTimeout(timer); user.holds.delete(hold); signal?.removeEventListener('abort', onAbort); resolve(); };
663
+ const onAbort = () => { clearTimeout(timer); user.holds.delete(hold); reject(aborted(signal)); };
664
+ const timer = setTimeout(finish, wait * 1000);
665
+ user.holds.add(hold);
666
+ if (signal?.aborted)
667
+ onAbort();
668
+ else
669
+ signal?.addEventListener('abort', onAbort, { once: true });
670
+ });
671
+ if (!user.active)
672
+ throw refuse(401, 'grant_revoked', 'grant is not active');
673
+ }
674
+ return { status: 200, body: user.dto(typeof claims['exp'] === 'number' ? claims['exp'] : undefined) };
675
+ }
676
+ runStart(user, body, rawKey) {
677
+ const params = objectBody(body);
678
+ for (const field of ['mode', 'parent', 'fork_from', 'node_id', 'env', 'tools', 'system_prompt'])
679
+ if (params[field] !== undefined)
680
+ throw refuse(403, 'owner_only', `${field} is owner-only`, { param: field });
681
+ for (const field of ['payer', 'model', 'model_policy', 'deadline', 'kind'])
682
+ if (params[field] !== undefined)
683
+ throw invalid(`${field} is not available`, field);
684
+ for (const field of Object.keys(params))
685
+ if (!['prompt', 'scopes', 'metadata', 'profile', 'isolated', 'cwd', 'worktree', 'output_schema', 'message'].includes(field))
686
+ throw invalid(`unknown field ${field}`, field);
687
+ if (params['profile'] !== undefined && (typeof params['profile'] !== 'string' || !params['profile']))
688
+ throw invalid('profile must be a profile id', 'profile');
689
+ if (params['isolated'] !== undefined && typeof params['isolated'] !== 'boolean')
690
+ throw invalid('isolated must be a boolean', 'isolated');
691
+ if (params['cwd'] !== undefined && (params['isolated'] !== true || typeof params['cwd'] !== 'string' || !params['cwd']))
692
+ throw invalid('cwd is available only for an isolated run and must be an absolute path inside its root folder', 'cwd');
693
+ if (params['worktree'] !== undefined && params['isolated'] !== true)
694
+ throw invalid('worktree is not available', 'worktree');
695
+ if (params['isolated'] === true && params['worktree'] !== undefined)
696
+ throw invalid('worktree is unavailable for an isolated run', 'worktree');
697
+ if (typeof params['prompt'] !== 'string' || !params['prompt'].trim())
698
+ throw invalid('prompt is required', 'prompt');
699
+ if (params['message'] !== undefined && (typeof params['message'] !== 'string' || !params['message'].trim()))
700
+ throw invalid('message must be a non-empty string', 'message');
701
+ const metadata = params['metadata'] === undefined ? {} : objectBody(params['metadata']);
702
+ if (Buffer.byteLength(JSON.stringify(metadata)) > 4096)
703
+ throw invalid('metadata exceeds 4 KB', 'metadata');
704
+ const outputSchema = params['output_schema'];
705
+ if (outputSchema !== undefined) {
706
+ if (!outputSchema || typeof outputSchema !== 'object' || Array.isArray(outputSchema))
707
+ throw invalid('output_schema must be a JSON Schema object', 'output_schema');
708
+ if (Buffer.byteLength(JSON.stringify(outputSchema)) > 256 * 1024)
709
+ throw invalid('output_schema exceeds 256 KB', 'output_schema');
710
+ }
711
+ const scopes = requestedScopes(user.scopeStrings(), params['scopes'], this.host.grantee);
712
+ // `startRun` (core/runs/operations.ts) refuses an isolated run without the root launcher before the idempotency replay.
713
+ if (params['isolated'] === true && this.options.isolatedRuns === 'unavailable')
714
+ throw refuse(503, 'isolated_unavailable', 'isolated runs require the Linux root launcher', { param: 'isolated' });
715
+ const key = this.idempotencyKey(rawKey);
716
+ const message = params['message'];
717
+ const hash = sha256Hex(JSON.stringify({
718
+ prompt: params['prompt'], scopes, metadata, profile: params['profile'], isolated: params['isolated'], cwd: params['cwd'],
719
+ ...(outputSchema === undefined ? {} : { output_schema: outputSchema }), ...(message === undefined ? {} : { message }),
720
+ }));
721
+ if (key !== null) {
722
+ const entry = user.idempotent(key);
723
+ if (entry) {
724
+ if (entry.hash !== hash)
725
+ throw refuse(409, 'idempotency_conflict', 'key used with a different request');
726
+ return { status: entry.status, body: structuredClone(entry.body) };
727
+ }
728
+ }
729
+ const runId = `run-${this.runSerial + 1}`;
730
+ // `startRun` checks an isolated run's `cwd` after the replay: it must be absolute and inside the run's root folder,
731
+ // `<apps root>/<app>/nodes/<run id>`, and a guest's apps root is `/apps`.
732
+ if (params['isolated'] === true && params['cwd'] !== undefined) {
733
+ const cwd = params['cwd'];
734
+ const beneath = posix.relative(this.isolatedRoot(runId), posix.resolve('/', cwd));
735
+ if (!cwd.startsWith('/') || beneath === '..' || beneath.startsWith('../') || posix.isAbsolute(beneath))
736
+ throw invalid('cwd must be inside the isolated root node folder', 'cwd');
737
+ }
738
+ this.runSerial++;
739
+ const run = new FakeRun(runId, user.id, params, key, this.host, metadata, scopes);
740
+ user.runs.push(run);
741
+ this.allRuns.push(run);
742
+ run.emit('run.started', { scopes, payer: 'user', usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, cost_usd: 0 } });
743
+ const accepted = message === undefined ? undefined : run.accept(message, false, key);
744
+ const response = {
745
+ run_id: run.id, node_id: run.id, grantee: this.host.grantee, grant: user.grantId, rev: user.rev, scopes, payer: 'user',
746
+ status: 'running', created_at: run.createdAt, metadata, sequence_number: 0,
747
+ ...(accepted ? { message: { run_id: run.id, message_id: accepted.message_id, sequence_number: accepted.sequence_number } } : {}),
748
+ };
749
+ if (key !== null)
750
+ user.idempotency.set(key, { hash, runId: run.id, status: 201, body: structuredClone(response), createdAt: this.clock.now() });
751
+ this.respond(run, 'start', accepted ? [accepted] : []);
752
+ return { status: 201, body: response };
753
+ }
754
+ /** An isolated run's root folder on a guest (`appSpace(app).nodes/<run id>`, apps root `/apps`). */
755
+ isolatedRoot(runId) { return `/apps/${this.clientId}/nodes/${runId}`; }
756
+ runMessage(user, run, body, rawKey) {
757
+ const params = objectBody(body);
758
+ const text = params['message'];
759
+ if (typeof text !== 'string' || !text.trim())
760
+ throw invalid('message is required', 'message');
761
+ if (params['start_turn'] !== undefined && typeof params['start_turn'] !== 'boolean')
762
+ throw invalid('start_turn must be a boolean', 'start_turn');
763
+ const startTurn = params['start_turn'] ?? true;
764
+ const key = this.idempotencyKey(rawKey);
765
+ const hash = sha256Hex(JSON.stringify(['run.message', run.id, text, startTurn]));
766
+ if (key !== null) {
767
+ const entry = user.idempotent(key);
768
+ if (entry) {
769
+ if (entry.hash !== hash)
770
+ throw refuse(409, 'idempotency_conflict', 'key used with a different request');
771
+ return { status: entry.status, body: structuredClone(entry.body) };
772
+ }
773
+ }
774
+ const reason = run.outcome?.reason;
775
+ if (reason === 'canceled' || reason === 'deadline_exceeded')
776
+ throw refuse(409, 'run_settled', 'canceled run cannot receive a message');
777
+ if (run.outcome && startTurn)
778
+ run.reopen();
779
+ const accepted = run.accept(text, startTurn, key);
780
+ const response = { run_id: run.id, message_id: accepted.message_id, sequence_number: accepted.sequence_number };
781
+ if (key !== null)
782
+ user.idempotency.set(key, { hash, runId: run.id, status: 202, body: structuredClone(response), createdAt: this.clock.now() });
783
+ if (startTurn)
784
+ this.respond(run, 'message', [accepted]);
785
+ return { status: 202, body: response };
786
+ }
787
+ question(user, questionId) {
788
+ const run = user.runs.find((candidate) => !candidate.deleted && candidate.questions.has(questionId));
789
+ if (!run)
790
+ throw refuse(404, 'not_found', 'question not found');
791
+ return { run, question: questionId };
792
+ }
793
+ memoryStore(query) {
794
+ const value = query.get('store');
795
+ if (value === null)
796
+ throw invalid('store is required', 'store');
797
+ if (value !== 'user')
798
+ throw invalid('store must be "user"', 'store');
799
+ }
800
+ memoryVisible(user, doc) {
801
+ return doc.privacy === 'public' || user.scopeStrings().some((scope) => covers(scope, 'crtr:memory:manage:user'));
802
+ }
803
+ memoryList(user, query) {
804
+ this.memoryStore(query);
805
+ const path = query.get('path');
806
+ if (path !== null && !MEMORY_NAME.test(path))
807
+ throw invalid('path must be name segments of letters, digits, `_` or `-` joined by `/`', 'path');
808
+ const kind = query.get('kind');
809
+ if (kind !== null && kind !== 'knowledge' && kind !== 'preference')
810
+ throw invalid('kind must be knowledge or preference', 'kind');
811
+ const raw = query.get('limit');
812
+ if (raw !== null && (!/^(0|[1-9]\d*)$/.test(raw) || Number(raw) < 1 || Number(raw) > 100))
813
+ throw invalid('limit must be an integer from 1 to 100', 'limit');
814
+ const limit = raw === null ? 50 : Number(raw);
815
+ const filter = createHash('sha256').update(JSON.stringify(['user', path, kind])).digest('base64url').slice(0, 16);
816
+ let after = null;
817
+ const cursor = query.get('cursor');
818
+ if (cursor !== null) {
819
+ try {
820
+ if (!/^[A-Za-z0-9_-]+$/.test(cursor))
821
+ throw new Error('not base64url');
822
+ const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
823
+ if (typeof value.n !== 'string' || !MEMORY_NAME.test(value.n) || value.f !== filter)
824
+ throw new Error('mismatch');
825
+ after = value.n;
826
+ }
827
+ catch {
828
+ throw invalid('cursor is not the next_cursor of a previous page with the same store, path and kind', 'cursor');
829
+ }
830
+ }
831
+ const prefix = path === null ? null : `${path}/`;
832
+ const visible = [...user.memory.values()]
833
+ .filter((doc) => (kind === null || doc.kind === kind) && (prefix === null || doc.name.startsWith(prefix)) && (after === null || doc.name > after) && this.memoryVisible(user, doc))
834
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
835
+ const page = visible.slice(0, limit);
836
+ const more = visible.length > limit;
837
+ return { items: page.map(memoryItem), next_cursor: more ? Buffer.from(JSON.stringify({ n: page.at(-1).name, f: filter }), 'utf8').toString('base64url') : null };
838
+ }
839
+ providerEntry(user, id, definition) {
840
+ const held = user.scopeStrings();
841
+ if (!held.some((scope) => scope.startsWith(`${id}:`)))
842
+ return null;
843
+ const displayName = definition.displayName ?? id;
844
+ if (definition.status && definition.status !== 'available') {
845
+ return { provider: id, display_name: displayName, status: definition.status, ...(definition.error ? { error: definition.error } : {}), groups: [], commands: [] };
846
+ }
847
+ const groups = new Map();
848
+ for (const [tool, spec] of Object.entries(definition.tools)) {
849
+ const group = spec.group ?? tool;
850
+ if (!held.includes(`${id}:${group}`))
851
+ continue;
852
+ const description = spec.description ?? tool;
853
+ groups.set(group, [...(groups.get(group) ?? []), { tool, description, summary: description, params: [], output: [] }]);
854
+ }
855
+ if (groups.size === 0)
856
+ return null;
857
+ return { provider: id, display_name: displayName, status: 'available', major: 1, groups: [...groups].map(([group, leaves]) => ({ group, leaves })), commands: [] };
858
+ }
859
+ providerTools(user, provider) {
860
+ if (provider !== undefined && (!PROVIDER_ID.test(provider) || provider === 'crtr'))
861
+ throw invalid('provider must be a provider id', 'provider');
862
+ const ids = [...this.providerDefs.keys()].sort();
863
+ const candidates = provider === undefined ? ids : ids.filter((id) => id === provider);
864
+ const visible = candidates.map((id) => this.providerEntry(user, id, this.providerDefs.get(id))).filter((entry) => entry !== null);
865
+ if (provider !== undefined) {
866
+ if (visible.length === 0)
867
+ throw refuse(404, 'not_found', `provider "${provider}" is not available`);
868
+ return { status: 200, body: visible[0] };
869
+ }
870
+ return { status: 200, body: { providers: visible } };
871
+ }
872
+ async providerCall(user, body, signal) {
873
+ const params = objectBody(body);
874
+ const provider = params['provider'];
875
+ if (typeof provider !== 'string' || !PROVIDER_ID.test(provider) || provider === 'crtr')
876
+ throw invalid('provider must be a provider id', 'provider');
877
+ const tool = params['tool'];
878
+ if (typeof tool !== 'string' || !tool.trim())
879
+ throw invalid('tool must be a non-empty string', 'tool');
880
+ const args = params['arguments'] ?? {};
881
+ if (!args || typeof args !== 'object' || Array.isArray(args))
882
+ throw invalid('arguments must be an object', 'arguments');
883
+ const definition = this.providerDefs.get(provider);
884
+ // The daemon installs a missing bundle on the first call (`ensureProviderBundle`) and answers any failure to do so,
885
+ // including a directory that serves no such provider, as a retryable `provider_unavailable` (core/providers/call.ts).
886
+ if (!definition)
887
+ throw refuse(503, 'provider_unavailable', `provider "${provider}" unavailable: bundle installation pending`);
888
+ if (definition.status && definition.status !== 'available') {
889
+ throw refuse(503, 'provider_unavailable', `provider "${provider}" unavailable: ${definition.error ?? 'bundle installation pending'}`);
890
+ }
891
+ const spec = definition.tools[tool.trim()];
892
+ if (!spec)
893
+ throw refuse(404, 'not_found', `tool "${tool}" is not in provider "${provider}"`, { param: 'tool' });
894
+ const scope = `${provider}:${spec.group ?? tool.trim()}`;
895
+ if (!user.scopeStrings().includes(scope))
896
+ throw refuse(403, 'scope_missing', `missing scope ${scope}`, { scope });
897
+ try {
898
+ const result = await spec.handler({ userId: user.id, arguments: args, signal });
899
+ return { status: 200, body: result ?? {} };
900
+ }
901
+ catch (error) {
902
+ if (isFakeError(error) || error instanceof FakeRefused)
903
+ throw error;
904
+ throw refuse(502, 'provider_error', error instanceof Error ? error.message : String(error), { origin: 'provider', retryable: false });
905
+ }
906
+ }
907
+ }
908
+ function memoryItem(doc) {
909
+ const cut = doc.name.lastIndexOf('/');
910
+ return {
911
+ name: doc.name, folder: cut < 0 ? '' : doc.name.slice(0, cut), kind: doc.kind, preview: doc.preview, summary: doc.summary,
912
+ privacy: doc.privacy, revision: doc.revision, created_at: doc.created_at, updated_at: doc.updated_at,
913
+ };
914
+ }