@agent-delivery-harness/cli 0.8.2 → 0.8.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-delivery-harness/cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "type": "module",
5
5
  "description": "Delivery harness CLI: admit, prepare, review-context, submit-evidence, gate, record, verify, check",
6
6
  "license": "FSL-1.1-ALv2",
@@ -13,7 +13,7 @@
13
13
  "node": ">=22.6.0"
14
14
  },
15
15
  "dependencies": {
16
- "@agent-delivery-harness/kernel": "0.8.2"
16
+ "@agent-delivery-harness/kernel": "0.8.3"
17
17
  },
18
18
  "exports": {
19
19
  ".": "./src/index.ts"
package/src/boundary.ts CHANGED
@@ -244,6 +244,7 @@ export interface CliRuntime {
244
244
  readonly command: readonly [string, ...string[]];
245
245
  readonly cwd: string;
246
246
  readonly env: EnvSnapshot;
247
+ readonly inheritDescriptors?: readonly (3 | 4)[];
247
248
  }) => Promise<ProviderRailSession>;
248
249
  }
249
250
 
@@ -533,6 +534,8 @@ async function runConfiguredCommand(
533
534
  const provider = config.providers.find((registration) => registration.id === providerId);
534
535
  if (provider?.command === undefined) return undefined;
535
536
  const command = provider.command;
537
+ // Only this provider's own declaration reaches its process.
538
+ const inherited = provider.inheritDescriptors;
536
539
  const requestId = randomUUID();
537
540
  const allocation = await artifacts.allocateRunRoot({ providerId, runId: requestId });
538
541
  if (!allocation.ok) {
@@ -566,10 +569,10 @@ async function runConfiguredCommand(
566
569
  requiresEvidence,
567
570
  },
568
571
  {
569
- open: () =>
570
- runtime.openProviderRail === undefined
571
- ? openProviderRailProcess({ command, cwd: runtime.cwd, env: { ...runtime.env } })
572
- : runtime.openProviderRail({ providerId, command, cwd: runtime.cwd, env: runtime.env }),
572
+ // One call, so the test seam is given exactly what the real launch is.
573
+ open: () => (runtime.openProviderRail ?? openProviderRailProcess)({
574
+ providerId, command, cwd: runtime.cwd, env: { ...runtime.env }, ...(inherited ? { inheritDescriptors: inherited } : {}),
575
+ }),
573
576
  publishManifest: (manifestPath) =>
574
577
  submitManifest(
575
578
  { rootDir: runtime.cwd, config, manifestPath },
@@ -1,6 +1,7 @@
1
1
  /** Private execution tree. Never shares writable source, objects, index or dependencies. */
2
2
  import { spawn } from "node:child_process";
3
- import { chmod, lstat, mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { chmod, lstat, mkdir, mkdtemp, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
5
6
  import path from "node:path";
6
7
  import { candidateTreeSourceReader, capturePhysicalSource, createExecPort, isPhysicalSourceDefinition, physicalSourceOutputOverlap, type CandidateCommandRunner, type PhysicalSourceDefinition, type ExecOutcome } from "@agent-delivery-harness/kernel";
@@ -60,8 +61,80 @@ function phaseMeter(onPhase?: (phase: SnapshotPhase, durationMs: number) => void
60
61
  try { return await operation(); } finally { try { onPhase?.(phase, performance.now() - started); } catch { /* Timing cannot change admission. */ } }
61
62
  };
62
63
  }
64
+ /** Where removed trees wait to be deleted: beside them in the temporary directory, so moving one is a rename on
65
+ * the same volume. Nothing here is evidence, and nothing reads it. */
66
+ const trash = () => path.join(tmpdir(), `delivery-harness-trash-${process.getuid?.() ?? "user"}`);
67
+ /**
68
+ * The trash, only when it is this user's own private directory. A temporary directory may be shared, and the
69
+ * sweep deletes what it lists, so a link or another user's directory at that name is never followed: the
70
+ * caller removes in place instead.
71
+ */
72
+ async function ownTrash(create: boolean): Promise<string | undefined> {
73
+ const dir = trash();
74
+ try {
75
+ if (create) await mkdir(dir, { mode: 0o700 }).catch(error => { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; });
76
+ const found = await lstat(dir);
77
+ // A link's own mode usually fails the mode test as well; isDirectory() still refuses one made private.
78
+ return found.isDirectory() && found.uid === process.getuid?.() && (found.mode & 0o077) === 0 ? dir : undefined;
79
+ } catch { return undefined; }
80
+ }
81
+ /** Deletes `paths` in a process this one does not wait for or outlive the need of. Best effort: whatever a stopped
82
+ * deletion leaves in the trash, the next session's sweep removes. Batched so no argument list outgrows the host's. */
83
+ function deleteDetached(paths: readonly string[]): void {
84
+ for (let at = 0; at < paths.length; at += 256) {
85
+ try {
86
+ const child = spawn("/bin/rm", ["-rf", "--", ...paths.slice(at, at + 256)], { detached: true, stdio: "ignore" });
87
+ child.on("error", () => { /* Left for the next sweep. */ });
88
+ child.unref();
89
+ } catch { /* Left for the next sweep. */ }
90
+ }
91
+ }
92
+ /** Whether the process that moved a tree aside may still be deleting it. A reused pid only delays a sweep. */
93
+ function ownerAlive(pid: number): boolean {
94
+ try { process.kill(pid, 0); return true; }
95
+ catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
96
+ }
97
+ /** Removes, in the background, what sessions that have ended left in the trash. An entry is named for the process
98
+ * that moved it there; one whose owner is still alive, this process included, is left to that owner's deletion. */
99
+ export async function sweepRemovedTrees(): Promise<void> {
100
+ if (process.platform === "win32") return;
101
+ const dir = await ownTrash(false);
102
+ if (!dir) return;
103
+ const entries = await readdir(dir).catch(() => [] as string[]);
104
+ deleteDetached(entries.filter(entry => {
105
+ const pid = Number(/^(\d+)-/.exec(entry)?.[1]);
106
+ return !Number.isSafeInteger(pid) || pid <= 0 || !ownerAlive(pid);
107
+ }).map(entry => path.join(dir, entry)));
108
+ }
109
+ /**
110
+ * Takes each owned tree out of its path at once, by renaming it into the trash, and deletes it in a detached
111
+ * process: a tree with installed dependencies can take ten seconds or more to remove, and the evidence it
112
+ * produced is already retained. A tree that cannot be moved is removed in place, as before, and only a tree
113
+ * neither moved nor removed fails the cleanup.
114
+ */
63
115
  async function removeTrees(directories: readonly string[]): Promise<void> {
64
- const removed = await Promise.allSettled(directories.map(dir => rm(dir, { recursive: true, force: true })));
116
+ const moved: string[] = [];
117
+ const into = process.platform === "win32" ? undefined : await ownTrash(true);
118
+ const removed = await Promise.allSettled(directories.map(async dir => {
119
+ if (into) {
120
+ try {
121
+ // A rename never copies; on another volume the tree is removed in place instead. The device check only
122
+ // spares a doomed rename: one across volumes fails with EXDEV and falls through to the same removal.
123
+ const [from, to] = await Promise.all([stat(path.dirname(dir)), stat(into)]);
124
+ if (from.dev === to.dev) {
125
+ const target = path.join(into, `${process.pid}-${randomUUID()}`);
126
+ await rename(dir, target);
127
+ moved.push(target);
128
+ return;
129
+ }
130
+ } catch (error) {
131
+ // Done only when the tree itself is gone, not when the trash went missing under the move.
132
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" && await lstat(dir).then(() => false, gone => (gone as NodeJS.ErrnoException).code === "ENOENT")) return;
133
+ }
134
+ }
135
+ await rm(dir, { recursive: true, force: true });
136
+ }));
137
+ deleteDetached(moved);
65
138
  if (removed.some(result => result.status === "rejected")) throw new CheckSnapshotError("check_snapshot_cleanup_failed", "Cannot remove the owned execution snapshot after retaining evidence.");
66
139
  }
67
140
  /** One copy-on-write clone of a directory tree into a path that must not exist yet. On macOS
@@ -6,11 +6,13 @@
6
6
  * delivery record and run no obligation, and that outcome is the whole record.
7
7
  * It journals no completion: `gate ok` in a run would read as a gate that passed.
8
8
  * Under `gate` the command carries on, with output as it would be had nothing
9
- * been declared. The one exception is a malformed declaration: a repository said
10
- * something and it didn't take, so its code and reason go to stderr, leaving
11
- * what a command writes to stdout as it is.
9
+ * been declared. There are two exceptions, each of which writes its code and
10
+ * reason to stderr, leaving what a command writes to stdout as it is: a malformed
11
+ * declaration, where a repository said something and it didn't take, and a
12
+ * command run from a directory that is not its repository's root, which is the
13
+ * caller's to fix rather than the base's.
12
14
  */
13
- import { resolveDeliveryPosture } from "@agent-delivery-harness/kernel";
15
+ import { deliveryPostureRefusedOutsideRoot, resolveDeliveryPosture } from "@agent-delivery-harness/kernel";
14
16
  import { CliInterruption, type CommandContext, type CommandResult } from "./boundary.ts";
15
17
  import { oneLine } from "./run-surface.ts";
16
18
 
@@ -34,6 +36,6 @@ export async function focusedOutcome(context: CommandContext): Promise<Extract<C
34
36
  ].join("\n"),
35
37
  };
36
38
  }
37
- if (resolved.code === "posture_malformed") context.writeError(`${resolved.code}: ${oneLine(resolved.reason, 512)}`);
39
+ if (resolved.code === "posture_malformed" || deliveryPostureRefusedOutsideRoot(resolved)) context.writeError(`${resolved.code}: ${oneLine(resolved.reason, 512)}`);
38
40
  return undefined;
39
41
  }
@@ -0,0 +1,50 @@
1
+ // V26-3314: run by live-providers.test.ts with a marker pipe on fd 3. Collects
2
+ // live results once with a provider that declares fd 3 and once with one that
3
+ // does not, and prints what each provider held on fd 3 and what its
4
+ // environment said it inherited.
5
+ import { execFile } from "node:child_process";
6
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import path from "node:path";
9
+ import { promisify } from "node:util";
10
+ import adopter from "../../../../harness.config.ts";
11
+ import { captureGitCandidate, channelIdentity, collectLiveProviderResults, computePreparationFingerprint, defineHarnessConfig, evaluateCandidateActivation, INHERITED_DESCRIPTORS_ENV, withDeliverableIdentity } from "@agent-delivery-harness/kernel";
12
+
13
+ const exec = promisify(execFile);
14
+ const provider = `const fs=require('fs');let held=null;try{held=fs.readFileSync(3,'utf8')}catch{}
15
+ fs.writeFileSync('.git/inherited',JSON.stringify({held,stated:process.env.${INHERITED_DESCRIPTORS_ENV}??null}));
16
+ require('readline').createInterface({input:process.stdin}).on('line',line=>{const m=JSON.parse(line);
17
+ if(m.kind==='negotiate')console.log(JSON.stringify({kind:'negotiation',outcome:'supported',selectedVersion:'delivery-provider-rails/1',supportedVersions:['delivery-provider-rails/1']}));
18
+ if(m.kind==='request')console.log(JSON.stringify({kind:'terminal',version:m.version,requestId:m.requestId,sequence:1,summary:'observed',outcome:'success'}));});`;
19
+
20
+ async function collect(env: NodeJS.ProcessEnv): Promise<unknown[]> {
21
+ const reports: unknown[] = [];
22
+ for (const declared of [false, true]) {
23
+ const dir = await mkdtemp(path.join(tmpdir(), "live-descriptors-"));
24
+ try {
25
+ const git = (...args: string[]) => exec("git", args, { cwd: dir });
26
+ await git("init", "-q"); await git("config", "user.name", "Test"); await git("config", "user.email", "test@example.invalid");
27
+ await writeFile(path.join(dir, "source.ts"), "export const a=1;"); await writeFile(path.join(dir, "harness.config.ts"), "export default {};");
28
+ await git("add", "."); await git("-c", "commit.gpgsign=false", "commit", "-qm", "base"); await git("branch", "origin/main");
29
+ const config = defineHarnessConfig({ ...adopter, preparationCommands: [], preparationWiringPaths: ["harness.config.ts"],
30
+ providers: [{ id: "live.sensor", findingCodes: [], command: [process.execPath, "-e", provider], ...(declared ? { inheritDescriptors: [3] as const } : {}) }],
31
+ obligations: [{ ...adopter.obligations[0]!, id: "live.current", freshness: "live", providers: ["live.sensor"], activation: { kind: "always" }, humanWaiverAllowed: false, allowedResolutionKinds: ["satisfied_live_fact", "not_applicable"] }] });
32
+ const capture = await captureGitCandidate({ rootDir: dir, config, workspaceId: "test-workspace", computeIdentity: withDeliverableIdentity() });
33
+ if (!capture.ok) throw new Error(JSON.stringify(capture));
34
+ const result = await collectLiveProviderResults({ rootDir: dir, config, candidate: capture.candidate,
35
+ projection: await evaluateCandidateActivation({ rootDir: dir, config, candidate: capture.candidate }),
36
+ evidenceContext: { preparationFingerprint: await computePreparationFingerprint(dir, config), release: null },
37
+ env: { ...env, [INHERITED_DESCRIPTORS_ENV]: `3:${channelIdentity(3)}` } });
38
+ reports.push({ status: result.liveResults[0]?.status ?? null, ...JSON.parse(await readFile(path.join(dir, ".git/inherited"), "utf8")) });
39
+ } finally {
40
+ await rm(dir, { recursive: true, force: true });
41
+ }
42
+ }
43
+ return reports;
44
+ }
45
+
46
+ async function main(): Promise<void> {
47
+ console.log(JSON.stringify(await collect(process.env)));
48
+ }
49
+
50
+ await main();
@@ -7,13 +7,28 @@ import { deliveryRecordPathFor, recordOutputProvider, computeDeliverableIdentity
7
7
  type CandidateBinding, type CapturedCandidate, type ProviderRegistration, type ScopedCheckPlan, type ScopedExecutionProfile, type ScopedRuntimeObservation, type RecordCandidateBinding } from "@agent-delivery-harness/kernel";
8
8
  import type { CommandContext } from "./boundary.ts";
9
9
  import { AttemptStore, type AttemptPayload, type StoredAttempt } from "./scoped-attempts.ts";
10
- import { CheckSnapshotError, createCheckSnapshot, executionPath, snapshotCloneMethod, type CheckSnapshot, type CloneMethod, type SnapshotRequest } from "./check-snapshot.ts";
10
+ import { CheckSnapshotError, createCheckSnapshot, executionPath, snapshotCloneMethod, sweepRemovedTrees, type CheckSnapshot, type CloneMethod, type SnapshotRequest } from "./check-snapshot.ts";
11
11
  import { captureInstalledDataBinding, type InstalledDataSourceTree } from "./scoped-installed-data.ts";
12
12
  import { captureOwnedInstalledDataResources } from "./installed-data-resources.ts";
13
13
  import { redactScopedStreams, scopedCommandDiagnostic, scopedDependencyDiagnostic, scopedDiagnosticFailure, type RecordedScopedAttemptDiagnostic, type ScopedSetupDiagnostic } from "./scoped-diagnostics.ts";
14
14
  export function scopedCandidate(candidate: CandidateBinding): RecordCandidateBinding {
15
15
  return { treeSha: candidate.treeSha, deliverableDigest: candidate.deliverable.digest, identityToken: candidate.deliverable.identity, baseRef: candidate.base.ref, baseTipSha: candidate.base.tipSha, mergeBaseSha: candidate.base.mergeBaseSha, workspaceId: candidate.workspaceId };
16
16
  }
17
+ /** A host tool's sha256 within this process, by the file's identity and last change: every scoped check binds
18
+ * the same node, package manager and setup tool, and each is often 50–120 MB. A changed file has a new key. */
19
+ const hostToolDigests = new Map<string, Promise<string>>();
20
+ export async function hostToolDigest(file: string, readBytes: (file: string) => Promise<Uint8Array> = readFile): Promise<{ sha256: string; mode: number }> {
21
+ const before = await stat(file);
22
+ const key = JSON.stringify([before.dev, before.ino, before.size, before.mtimeMs, before.ctimeMs]);
23
+ let digest = hostToolDigests.get(key);
24
+ if (!digest) {
25
+ // ctime moves on every write, so a file changed during this read can never present this key again.
26
+ digest = readBytes(file).then(bytes => sha256Hex(bytes));
27
+ hostToolDigests.set(key, digest);
28
+ digest.catch(() => { if (hostToolDigests.get(key) === digest) hostToolDigests.delete(key); });
29
+ }
30
+ return { sha256: await digest, mode: before.mode & 0o111 };
31
+ }
17
32
  async function executableIdentity(command: string, root: string, searchPath: string, cwd: string, read: Awaited<ReturnType<typeof candidateTreeEvidenceReader>>) {
18
33
  if (command.includes(path.sep) && !path.isAbsolute(command)) {
19
34
  const file = path.posix.normalize(path.posix.join(cwd, command));
@@ -27,7 +42,8 @@ async function executableIdentity(command: string, root: string, searchPath: str
27
42
  try { await access(file); } catch { continue; }
28
43
  const relative = path.relative(await realpath(root), await realpath(file));
29
44
  if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) throw new CheckSnapshotError("check_runtime_author_path", "Repository execution tools must use a relative command so they run from the private prepared tree.");
30
- return { command, sha256: sha256Hex(await readFile(file)), mode: (await stat(file)).mode & 0o111 };
45
+ const { sha256, mode } = await hostToolDigest(file);
46
+ return { command, sha256, mode };
31
47
  }
32
48
  throw new CheckSnapshotError("check_runtime_unavailable", "A declared execution tool cannot be resolved and bound.");
33
49
  }
@@ -167,6 +183,8 @@ export class ScopedChecks {
167
183
  static async create(context: CommandContext, candidate: CapturedCandidate): Promise<ScopedChecks | undefined> {
168
184
  const providers = scopedProviders(context);
169
185
  if (!providers.length) return undefined;
186
+ // Trees an earlier session moved aside but did not finish deleting: one listing, in the background.
187
+ void sweepRemovedTrees().catch(() => undefined);
170
188
  const session = new ScopedChecks(context, candidate);
171
189
  const storage = await resolveRecordStorage(context.rootDir, { storageNamespace: context.config.storageNamespace, leaf: "scoped-attempts" });
172
190
  const { inventory, read } = await preparedSource(context, candidate);