@userreason/cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +207 -0
  2. package/dist/bin.js +93 -8
  3. package/dist/catalog.js +29 -3
  4. package/dist/client.d.ts +8 -2
  5. package/dist/client.js +26 -11
  6. package/dist/first-survey-preparation.d.ts +8 -0
  7. package/dist/first-survey-preparation.js +29 -0
  8. package/dist/first-survey-source.d.ts +60 -0
  9. package/dist/first-survey-source.js +289 -0
  10. package/dist/first-survey-terminal.d.ts +5 -0
  11. package/dist/first-survey-terminal.js +16 -0
  12. package/dist/first-survey.d.ts +88 -0
  13. package/dist/first-survey.js +227 -0
  14. package/dist/help.js +6 -6
  15. package/dist/index.d.ts +14 -0
  16. package/dist/index.js +14 -0
  17. package/dist/profile.js +1 -1
  18. package/dist/recommended-surveys.d.ts +30 -0
  19. package/dist/recommended-surveys.js +201 -0
  20. package/dist/setup-executor.d.ts +35 -0
  21. package/dist/setup-executor.js +329 -0
  22. package/dist/setup-files.d.ts +11 -0
  23. package/dist/setup-files.js +96 -0
  24. package/dist/setup-runner.d.ts +14 -0
  25. package/dist/setup-runner.js +142 -0
  26. package/dist/setup-source.d.ts +31 -0
  27. package/dist/setup-source.js +48 -0
  28. package/dist/setup-store.d.ts +37 -0
  29. package/dist/setup-store.js +256 -0
  30. package/dist/setup-terminal.d.ts +3 -0
  31. package/dist/setup-terminal.js +6 -0
  32. package/dist/setup-wire.d.ts +1237 -0
  33. package/dist/setup-wire.js +175 -0
  34. package/dist/setup.d.ts +1223 -0
  35. package/dist/setup.js +258 -0
  36. package/dist/starter-survey.d.ts +22 -0
  37. package/dist/starter-survey.js +457 -0
  38. package/dist/telemetry.d.ts +67 -0
  39. package/dist/telemetry.js +304 -0
  40. package/dist/workos.d.ts +1 -1
  41. package/dist/workos.js +4 -2
  42. package/package.json +3 -2
  43. package/skills/userreason/SKILL.md +43 -0
  44. package/skills/userreason/references/management.md +6 -0
@@ -0,0 +1,329 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, opendir, rename, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { SetupError, setupPrivateDirectory, setupProjectFingerprint, withSetupProjectLock } from './setup-store.js';
6
+ import { parseSetupCommandResultInput, parseSetupLocalPlan } from './setup-wire.js';
7
+ import { readSetupFile, setupDigest, setupHash, verifySetupFiles } from './setup-files.js';
8
+ const terminal = new Set(['complete', 'cancelled', 'expired']);
9
+ const stopped = new Set(['ready_for_recommendations', 'analyzing', 'selecting_drafts', 'creating_drafts', 'agent_test']);
10
+ const limit = 8_000_000;
11
+ const retention = 7 * 86_400_000;
12
+ function validatePrepared(value) {
13
+ if (!value || Object.keys(value).some(key => !['wire', 'operations'].includes(key)))
14
+ throw new SetupError('setup_plan_invalid', 'Invalid local plan.');
15
+ const wire = parseSetupLocalPlan(value.wire);
16
+ if (!Array.isArray(value.operations) || value.operations.length !== wire.effects.length)
17
+ throw new SetupError('setup_plan_invalid', 'The local plan is incomplete.');
18
+ for (const effect of wire.effects) {
19
+ const operation = value.operations.find(item => item.effect.effect_id === effect.effect_id);
20
+ if (!operation || setupDigest(operation.effect) !== setupDigest(effect) || operation.files.length !== effect.files.length)
21
+ throw new SetupError('setup_plan_invalid', 'The local operation differs from its reviewed metadata.');
22
+ if (Object.keys(operation).some(key => !['effect', 'files', 'guards'].includes(key)))
23
+ throw new SetupError('setup_plan_invalid', 'Unknown local operation metadata.');
24
+ for (const file of operation.files) {
25
+ const metadata = effect.files.find(item => item.path === file.path);
26
+ if (Object.keys(file).some(key => !['path', 'before', 'after'].includes(key)) || !metadata || typeof file.after !== 'string' || file.before !== null && typeof file.before !== 'string' ||
27
+ metadata.before_hash !== (file.before === null ? null : setupHash(file.before)) || metadata.after_hash !== setupHash(file.after))
28
+ throw new SetupError('setup_plan_invalid', 'The reviewed file hashes do not match the local plan.');
29
+ }
30
+ if (new Set(operation.files.map(file => file.path)).size !== operation.files.length)
31
+ throw new SetupError('setup_plan_invalid', 'The local plan contains duplicate paths.');
32
+ if (operation.guards && (!Array.isArray(operation.guards) || operation.guards.length > 8 || new Set(operation.guards.map(item => item.path)).size !== operation.guards.length || operation.guards.some(item => Object.keys(item).some(key => !['path', 'before_hash'].includes(key)) || !['.npmrc', '.yarnrc', '.yarnrc.yml'].includes(item.path) || item.before_hash !== null && !/^[a-f0-9]{64}$/.test(item.before_hash))))
33
+ throw new SetupError('setup_plan_invalid', 'Invalid local package configuration guards.');
34
+ }
35
+ return { wire, operations: value.operations };
36
+ }
37
+ async function journalPath(client) {
38
+ const directory = await setupPrivateDirectory('receipts');
39
+ await pruneSetupExecutionReceipts();
40
+ return join(directory, `${setupHash(`${client.identity.api_url}\n${client.identity.setup_id}`)}.json`);
41
+ }
42
+ /** Bounded local cleanup never restores authority or follows an unknown file/link. */
43
+ export async function pruneSetupExecutionReceipts(now = Date.now()) {
44
+ const directory = await setupPrivateDirectory('receipts');
45
+ let removed = 0, scanned = 0, bytes = 0;
46
+ for await (const entry of await opendir(directory)) {
47
+ if (++scanned > 128)
48
+ break;
49
+ if (!entry.isFile() || !/^[a-f0-9]{64}\.json$/.test(entry.name))
50
+ continue;
51
+ const path = join(directory, entry.name);
52
+ let handle;
53
+ try {
54
+ const info = await lstat(path);
55
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > limit)
56
+ continue;
57
+ bytes += info.size;
58
+ if (bytes > 16_000_000)
59
+ break;
60
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
61
+ const opened = await handle.stat();
62
+ if (opened.ino !== info.ino || opened.dev !== info.dev)
63
+ continue;
64
+ const data = JSON.parse(await handle.readFile('utf8'));
65
+ validateJournal(data, data, false);
66
+ if (data.version !== 1 || typeof data.api_url !== 'string' || typeof data.setup_id !== 'string' || entry.name !== `${setupHash(`${data.api_url}\n${data.setup_id}`)}.json` ||
67
+ !Number.isFinite(Date.parse(data.created_at)) || Date.parse(data.retain_until) > Date.parse(data.created_at) + retention || !(Date.parse(data.retain_until) < now))
68
+ continue;
69
+ const current = await lstat(path);
70
+ if (current.ino === info.ino && current.dev === info.dev && current.isFile() && !current.isSymbolicLink()) {
71
+ await rm(path);
72
+ removed++;
73
+ }
74
+ }
75
+ catch { /* Unknown or damaged files stay for explicit local review. */ }
76
+ finally {
77
+ await handle?.close();
78
+ }
79
+ }
80
+ return removed;
81
+ }
82
+ function validateJournal(data, identity, active = true) {
83
+ const uuid = (value) => typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(value);
84
+ if (Object.keys(data).some(key => !['version', 'setup_id', 'api_url', 'project_fingerprint', 'created_at', 'retain_until', 'plan', 'target_digest', 'plan_request_id', 'claim_request_id', 'receipts'].includes(key)) ||
85
+ data.version !== 1 || !uuid(data.setup_id) || typeof data.api_url !== 'string' || !/^[a-f0-9]{64}$/.test(data.project_fingerprint) || data.setup_id !== identity.setup_id || data.api_url !== identity.api_url || data.project_fingerprint !== identity.project_fingerprint ||
86
+ !Number.isFinite(Date.parse(data.created_at)) || !Number.isFinite(Date.parse(data.retain_until)) || Date.parse(data.retain_until) <= Date.parse(data.created_at) || Date.parse(data.retain_until) > Date.parse(data.created_at) + retention ||
87
+ !data.receipts || typeof data.receipts !== 'object' || Array.isArray(data.receipts) || Object.keys(data.receipts).length > 32 ||
88
+ !uuid(data.plan_request_id) || !uuid(data.claim_request_id) || data.target_digest !== undefined && !/^[a-f0-9]{64}$/.test(data.target_digest))
89
+ throw new Error('invalid receipt');
90
+ if (active && Date.parse(data.retain_until) <= Date.now())
91
+ throw new Error('expired local receipt');
92
+ if (data.plan)
93
+ data.plan = validatePrepared(data.plan);
94
+ for (const [operationId, receipt] of Object.entries(data.receipts)) {
95
+ if (!uuid(operationId) || !receipt || typeof receipt !== 'object' || Array.isArray(receipt) ||
96
+ Object.keys(receipt).some(key => !['input_digest', 'stage', 'outcome', 'outputs', 'request_id'].includes(key)) ||
97
+ !/^[a-f0-9]{64}$/.test(receipt.input_digest) || !['claimed', 'running', 'settled'].includes(receipt.stage) || !uuid(receipt.request_id) ||
98
+ (receipt.stage === 'settled') !== Boolean(receipt.outcome))
99
+ throw new Error('invalid operation receipt');
100
+ const operation = data.plan?.operations.find(item => setupDigest(item.effect) === receipt.input_digest);
101
+ if (!operation)
102
+ throw new Error('receipt has no prepared operation');
103
+ if (receipt.outcome) {
104
+ const parsed = parseSetupCommandResultInput({ schema_version: 1, setup_id: data.setup_id, operation_id: operationId, executor_generation: 1, lease_generation: 1, ...receipt.outcome });
105
+ if (Object.keys(receipt.outcome).some(key => !['status', 'result'].includes(key)))
106
+ throw new Error('invalid outcome');
107
+ receipt.outcome = { status: parsed.status, result: parsed.result };
108
+ }
109
+ if (receipt.outputs !== undefined) {
110
+ const allowed = operation.effect.kind === 'install_packages' ? operation.effect.managed_outputs?.map(item => item.path) ?? [] : [];
111
+ if (!receipt.outputs || typeof receipt.outputs !== 'object' || Array.isArray(receipt.outputs) || Object.entries(receipt.outputs).some(([key, value]) => !allowed.includes(key) || value !== null && (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value))))
112
+ throw new Error('invalid managed outputs');
113
+ }
114
+ }
115
+ return data;
116
+ }
117
+ async function loadJournal(path, client) {
118
+ let handle;
119
+ try {
120
+ const info = await lstat(path);
121
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > limit)
122
+ throw new Error('unsafe receipt');
123
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
124
+ const current = await handle.stat();
125
+ if (info.ino !== current.ino || info.dev !== current.dev || current.size > limit)
126
+ throw new Error('changed receipt');
127
+ const bytes = await handle.readFile();
128
+ if (bytes.length > limit)
129
+ throw new Error('large receipt');
130
+ const data = JSON.parse(bytes.toString());
131
+ return validateJournal(data, client.identity);
132
+ }
133
+ catch (error) {
134
+ if (error.code === 'ENOENT') {
135
+ const now = Date.now();
136
+ return { version: 1, setup_id: client.identity.setup_id, api_url: client.identity.api_url, project_fingerprint: client.identity.project_fingerprint, created_at: new Date(now).toISOString(), retain_until: new Date(now + retention).toISOString(), plan_request_id: randomUUID(), claim_request_id: randomUUID(), receipts: {} };
137
+ }
138
+ throw new SetupError('setup_receipt_invalid', 'The local receipt needs review. No local effects were repeated.');
139
+ }
140
+ finally {
141
+ await handle?.close();
142
+ }
143
+ }
144
+ async function saveJournal(path, journal) {
145
+ const contents = JSON.stringify(journal);
146
+ if (Buffer.byteLength(contents) > limit)
147
+ throw new SetupError('setup_plan_too_large', 'The prepared local plan exceeds the private receipt limit.');
148
+ const temporary = `${path}.${randomUUID()}.tmp`;
149
+ try {
150
+ const handle = await open(temporary, 'wx', 0o600);
151
+ try {
152
+ await handle.writeFile(contents);
153
+ await handle.sync();
154
+ }
155
+ finally {
156
+ await handle.close();
157
+ }
158
+ await rename(temporary, path);
159
+ }
160
+ finally {
161
+ await rm(temporary, { force: true }).catch(() => { });
162
+ }
163
+ }
164
+ async function outputs(directory, operation) {
165
+ const entries = operation.effect.kind === 'install_packages' ? operation.effect.managed_outputs ?? [] : [];
166
+ const result = {};
167
+ for (const file of entries) {
168
+ const content = await readSetupFile(directory, file.path);
169
+ result[file.path] = content === null ? null : setupHash(content);
170
+ }
171
+ return result;
172
+ }
173
+ async function verified(directory, operation, runtime, receipt) {
174
+ if (!await verifySetupFiles(directory, operation.files, 'after'))
175
+ return false;
176
+ const managed = operation.effect.kind === 'install_packages' ? operation.effect.managed_outputs ?? [] : [];
177
+ if (managed.length && (!receipt?.outputs || setupDigest(await outputs(directory, operation)) !== setupDigest(receipt.outputs)))
178
+ return false;
179
+ return runtime.verify ? runtime.verify(operation) : operation.effect.kind !== 'install_packages';
180
+ }
181
+ /** Runs only after browser approval. Durable intents precede effects and durable outcomes precede reports. */
182
+ export async function runSetupExecutor(client, directory, runtime, options = {}) {
183
+ if (await setupProjectFingerprint(directory) !== client.identity.project_fingerprint)
184
+ throw new SetupError('setup_binding_mismatch', 'This executor belongs to another project.');
185
+ return withSetupProjectLock(directory, async () => {
186
+ const path = await journalPath(client), journal = await loadJournal(path, client);
187
+ let session = await client.poll(), polls = 0;
188
+ const scope = { schema_version: 1, setup_id: client.identity.setup_id };
189
+ const pause = async () => { if (options.sleep)
190
+ await options.sleep(options.pollMs ?? 2000);
191
+ else
192
+ await new Promise((resolve, reject) => { const timer = setTimeout(done, options.pollMs ?? 2000); function done() { options.signal?.removeEventListener('abort', abort); resolve(); } function abort() { clearTimeout(timer); reject(options.signal?.reason); } options.signal?.addEventListener('abort', abort, { once: true }); }); };
193
+ while (true) {
194
+ options.signal?.throwIfAborted();
195
+ options.onSession?.(session);
196
+ if (terminal.has(session.phase) || stopped.has(session.phase) || options.maxPolls !== undefined && polls++ >= options.maxPolls) {
197
+ if (session.phase === 'cancelled') {
198
+ const known = session.commands.find(command => command.status === 'running' && journal.receipts[command.operation_id]?.outcome);
199
+ if (known)
200
+ session = await client.report({ ...scope, operation_id: known.operation_id, executor_generation: known.executor_generation, lease_generation: known.lease_generation, ...journal.receipts[known.operation_id].outcome });
201
+ }
202
+ return session;
203
+ }
204
+ if (session.phase === 'configuring') {
205
+ if (!session.site_id || !session.workspace_id) {
206
+ session = await client.heartbeat({ ...scope, executor_generation: session.executor_generation, operation_id: null, lease_generation: null });
207
+ await pause();
208
+ session = await client.poll();
209
+ continue;
210
+ }
211
+ const targetDigest = setupDigest({ workspace_id: session.workspace_id, site_id: session.site_id, api_url: client.identity.api_url });
212
+ if (!journal.plan || journal.plan.wire.choices_digest !== setupDigest(session.choices) || journal.target_digest !== targetDigest) {
213
+ if (Object.keys(journal.receipts).length)
214
+ throw new SetupError('setup_plan_changed', 'A completed local plan cannot be replaced without a new setup.');
215
+ journal.plan = validatePrepared(await runtime.prepare(session));
216
+ journal.target_digest = targetDigest;
217
+ journal.plan_request_id = randomUUID();
218
+ await saveJournal(path, journal);
219
+ }
220
+ session = await client.plan({ ...scope, request_id: journal.plan_request_id, expected_revision: session.revision, plan: journal.plan.wire });
221
+ continue;
222
+ }
223
+ if (!journal.plan || !session.local_plan || setupDigest(session.local_plan) !== setupDigest(journal.plan.wire))
224
+ throw new SetupError('setup_plan_missing', 'The browser-approved local plan is unavailable on this computer. No effects were applied.');
225
+ if (session.phase !== 'applying') {
226
+ session = await client.heartbeat({ ...scope, executor_generation: session.executor_generation, operation_id: null, lease_generation: null });
227
+ await pause();
228
+ session = await client.poll();
229
+ continue;
230
+ }
231
+ const unknown = session.commands.find(command => command.status === 'outcome_unknown' || command.status === 'running' || command.status === 'leased' && journal.receipts[command.operation_id]);
232
+ if (unknown) {
233
+ const operation = journal.plan.operations.find(item => setupDigest(item.effect) === unknown.input_digest);
234
+ if (!operation)
235
+ throw new SetupError('setup_command_mismatch', 'The command does not match the local reviewed plan.');
236
+ const receipt = journal.receipts[unknown.operation_id];
237
+ // A running command belongs to an interrupted process. Wait for the server
238
+ // to close its lease; never issue the side effect a second time.
239
+ if (unknown.status !== 'outcome_unknown') {
240
+ await pause();
241
+ session = await client.poll();
242
+ continue;
243
+ }
244
+ const stillVerified = await verified(directory, operation, runtime, receipt);
245
+ const outcome = receipt?.outcome && receipt.outcome.status !== 'succeeded' ? receipt.outcome : stillVerified
246
+ ? { status: 'succeeded', result: { code: 'already_present', verified: true } }
247
+ : { status: 'manual', result: { code: receipt?.outcome?.status === 'succeeded' ? 'review_required' : 'outcome_unknown' } };
248
+ const requestId = receipt?.request_id ?? randomUUID();
249
+ journal.receipts[unknown.operation_id] = { input_digest: unknown.input_digest, stage: 'settled', outcome, outputs: receipt?.outputs, request_id: requestId };
250
+ await saveJournal(path, journal);
251
+ session = await client.reconcile({ ...scope, request_id: requestId, project_fingerprint: client.identity.project_fingerprint, executor_generation: session.executor_generation, operation_id: unknown.operation_id, input_digest: unknown.input_digest, ...outcome });
252
+ continue;
253
+ }
254
+ const claimed = await client.claim({ ...scope, request_id: journal.claim_request_id, executor_generation: session.executor_generation });
255
+ session = claimed.session;
256
+ const command = claimed.command;
257
+ if (!command) {
258
+ await pause();
259
+ session = await client.poll();
260
+ continue;
261
+ }
262
+ if (command.status !== 'leased')
263
+ continue;
264
+ const operation = journal.plan.operations.find(item => item.effect.effect_id === command.input.effect_id);
265
+ if (!operation || setupDigest(operation.effect) !== command.input_digest || setupDigest(operation.effect) !== setupDigest(command.input))
266
+ throw new SetupError('setup_command_mismatch', 'The command differs from the approved local plan.');
267
+ const lease = { ...scope, operation_id: command.operation_id, executor_generation: command.executor_generation, lease_generation: command.lease_generation };
268
+ if (journal.receipts[command.operation_id])
269
+ throw new SetupError('setup_replay_refused', 'A local receipt already exists. Reconcile before attempting this command again.');
270
+ const receipt = { input_digest: command.input_digest, stage: 'claimed', request_id: randomUUID() };
271
+ journal.receipts[command.operation_id] = receipt;
272
+ await saveJournal(path, journal);
273
+ session = await client.startCommand(lease);
274
+ receipt.stage = 'running';
275
+ await saveJournal(path, journal);
276
+ const controller = new AbortController();
277
+ const abort = () => controller.abort(options.signal?.reason);
278
+ options.signal?.addEventListener('abort', abort, { once: true });
279
+ if (options.signal?.aborted)
280
+ abort();
281
+ if (terminal.has(session.phase))
282
+ controller.abort(new Error('Setup ended before local execution'));
283
+ let heartbeatFailure, heartbeatBusy = false;
284
+ let inFlightHeartbeat;
285
+ const timer = setInterval(() => {
286
+ if (heartbeatBusy)
287
+ return;
288
+ heartbeatBusy = true;
289
+ inFlightHeartbeat = client.heartbeat(lease).then(value => { session = value; if (terminal.has(value.phase))
290
+ controller.abort(new Error('Setup ended')); }, error => { heartbeatFailure = error; controller.abort(error); }).finally(() => { heartbeatBusy = false; });
291
+ }, options.heartbeatMs ?? 10_000);
292
+ try {
293
+ controller.signal.throwIfAborted();
294
+ if (!await verifySetupFiles(directory, operation.files, 'before'))
295
+ throw new SetupError('setup_files_changed', 'Files changed after browser approval.');
296
+ for (const guard of operation.guards ?? []) {
297
+ const content = await readSetupFile(directory, guard.path);
298
+ if ((content === null ? null : setupHash(content)) !== guard.before_hash)
299
+ throw new SetupError('setup_files_changed', 'A package configuration changed after review.');
300
+ }
301
+ if (operation.effect.kind === 'install_packages')
302
+ for (const file of operation.effect.managed_outputs ?? []) {
303
+ const content = await readSetupFile(directory, file.path);
304
+ if ((content === null ? null : setupHash(content)) !== file.before_hash)
305
+ throw new SetupError('setup_files_changed', 'Package-manager outputs changed after approval.');
306
+ }
307
+ const code = await runtime.apply(operation, controller.signal);
308
+ controller.signal.throwIfAborted();
309
+ receipt.outputs = await outputs(directory, operation);
310
+ if (!await verified(directory, operation, runtime, receipt))
311
+ throw new SetupError('setup_verification_failed', 'The completed effect did not match the reviewed files.');
312
+ receipt.outcome = { status: 'succeeded', result: { code, verified: true } };
313
+ }
314
+ catch (error) {
315
+ receipt.outcome = { status: error instanceof SetupError && error.code === 'setup_files_changed' ? 'manual' : 'failed', result: { code: controller.signal.aborted || heartbeatFailure ? 'outcome_unknown' : error instanceof SetupError && error.code === 'setup_files_changed' ? 'review_required' : 'operation_failed' } };
316
+ }
317
+ finally {
318
+ clearInterval(timer);
319
+ await inFlightHeartbeat;
320
+ options.signal?.removeEventListener('abort', abort);
321
+ }
322
+ receipt.stage = 'settled';
323
+ journal.claim_request_id = randomUUID();
324
+ await saveJournal(path, journal);
325
+ // A lost report is retried from its saved receipt on resume; the effect is never rerun.
326
+ session = await client.report({ ...lease, ...receipt.outcome });
327
+ }
328
+ });
329
+ }
@@ -0,0 +1,11 @@
1
+ export declare const setupHash: (value: string | Buffer) => string;
2
+ export declare function setupCanonical(value: unknown): string;
3
+ export declare const setupDigest: (value: unknown) => string;
4
+ export interface SetupLocalFile {
5
+ path: string;
6
+ before: string | null;
7
+ after: string;
8
+ }
9
+ export declare function readSetupFile(directory: string, path: string): Promise<string | null>;
10
+ export declare function writeSetupFile(directory: string, file: SetupLocalFile): Promise<void>;
11
+ export declare function verifySetupFiles(directory: string, files: SetupLocalFile[], state: 'before' | 'after'): Promise<boolean>;
@@ -0,0 +1,96 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, realpath } from 'node:fs/promises';
4
+ import { join, relative, resolve, sep } from 'node:path';
5
+ import { SetupError } from './setup-store.js';
6
+ export const setupHash = (value) => createHash('sha256').update(value).digest('hex');
7
+ export function setupCanonical(value) {
8
+ if (Array.isArray(value))
9
+ return `[${value.map(setupCanonical).join(',')}]`;
10
+ if (value && typeof value === 'object')
11
+ return `{${Object.entries(value).filter(([, v]) => v !== undefined).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${setupCanonical(v)}`).join(',')}}`;
12
+ return JSON.stringify(value);
13
+ }
14
+ export const setupDigest = (value) => setupHash(setupCanonical(value));
15
+ /** All paths are project relative. Refuse links, hard links and escaping ancestors. */
16
+ async function checkedPath(directory, name, createParents = false) {
17
+ const root = await realpath(directory);
18
+ if (!name || name.length > 240 || /[\\:\x00-\x1f\x7f]/.test(name) || name.split('/').some(p => !p || p === '.' || p === '..'))
19
+ throw new SetupError('setup_path_unsafe', 'The reviewed path must be inside the original real project directory.');
20
+ const target = resolve(root, name);
21
+ if (relative(root, target).startsWith(`..${sep}`))
22
+ throw new SetupError('setup_path_unsafe', 'The reviewed path escapes the project.');
23
+ let parent = root;
24
+ for (const part of name.split('/').slice(0, -1)) {
25
+ parent = join(parent, part);
26
+ let info = await lstat(parent).catch(error => { if (error.code === 'ENOENT')
27
+ return null; throw error; });
28
+ if (!info && createParents) {
29
+ await mkdir(parent);
30
+ info = await lstat(parent);
31
+ }
32
+ if (!info)
33
+ return target;
34
+ if (!info.isDirectory() || info.isSymbolicLink())
35
+ throw new SetupError('setup_path_unsafe', 'A reviewed directory was replaced or is a link.');
36
+ }
37
+ return target;
38
+ }
39
+ export async function readSetupFile(directory, path) {
40
+ const target = await checkedPath(directory, path);
41
+ let before;
42
+ try {
43
+ before = await lstat(target);
44
+ }
45
+ catch (error) {
46
+ if (error.code === 'ENOENT')
47
+ return null;
48
+ throw error;
49
+ }
50
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > 2_000_000)
51
+ throw new SetupError('setup_path_unsafe', 'A reviewed file is not a bounded ordinary file.');
52
+ const handle = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
53
+ try {
54
+ const current = await handle.stat();
55
+ if (before.dev !== current.dev || before.ino !== current.ino || current.nlink !== 1 || current.size > 2_000_000)
56
+ throw new SetupError('setup_path_unsafe', 'A reviewed file changed while reading.');
57
+ const bytes = await handle.readFile();
58
+ if (bytes.length > 2_000_000)
59
+ throw new SetupError('setup_path_unsafe', 'A reviewed file exceeds the size limit.');
60
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
61
+ }
62
+ finally {
63
+ await handle.close();
64
+ }
65
+ }
66
+ export async function writeSetupFile(directory, file) {
67
+ // Verify drift before creating even a parent directory for a planned file.
68
+ if (await readSetupFile(directory, file.path) !== file.before)
69
+ throw new SetupError('setup_files_changed', 'The reviewed file changed. Review a new plan before applying it.');
70
+ const target = await checkedPath(directory, file.path, true);
71
+ if (await readSetupFile(directory, file.path) !== file.before)
72
+ throw new SetupError('setup_files_changed', 'The reviewed file changed. Review a new plan before applying it.');
73
+ if (file.before === file.after)
74
+ return;
75
+ const info = file.before === null ? null : await lstat(target);
76
+ const handle = await open(target, info ? constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0) : constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o644);
77
+ try {
78
+ const now = await handle.stat();
79
+ if (!now.isFile() || now.nlink !== 1 || info && (now.dev !== info.dev || now.ino !== info.ino))
80
+ throw new SetupError('setup_files_changed', 'The reviewed file changed while opening.');
81
+ if (info && await readSetupFile(directory, file.path) !== file.before)
82
+ throw new SetupError('setup_files_changed', 'The reviewed file changed while opening.');
83
+ await handle.writeFile(file.after);
84
+ await handle.truncate(Buffer.byteLength(file.after));
85
+ await handle.sync();
86
+ }
87
+ finally {
88
+ await handle.close();
89
+ }
90
+ }
91
+ export async function verifySetupFiles(directory, files, state) {
92
+ for (const file of files)
93
+ if (await readSetupFile(directory, file.path) !== file[state])
94
+ return false;
95
+ return true;
96
+ }
@@ -0,0 +1,14 @@
1
+ import { type BrowserSetupClient } from './setup.js';
2
+ import { type SetupExecutorOptions, type SetupExecutorRuntime } from './setup-executor.js';
3
+ import { type PreparedSetupSource } from './setup-source.js';
4
+ import { type SetupSessionView } from './setup-wire.js';
5
+ export interface BrowserSetupRunnerOptions extends SetupExecutorOptions {
6
+ onSourceIssue?: (code: string) => void;
7
+ /** Local display only. Never forward this callback's source content to telemetry. */
8
+ onSourcePrepared?: (packet: PreparedSetupSource) => void;
9
+ onSample?: (session: SetupSessionView) => void;
10
+ }
11
+ export type BrowserSetupRunnerClient = Pick<BrowserSetupClient, 'identity' | 'poll' | 'plan' | 'claim' | 'startCommand' | 'report' | 'heartbeat' | 'reconcile' | 'discovery' | 'sourceInfo' | 'sourcePlan' | 'sourceUpload'>;
12
+ /** Browser controls consent and drafts. This runner holds only the setup capability,
13
+ * executes reviewed local effects, and uploads the separately approved source packet. */
14
+ export declare function runBrowserSetup(client: BrowserSetupRunnerClient, directory: string, runtime: SetupExecutorRuntime, options?: BrowserSetupRunnerOptions): Promise<SetupSessionView>;
@@ -0,0 +1,142 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { realpath } from 'node:fs/promises';
3
+ import { BrowserSetupUnavailable } from './setup.js';
4
+ import { runSetupExecutor } from './setup-executor.js';
5
+ import { prepareSetupSource, restoreSetupSource, verifySetupSource } from './setup-source.js';
6
+ import { SetupError, withSetupProjectLock } from './setup-store.js';
7
+ const terminal = new Set(['complete', 'cancelled', 'expired']);
8
+ const localPhases = new Set(['configuring', 'awaiting_local_approval', 'applying']);
9
+ function pause(milliseconds, options) {
10
+ options.signal?.throwIfAborted();
11
+ if (options.sleep)
12
+ return options.sleep(milliseconds).then(() => options.signal?.throwIfAborted());
13
+ return new Promise((resolve, reject) => {
14
+ const timer = setTimeout(() => { options.signal?.removeEventListener('abort', abort); resolve(); }, milliseconds);
15
+ function abort() { clearTimeout(timer); reject(options.signal?.reason); }
16
+ options.signal?.addEventListener('abort', abort, { once: true });
17
+ });
18
+ }
19
+ /** Browser controls consent and drafts. This runner holds only the setup capability,
20
+ * executes reviewed local effects, and uploads the separately approved source packet. */
21
+ export async function runBrowserSetup(client, directory, runtime, options = {}) {
22
+ // Canonicalize platform parent aliases before the executor verifies the
23
+ // approved fingerprint. Source descendants still reject every symlink.
24
+ directory = await realpath(directory);
25
+ let polls = 0;
26
+ while (true) {
27
+ let session = await runSetupExecutor(client, directory, runtime, options);
28
+ if (terminal.has(session.phase) || localPhases.has(session.phase))
29
+ return session;
30
+ const result = await withSetupProjectLock(directory, async () => {
31
+ let packet, observedPreparation;
32
+ let preparationAttempted = false, failedPreparationRevision, notifiedSample = false, heartbeatError;
33
+ const uploadAttempts = new Set(), issues = new Set();
34
+ const scope = { schema_version: 1, setup_id: client.identity.setup_id };
35
+ const accept = (next) => { if (BigInt(next.revision) >= BigInt(session.revision))
36
+ session = next; };
37
+ const issue = (code) => { if (!issues.has(code)) {
38
+ issues.add(code);
39
+ options.onSourceIssue?.(code);
40
+ } };
41
+ let sourceAvailable = false;
42
+ try {
43
+ sourceAvailable = (await client.discovery()).browser_setup.source_analysis && session.capabilities.source_review;
44
+ }
45
+ catch (error) {
46
+ if (!(error instanceof BrowserSetupUnavailable))
47
+ throw error;
48
+ }
49
+ let heartbeat;
50
+ const timer = setInterval(() => {
51
+ if (heartbeat)
52
+ return;
53
+ heartbeat = client.heartbeat({ ...scope, executor_generation: session.executor_generation, operation_id: null, lease_generation: null })
54
+ .then(accept, error => { heartbeatError = error; }).finally(() => { heartbeat = undefined; });
55
+ }, options.heartbeatMs ?? 10_000);
56
+ try {
57
+ while (true) {
58
+ options.signal?.throwIfAborted();
59
+ if (heartbeatError)
60
+ throw heartbeatError;
61
+ options.onSession?.(session);
62
+ if (terminal.has(session.phase) || localPhases.has(session.phase) || options.maxPolls !== undefined && polls++ >= options.maxPolls)
63
+ return session;
64
+ if (session.phase === 'agent_test' && session.sample.status === 'waiting' && !notifiedSample) {
65
+ notifiedSample = true;
66
+ options.onSample?.(session);
67
+ }
68
+ const source = session.source;
69
+ // A browser retry with no preparation (for example provider config
70
+ // unavailable) changes revision. Heartbeat never changes revision.
71
+ if (!source && failedPreparationRevision && session.revision !== failedPreparationRevision) {
72
+ preparationAttempted = false;
73
+ failedPreparationRevision = undefined;
74
+ issues.clear();
75
+ }
76
+ if (source?.preparation_id !== observedPreparation) {
77
+ // Retry is a new browser decision, never an automatic second spend.
78
+ if (observedPreparation && !source)
79
+ preparationAttempted = false;
80
+ packet = undefined;
81
+ observedPreparation = source?.preparation_id;
82
+ issues.clear();
83
+ }
84
+ if (session.phase === 'ready_for_recommendations' && !source && sourceAvailable && !preparationAttempted) {
85
+ preparationAttempted = true;
86
+ try {
87
+ packet = await prepareSetupSource(directory, randomUUID(), options.signal);
88
+ options.onSourcePrepared?.(packet);
89
+ accept(await client.sourcePlan({ ...scope, request_id: packet.preparation_id, expected_revision: session.revision,
90
+ manifest: packet.manifest, packages: packet.packages }));
91
+ observedPreparation = session.source?.preparation_id;
92
+ }
93
+ catch (error) {
94
+ options.signal?.throwIfAborted();
95
+ failedPreparationRevision = session.revision;
96
+ issue(error instanceof SetupError ? error.code : 'source_unavailable');
97
+ }
98
+ }
99
+ else if (source?.status === 'approved' && sourceAvailable && !uploadAttempts.has(source.preparation_id)) {
100
+ uploadAttempts.add(source.preparation_id);
101
+ try {
102
+ const info = await client.sourceInfo();
103
+ accept(info.session);
104
+ const approved = info.preparation, current = session.source;
105
+ if (!approved || current?.status !== 'approved' || approved.preparation_id !== source.preparation_id ||
106
+ approved.manifest.packet_digest !== current.packet_digest || approved.configuration.configuration_fingerprint !== current.provider_fingerprint)
107
+ throw new SetupError('setup_source_changed', 'Review a new source packet in the browser.');
108
+ if (Date.parse(current.expires_at) <= Date.now())
109
+ throw new SetupError('planning_consent_expired', 'Review source consent again in the browser.');
110
+ // Resume never trusts a stale in-memory packet or stores raw source.
111
+ packet = packet?.preparation_id === approved.preparation_id ? packet : await restoreSetupSource(directory, approved, options.signal);
112
+ options.onSourcePrepared?.(packet);
113
+ await verifySetupSource(directory, packet, options.signal);
114
+ if (heartbeatError)
115
+ throw heartbeatError;
116
+ if (terminal.has(session.phase) || session.source?.preparation_id !== approved.preparation_id || session.source.status !== 'approved')
117
+ throw new SetupError('setup_source_changed', 'Source approval changed before upload.');
118
+ accept(await client.sourceUpload({ ...scope, preparation_id: packet.preparation_id,
119
+ project_fingerprint: client.identity.project_fingerprint, evidence: packet.evidence }));
120
+ packet = undefined;
121
+ }
122
+ catch (error) {
123
+ options.signal?.throwIfAborted();
124
+ issue(error instanceof SetupError ? error.code : 'source_unavailable');
125
+ // A dropped reply can hide an accepted upload. Poll instead of
126
+ // resending or collecting a different packet under old consent.
127
+ }
128
+ }
129
+ await pause(options.pollMs ?? 2_000, options);
130
+ accept(await client.poll());
131
+ }
132
+ }
133
+ finally {
134
+ clearInterval(timer);
135
+ await heartbeat;
136
+ packet = undefined;
137
+ }
138
+ });
139
+ if (!localPhases.has(result.phase) || options.maxPolls !== undefined)
140
+ return result;
141
+ }
142
+ }
@@ -0,0 +1,31 @@
1
+ import { type LocalEvidence } from './first-survey-source.js';
2
+ /** One memory-only packet. The wire manifest contains hashes/ranges, never source text. */
3
+ export interface PreparedSetupSource {
4
+ preparation_id: string;
5
+ manifest: {
6
+ packet_digest: string;
7
+ snapshot_id: string;
8
+ files: {
9
+ path: string;
10
+ file_hash: string;
11
+ excerpt_hash: string;
12
+ bytes: number;
13
+ start_line: number;
14
+ end_line: number;
15
+ }[];
16
+ omitted_files: number;
17
+ truncated: boolean;
18
+ git_included: false;
19
+ };
20
+ packages: {
21
+ name: string;
22
+ version: string;
23
+ }[];
24
+ evidence: LocalEvidence[];
25
+ }
26
+ /** Reuse the bounded application collector without Git, provider calls or local packet persistence. */
27
+ export declare function prepareSetupSource(directory: string, preparationId?: string, signal?: AbortSignal): Promise<PreparedSetupSource>;
28
+ /** An approved preparation is rebuilt with its original snapshot ID; no raw source needs a receipt. */
29
+ export declare function restoreSetupSource(directory: string, approved: Pick<PreparedSetupSource, 'preparation_id' | 'manifest' | 'packages'>, signal?: AbortSignal): Promise<PreparedSetupSource>;
30
+ /** Revalidate immediately before upload; only the immutable reviewed memory packet may leave the process. */
31
+ export declare function verifySetupSource(directory: string, packet: PreparedSetupSource, signal?: AbortSignal): Promise<void>;