harnery 0.22.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +302 -5
- package/dist/core/work/state.d.ts +7 -0
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +112 -23
- package/dist/core/workflow/child-env.d.ts +10 -6
- package/dist/core/workflow/child-env.d.ts.map +1 -1
- package/dist/core/workflow/child-env.js +15 -7
- package/dist/core/workflow/durable-record.d.ts +7 -0
- package/dist/core/workflow/durable-record.d.ts.map +1 -0
- package/dist/core/workflow/durable-record.js +83 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +208 -29
- package/dist/core/workflow/index.d.ts +2 -0
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +1 -0
- package/dist/core/workflow/proof.d.ts +4 -0
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +50 -15
- package/dist/core/workflow/run-state.d.ts +3 -0
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +25 -13
- package/dist/core/workflow/spawn-cursor.d.ts +3 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +8 -4
- package/dist/core/workflow/types.d.ts +10 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/attestation-error.d.ts +7 -0
- package/dist/core/workflow/workspaces/attestation-error.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/attestation-error.js +25 -0
- package/dist/core/workflow/workspaces/cancellation.d.ts +8 -0
- package/dist/core/workflow/workspaces/cancellation.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/cancellation.js +95 -0
- package/dist/core/workflow/workspaces/cleanup.d.ts +9 -0
- package/dist/core/workflow/workspaces/cleanup.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/cleanup.js +223 -0
- package/dist/core/workflow/workspaces/execution.d.ts +25 -0
- package/dist/core/workflow/workspaces/execution.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/execution.js +316 -0
- package/dist/core/workflow/workspaces/git.d.ts +25 -0
- package/dist/core/workflow/workspaces/git.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/git.js +102 -0
- package/dist/core/workflow/workspaces/index.d.ts +15 -0
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/index.js +7 -0
- package/dist/core/workflow/workspaces/inspect.d.ts +85 -0
- package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/inspect.js +255 -0
- package/dist/core/workflow/workspaces/integration.d.ts +32 -0
- package/dist/core/workflow/workspaces/integration.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/integration.js +631 -0
- package/dist/core/workflow/workspaces/leases.d.ts +37 -0
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/leases.js +397 -0
- package/dist/core/workflow/workspaces/lifecycle.d.ts +43 -0
- package/dist/core/workflow/workspaces/lifecycle.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/lifecycle.js +622 -0
- package/dist/core/workflow/workspaces/local-git.d.ts +8 -0
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/local-git.js +1474 -0
- package/dist/core/workflow/workspaces/paths.d.ts +23 -0
- package/dist/core/workflow/workspaces/paths.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/paths.js +191 -0
- package/dist/core/workflow/workspaces/recovery.d.ts +3 -0
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/recovery.js +50 -0
- package/dist/core/workflow/workspaces/state.d.ts +60 -0
- package/dist/core/workflow/workspaces/state.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/state.js +296 -0
- package/dist/core/workflow/workspaces/types.d.ts +445 -0
- package/dist/core/workflow/workspaces/types.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/types.js +2 -0
- package/dist/core/workflow/workspaces/validate.d.ts +14 -0
- package/dist/core/workflow/workspaces/validate.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/validate.js +476 -0
- package/package.json +5 -3
- package/src/commands/workflow.ts +366 -8
- package/src/core/work/state.ts +156 -28
- package/src/core/workflow/child-env.ts +18 -7
- package/src/core/workflow/durable-record.ts +94 -0
- package/src/core/workflow/engine.ts +230 -30
- package/src/core/workflow/index.ts +74 -0
- package/src/core/workflow/proof.ts +70 -22
- package/src/core/workflow/run-state.ts +46 -11
- package/src/core/workflow/spawn-cursor.ts +8 -4
- package/src/core/workflow/types.ts +14 -0
- package/src/core/workflow/workspaces/attestation-error.ts +30 -0
- package/src/core/workflow/workspaces/cancellation.ts +137 -0
- package/src/core/workflow/workspaces/cleanup.ts +308 -0
- package/src/core/workflow/workspaces/execution.ts +412 -0
- package/src/core/workflow/workspaces/git.ts +125 -0
- package/src/core/workflow/workspaces/index.ts +89 -0
- package/src/core/workflow/workspaces/inspect.ts +421 -0
- package/src/core/workflow/workspaces/integration.ts +845 -0
- package/src/core/workflow/workspaces/leases.ts +517 -0
- package/src/core/workflow/workspaces/lifecycle.ts +792 -0
- package/src/core/workflow/workspaces/local-git.ts +1892 -0
- package/src/core/workflow/workspaces/paths.ts +247 -0
- package/src/core/workflow/workspaces/recovery.ts +56 -0
- package/src/core/workflow/workspaces/state.ts +481 -0
- package/src/core/workflow/workspaces/types.ts +517 -0
- package/src/core/workflow/workspaces/validate.ts +595 -0
package/src/core/work/state.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
appendFileSync,
|
|
4
4
|
chmodSync,
|
|
@@ -15,8 +15,10 @@ import {
|
|
|
15
15
|
import { hostname } from "node:os";
|
|
16
16
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
17
17
|
import { readWorkflowApproval } from "../workflow/approvals.ts";
|
|
18
|
+
import { stableDigest } from "../workflow/durable-record.ts";
|
|
18
19
|
import { readWorkflowProof } from "../workflow/proof.ts";
|
|
19
20
|
import { readWorkflowRunManifest, workflowScriptDigest } from "../workflow/run-state.ts";
|
|
21
|
+
import { isWorkspaceBoundExecutionEvidence } from "../workflow/workspaces/validate.ts";
|
|
20
22
|
|
|
21
23
|
export const WORK_INTENT_SCHEMA_VERSION = 1 as const;
|
|
22
24
|
export const WORK_EVENT_SCHEMA_VERSION = 1 as const;
|
|
@@ -92,6 +94,10 @@ export interface WorkEvent {
|
|
|
92
94
|
run_id?: string;
|
|
93
95
|
attempt?: number;
|
|
94
96
|
trigger?: WorkAttemptTrigger;
|
|
97
|
+
workspace_binding_id?: string;
|
|
98
|
+
proof_sha256?: string;
|
|
99
|
+
terminal_attestation_sha256?: string;
|
|
100
|
+
accepted_unknowns?: string[];
|
|
95
101
|
state?: WorkState;
|
|
96
102
|
next_action?: WorkNextAction;
|
|
97
103
|
}
|
|
@@ -264,7 +270,7 @@ export function reconcileAllWorkItems(coordRoot: string, actor?: string): WorkRe
|
|
|
264
270
|
export function acceptWorkItem(
|
|
265
271
|
coordRoot: string,
|
|
266
272
|
workId: string,
|
|
267
|
-
input: { actor?: string; reason?: string } = {},
|
|
273
|
+
input: { actor?: string; reason?: string; acceptedUnknowns?: readonly string[] } = {},
|
|
268
274
|
): WorkRecord {
|
|
269
275
|
return appendGovernanceEvent(coordRoot, workId, "work.accepted", ["in_review"], input);
|
|
270
276
|
}
|
|
@@ -278,7 +284,7 @@ export function cancelWorkItem(
|
|
|
278
284
|
coordRoot,
|
|
279
285
|
workId,
|
|
280
286
|
"work.cancelled",
|
|
281
|
-
["waiting", "ready", "blocked", "in_review"],
|
|
287
|
+
["waiting", "ready", "running", "awaiting_approval", "blocked", "in_review"],
|
|
282
288
|
input,
|
|
283
289
|
);
|
|
284
290
|
}
|
|
@@ -531,48 +537,109 @@ function appendGovernanceEvent(
|
|
|
531
537
|
workId: string,
|
|
532
538
|
event: "work.accepted" | "work.cancelled" | "work.reopened",
|
|
533
539
|
allowed: WorkState[],
|
|
534
|
-
input: { actor?: string; reason?: string },
|
|
540
|
+
input: { actor?: string; reason?: string; acceptedUnknowns?: readonly string[] },
|
|
535
541
|
): WorkRecord {
|
|
536
|
-
const
|
|
542
|
+
const releaseWork = event === "work.cancelled" ? undefined : acquireWorkLease(coordRoot, workId);
|
|
543
|
+
const releaseEvent = acquireWorkEventLease(coordRoot, workId);
|
|
537
544
|
try {
|
|
538
|
-
const current =
|
|
545
|
+
const current = releaseWork
|
|
546
|
+
? readWorkItemIgnoringLease(coordRoot, workId)
|
|
547
|
+
: readWorkItem(coordRoot, workId);
|
|
539
548
|
if (!allowed.includes(current.projection.state)) {
|
|
540
549
|
throw new Error(
|
|
541
550
|
`work item ${workId} cannot ${event.replace("work.", "")} from state ${current.projection.state}`,
|
|
542
551
|
);
|
|
543
552
|
}
|
|
544
|
-
|
|
553
|
+
const acceptance =
|
|
554
|
+
event === "work.accepted"
|
|
555
|
+
? acceptanceAuthority(coordRoot, current, input.acceptedUnknowns ?? [])
|
|
556
|
+
: undefined;
|
|
557
|
+
appendWorkEventUnderLease(coordRoot, workId, {
|
|
545
558
|
event,
|
|
546
559
|
actor: boundedActor(input.actor),
|
|
547
560
|
reason: boundedOptional(input.reason, "work reason", MAX_REASON),
|
|
561
|
+
...acceptance,
|
|
548
562
|
});
|
|
549
563
|
} finally {
|
|
550
|
-
|
|
564
|
+
releaseEvent();
|
|
565
|
+
releaseWork?.();
|
|
551
566
|
}
|
|
552
567
|
return reconcileWorkItem(coordRoot, workId, input.actor);
|
|
553
568
|
}
|
|
554
569
|
|
|
570
|
+
function acceptanceAuthority(
|
|
571
|
+
coordRoot: string,
|
|
572
|
+
current: WorkRecord,
|
|
573
|
+
acceptedUnknowns: readonly string[],
|
|
574
|
+
): Pick<
|
|
575
|
+
WorkEvent,
|
|
576
|
+
| "run_id"
|
|
577
|
+
| "attempt"
|
|
578
|
+
| "proof_sha256"
|
|
579
|
+
| "workspace_binding_id"
|
|
580
|
+
| "terminal_attestation_sha256"
|
|
581
|
+
| "accepted_unknowns"
|
|
582
|
+
> {
|
|
583
|
+
const latest = current.projection.attempts.at(-1);
|
|
584
|
+
if (!latest?.proof_path) throw new Error("work acceptance requires terminal workflow proof");
|
|
585
|
+
const manifest = readWorkflowRunManifest(coordRoot, latest.run_id);
|
|
586
|
+
const proof = readWorkflowProof(coordRoot, latest.run_id);
|
|
587
|
+
if (
|
|
588
|
+
proof.run.work_item_id !== current.intent.id ||
|
|
589
|
+
proof.run.attempt_context?.number !== latest.number
|
|
590
|
+
) {
|
|
591
|
+
throw new Error("work acceptance proof does not match the durable attempt");
|
|
592
|
+
}
|
|
593
|
+
const accepted = [...new Set(acceptedUnknowns)].sort();
|
|
594
|
+
for (const code of accepted) {
|
|
595
|
+
boundedString(code, "accepted unknown code", 200);
|
|
596
|
+
}
|
|
597
|
+
const execution = proof.execution;
|
|
598
|
+
return {
|
|
599
|
+
run_id: latest.run_id,
|
|
600
|
+
attempt: latest.number,
|
|
601
|
+
proof_sha256: createHash("sha256").update(readFileSync(latest.proof_path)).digest("hex"),
|
|
602
|
+
workspace_binding_id: manifest.execution.workspace_binding?.binding_id,
|
|
603
|
+
terminal_attestation_sha256: isWorkspaceBoundExecutionEvidence(execution)
|
|
604
|
+
? stableDigest(execution.terminal_attestation)
|
|
605
|
+
: undefined,
|
|
606
|
+
accepted_unknowns: execution ? accepted : undefined,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
555
610
|
/** @internal Runner seam; not a separate persistence contract. */
|
|
556
611
|
export function appendReconcileIfChanged(
|
|
557
612
|
coordRoot: string,
|
|
558
613
|
record: WorkRecord,
|
|
559
614
|
actor: string,
|
|
560
615
|
): void {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
event
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
616
|
+
let release: (() => void) | undefined;
|
|
617
|
+
try {
|
|
618
|
+
release = acquireWorkEventLease(coordRoot, record.intent.id);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
if (workEventLeaseIsLive(coordRoot, record.intent.id)) return;
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
const current = readWorkItemIgnoringLease(coordRoot, record.intent.id);
|
|
625
|
+
const last = [...current.events].reverse().find((event) => event.event === "work.reconciled");
|
|
626
|
+
if (
|
|
627
|
+
last?.state === current.projection.state &&
|
|
628
|
+
last.reason === current.projection.reason &&
|
|
629
|
+
last.next_action === current.projection.next_action
|
|
630
|
+
) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
appendWorkEventUnderLease(coordRoot, current.intent.id, {
|
|
634
|
+
event: "work.reconciled",
|
|
635
|
+
actor,
|
|
636
|
+
reason: current.projection.reason,
|
|
637
|
+
state: current.projection.state,
|
|
638
|
+
next_action: current.projection.next_action,
|
|
639
|
+
});
|
|
640
|
+
} finally {
|
|
641
|
+
release();
|
|
642
|
+
}
|
|
576
643
|
}
|
|
577
644
|
|
|
578
645
|
/** @internal Runner seam; not a separate persistence contract. */
|
|
@@ -580,15 +647,28 @@ export function appendWorkEvent(
|
|
|
580
647
|
coordRoot: string,
|
|
581
648
|
workId: string,
|
|
582
649
|
input: Omit<WorkEvent, "schema_version" | "work_id" | "seq" | "ts">,
|
|
650
|
+
): WorkEvent {
|
|
651
|
+
const release = acquireWorkEventLease(coordRoot, workId);
|
|
652
|
+
try {
|
|
653
|
+
return appendWorkEventUnderLease(coordRoot, workId, input);
|
|
654
|
+
} finally {
|
|
655
|
+
release();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function appendWorkEventUnderLease(
|
|
660
|
+
coordRoot: string,
|
|
661
|
+
workId: string,
|
|
662
|
+
input: Omit<WorkEvent, "schema_version" | "work_id" | "seq" | "ts">,
|
|
583
663
|
): WorkEvent {
|
|
584
664
|
const events = readWorkEvents(coordRoot, workId);
|
|
585
665
|
if (events.length === 0 && input.event !== "work.created") {
|
|
586
|
-
|
|
666
|
+
appendWorkEventUnderLease(coordRoot, workId, {
|
|
587
667
|
event: "work.created",
|
|
588
668
|
actor: "recovery",
|
|
589
669
|
reason: "recovered creation receipt from immutable intent",
|
|
590
670
|
});
|
|
591
|
-
return
|
|
671
|
+
return appendWorkEventUnderLease(coordRoot, workId, input);
|
|
592
672
|
}
|
|
593
673
|
if (events.length >= MAX_EVENTS)
|
|
594
674
|
throw new Error(`work item ${workId} exceeds ${MAX_EVENTS} events`);
|
|
@@ -699,7 +779,11 @@ function validateWorkEvent(event: WorkEvent, workId: string, seq: number): void
|
|
|
699
779
|
}
|
|
700
780
|
boundedActor(event.actor);
|
|
701
781
|
boundedOptional(event.reason, "work reason", MAX_REASON);
|
|
702
|
-
if (
|
|
782
|
+
if (
|
|
783
|
+
event.event === "attempt.started" ||
|
|
784
|
+
event.event === "attempt.resumed" ||
|
|
785
|
+
(event.event === "work.accepted" && event.run_id !== undefined)
|
|
786
|
+
) {
|
|
703
787
|
if (
|
|
704
788
|
!event.run_id ||
|
|
705
789
|
!RUN_ID.test(event.run_id) ||
|
|
@@ -709,6 +793,26 @@ function validateWorkEvent(event: WorkEvent, workId: string, seq: number): void
|
|
|
709
793
|
throw new Error(`work item ${workId} event ${seq} has invalid attempt data`);
|
|
710
794
|
}
|
|
711
795
|
}
|
|
796
|
+
if (event.event === "work.accepted" && event.run_id !== undefined) {
|
|
797
|
+
if (event.workspace_binding_id !== undefined) {
|
|
798
|
+
boundedString(event.workspace_binding_id, "workspace binding id", 200);
|
|
799
|
+
}
|
|
800
|
+
if (!/^[a-f0-9]{64}$/.test(event.proof_sha256 ?? "")) {
|
|
801
|
+
throw new Error(`work item ${workId} event ${seq} has invalid proof digest`);
|
|
802
|
+
}
|
|
803
|
+
if (event.workspace_binding_id !== undefined) {
|
|
804
|
+
if (
|
|
805
|
+
!/^[a-f0-9]{64}$/.test(event.terminal_attestation_sha256 ?? "") ||
|
|
806
|
+
!Array.isArray(event.accepted_unknowns) ||
|
|
807
|
+
event.accepted_unknowns.some(
|
|
808
|
+
(code) => typeof code !== "string" || code.length === 0 || code.length > 200,
|
|
809
|
+
) ||
|
|
810
|
+
new Set(event.accepted_unknowns).size !== event.accepted_unknowns.length
|
|
811
|
+
) {
|
|
812
|
+
throw new Error(`work item ${workId} event ${seq} has invalid execution review evidence`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
712
816
|
if (
|
|
713
817
|
event.trigger !== undefined &&
|
|
714
818
|
(event.event !== "attempt.started" ||
|
|
@@ -802,8 +906,27 @@ function workDir(coordRoot: string, workId: string): string {
|
|
|
802
906
|
|
|
803
907
|
/** @internal Runner seam; not a separate persistence contract. */
|
|
804
908
|
export function acquireWorkLease(coordRoot: string, workId: string): () => void {
|
|
909
|
+
return acquireWorkFileLease(coordRoot, workId, "lease.json", "is already active");
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** @internal Work journal mutation seam. */
|
|
913
|
+
export function acquireWorkEventLease(coordRoot: string, workId: string): () => void {
|
|
914
|
+
return acquireWorkFileLease(
|
|
915
|
+
coordRoot,
|
|
916
|
+
workId,
|
|
917
|
+
"events.lease.json",
|
|
918
|
+
"event journal is already active",
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function acquireWorkFileLease(
|
|
923
|
+
coordRoot: string,
|
|
924
|
+
workId: string,
|
|
925
|
+
filename: string,
|
|
926
|
+
activeMessage: string,
|
|
927
|
+
): () => void {
|
|
805
928
|
readWorkIntent(coordRoot, workId);
|
|
806
|
-
const path = join(workDir(coordRoot, workId),
|
|
929
|
+
const path = join(workDir(coordRoot, workId), filename);
|
|
807
930
|
const owner = {
|
|
808
931
|
pid: process.pid,
|
|
809
932
|
host: hostname(),
|
|
@@ -829,7 +952,7 @@ export function acquireWorkLease(coordRoot: string, workId: string): () => void
|
|
|
829
952
|
const existing = readLease(path);
|
|
830
953
|
if (existing && leaseIsLive(existing)) {
|
|
831
954
|
throw new Error(
|
|
832
|
-
`work item ${workId}
|
|
955
|
+
`work item ${workId} ${activeMessage} under pid ${existing.pid} on ${existing.host}`,
|
|
833
956
|
);
|
|
834
957
|
}
|
|
835
958
|
unlinkSync(path);
|
|
@@ -850,6 +973,11 @@ function workLeaseIsLive(coordRoot: string, workId: string): boolean {
|
|
|
850
973
|
return lease ? leaseIsLive(lease) : false;
|
|
851
974
|
}
|
|
852
975
|
|
|
976
|
+
function workEventLeaseIsLive(coordRoot: string, workId: string): boolean {
|
|
977
|
+
const lease = readLease(join(workDir(coordRoot, workId), "events.lease.json"));
|
|
978
|
+
return lease ? leaseIsLive(lease) : false;
|
|
979
|
+
}
|
|
980
|
+
|
|
853
981
|
function workflowResumeLeaseIsLive(coordRoot: string, runId: string): boolean {
|
|
854
982
|
const lease = readLease(join(coordRoot, ".harnery", "workflows", runId, "resume.lock"));
|
|
855
983
|
return lease ? leaseIsLive(lease) : false;
|
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
* harness CLI under a live session inherits vars that make it exit 1 with
|
|
7
7
|
* empty output; an empty-string var still reads as set. Scrub all three
|
|
8
8
|
* families (CLAUDE*, CODEX*, CURSOR*) regardless of which adapter spawns —
|
|
9
|
-
* a codex child launched from inside a Claude Code session must not
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* a codex child launched from inside a Claude Code session must not inherit
|
|
10
|
+
* CLAUDE* either. Preserve explicit auth/config locators such as CODEX_HOME:
|
|
11
|
+
* changing the home silently changes which subscription account the child
|
|
12
|
+
* uses.
|
|
13
|
+
* 2. **The scrub targets SESSION vars, not credentials or auth locators.** CURSOR_API_KEY
|
|
12
14
|
* matches the CURSOR* prefix but is an auth credential, so it is carved
|
|
13
|
-
* out and re-added — a key-only cursor host must keep working.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* out and re-added — a key-only cursor host must keep working. CODEX_HOME
|
|
16
|
+
* is likewise preserved because codex reads its ChatGPT subscription login
|
|
17
|
+
* from `$CODEX_HOME/auth.json`. (The other two key vars,
|
|
18
|
+
* ANTHROPIC_API_KEY and OPENAI_API_KEY, don't collide with the scrub
|
|
19
|
+
* prefixes.)
|
|
16
20
|
* 3. **subscriptionOnly deletes every API-key var** (see billing.ts) so the
|
|
17
21
|
* child can only authenticate via its stored subscription login — the
|
|
18
22
|
* guarantee behind `workflow run --subscription-only`.
|
|
@@ -26,6 +30,7 @@
|
|
|
26
30
|
import { API_KEY_VARS } from "./billing.ts";
|
|
27
31
|
|
|
28
32
|
const SCRUB_PREFIXES = ["CLAUDE", "CODEX", "CURSOR"];
|
|
33
|
+
const PRESERVED_HARNESS_VARS = new Set(["CODEX_HOME"]);
|
|
29
34
|
|
|
30
35
|
export interface ChildEnvOpts {
|
|
31
36
|
/** Delete all API-key vars so children can only use stored logins. */
|
|
@@ -38,7 +43,13 @@ export function buildChildEnv(runId?: string, opts: ChildEnvOpts = {}): Record<s
|
|
|
38
43
|
for (const [k, v] of Object.entries(process.env)) {
|
|
39
44
|
if (v === undefined) continue;
|
|
40
45
|
if (opts.subscriptionOnly && keyVars.has(k)) continue;
|
|
41
|
-
if (
|
|
46
|
+
if (
|
|
47
|
+
SCRUB_PREFIXES.some((p) => k.startsWith(p)) &&
|
|
48
|
+
!keyVars.has(k) &&
|
|
49
|
+
!PRESERVED_HARNESS_VARS.has(k)
|
|
50
|
+
) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
42
53
|
env[k] = v;
|
|
43
54
|
}
|
|
44
55
|
env.HARNERY_WORKFLOW_CHILD = "1";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
linkSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
statSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
16
|
+
|
|
17
|
+
const JSON_LIMIT = 512 * 1024;
|
|
18
|
+
|
|
19
|
+
export function stableDigest(value: unknown): string {
|
|
20
|
+
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function canonicalJson(value: unknown): string {
|
|
24
|
+
return JSON.stringify(canonicalize(value));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fileSha256(path: string): string {
|
|
28
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function writeImmutableJson(path: string, value: unknown): boolean {
|
|
32
|
+
const body = `${JSON.stringify(value, null, 2)}\n`;
|
|
33
|
+
if (Buffer.byteLength(body) > JSON_LIMIT) {
|
|
34
|
+
throw new Error(`immutable record exceeds ${JSON_LIMIT} bytes`);
|
|
35
|
+
}
|
|
36
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
37
|
+
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
38
|
+
let fd: number | undefined;
|
|
39
|
+
try {
|
|
40
|
+
fd = openSync(temporary, "wx", 0o600);
|
|
41
|
+
writeFileSync(fd, body, "utf8");
|
|
42
|
+
fsyncSync(fd);
|
|
43
|
+
closeSync(fd);
|
|
44
|
+
fd = undefined;
|
|
45
|
+
try {
|
|
46
|
+
linkSync(temporary, path);
|
|
47
|
+
chmodSync(path, 0o600);
|
|
48
|
+
fsyncParentDirectory(path);
|
|
49
|
+
return true;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
52
|
+
const existing = readFileSync(path, "utf8");
|
|
53
|
+
if (existing !== body) throw new Error(`immutable record already exists at ${path}`);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
if (fd !== undefined) closeSync(fd);
|
|
58
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function fsyncParentDirectory(path: string): void {
|
|
63
|
+
const directory = dirname(path);
|
|
64
|
+
const fd = openSync(directory, "r");
|
|
65
|
+
try {
|
|
66
|
+
fsyncSync(fd);
|
|
67
|
+
} finally {
|
|
68
|
+
closeSync(fd);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readJsonRecord<T>(path: string, label: string): T {
|
|
73
|
+
if (!existsSync(path)) throw new Error(`${label} does not exist at ${path}`);
|
|
74
|
+
const size = statSync(path).size;
|
|
75
|
+
if (size <= 0 || size > JSON_LIMIT) throw new Error(`${label} has invalid size ${size}`);
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`cannot parse ${label} at ${path}: ${(error as Error).message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function canonicalize(value: unknown): unknown {
|
|
84
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
85
|
+
if (value && typeof value === "object") {
|
|
86
|
+
return Object.fromEntries(
|
|
87
|
+
Object.entries(value as Record<string, unknown>)
|
|
88
|
+
.filter(([, item]) => item !== undefined)
|
|
89
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
90
|
+
.map(([key, item]) => [key, canonicalize(item)]),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|