@hue-run/sdk 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLI.md +52 -0
- package/ENVIRONMENTS.md +24 -6
- package/EVALUATIONS.md +69 -2
- package/README.md +20 -0
- package/dist/environment/client.d.ts +2 -2
- package/dist/environment/types.d.ts +33 -1
- package/dist/evals/client.d.ts +30 -1
- package/dist/evals/client.js +16 -0
- package/dist/evals/environment-target.d.ts +84 -0
- package/dist/evals/environment-target.js +201 -0
- package/dist/evals/local-worker.d.ts +88 -0
- package/dist/evals/local-worker.js +171 -0
- package/dist/evals/runner.d.ts +1 -1
- package/dist/evals/runner.js +22 -12
- package/dist/evals/scorer-publication.js +17 -1
- package/dist/evals/scorers.d.ts +10 -4
- package/dist/evals/scorers.js +11 -8
- package/dist/evals/simulation.d.ts +6 -6
- package/dist/evals/simulation.js +45 -222
- package/dist/evals/types.d.ts +39 -1
- package/dist/evals.d.ts +2 -0
- package/dist/evals.js +1 -0
- package/dist/setup/checkpoint.d.ts +14 -0
- package/dist/setup/checkpoint.js +186 -0
- package/dist/setup/cli.d.ts +2 -0
- package/dist/setup/cli.js +150 -0
- package/dist/setup/detect.d.ts +3 -0
- package/dist/setup/detect.js +146 -0
- package/dist/setup/machine.d.ts +109 -0
- package/dist/setup/machine.js +43 -0
- package/dist/setup/render.d.ts +16 -0
- package/dist/setup/render.js +111 -0
- package/dist/setup/runner.d.ts +101 -0
- package/dist/setup/runner.js +117 -0
- package/dist/setup/types.d.ts +145 -0
- package/dist/setup/types.js +2 -0
- package/dist/setup.d.ts +6 -0
- package/dist/setup.js +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +12 -1
- package/setup-events.schema.json +134 -0
package/dist/evals/simulation.js
CHANGED
|
@@ -1,56 +1,25 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { HueEnvironmentError } from "../environment/client.js";
|
|
4
|
-
import { bindEnvironmentTools } from "../environment/tools.js";
|
|
5
4
|
import { HueApiError } from "./client.js";
|
|
6
5
|
import { CheckpointStore } from "./checkpoint.js";
|
|
7
6
|
import { MAX_ENVIRONMENT_STEPS } from "./environment-evidence.js";
|
|
8
7
|
import { aggregateBounds, digest, json } from "./json.js";
|
|
9
8
|
import { normalizeScorerDefinitionForPublication } from "./scorer-publication.js";
|
|
10
|
-
import {
|
|
11
|
-
import { runExperiment
|
|
9
|
+
import { pinRequestedAttemptV2, requestedAttemptV2, runEnvironmentTarget, } from "./environment-target.js";
|
|
10
|
+
import { runExperiment } from "./runner.js";
|
|
12
11
|
const scorerDefinition = (entry) => "definition" in entry.scorer ? entry.scorer.definition : entry.scorer;
|
|
13
|
-
function requestedAttempt(options) {
|
|
14
|
-
const requested = options.requestedProviders !== undefined;
|
|
15
|
-
const selected = options.mcpSurface !== undefined;
|
|
16
|
-
if (!requested && !selected) {
|
|
17
|
-
if (options.actualAgentManifest !== undefined)
|
|
18
|
-
throw new TypeError("actualAgentManifest requires requestedProviders and mcpSurface");
|
|
19
|
-
return undefined;
|
|
20
|
-
}
|
|
21
|
-
if (!requested || !selected)
|
|
22
|
-
throw new TypeError("requestedProviders and mcpSurface must be supplied together");
|
|
23
|
-
const requestedProviders = requestedAttemptProvidersV2.parse(options.requestedProviders);
|
|
24
|
-
const mcpSurface = options.mcpSurface;
|
|
25
|
-
const provider = requestedProviders.find((candidate) => candidate.providerInstanceKey === mcpSurface.providerInstanceKey);
|
|
26
|
-
if (!provider?.surfaceKeys.includes(mcpSurface.surfaceKey))
|
|
27
|
-
throw new TypeError("mcpSurface must identify an exactly requested MCP surface");
|
|
28
|
-
const actualAgentManifest = typeof options.actualAgentManifest === "function"
|
|
29
|
-
? options.actualAgentManifest
|
|
30
|
-
: actualAgentManifestV2.parse(options.actualAgentManifest);
|
|
31
|
-
return {
|
|
32
|
-
actualAgentManifest,
|
|
33
|
-
requestedProviders,
|
|
34
|
-
mcpSurface: { ...mcpSurface },
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
function expectedManifestDigest(config) {
|
|
38
|
-
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
39
|
-
throw new TypeError("Provider-profile simulations require an immutable V2 attempt baseline");
|
|
40
|
-
const source = config;
|
|
41
|
-
const baseline = attemptBaselineV2.safeParse(source.attemptBaselineV2);
|
|
42
|
-
if (!baseline.success) {
|
|
43
|
-
if (source.attemptBaselineV2 === undefined && source.attemptBaselineV1 !== undefined)
|
|
44
|
-
throw new TypeError("Legacy V1 attempts require a fresh experiment with a V2 baseline");
|
|
45
|
-
if (source.attemptBaselineV2 !== undefined)
|
|
46
|
-
throw new TypeError("The immutable V2 attempt baseline is invalid");
|
|
47
|
-
throw new TypeError("Provider-profile simulations require an immutable V2 attempt baseline");
|
|
48
|
-
}
|
|
49
|
-
return baseline.data.expectedAgentManifestDigest;
|
|
50
|
-
}
|
|
51
12
|
function normalizedEnvironmentDefinition(definition) {
|
|
52
13
|
return json({
|
|
53
14
|
...definition,
|
|
15
|
+
...(definition.schemaVersion === 2
|
|
16
|
+
? {
|
|
17
|
+
providerInstances: definition.providerInstances.map((instance) => ({
|
|
18
|
+
...instance,
|
|
19
|
+
syntheticPrincipalId: instance.syntheticPrincipalId.toLowerCase(),
|
|
20
|
+
})),
|
|
21
|
+
}
|
|
22
|
+
: {}),
|
|
54
23
|
determinism: {
|
|
55
24
|
clock: {
|
|
56
25
|
startNs: definition.determinism?.clock?.startNs ?? "0",
|
|
@@ -341,24 +310,11 @@ async function resolveExperiment(options, idempotencyKey) {
|
|
|
341
310
|
bindings: [...scorers.bindings, ...(options.localScorers ?? [])],
|
|
342
311
|
};
|
|
343
312
|
}
|
|
344
|
-
async function seal(client, runId, executionId, status) {
|
|
345
|
-
try {
|
|
346
|
-
await client.finishRun(runId, {
|
|
347
|
-
idempotencyKey: `execution:${executionId}:${status}`,
|
|
348
|
-
status,
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
catch (error) {
|
|
352
|
-
const recovered = await client.getRun(runId).catch(() => undefined);
|
|
353
|
-
if (recovered?.status !== status)
|
|
354
|
-
throw error;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
313
|
/** Run an existing agent callback against one fresh hosted world per case. The helper
|
|
358
314
|
* owns immutable resolution, execution linkage, finalization, scoring and resumable uploads.
|
|
359
315
|
*/
|
|
360
316
|
export async function runSimulation(options) {
|
|
361
|
-
const requestedConfiguration =
|
|
317
|
+
const requestedConfiguration = requestedAttemptV2(options);
|
|
362
318
|
if (options.maxSteps !== undefined &&
|
|
363
319
|
(!Number.isInteger(options.maxSteps) ||
|
|
364
320
|
options.maxSteps < 1 ||
|
|
@@ -408,10 +364,7 @@ export async function runSimulation(options) {
|
|
|
408
364
|
const runUrl = new URL(`/experiments/${experimentId}`, options.client.baseUrl).toString();
|
|
409
365
|
await options.onProgress?.({ type: "run_created", experimentId, runUrl });
|
|
410
366
|
const requested = requestedConfiguration
|
|
411
|
-
?
|
|
412
|
-
...requestedConfiguration,
|
|
413
|
-
expectedAgentManifestDigest: expectedManifestDigest((await options.client.getExperiment(experimentId)).config),
|
|
414
|
-
}
|
|
367
|
+
? pinRequestedAttemptV2(requestedConfiguration, (await options.client.getExperiment(experimentId)).config)
|
|
415
368
|
: undefined;
|
|
416
369
|
const report = await runExperiment({
|
|
417
370
|
client: options.client,
|
|
@@ -424,172 +377,42 @@ export async function runSimulation(options) {
|
|
|
424
377
|
scorers: bindings,
|
|
425
378
|
concurrency: options.concurrency,
|
|
426
379
|
schemaTimeoutMillis: options.schemaTimeoutMillis,
|
|
427
|
-
target:
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
type,
|
|
380
|
+
target: (inputs, context) => runEnvironmentTarget({
|
|
381
|
+
client: options.client,
|
|
382
|
+
environmentClient: options.environmentClient,
|
|
383
|
+
hue: options.hue,
|
|
384
|
+
inputs,
|
|
385
|
+
context,
|
|
386
|
+
requested,
|
|
387
|
+
maxSteps: options.maxSteps,
|
|
388
|
+
ttlSeconds: options.ttlSeconds,
|
|
389
|
+
signal: options.signal,
|
|
390
|
+
onProgress: (event) => options.onProgress?.({
|
|
391
|
+
...event,
|
|
440
392
|
experimentId,
|
|
441
393
|
executionId: context.executionId,
|
|
442
394
|
caseId: context.item.id,
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
})
|
|
465
|
-
: requested.actualAgentManifest);
|
|
466
|
-
let prepared;
|
|
467
|
-
try {
|
|
468
|
-
prepared = await options.client.prepareAttempt({
|
|
469
|
-
schemaVersion: 2,
|
|
470
|
-
idempotencyKey: randomUUID(),
|
|
471
|
-
executionId: context.executionId,
|
|
472
|
-
environmentRunId: run.id,
|
|
473
|
-
expectedAgentManifestDigest: requested.expectedAgentManifestDigest,
|
|
474
|
-
actualManifest,
|
|
475
|
-
requestedProviders: requested.requestedProviders,
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
catch (error) {
|
|
479
|
-
// A transport failure or malformed credential-bearing response may
|
|
480
|
-
// follow a committed decision. Preserve the running checkpoint and
|
|
481
|
-
// never reacquire credentials or replay the target on resume.
|
|
482
|
-
throw new TargetOutcomeUncertainError(context.executionId, { cause: error });
|
|
483
|
-
}
|
|
484
|
-
await options.onProgress?.({
|
|
485
|
-
type: "attempt_prepared",
|
|
486
|
-
experimentId,
|
|
487
|
-
executionId: context.executionId,
|
|
488
|
-
caseId: context.item.id,
|
|
489
|
-
environmentRunId: run.id,
|
|
490
|
-
bindingId: prepared.status === "ready" ? prepared.bundle.bindingId : prepared.bindingId,
|
|
491
|
-
status: prepared.status,
|
|
492
|
-
findingCodes: prepared.preflightReport.findings.map((finding) => finding.code),
|
|
493
|
-
...(prepared.status === "ready"
|
|
494
|
-
? {
|
|
495
|
-
executionManifestDigest: prepared.bundle.parity.executionManifestDigest,
|
|
496
|
-
}
|
|
497
|
-
: {}),
|
|
498
|
-
});
|
|
499
|
-
if (prepared.status === "environment_incomplete") {
|
|
500
|
-
try {
|
|
501
|
-
await seal(options.environmentClient, run.id, context.executionId, "completed");
|
|
502
|
-
}
|
|
503
|
-
catch (error) {
|
|
504
|
-
throw new TargetOutcomeUncertainError(context.executionId, { cause: error });
|
|
505
|
-
}
|
|
506
|
-
finalized = true;
|
|
507
|
-
await Promise.resolve(progress("world_sealed")).catch(() => undefined);
|
|
508
|
-
return undefined;
|
|
509
|
-
}
|
|
510
|
-
connectionBundle = validateAttemptConnectionBundleV2(prepared.bundle, {
|
|
511
|
-
requireFresh: true,
|
|
512
|
-
});
|
|
513
|
-
const projected = projectMcpConnectionV2(connectionBundle, requested.mcpSurface.providerInstanceKey);
|
|
514
|
-
if (!projected)
|
|
515
|
-
throw new TypeError("The prepared attempt has no selected MCP surface");
|
|
516
|
-
mcp = projected;
|
|
517
|
-
}
|
|
518
|
-
else {
|
|
519
|
-
mcp = await options.client.createSimulationMcpCapability({
|
|
520
|
-
runId: run.id,
|
|
521
|
-
executionId: context.executionId,
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
if (options.signal?.aborted)
|
|
525
|
-
throw new TargetCancelledError();
|
|
526
|
-
await progress("target_started");
|
|
527
|
-
const output = await options.target(inputs, {
|
|
528
|
-
config: context.config,
|
|
529
|
-
item: context.item,
|
|
530
|
-
executionId: context.executionId,
|
|
531
|
-
environmentRunId: run.id,
|
|
532
|
-
tools,
|
|
533
|
-
mcp,
|
|
534
|
-
...(connectionBundle ? { connectionBundle } : {}),
|
|
535
|
-
signal: options.signal,
|
|
536
|
-
});
|
|
537
|
-
try {
|
|
538
|
-
await seal(options.environmentClient, run.id, context.executionId, "completed");
|
|
539
|
-
}
|
|
540
|
-
catch (error) {
|
|
541
|
-
throw new TargetOutcomeUncertainError(context.executionId, { cause: error });
|
|
542
|
-
}
|
|
543
|
-
finalized = true;
|
|
544
|
-
await Promise.resolve(progress("world_sealed")).catch(() => undefined);
|
|
545
|
-
return output;
|
|
546
|
-
}
|
|
547
|
-
catch (error) {
|
|
548
|
-
if (error instanceof TargetOutcomeUncertainError || finalized)
|
|
549
|
-
throw error;
|
|
550
|
-
let environmentIncomplete;
|
|
551
|
-
try {
|
|
552
|
-
environmentIncomplete =
|
|
553
|
-
(await options.environmentClient.getRun(run.id)).validity ===
|
|
554
|
-
"environment_incomplete";
|
|
555
|
-
}
|
|
556
|
-
catch (inspectionError) {
|
|
557
|
-
// A target error can be the adapter surfacing a coverage gap. If the
|
|
558
|
-
// authoritative run cannot be read, do not guess that it was an agent
|
|
559
|
-
// failure or replay the target on resume.
|
|
560
|
-
throw new TargetOutcomeUncertainError(context.executionId, {
|
|
561
|
-
cause: new AggregateError([error, inspectionError]),
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
// A durable coverage gap invalidates parity independently of caller timing;
|
|
565
|
-
// do not let a racing local abort hide it as an ordinary cancellation.
|
|
566
|
-
if (environmentIncomplete) {
|
|
567
|
-
try {
|
|
568
|
-
await seal(options.environmentClient, run.id, context.executionId, "completed");
|
|
569
|
-
}
|
|
570
|
-
catch (finalizationError) {
|
|
571
|
-
throw new TargetOutcomeUncertainError(context.executionId, {
|
|
572
|
-
cause: new AggregateError([error, finalizationError]),
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
finalized = true;
|
|
576
|
-
await Promise.resolve(progress("world_sealed")).catch(() => undefined);
|
|
577
|
-
return undefined;
|
|
578
|
-
}
|
|
579
|
-
try {
|
|
580
|
-
await seal(options.environmentClient, run.id, context.executionId, "abandoned");
|
|
581
|
-
}
|
|
582
|
-
catch (finalizationError) {
|
|
583
|
-
throw new TargetOutcomeUncertainError(context.executionId, {
|
|
584
|
-
cause: new AggregateError([error, finalizationError]),
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
await Promise.resolve(progress("world_sealed")).catch(() => undefined);
|
|
588
|
-
if (options.signal?.aborted && !(error instanceof TargetCancelledError))
|
|
589
|
-
throw new TargetCancelledError();
|
|
590
|
-
throw error;
|
|
591
|
-
}
|
|
592
|
-
},
|
|
395
|
+
}),
|
|
396
|
+
target: (targetInputs, targetContext) => options.target(structuredClone(targetInputs), {
|
|
397
|
+
config: structuredClone(targetContext.config),
|
|
398
|
+
item: {
|
|
399
|
+
id: targetContext.item.id,
|
|
400
|
+
externalKey: targetContext.item.externalKey,
|
|
401
|
+
},
|
|
402
|
+
executionId: targetContext.executionId,
|
|
403
|
+
environmentRunId: targetContext.environmentRunId,
|
|
404
|
+
tools: targetContext.tools,
|
|
405
|
+
mcp: {
|
|
406
|
+
url: targetContext.mcp.url,
|
|
407
|
+
token: targetContext.mcp.token,
|
|
408
|
+
expiresAt: targetContext.mcp.expiresAt,
|
|
409
|
+
},
|
|
410
|
+
...(targetContext.connectionBundle
|
|
411
|
+
? { connectionBundle: structuredClone(targetContext.connectionBundle) }
|
|
412
|
+
: {}),
|
|
413
|
+
signal: targetContext.signal,
|
|
414
|
+
}),
|
|
415
|
+
}),
|
|
593
416
|
});
|
|
594
417
|
const complete = { ...report, experimentId, runUrl };
|
|
595
418
|
attempt.stage = "completed";
|
package/dist/evals/types.d.ts
CHANGED
|
@@ -119,7 +119,7 @@ export type MetricDefinition = {
|
|
|
119
119
|
/** Allowed values. */
|
|
120
120
|
categories: string[];
|
|
121
121
|
};
|
|
122
|
-
/** A pinned scorer definition
|
|
122
|
+
/** A pinned scorer definition executed locally, by a person, or by Hue. */
|
|
123
123
|
export type ScorerDefinition = {
|
|
124
124
|
/** Hue built-in scorer. */
|
|
125
125
|
kind: "builtin";
|
|
@@ -158,6 +158,13 @@ export type ScorerDefinition = {
|
|
|
158
158
|
sourceDigest: string;
|
|
159
159
|
/** Metrics the callback reports. */
|
|
160
160
|
metrics: MetricDefinition[];
|
|
161
|
+
} | {
|
|
162
|
+
/** Scored inside Hue using immutable world evidence; the local runner defers it. */
|
|
163
|
+
kind: "world_outcome";
|
|
164
|
+
/** Pinned Hue-executed outcome evaluator. */
|
|
165
|
+
entry: "hue.conversion_outcome.v1";
|
|
166
|
+
/** The seven fixed boolean metrics defined by the entry. */
|
|
167
|
+
metrics: MetricDefinition[];
|
|
161
168
|
} | {
|
|
162
169
|
/** Scored by a person in Hue; the local runner defers it. */
|
|
163
170
|
kind: "manual";
|
|
@@ -603,3 +610,34 @@ export interface SimulationMcpCapability {
|
|
|
603
610
|
/** Credential expiry timestamp. */
|
|
604
611
|
expiresAt: string;
|
|
605
612
|
}
|
|
613
|
+
/** Identity and capabilities of one fixed local agent entry point. */
|
|
614
|
+
export interface LocalAgentRegistration {
|
|
615
|
+
/** Stable application-selected agent key. */
|
|
616
|
+
key: string;
|
|
617
|
+
/** Display name. */
|
|
618
|
+
name: string;
|
|
619
|
+
/** Application-selected revision of the agent configuration. */
|
|
620
|
+
revision: string;
|
|
621
|
+
/** Supported execution contracts; defaults to environment:v1 in the worker. */
|
|
622
|
+
capabilities?: string[];
|
|
623
|
+
/** Local scorer source digests available in this process. */
|
|
624
|
+
scorerDigests?: string[];
|
|
625
|
+
}
|
|
626
|
+
/** Server registration and heartbeat timestamps for a local agent. */
|
|
627
|
+
export interface RegisteredLocalAgent extends Required<LocalAgentRegistration> {
|
|
628
|
+
/** Registered agent identity. */
|
|
629
|
+
id: string;
|
|
630
|
+
/** Whether Hue permits this registration to receive runs. */
|
|
631
|
+
enabled: boolean;
|
|
632
|
+
/** Latest registration heartbeat, as an ISO timestamp. */
|
|
633
|
+
lastSeenAt: string;
|
|
634
|
+
/** Registration creation timestamp. */
|
|
635
|
+
createdAt: string;
|
|
636
|
+
}
|
|
637
|
+
/** Queue claim connecting a local run to a pinned experiment. */
|
|
638
|
+
export interface LocalAgentClaim {
|
|
639
|
+
/** Claimed queue-run identity. */
|
|
640
|
+
runId: string;
|
|
641
|
+
/** Pinned experiment to execute. */
|
|
642
|
+
experimentId: string;
|
|
643
|
+
}
|
package/dist/evals.d.ts
CHANGED
|
@@ -9,3 +9,5 @@ export type { ActualAgentManifestInputV2, ActualAgentManifestV2, AttemptBaseline
|
|
|
9
9
|
export { builtins, defineLocalScorer, scoreLocally } from "./evals/scorers.js";
|
|
10
10
|
export { sourceDigest } from "./evals/json.js";
|
|
11
11
|
export type * from "./evals/types.js";
|
|
12
|
+
export { runLocalAgent } from "./evals/local-worker.js";
|
|
13
|
+
export type { LocalAgentTargetContext, RunLocalAgentOptions } from "./evals/local-worker.js";
|
package/dist/evals.js
CHANGED
|
@@ -4,3 +4,4 @@ export { runSimulation } from "./evals/simulation.js";
|
|
|
4
4
|
export { actualAgentManifestV2, agentManifestDigestV2, attemptBaselineV2, attemptBindingRead, attemptConnectionBundleV2, attemptIdentityV2, dependencyManifestV2, dependencyProviderV2, expectedAgentManifestV2, executionManifestDigestV2, parityEvidenceV2, preflightFindingV2, preflightReportV2, prepareAttemptInputV2, projectMcpConnectionV2, secretFreeBindingV2, surfaceBindingV2, } from "./evals/attempt.js";
|
|
5
5
|
export { builtins, defineLocalScorer, scoreLocally } from "./evals/scorers.js";
|
|
6
6
|
export { sourceDigest } from "./evals/json.js";
|
|
7
|
+
export { runLocalAgent } from "./evals/local-worker.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SetupCheckpointAdapter } from "./runner.js";
|
|
2
|
+
import type { SetupMachineState } from "./machine.js";
|
|
3
|
+
export declare function defaultSetupStateDirectory(env?: NodeJS.ProcessEnv): string;
|
|
4
|
+
/** Stable installer-session identifier; it is not a Hue Run and reveals no path or file contents. */
|
|
5
|
+
export declare function setupRunId(projectRoot: string): string;
|
|
6
|
+
export declare class FileSetupCheckpointAdapter implements SetupCheckpointAdapter {
|
|
7
|
+
readonly directory: string;
|
|
8
|
+
private readonly runtimePlatform;
|
|
9
|
+
constructor(directory?: string, runtimePlatform?: NodeJS.Platform);
|
|
10
|
+
private get enforcesPosixPermissions();
|
|
11
|
+
private pathFor;
|
|
12
|
+
load(runId: string, projectRoot: string): Promise<SetupMachineState | undefined>;
|
|
13
|
+
save(state: SetupMachineState): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { chmod, lstat, mkdir, open, realpath, rename } from "node:fs/promises";
|
|
4
|
+
import { homedir, platform } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
const MAX_CHECKPOINT_BYTES = 256 * 1024;
|
|
7
|
+
const STEPS = ["detect-project", "configure-telemetry", "verify-receipt", "claim-project"];
|
|
8
|
+
function isInside(parent, child) {
|
|
9
|
+
const path = relative(parent, child);
|
|
10
|
+
return path === "" || (!path.startsWith("..") && !isAbsolute(path));
|
|
11
|
+
}
|
|
12
|
+
export function defaultSetupStateDirectory(env = process.env) {
|
|
13
|
+
if (platform() === "darwin")
|
|
14
|
+
return join(homedir(), "Library", "Application Support", "Hue", "setup");
|
|
15
|
+
if (platform() === "win32")
|
|
16
|
+
return join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "Hue", "setup");
|
|
17
|
+
return join(env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "hue", "setup");
|
|
18
|
+
}
|
|
19
|
+
/** Stable installer-session identifier; it is not a Hue Run and reveals no path or file contents. */
|
|
20
|
+
export function setupRunId(projectRoot) {
|
|
21
|
+
return `setup_${createHash("sha256")
|
|
22
|
+
.update(`hue-setup-v1\0${resolve(projectRoot)}`)
|
|
23
|
+
.digest("hex")
|
|
24
|
+
.slice(0, 24)}`;
|
|
25
|
+
}
|
|
26
|
+
function digest(value) {
|
|
27
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
function hasExactKeys(value, keys) {
|
|
30
|
+
return Object.keys(value).sort().join("\0") === [...keys].sort().join("\0");
|
|
31
|
+
}
|
|
32
|
+
function validDetection(value, projectRoot) {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
34
|
+
return false;
|
|
35
|
+
const item = value;
|
|
36
|
+
if (!hasExactKeys(item, [
|
|
37
|
+
"root",
|
|
38
|
+
"fingerprint",
|
|
39
|
+
"languages",
|
|
40
|
+
"packageManagers",
|
|
41
|
+
"frameworks",
|
|
42
|
+
"hue",
|
|
43
|
+
"openTelemetry",
|
|
44
|
+
]))
|
|
45
|
+
return false;
|
|
46
|
+
const allowed = (items, values, maximum) => Array.isArray(items) &&
|
|
47
|
+
items.length <= maximum &&
|
|
48
|
+
new Set(items).size === items.length &&
|
|
49
|
+
items.every((entry) => typeof entry === "string" && values.includes(entry));
|
|
50
|
+
return (item.root === projectRoot &&
|
|
51
|
+
typeof item.fingerprint === "string" &&
|
|
52
|
+
/^[a-f0-9]{64}$/u.test(item.fingerprint) &&
|
|
53
|
+
allowed(item.languages, ["typescript", "python"], 2) &&
|
|
54
|
+
allowed(item.packageManagers, ["bun", "npm", "pnpm", "yarn", "uv", "poetry", "pip"], 7) &&
|
|
55
|
+
allowed(item.frameworks, ["nextjs", "nestjs", "express", "fastapi", "django", "flask", "vercel-ai-sdk"], 7) &&
|
|
56
|
+
["absent", "typescript", "python", "multiple"].includes(item.hue) &&
|
|
57
|
+
["absent", "typescript", "python", "multiple"].includes(item.openTelemetry));
|
|
58
|
+
}
|
|
59
|
+
function validState(value, runId, projectRoot) {
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
61
|
+
return false;
|
|
62
|
+
const state = value;
|
|
63
|
+
if (state.format !== 1 || state.runId !== runId || state.projectRoot !== projectRoot)
|
|
64
|
+
return false;
|
|
65
|
+
if (state.phase === "created" || state.phase === "detecting")
|
|
66
|
+
return hasExactKeys(state, ["format", "phase", "runId", "projectRoot"]);
|
|
67
|
+
if (state.phase !== "local-ready" ||
|
|
68
|
+
!hasExactKeys(state, ["format", "phase", "runId", "projectRoot", "project", "plan"]) ||
|
|
69
|
+
!validDetection(state.project, projectRoot))
|
|
70
|
+
return false;
|
|
71
|
+
const plan = state.plan;
|
|
72
|
+
return (!!plan &&
|
|
73
|
+
typeof plan === "object" &&
|
|
74
|
+
!Array.isArray(plan) &&
|
|
75
|
+
hasExactKeys(plan, ["steps", "mutatesProject", "backendRequired"]) &&
|
|
76
|
+
JSON.stringify(plan.steps) === JSON.stringify(STEPS) &&
|
|
77
|
+
plan.mutatesProject === false &&
|
|
78
|
+
plan.backendRequired === true);
|
|
79
|
+
}
|
|
80
|
+
export class FileSetupCheckpointAdapter {
|
|
81
|
+
directory;
|
|
82
|
+
runtimePlatform;
|
|
83
|
+
constructor(directory = defaultSetupStateDirectory(), runtimePlatform = platform()) {
|
|
84
|
+
this.directory = directory;
|
|
85
|
+
this.runtimePlatform = runtimePlatform;
|
|
86
|
+
}
|
|
87
|
+
get enforcesPosixPermissions() {
|
|
88
|
+
return this.runtimePlatform !== "win32";
|
|
89
|
+
}
|
|
90
|
+
async pathFor(runId, projectRoot) {
|
|
91
|
+
if (!/^setup_[a-f0-9]{24}$/u.test(runId))
|
|
92
|
+
throw new Error("Invalid setup run identifier");
|
|
93
|
+
const root = resolve(this.directory);
|
|
94
|
+
const project = await realpath(projectRoot);
|
|
95
|
+
if (isInside(project, root))
|
|
96
|
+
throw new Error("Setup checkpoints must be outside the project repository");
|
|
97
|
+
let info;
|
|
98
|
+
try {
|
|
99
|
+
info = await lstat(root);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error.code !== "ENOENT")
|
|
103
|
+
throw error;
|
|
104
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
105
|
+
info = await lstat(root);
|
|
106
|
+
}
|
|
107
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
108
|
+
throw new Error("Setup checkpoint directory must be private (mode 0700, no symlink)");
|
|
109
|
+
if (this.enforcesPosixPermissions) {
|
|
110
|
+
await chmod(root, 0o700);
|
|
111
|
+
info = await lstat(root);
|
|
112
|
+
if ((info.mode & 0o077) !== 0)
|
|
113
|
+
throw new Error("Setup checkpoint directory must be private (mode 0700, no symlink)");
|
|
114
|
+
}
|
|
115
|
+
return join(root, `${runId}.json`);
|
|
116
|
+
}
|
|
117
|
+
async load(runId, projectRoot) {
|
|
118
|
+
const path = await this.pathFor(runId, projectRoot);
|
|
119
|
+
let handle;
|
|
120
|
+
try {
|
|
121
|
+
if (this.runtimePlatform === "win32") {
|
|
122
|
+
const entry = await lstat(path);
|
|
123
|
+
if (entry.isSymbolicLink())
|
|
124
|
+
throw new Error("Unsafe setup checkpoint symlink");
|
|
125
|
+
}
|
|
126
|
+
const noFollow = this.runtimePlatform === "win32" ? 0 : constants.O_NOFOLLOW;
|
|
127
|
+
handle = await open(path, constants.O_RDONLY | noFollow);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error.code === "ENOENT")
|
|
131
|
+
return undefined;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const info = await handle.stat();
|
|
136
|
+
if (!info.isFile() ||
|
|
137
|
+
info.size > MAX_CHECKPOINT_BYTES ||
|
|
138
|
+
(this.enforcesPosixPermissions && (info.mode & 0o077) !== 0))
|
|
139
|
+
throw new Error("Unsafe or oversized setup checkpoint");
|
|
140
|
+
const envelope = JSON.parse(await handle.readFile("utf8"));
|
|
141
|
+
if (!envelope ||
|
|
142
|
+
typeof envelope !== "object" ||
|
|
143
|
+
Array.isArray(envelope) ||
|
|
144
|
+
!hasExactKeys(envelope, ["state", "digest"]))
|
|
145
|
+
throw new Error("Invalid setup checkpoint envelope");
|
|
146
|
+
const { state, digest: expected } = envelope;
|
|
147
|
+
if (typeof expected !== "string" || expected !== digest(state))
|
|
148
|
+
throw new Error("Setup checkpoint integrity check failed");
|
|
149
|
+
if (!validState(state, runId, await realpath(projectRoot)))
|
|
150
|
+
throw new Error("Setup checkpoint identity or shape does not match this project");
|
|
151
|
+
return state;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await handle.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async save(state) {
|
|
158
|
+
const path = await this.pathFor(state.runId, state.projectRoot);
|
|
159
|
+
const encoded = `${JSON.stringify({ state, digest: digest(state) })}\n`;
|
|
160
|
+
if (Buffer.byteLength(encoded) > MAX_CHECKPOINT_BYTES)
|
|
161
|
+
throw new Error("Setup checkpoint exceeds 256 KiB");
|
|
162
|
+
const temporary = join(dirname(path), `.${state.runId}.${randomUUID()}.tmp`);
|
|
163
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
164
|
+
try {
|
|
165
|
+
await handle.writeFile(encoded);
|
|
166
|
+
await handle.sync();
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
await handle.close();
|
|
170
|
+
}
|
|
171
|
+
await rename(temporary, path);
|
|
172
|
+
// Windows cannot open a directory as a file handle for fsync. The atomic rename and
|
|
173
|
+
// per-user state directory still provide resumability there; POSIX additionally fsyncs
|
|
174
|
+
// the containing directory so the rename survives a sudden interruption.
|
|
175
|
+
if (this.runtimePlatform !== "win32") {
|
|
176
|
+
await chmod(path, 0o600);
|
|
177
|
+
const directory = await open(dirname(path), "r");
|
|
178
|
+
try {
|
|
179
|
+
await directory.sync();
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
await directory.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|