@hue-run/sdk 0.3.2 → 0.4.2

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 (46) hide show
  1. package/CLI.md +270 -47
  2. package/ENVIRONMENTS.md +11 -1
  3. package/README.md +19 -3
  4. package/dist/client.d.ts +5 -5
  5. package/dist/client.js +13 -6
  6. package/dist/environment/tools.d.ts +6 -1
  7. package/dist/environment/tools.js +7 -1
  8. package/dist/environment/types.d.ts +6 -1
  9. package/dist/evals/simulation.d.ts +12 -4
  10. package/dist/evals/simulation.js +34 -24
  11. package/dist/evals.d.ts +1 -1
  12. package/dist/receipt.js +36 -8
  13. package/dist/setup/application.d.ts +74 -0
  14. package/dist/setup/application.js +766 -0
  15. package/dist/setup/backend.d.ts +229 -0
  16. package/dist/setup/backend.js +855 -0
  17. package/dist/setup/checkpoint.js +100 -30
  18. package/dist/setup/cli.js +20 -4
  19. package/dist/setup/configure.d.ts +13 -0
  20. package/dist/setup/configure.js +454 -0
  21. package/dist/setup/credential.d.ts +2 -0
  22. package/dist/setup/credential.js +9 -0
  23. package/dist/setup/detect.js +4 -1
  24. package/dist/setup/installation.d.ts +118 -0
  25. package/dist/setup/installation.js +605 -0
  26. package/dist/setup/lock.d.ts +2 -0
  27. package/dist/setup/lock.js +38 -0
  28. package/dist/setup/machine.d.ts +1 -10
  29. package/dist/setup/machine.js +8 -7
  30. package/dist/setup/render.d.ts +3 -1
  31. package/dist/setup/render.js +209 -6
  32. package/dist/setup/runner.d.ts +26 -76
  33. package/dist/setup/runner.js +320 -45
  34. package/dist/setup/socket.d.ts +7 -0
  35. package/dist/setup/socket.js +144 -0
  36. package/dist/setup/source.d.ts +9 -0
  37. package/dist/setup/source.js +269 -0
  38. package/dist/setup/types.d.ts +16 -9
  39. package/dist/setup/types.js +1 -1
  40. package/dist/setup.d.ts +6 -2
  41. package/dist/setup.js +3 -0
  42. package/dist/types.d.ts +24 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +2 -1
  46. package/setup-events.schema.json +16 -9
@@ -0,0 +1,605 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { constants } from "node:fs";
4
+ import { chmod, lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
5
+ import { basename, dirname, join, relative, resolve } from "node:path";
6
+ import { validSetupCredentialIdentity } from "./credential.js";
7
+ const MAX_FILE_BYTES = 32 * 1024;
8
+ const IGNORE_RULES = [
9
+ ".hue/installation-*.json",
10
+ ".hue/.installation-*.tmp",
11
+ ".hue/claim-handoff-*.html",
12
+ ".hue/.claim-handoff-*.tmp",
13
+ ".hue/application-evidence-*.json",
14
+ ".hue/.application-evidence-*.tmp",
15
+ ".hue/application-evidence-*.tmp",
16
+ ];
17
+ const LOCAL_IGNORE_RULES = [
18
+ "installation-*.json",
19
+ ".installation-*.tmp",
20
+ "claim-handoff-*.html",
21
+ ".claim-handoff-*.tmp",
22
+ "application-evidence-*.json",
23
+ ".application-evidence-*.tmp",
24
+ "application-evidence-*.tmp",
25
+ ];
26
+ async function gitDiagnostic(root, args, input) {
27
+ // Repository discovery must not be redirected to another index/worktree by inherited Git options.
28
+ const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_")));
29
+ return new Promise((resolvePromise, reject) => {
30
+ const child = execFile("git", ["--no-optional-locks", "-c", "core.fsmonitor=false", ...args], {
31
+ cwd: root,
32
+ env: { ...environment, GIT_TERMINAL_PROMPT: "0" },
33
+ shell: false,
34
+ windowsHide: true,
35
+ timeout: 5_000,
36
+ killSignal: "SIGKILL",
37
+ maxBuffer: 64 * 1024,
38
+ encoding: "utf8",
39
+ }, (error, output) => {
40
+ if (error && error.code !== 1) {
41
+ reject(new Error("Refusing setup because private-file Git protection could not be checked"));
42
+ return;
43
+ }
44
+ resolvePromise({ code: error ? 1 : 0, output });
45
+ });
46
+ child.stdin?.on("error", () => undefined);
47
+ child.stdin?.end(input);
48
+ });
49
+ }
50
+ function inside(parent, child) {
51
+ const path = relative(parent, child);
52
+ return path === "" || (!path.startsWith("..") && !path.startsWith("/"));
53
+ }
54
+ async function rejectSymlink(path, missing = false) {
55
+ try {
56
+ if ((await lstat(path)).isSymbolicLink())
57
+ throw new Error("Unsafe setup path symlink");
58
+ }
59
+ catch (error) {
60
+ if (missing && error.code === "ENOENT")
61
+ return;
62
+ throw error;
63
+ }
64
+ }
65
+ async function cleanupTemporary(path) {
66
+ try {
67
+ const info = await lstat(path);
68
+ if (info.isFile())
69
+ await unlink(path);
70
+ }
71
+ catch (error) {
72
+ if (error.code !== "ENOENT")
73
+ throw error;
74
+ }
75
+ }
76
+ async function atomicWrite(path, contents, mode, guard) {
77
+ await rejectSymlink(path, true);
78
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
79
+ let handle;
80
+ try {
81
+ handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);
82
+ await handle.writeFile(contents, "utf8");
83
+ await handle.sync();
84
+ await handle.close();
85
+ handle = undefined;
86
+ await chmod(temporary, mode);
87
+ await rejectSymlink(path, true);
88
+ await guard?.();
89
+ await rename(temporary, path);
90
+ if (process.platform !== "win32") {
91
+ const directory = await open(dirname(path), constants.O_RDONLY);
92
+ try {
93
+ await directory.sync();
94
+ }
95
+ finally {
96
+ await directory.close();
97
+ }
98
+ }
99
+ }
100
+ finally {
101
+ await handle?.close();
102
+ await cleanupTemporary(temporary);
103
+ }
104
+ }
105
+ function exactKeys(value, keys) {
106
+ return Object.keys(value).sort().join("\0") === [...keys].sort().join("\0");
107
+ }
108
+ function validCredential(value) {
109
+ if (!value || typeof value !== "object" || Array.isArray(value))
110
+ return false;
111
+ const item = value;
112
+ return (exactKeys(item, ["apiKey", "keyId", "version", "kind", "capabilities"]) &&
113
+ item.kind === "anonymous_trial" &&
114
+ Array.isArray(item.capabilities) &&
115
+ item.capabilities.length === 1 &&
116
+ item.capabilities[0] === "setup_telemetry_write" &&
117
+ validSetupCredentialIdentity(item.apiKey, item.keyId) &&
118
+ (item.version === 0 || item.version === 1));
119
+ }
120
+ function validProbe(value) {
121
+ if (!value || typeof value !== "object" || Array.isArray(value))
122
+ return false;
123
+ const item = value;
124
+ return (exactKeys(item, ["traceId", "spanId", "credentialVersion", "verified"]) &&
125
+ typeof item.traceId === "string" &&
126
+ /^[a-f0-9]{32}$/u.test(item.traceId) &&
127
+ !/^0+$/u.test(item.traceId) &&
128
+ typeof item.spanId === "string" &&
129
+ /^[a-f0-9]{16}$/u.test(item.spanId) &&
130
+ !/^0+$/u.test(item.spanId) &&
131
+ (item.credentialVersion === 0 || item.credentialVersion === 1) &&
132
+ typeof item.verified === "boolean");
133
+ }
134
+ function validApplicationEvidence(value) {
135
+ if (!value || typeof value !== "object" || Array.isArray(value))
136
+ return false;
137
+ const item = value;
138
+ return (exactKeys(item, ["traceId", "spanId", "credentialVersion", "verified", "source"]) &&
139
+ item.source === "existing-application-request" &&
140
+ validProbe({
141
+ traceId: item.traceId,
142
+ spanId: item.spanId,
143
+ credentialVersion: item.credentialVersion,
144
+ verified: item.verified,
145
+ }));
146
+ }
147
+ function validApplicationAttempt(value) {
148
+ if (!value || typeof value !== "object" || Array.isArray(value))
149
+ return false;
150
+ const item = value;
151
+ return (exactKeys(item, ["credentialVersion", "startedAt"]) &&
152
+ (item.credentialVersion === 0 || item.credentialVersion === 1) &&
153
+ typeof item.startedAt === "string" &&
154
+ item.startedAt.length <= 40 &&
155
+ Number.isFinite(Date.parse(item.startedAt)));
156
+ }
157
+ function validClaimHandoff(value) {
158
+ if (!value || typeof value !== "object" || Array.isArray(value))
159
+ return false;
160
+ const item = value;
161
+ const allowed = [
162
+ "id",
163
+ "previousHandoffId",
164
+ ...(item.state === undefined ? [] : ["state"]),
165
+ ...(item.expiresAt === undefined ? [] : ["expiresAt"]),
166
+ ...(item.sessionExpiresAt === undefined ? [] : ["sessionExpiresAt"]),
167
+ ];
168
+ return (exactKeys(item, allowed) &&
169
+ typeof item.id === "string" &&
170
+ /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u.test(item.id) &&
171
+ (item.previousHandoffId === null ||
172
+ (typeof item.previousHandoffId === "string" &&
173
+ /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u.test(item.previousHandoffId))) &&
174
+ (item.state === undefined ||
175
+ ["pending", "consumed", "expired", "revoked"].includes(item.state)) &&
176
+ (item.expiresAt === undefined ||
177
+ (typeof item.expiresAt === "string" &&
178
+ item.expiresAt.length <= 40 &&
179
+ Number.isFinite(Date.parse(item.expiresAt)))) &&
180
+ (item.sessionExpiresAt === undefined ||
181
+ item.sessionExpiresAt === null ||
182
+ (typeof item.sessionExpiresAt === "string" &&
183
+ item.sessionExpiresAt.length <= 40 &&
184
+ Number.isFinite(Date.parse(item.sessionExpiresAt)))) &&
185
+ ((item.state === undefined &&
186
+ item.expiresAt === undefined &&
187
+ item.sessionExpiresAt === undefined) ||
188
+ (item.state !== undefined && item.expiresAt !== undefined)));
189
+ }
190
+ function parseRecord(value, origin) {
191
+ if (!value || typeof value !== "object" || Array.isArray(value))
192
+ throw new Error("Invalid setup installation record");
193
+ const item = value;
194
+ const allowed = [
195
+ "format",
196
+ "origin",
197
+ "installationId",
198
+ "installationSecret",
199
+ "provisionAttempts",
200
+ "managedFiles",
201
+ ...(item.credential === undefined ? [] : ["credential"]),
202
+ ...(item.revocationCredential === undefined ? [] : ["revocationCredential"]),
203
+ ...(item.anonymousKeyRevoked === undefined ? [] : ["anonymousKeyRevoked"]),
204
+ ...(item.probe === undefined ? [] : ["probe"]),
205
+ ...(item.applicationEvidence === undefined ? [] : ["applicationEvidence"]),
206
+ ...(item.applicationAttempt === undefined ? [] : ["applicationAttempt"]),
207
+ ...(item.claimHandoff === undefined ? [] : ["claimHandoff"]),
208
+ ];
209
+ if (!exactKeys(item, allowed) ||
210
+ item.format !== 1 ||
211
+ item.origin !== origin ||
212
+ typeof item.installationId !== "string" ||
213
+ !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u.test(item.installationId) ||
214
+ typeof item.installationSecret !== "string" ||
215
+ !/^[A-Za-z0-9_-]{43}$/u.test(item.installationSecret) ||
216
+ !Array.isArray(item.provisionAttempts) ||
217
+ item.provisionAttempts.length > 5 ||
218
+ item.provisionAttempts.some((entry) => typeof entry !== "string" || entry.length > 40 || !Number.isFinite(Date.parse(entry))) ||
219
+ (item.credential !== undefined && !validCredential(item.credential)) ||
220
+ (item.anonymousKeyRevoked !== undefined &&
221
+ (item.anonymousKeyRevoked !== true ||
222
+ !validCredential(item.credential) ||
223
+ item.credential.version !== 1 ||
224
+ item.revocationCredential !== undefined)) ||
225
+ (item.revocationCredential !== undefined &&
226
+ (!validCredential(item.revocationCredential) ||
227
+ item.revocationCredential.version !== 0 ||
228
+ !validCredential(item.credential) ||
229
+ item.credential.version !== 1)) ||
230
+ (item.probe !== undefined && !validProbe(item.probe)) ||
231
+ (item.applicationEvidence !== undefined &&
232
+ !validApplicationEvidence(item.applicationEvidence)) ||
233
+ (item.applicationAttempt !== undefined && !validApplicationAttempt(item.applicationAttempt)) ||
234
+ (item.claimHandoff !== undefined && !validClaimHandoff(item.claimHandoff)) ||
235
+ (item.applicationEvidence !== undefined &&
236
+ (!validApplicationAttempt(item.applicationAttempt) ||
237
+ item.applicationEvidence.credentialVersion !==
238
+ item.applicationAttempt.credentialVersion)) ||
239
+ !item.managedFiles ||
240
+ typeof item.managedFiles !== "object" ||
241
+ Array.isArray(item.managedFiles) ||
242
+ Object.entries(item.managedFiles).some(([path, digest]) => !path ||
243
+ path.length > 4096 ||
244
+ typeof digest !== "string" ||
245
+ !/^[a-f0-9]{64}$/u.test(digest)))
246
+ throw new Error("Invalid setup installation record");
247
+ return item;
248
+ }
249
+ /** Owner-only, project/origin-scoped storage for installation proof and telemetry credentials. */
250
+ export class FileSetupInstallationStore {
251
+ lastSnapshot;
252
+ /** Resolved project directory containing the installation state. */
253
+ projectRoot;
254
+ /** Exact normalized Hue origin scoped to this store. */
255
+ origin;
256
+ /** Owner-only `.hue` directory. */
257
+ directory;
258
+ /** Origin-scoped ignored installation record path. */
259
+ path;
260
+ /** Origin-scoped owner-only browser handoff; its contents are never emitted. */
261
+ claimHandoffPath;
262
+ /** Origin-scoped private application evidence transfer path. */
263
+ applicationEvidencePath;
264
+ constructor(projectRoot, origin) {
265
+ this.projectRoot = resolve(projectRoot);
266
+ this.origin = origin;
267
+ this.directory = join(this.projectRoot, ".hue");
268
+ const originHash = createHash("sha256").update(origin).digest("hex").slice(0, 20);
269
+ this.path = join(this.directory, `installation-${originHash}.json`);
270
+ this.claimHandoffPath = join(this.directory, `claim-handoff-${originHash}.html`);
271
+ this.applicationEvidencePath = join(this.directory, `application-evidence-${originHash}.json`);
272
+ if (!inside(this.projectRoot, this.path))
273
+ throw new Error("Unsafe setup installation path");
274
+ if (!inside(this.projectRoot, this.claimHandoffPath))
275
+ throw new Error("Unsafe setup claim handoff path");
276
+ if (!inside(this.projectRoot, this.applicationEvidencePath))
277
+ throw new Error("Unsafe setup application evidence path");
278
+ }
279
+ async rejectUnsafeProjectRoot() {
280
+ let path = this.projectRoot;
281
+ for (;;) {
282
+ const info = await lstat(path);
283
+ if (!info.isDirectory() || info.isSymbolicLink())
284
+ throw new Error("Unsafe setup project root symlink");
285
+ const parent = dirname(path);
286
+ if (parent === path)
287
+ break;
288
+ path = parent;
289
+ }
290
+ }
291
+ async hasGitWorktree() {
292
+ let directory = this.projectRoot;
293
+ for (;;) {
294
+ try {
295
+ const marker = await lstat(join(directory, ".git"));
296
+ if (marker.isSymbolicLink() || (!marker.isDirectory() && !marker.isFile()))
297
+ throw new Error("Refusing unsafe Git metadata while protecting setup private files");
298
+ const result = await gitDiagnostic(this.projectRoot, [
299
+ "rev-parse",
300
+ "--is-inside-work-tree",
301
+ ]);
302
+ if (result.code !== 0 || result.output.trim() !== "true")
303
+ throw new Error("Refusing setup outside a verifiable Git worktree");
304
+ return true;
305
+ }
306
+ catch (error) {
307
+ if (error.code !== "ENOENT")
308
+ throw error;
309
+ }
310
+ const parent = dirname(directory);
311
+ if (parent === directory)
312
+ return false;
313
+ directory = parent;
314
+ }
315
+ }
316
+ privatePaths() {
317
+ const names = [this.path, this.claimHandoffPath, this.applicationEvidencePath].map((path) => basename(path));
318
+ return [
319
+ ...names,
320
+ ...names.map((name) => `.${name}.00000000-0000-4000-8000-000000000000.tmp`),
321
+ `${basename(this.applicationEvidencePath)}.0.tmp`,
322
+ ].map((name) => `.hue/${name}`);
323
+ }
324
+ async assertPrivateGitProtection(requireIgnored) {
325
+ // This is a private storage directory, not a general project ignore file.
326
+ // Refuse selective negations too: sampling a random temporary name cannot
327
+ // establish protection for every future atomic-write/app-process filename.
328
+ const localIgnore = join(this.directory, ".gitignore");
329
+ await rejectSymlink(this.directory, true);
330
+ await rejectSymlink(localIgnore, true);
331
+ try {
332
+ const handle = await open(localIgnore, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
333
+ try {
334
+ const info = await handle.stat();
335
+ if (!info.isFile() || info.size > 1024 * 1024)
336
+ throw new Error("Unsafe setup private ignore file");
337
+ if ((await handle.readFile("utf8")).split(/\r?\n/u).some((line) => line.startsWith("!")))
338
+ throw new Error("Refusing conflicting Git ignore rules for setup private files");
339
+ }
340
+ finally {
341
+ await handle.close();
342
+ }
343
+ }
344
+ catch (error) {
345
+ if (error.code !== "ENOENT")
346
+ throw error;
347
+ }
348
+ if (!(await this.hasGitWorktree()))
349
+ return;
350
+ const tracked = await gitDiagnostic(this.projectRoot, [
351
+ "--literal-pathspecs",
352
+ "ls-files",
353
+ "--cached",
354
+ "-z",
355
+ "--",
356
+ ".hue",
357
+ ]);
358
+ if (tracked.code !== 0 ||
359
+ tracked.output
360
+ .split("\0")
361
+ .some((path) => /^\.hue\/\.?(?:installation-|claim-handoff-|application-evidence-)/u.test(path)))
362
+ throw new Error("Refusing tracked setup private files; remove them from the Git index first");
363
+ const paths = this.privatePaths();
364
+ const result = await gitDiagnostic(this.projectRoot, ["check-ignore", "--no-index", "--verbose", "--non-matching", "-z", "--stdin"], `${paths.join("\0")}\0`);
365
+ const fields = result.output.split("\0");
366
+ if (fields.pop() !== "" || fields.length !== paths.length * 4)
367
+ throw new Error("Refusing unverifiable setup private-file ignore rules");
368
+ for (let index = 0; index < paths.length; index++) {
369
+ const pattern = fields[index * 4 + 2];
370
+ if (fields[index * 4 + 3] !== paths[index])
371
+ throw new Error("Refusing unverifiable setup private-file ignore rules");
372
+ if (pattern.startsWith("!") || (requireIgnored && !pattern))
373
+ throw new Error("Refusing conflicting Git ignore rules for setup private files");
374
+ }
375
+ }
376
+ async snapshot() {
377
+ await this.rejectUnsafeProjectRoot();
378
+ await rejectSymlink(this.directory);
379
+ const handle = await open(this.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
380
+ try {
381
+ const info = await handle.stat();
382
+ if (!info.isFile() ||
383
+ info.size > MAX_FILE_BYTES ||
384
+ (process.platform !== "win32" &&
385
+ ((info.mode & 0o077) !== 0 || info.uid !== process.getuid?.())))
386
+ throw new Error("Unsafe setup installation record; owner-only mode 0600 is required");
387
+ const contents = await handle.readFile("utf8");
388
+ const after = await handle.stat();
389
+ if (info.mtimeMs !== after.mtimeMs ||
390
+ info.ctimeMs !== after.ctimeMs ||
391
+ info.size !== after.size)
392
+ throw new Error("Refusing changed setup installation record");
393
+ return { contents, info };
394
+ }
395
+ finally {
396
+ await handle.close();
397
+ }
398
+ }
399
+ async ensureIgnoreFile(ignorePath, rule) {
400
+ await rejectSymlink(ignorePath, true);
401
+ let source = "";
402
+ let mode = 0o644;
403
+ try {
404
+ const info = await lstat(ignorePath);
405
+ if (!info.isFile() || info.size > 1024 * 1024)
406
+ throw new Error("Unsafe project .gitignore");
407
+ mode = info.mode & 0o777;
408
+ source = await readFile(ignorePath, "utf8");
409
+ }
410
+ catch (error) {
411
+ if (error.code !== "ENOENT")
412
+ throw error;
413
+ }
414
+ if (source.split(/\r?\n/u).includes(rule))
415
+ return;
416
+ const separator = source.length === 0 || source.endsWith("\n") ? "" : "\n";
417
+ await atomicWrite(ignorePath, `${source}${separator}${rule}\n`, mode, async () => {
418
+ await rejectSymlink(ignorePath, true);
419
+ let current = "";
420
+ try {
421
+ current = await readFile(ignorePath, "utf8");
422
+ }
423
+ catch (error) {
424
+ if (error.code !== "ENOENT")
425
+ throw error;
426
+ }
427
+ if (current !== source)
428
+ throw new Error("Refusing concurrently changed setup ignore rules");
429
+ });
430
+ }
431
+ async ensureRootIgnored() {
432
+ for (const rule of IGNORE_RULES)
433
+ await this.ensureIgnoreFile(join(this.projectRoot, ".gitignore"), rule);
434
+ }
435
+ /** Revalidates owner-only storage and ignore rules before an existing proof is used for I/O. */
436
+ async ensureIgnored() {
437
+ await this.rejectUnsafeProjectRoot();
438
+ await this.assertPrivateGitProtection(false);
439
+ await this.ensureRootIgnored();
440
+ await rejectSymlink(this.directory);
441
+ const info = await lstat(this.directory);
442
+ if (!info.isDirectory())
443
+ throw new Error("Unsafe setup installation directory");
444
+ if (process.platform !== "win32")
445
+ await chmod(this.directory, 0o700);
446
+ for (const rule of LOCAL_IGNORE_RULES)
447
+ await this.ensureIgnoreFile(join(this.directory, ".gitignore"), rule);
448
+ await this.assertPrivateGitProtection(true);
449
+ }
450
+ /** Loads a valid owner-only record without creating one. */
451
+ async load() {
452
+ await this.rejectUnsafeProjectRoot();
453
+ await rejectSymlink(this.directory, true);
454
+ await rejectSymlink(this.path, true);
455
+ await this.assertPrivateGitProtection(false);
456
+ try {
457
+ await lstat(this.path);
458
+ await this.assertPrivateGitProtection(true);
459
+ const current = await this.snapshot();
460
+ let value;
461
+ try {
462
+ value = JSON.parse(current.contents);
463
+ }
464
+ catch {
465
+ throw new Error("Invalid setup installation record");
466
+ }
467
+ const record = parseRecord(value, this.origin);
468
+ this.lastSnapshot = current;
469
+ return record;
470
+ }
471
+ catch (error) {
472
+ if (error.code === "ENOENT")
473
+ return undefined;
474
+ throw error;
475
+ }
476
+ }
477
+ /** Creates and durably saves the installation proof before any caller may perform network I/O. */
478
+ async loadOrCreate() {
479
+ const existing = await this.load();
480
+ if (existing) {
481
+ await this.ensureIgnored();
482
+ return existing;
483
+ }
484
+ await this.assertPrivateGitProtection(false);
485
+ await this.ensureRootIgnored();
486
+ await rejectSymlink(this.directory, true);
487
+ await mkdir(this.directory, { recursive: false, mode: 0o700 }).catch((error) => {
488
+ if (error.code !== "EEXIST")
489
+ throw error;
490
+ });
491
+ await rejectSymlink(this.directory);
492
+ if (process.platform !== "win32")
493
+ await chmod(this.directory, 0o700);
494
+ for (const rule of LOCAL_IGNORE_RULES)
495
+ await this.ensureIgnoreFile(join(this.directory, ".gitignore"), rule);
496
+ await this.assertPrivateGitProtection(true);
497
+ const record = {
498
+ format: 1,
499
+ origin: this.origin,
500
+ installationId: randomUUID().toLowerCase(),
501
+ installationSecret: randomBytes(32).toString("base64url"),
502
+ provisionAttempts: [],
503
+ managedFiles: {},
504
+ };
505
+ try {
506
+ const handle = await open(this.path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
507
+ try {
508
+ await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
509
+ await handle.sync();
510
+ }
511
+ finally {
512
+ await handle.close();
513
+ }
514
+ if (process.platform !== "win32")
515
+ await chmod(this.path, 0o600);
516
+ if (process.platform !== "win32") {
517
+ const directory = await open(this.directory, constants.O_RDONLY);
518
+ try {
519
+ await directory.sync();
520
+ }
521
+ finally {
522
+ await directory.close();
523
+ }
524
+ }
525
+ this.lastSnapshot = await this.snapshot();
526
+ return record;
527
+ }
528
+ catch (error) {
529
+ if (error.code === "EEXIST") {
530
+ const raced = await this.load();
531
+ if (raced)
532
+ return raced;
533
+ }
534
+ throw error;
535
+ }
536
+ }
537
+ /** Atomically replaces this store's validated owner-only record. */
538
+ async save(record) {
539
+ parseRecord(record, this.origin);
540
+ await this.assertPrivateGitProtection(true);
541
+ await rejectSymlink(this.directory);
542
+ const expected = this.lastSnapshot;
543
+ if (!expected)
544
+ throw new Error("Refusing to save an installation without loading its current state");
545
+ await atomicWrite(this.path, `${JSON.stringify(record)}\n`, 0o600, async () => {
546
+ await this.assertPrivateGitProtection(true);
547
+ const current = await this.snapshot();
548
+ if (current.contents !== expected.contents ||
549
+ current.info.dev !== expected.info.dev ||
550
+ current.info.ino !== expected.info.ino ||
551
+ current.info.mode !== expected.info.mode ||
552
+ current.info.mtimeMs !== expected.info.mtimeMs ||
553
+ current.info.ctimeMs !== expected.info.ctimeMs)
554
+ throw new Error("Refusing concurrently changed setup installation state; rerun after the other command finishes");
555
+ });
556
+ this.lastSnapshot = await this.snapshot();
557
+ }
558
+ /** Saves a private browser redirect without putting its capability in a process argument. */
559
+ async saveClaimHandoff(claimUrl) {
560
+ await this.ensureIgnored();
561
+ const encoded = JSON.stringify(claimUrl).replaceAll("<", "\\u003c");
562
+ const html = `<!doctype html>
563
+ <meta charset="utf-8">
564
+ <meta name="referrer" content="no-referrer">
565
+ <meta http-equiv="cache-control" content="no-store">
566
+ <title>Continue Hue setup</title>
567
+ <script>location.replace(${encoded})</script>
568
+ <p>This private Hue setup handoff is opened locally. Close this page if it does not continue.</p>
569
+ `;
570
+ await atomicWrite(this.claimHandoffPath, html, 0o600, () => this.assertPrivateGitProtection(true));
571
+ return this.claimHandoffPath;
572
+ }
573
+ /** Removes a consumed or terminal claim handoff without following symlinks. */
574
+ async removeClaimHandoff() {
575
+ await rejectSymlink(this.claimHandoffPath, true);
576
+ try {
577
+ const info = await lstat(this.claimHandoffPath);
578
+ if (!info.isFile())
579
+ throw new Error("Unsafe setup claim handoff path");
580
+ await unlink(this.claimHandoffPath);
581
+ }
582
+ catch (error) {
583
+ if (error.code !== "ENOENT")
584
+ throw error;
585
+ }
586
+ }
587
+ /** Removes the private child-to-parent evidence transfer file without following symlinks. */
588
+ async removeApplicationEvidence() {
589
+ await rejectSymlink(this.applicationEvidencePath, true);
590
+ try {
591
+ const info = await lstat(this.applicationEvidencePath);
592
+ if (!info.isFile())
593
+ throw new Error("Unsafe setup application evidence path");
594
+ await unlink(this.applicationEvidencePath);
595
+ }
596
+ catch (error) {
597
+ if (error.code !== "ENOENT")
598
+ throw error;
599
+ }
600
+ }
601
+ }
602
+ /** Computes the digest used to detect unexpected edits to managed files. */
603
+ export function setupManagedDigest(contents) {
604
+ return createHash("sha256").update(contents).digest("hex");
605
+ }
@@ -0,0 +1,2 @@
1
+ /** Serializes all origins for one project: they share manifests and application files. */
2
+ export declare function acquireSetupCommandLock(projectRoot: string): Promise<() => Promise<void>>;
@@ -0,0 +1,38 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, realpath, rmdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ /** Serializes all origins for one project: they share manifests and application files. */
5
+ export async function acquireSetupCommandLock(projectRoot) {
6
+ const root = await realpath(projectRoot);
7
+ if ((process.platform !== "linux" && process.platform !== "darwin") || !process.getuid)
8
+ throw new Error("Automatic setup requires supported POSIX command ownership");
9
+ // The OS-wide canonical temporary directory is fixed, not selected by TMPDIR,
10
+ // HOME or XDG variables. All aliases/origins for the same real project share it.
11
+ const parent = join(await realpath("/tmp"), `hue-setup-locks-${process.getuid()}`);
12
+ await mkdir(parent, { mode: 0o700 }).catch((error) => {
13
+ if (error.code !== "EEXIST")
14
+ throw error;
15
+ });
16
+ const info = await lstat(parent);
17
+ if (!info.isDirectory() ||
18
+ info.isSymbolicLink() ||
19
+ (info.mode & 0o077) !== 0 ||
20
+ info.uid !== process.getuid())
21
+ throw new Error("Refusing unsafe setup command lock directory");
22
+ const path = join(parent, createHash("sha256").update(root).digest("hex"));
23
+ try {
24
+ await mkdir(path, { mode: 0o700 });
25
+ }
26
+ catch (error) {
27
+ if (error.code === "EEXIST")
28
+ throw new Error("Refusing concurrent setup commands. Wait for the existing command to finish. If it was forcibly killed, the owner must inspect and remove its stale local command lock before resuming.");
29
+ throw error;
30
+ }
31
+ const acquired = await lstat(path);
32
+ return async () => {
33
+ const current = await lstat(path);
34
+ if (current.ino !== acquired.ino || current.dev !== acquired.dev || !current.isDirectory())
35
+ throw new Error("Refusing to release a changed setup command lock");
36
+ await rmdir(path);
37
+ };
38
+ }
@@ -83,16 +83,7 @@ interface PlanReadyTransitionEvent {
83
83
  plan: SetupPlan;
84
84
  }
85
85
  /** @inline */
86
- interface ConfigureTelemetryRequiredTransitionEvent {
87
- /** Transition-event discriminator. */
88
- event: "action.required";
89
- /** Project action needed next. */
90
- action: "configure";
91
- /** Secret-free explanation. */
92
- message: string;
93
- }
94
- /** @inline */
95
- type SetupTransitionEvent = StartStepTransitionEvent | ProjectDetectedTransitionEvent | CompleteStepTransitionEvent | PlanReadyTransitionEvent | ConfigureTelemetryRequiredTransitionEvent;
86
+ type SetupTransitionEvent = StartStepTransitionEvent | ProjectDetectedTransitionEvent | CompleteStepTransitionEvent | PlanReadyTransitionEvent;
96
87
  /** Pure transition result. Events are templates completed by the runner. */
97
88
  export interface SetupTransition {
98
89
  /** State to checkpoint before executing another effect. */