mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-10-02 00:27:21 +08:00
fix(daemon): keep binary files byte-identical in rig bundles (#257)
* fix(daemon): keep binary files byte-identical in rig bundles Bundle create read every vendored agent-package file, and the declared culture, docs and startup files, as UTF-8 text and wrote it back as UTF-8. Any byte sequence that is not valid UTF-8 became U+FFFD, and the integrity manifest was hashed from the changed copy, so inspect and install verified the corrupted bytes as intact. Copy those files as bytes: the pod assembler reads them with readFileBuffer and writes the same bytes, and the install-side skills and workflow-spec routers copy with fs.copyFileSync. Rewriting rig.yaml and agent.yaml import refs stays a text operation. Refs #245 * test(daemon): assert async ps samplers stay pending through a microtask drain The three anti-sync discriminators in list-processes-async.test.ts raced a 0ms timer against the sampler's result. Timer order is not a valid guarantee of async execution: in a local probe, a 70ms synchronous stall after spawning a single-PID `ps eww` let the result settle before the 0ms timer in most trials. The CI failure of this test is consistent with that race; the CI timing itself was not measured. Assert instead that the returned promise is still pending after draining 20 microtask ticks: an async execFile cannot settle without an event-loop turn, while a synchronous ps inside the call settles within a few ticks. The real rows and HOME assertions are unchanged. Refs #245 --------- Co-authored-by: OpenRig contributors <noreply@openrig.dev>
This commit is contained in:
co-authored by
OpenRig contributors
parent
0a22ea184c
commit
2174b11e5d
@@ -22,8 +22,8 @@ import nodePath from "node:path";
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface SkillsRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
readFile: (path: string) => string;
|
||||
writeFile: (path: string, content: string) => void;
|
||||
/** Byte-for-byte copy; routed files are never decoded. */
|
||||
copyFile: (src: string, dest: string) => void;
|
||||
mkdirp: (path: string) => void;
|
||||
}
|
||||
|
||||
@@ -125,8 +125,7 @@ export function routeSkills(input: RouteSkillsInput, fs: SkillsRouterFsOps): Rou
|
||||
continue;
|
||||
}
|
||||
fs.mkdirp(nodePath.dirname(targetAbs));
|
||||
const content = fs.readFile(sourceAbs);
|
||||
fs.writeFile(targetAbs, content);
|
||||
fs.copyFile(sourceAbs, targetAbs);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "routed",
|
||||
|
||||
@@ -31,8 +31,8 @@ import nodePath from "node:path";
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface WorkflowSpecsRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
readFile: (path: string) => string;
|
||||
writeFile: (path: string, content: string) => void;
|
||||
/** Byte-for-byte copy; routed files are never decoded. */
|
||||
copyFile: (src: string, dest: string) => void;
|
||||
mkdirp: (path: string) => void;
|
||||
}
|
||||
|
||||
@@ -195,8 +195,7 @@ export function routeWorkflowSpecs(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFile(sourceAbs);
|
||||
fs.writeFile(targetAbs, content);
|
||||
fs.copyFile(sourceAbs, targetAbs);
|
||||
routedBasenames.add(basename);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
|
||||
@@ -7,8 +7,10 @@ import { serializePodBundleManifest, type PodBundleManifest, type PodBundleAgent
|
||||
import type { RigSpec, StartupBlock } from "./types.js";
|
||||
|
||||
export interface PodAssemblerFsOps extends AgentResolverFsOps {
|
||||
/** Raw bytes, for files the bundle copies verbatim (agent packages, culture, docs, startup files). */
|
||||
readFileBuffer(path: string): Uint8Array;
|
||||
mkdirp(path: string): void;
|
||||
writeFile(path: string, content: string): void;
|
||||
writeFile(path: string, content: string | Uint8Array): void;
|
||||
copyDir(src: string, dest: string): void;
|
||||
listFiles(dirPath: string): string[];
|
||||
}
|
||||
@@ -214,7 +216,7 @@ export class PodBundleAssembler {
|
||||
throw new Error(`Path traversal detected: "${relPath}" escapes rig root`);
|
||||
}
|
||||
if (!this.fs.exists(absPath)) return; // optional files may not exist
|
||||
const content = this.fs.readFile(absPath);
|
||||
const content = this.fs.readFileBuffer(absPath);
|
||||
assertShippableSubstance([{ path: relPath, bytes: content }]);
|
||||
this.fs.mkdirp(nodePath.dirname(nodePath.join(outputDir, relPath)));
|
||||
this.fs.writeFile(nodePath.join(outputDir, relPath), content);
|
||||
@@ -232,7 +234,7 @@ export class PodBundleAssembler {
|
||||
const files = this.fs.listFiles(srcDir);
|
||||
const sources = files.map((file) => ({
|
||||
file,
|
||||
content: this.fs.readFile(nodePath.join(srcDir, file)),
|
||||
content: this.fs.readFileBuffer(nodePath.join(srcDir, file)),
|
||||
}));
|
||||
assertShippableSubstance(sources.map(({ file, content }) => ({
|
||||
path: nodePath.join(relPrefix, file),
|
||||
|
||||
@@ -346,6 +346,8 @@ function podAssemblerFsOps(): PodAssemblerFsOps {
|
||||
return {
|
||||
...assemblerFsOps(),
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
readFileBuffer: (p) => fs.readFileSync(p),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
listFiles: (dir) => realFsOps().listFiles!(dir),
|
||||
};
|
||||
@@ -365,8 +367,7 @@ function pluginsRouterFsOps(): PluginsRouterFsOps {
|
||||
function workflowSpecsRouterFsOps(): WorkflowSpecsRouterFsOps {
|
||||
return {
|
||||
exists: (p) => fs.existsSync(p),
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
|
||||
copyFile: (s, d) => fs.copyFileSync(s, d),
|
||||
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
|
||||
};
|
||||
}
|
||||
@@ -592,8 +593,7 @@ async function routePluginsAfterBootstrap(bundlePath: string): Promise<RoutePlug
|
||||
function skillsRouterFsOps(): SkillsRouterFsOps {
|
||||
return {
|
||||
exists: (p) => fs.existsSync(p),
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
|
||||
copyFile: (s, d) => fs.copyFileSync(s, d),
|
||||
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { createTestApp } from "./helpers/test-app.js";
|
||||
import { unpack } from "../src/domain/bundle-archive.js";
|
||||
import { materializePodBundle } from "../src/domain/bundle-source-resolver.js";
|
||||
|
||||
// A pod bundle copies agent packages and declared rig files verbatim. Every byte must survive
|
||||
// create -> archive -> install, whether or not the file is UTF-8 text.
|
||||
|
||||
const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
|
||||
|
||||
function pseudoRandom(n: number, seed: number): Buffer {
|
||||
const out = Buffer.alloc(n);
|
||||
let x = seed >>> 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
x = (Math.imul(x, 1664525) + 1013904223) >>> 0;
|
||||
out[i] = x >>> 24;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PAYLOADS: Record<string, Buffer> = {
|
||||
"agents/impl/assets/random.bin": pseudoRandom(4096, 7),
|
||||
"agents/impl/assets/logo.png": Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), Buffer.from(Array.from({ length: 128 }, (_, i) => 128 + i))]),
|
||||
"agents/impl/assets/nul.dat": Buffer.from([0x61, 0x00, 0x62, 0x00, 0x00, 0x63]),
|
||||
"agents/impl/assets/invalid-utf8.txt": Buffer.from([0x6f, 0x6b, 0x20, 0xc3, 0x28, 0x20, 0xa0, 0xa1, 0x20, 0xff]),
|
||||
"agents/impl/assets/empty.txt": Buffer.alloc(0),
|
||||
"agents/impl/skills/greet/SKILL.md": Buffer.from("Greet ü 漢 😀\r\nCRLF line\r\nno trailing newline", "utf8"),
|
||||
"startup/blob.bin": Buffer.from([0xfe, 0xed, 0xfa, 0xce, 0x80, 0x00, 0xc0]),
|
||||
};
|
||||
|
||||
const AGENT_YAML = [
|
||||
'name: impl',
|
||||
'version: "1.0.0"',
|
||||
"resources:",
|
||||
" skills:",
|
||||
" - id: greet",
|
||||
" path: skills/greet",
|
||||
"profiles:",
|
||||
" default:",
|
||||
" uses:",
|
||||
" skills: [greet]",
|
||||
].join("\n");
|
||||
|
||||
const RIG_YAML = [
|
||||
'version: "0.2"',
|
||||
"name: bytes-rig",
|
||||
"pods:",
|
||||
" - id: dev",
|
||||
" label: Dev",
|
||||
" members:",
|
||||
" - id: impl",
|
||||
' agent_ref: "local:agents/impl"',
|
||||
" profile: default",
|
||||
" runtime: claude-code",
|
||||
" cwd: .",
|
||||
" startup:",
|
||||
" files:",
|
||||
" - path: startup/blob.bin",
|
||||
" actions: []",
|
||||
" edges: []",
|
||||
"edges: []",
|
||||
].join("\n");
|
||||
|
||||
describe("pod bundle byte preservation (create -> archive -> install)", () => {
|
||||
let db: Database.Database;
|
||||
let app: ReturnType<typeof createTestApp>["app"];
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-bytes-"));
|
||||
app = createTestApp(db).app;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps binary, non-UTF-8, NUL, empty and multibyte files byte-identical, with truthful integrity", async () => {
|
||||
const src = path.join(tmpDir, "src");
|
||||
for (const [rel, bytes] of Object.entries(PAYLOADS)) {
|
||||
fs.mkdirSync(path.dirname(path.join(src, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(src, rel), bytes);
|
||||
}
|
||||
fs.writeFileSync(path.join(src, "agents/impl/agent.yaml"), AGENT_YAML);
|
||||
fs.writeFileSync(path.join(src, "rig.yaml"), RIG_YAML);
|
||||
const sourceBefore = Object.fromEntries(Object.keys(PAYLOADS).map((rel) => [rel, sha(fs.readFileSync(path.join(src, rel)))]));
|
||||
|
||||
const outputPath = path.join(tmpDir, "bytes.rigbundle");
|
||||
const created = await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath: path.join(src, "rig.yaml"), rigRoot: src, bundleName: "bytes", bundleVersion: "0.1.0", outputPath }),
|
||||
});
|
||||
expect(created.status, await created.clone().text()).toBe(201);
|
||||
expect((await created.json()).schemaVersion).toBe(2);
|
||||
|
||||
// Source assets stay untouched.
|
||||
for (const rel of Object.keys(PAYLOADS)) {
|
||||
expect(sha(fs.readFileSync(path.join(src, rel))), rel).toBe(sourceBefore[rel]);
|
||||
}
|
||||
|
||||
// Archive: every copied file equals its source, and the manifest hashes the source bytes.
|
||||
const extracted = path.join(tmpDir, "extracted");
|
||||
fs.mkdirSync(extracted);
|
||||
await unpack(outputPath, extracted);
|
||||
const manifest = fs.readFileSync(path.join(extracted, "bundle.yaml"), "utf8");
|
||||
for (const [rel, bytes] of Object.entries(PAYLOADS)) {
|
||||
const archived = fs.readFileSync(path.join(extracted, rel));
|
||||
expect(archived.equals(bytes), `${rel} in archive`).toBe(true);
|
||||
expect(manifest, `${rel} integrity hash`).toContain(`${rel}: ${sha(bytes)}`);
|
||||
}
|
||||
|
||||
const inspected = await app.request("/api/bundles/inspect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bundlePath: outputPath }),
|
||||
});
|
||||
const view = await inspected.json();
|
||||
expect(view.digestValid).toBe(true);
|
||||
expect(view.integrityResult.passed).toBe(true);
|
||||
|
||||
// Install: the materialized target holds the source bytes.
|
||||
const target = path.join(tmpDir, "target");
|
||||
fs.mkdirSync(target);
|
||||
expect(materializePodBundle(extracted, target)).toEqual({ ok: true });
|
||||
for (const [rel, bytes] of Object.entries(PAYLOADS)) {
|
||||
expect(fs.readFileSync(path.join(target, rel)).equals(bytes), `${rel} installed`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import nodePath from "node:path";
|
||||
import realFs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { routeSkills, type SkillsRouterFsOps, type RouteSkillsInput } from "../src/domain/bundle-skills-router.js";
|
||||
|
||||
// Item 6 / slice-05 Checkpoint 7.2: bundle-skills-router pure-function tests.
|
||||
@@ -11,12 +13,11 @@ function mockFs(initialFiles: Record<string, string> = {}): SkillsRouterFsOps &
|
||||
_written: written,
|
||||
_mkdirpCalls: mkdirpCalls,
|
||||
exists: (p: string) => written.has(p),
|
||||
readFile: (p: string) => {
|
||||
const v = written.get(p);
|
||||
if (v === undefined) throw new Error(`File not found in mock: ${p}`);
|
||||
return v;
|
||||
copyFile: (src: string, dest: string) => {
|
||||
const v = written.get(src);
|
||||
if (v === undefined) throw new Error(`File not found in mock: ${src}`);
|
||||
written.set(dest, v);
|
||||
},
|
||||
writeFile: (p: string, c: string) => { written.set(p, c); },
|
||||
mkdirp: (p: string) => { mkdirpCalls.push(p); },
|
||||
};
|
||||
}
|
||||
@@ -173,3 +174,25 @@ describe("routeSkills", () => {
|
||||
expect(fs._written.get(`${packageRoot}/test-pkg/skills/DUAL.md`)).toBe("dual");
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeSkills copies bytes through the real filesystem", () => {
|
||||
it("a non-UTF-8 file arrives byte-identical", () => {
|
||||
const root = realFs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-skills-router-bytes-"));
|
||||
try {
|
||||
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x80, 0x00, 0xc3, 0x28, 0xff, 0xfe]);
|
||||
const bundleRoot = nodePath.join(root, "bundle");
|
||||
realFs.mkdirSync(nodePath.join(bundleRoot, "skills/blob"), { recursive: true });
|
||||
realFs.writeFileSync(nodePath.join(bundleRoot, "skills/blob", "logo.png"), bytes);
|
||||
const target = nodePath.join(root, "target");
|
||||
const result = routeSkills(
|
||||
{ ...makeInput(), bundleRoot, declaredSkills: ["skills/blob/logo.png"], targetSkillsDir: target },
|
||||
{ exists: realFs.existsSync, mkdirp: (p) => realFs.mkdirSync(p, { recursive: true }), copyFile: realFs.copyFileSync },
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
const installedAt = result.records[0]!.installedAt!;
|
||||
expect(realFs.readFileSync(installedAt).equals(bytes)).toBe(true);
|
||||
} finally {
|
||||
realFs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import nodePath from "node:path";
|
||||
import realFs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { routeWorkflowSpecs, type WorkflowSpecsRouterFsOps, type RouteWorkflowSpecsInput } from "../src/domain/bundle-workflow-specs-router.js";
|
||||
|
||||
// Item 6 / slice-05 Checkpoint 7.3e step 2: bundle-workflow-specs-router
|
||||
@@ -13,12 +15,11 @@ function mockFs(initialFiles: Record<string, string> = {}): WorkflowSpecsRouterF
|
||||
_written: written,
|
||||
_mkdirpCalls: mkdirpCalls,
|
||||
exists: (p: string) => written.has(p),
|
||||
readFile: (p: string) => {
|
||||
const v = written.get(p);
|
||||
if (v === undefined) throw new Error(`File not found in mock: ${p}`);
|
||||
return v;
|
||||
copyFile: (src: string, dest: string) => {
|
||||
const v = written.get(src);
|
||||
if (v === undefined) throw new Error(`File not found in mock: ${src}`);
|
||||
written.set(dest, v);
|
||||
},
|
||||
writeFile: (p: string, c: string) => { written.set(p, c); },
|
||||
mkdirp: (p: string) => { mkdirpCalls.push(p); },
|
||||
};
|
||||
}
|
||||
@@ -230,3 +231,25 @@ describe("routeWorkflowSpecs", () => {
|
||||
expect(r2.records[0]!.status).toBe("routed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeWorkflowSpecs copies bytes through the real filesystem", () => {
|
||||
it("a non-UTF-8 file arrives byte-identical", () => {
|
||||
const root = realFs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-workflow-specs-router-bytes-"));
|
||||
try {
|
||||
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x80, 0x00, 0xc3, 0x28, 0xff, 0xfe]);
|
||||
const bundleRoot = nodePath.join(root, "bundle");
|
||||
realFs.mkdirSync(nodePath.join(bundleRoot, "workflows"), { recursive: true });
|
||||
realFs.writeFileSync(nodePath.join(bundleRoot, "workflows", "latin1.yaml"), bytes);
|
||||
const target = nodePath.join(root, "target");
|
||||
const result = routeWorkflowSpecs(
|
||||
{ ...makeInput(), bundleRoot, declaredWorkflowSpecs: ["workflows/latin1.yaml"], targetWorkflowSpecsDir: target },
|
||||
{ exists: realFs.existsSync, mkdirp: (p) => realFs.mkdirSync(p, { recursive: true }), copyFile: realFs.copyFileSync },
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
const installedAt = result.records[0]!.installedAt!;
|
||||
expect(realFs.readFileSync(installedAt).equals(bytes)).toBe(true);
|
||||
} finally {
|
||||
realFs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// pre-B12 implementation violated: invoking the sampler must hand control back to the event loop
|
||||
// immediately instead of blocking for the whole spawn. The old code ran ps via execSync inside the
|
||||
// async body, so the CALL ITSELF stalled for the full ps duration (measured ~100-220ms on this
|
||||
// class of box) and every HTTP request queued behind it. The discriminator is an ORDERING
|
||||
// class of box) and every HTTP request queued behind it. The discriminator is a SETTLEMENT
|
||||
// property, not a wall-clock bound — the inline note records why a bound was tried and dropped.
|
||||
// Door test: revert the async wrap locally and this fails; on the candidate it passes.
|
||||
|
||||
@@ -14,6 +14,23 @@ import { describe, it, expect } from "vitest";
|
||||
import { defaultListProcesses as refresherListProcesses } from "../src/domain/resume-metadata-refresher.js";
|
||||
import { defaultListProcesses as codexListProcesses } from "../src/adapters/codex-runtime-adapter.js";
|
||||
|
||||
// The discriminator. Drain microtasks only — no timers, no I/O. An async ps sampler cannot settle
|
||||
// here, because its child-process I/O needs an event-loop turn that no microtask provides. A sampler
|
||||
// that ran ps synchronously inside the call settles within a few ticks (the pre-B12/F1 shape needs
|
||||
// about 3). The depth is a bounded assumption, not a proof that nothing can block: a synchronous
|
||||
// spawn hidden behind more than MICROTASK_DRAIN_TICKS awaits would also read as pending. An earlier
|
||||
// version raced a 0ms timer against the result instead. Timer order can race: in a local probe, a
|
||||
// 70ms synchronous stall after spawning a single-PID ps let the result settle before a 0ms timer in
|
||||
// most trials. A CI failure of that version is consistent with this race (inferred; the CI timing
|
||||
// was not measured), so timer order is not asserted.
|
||||
const MICROTASK_DRAIN_TICKS = 20;
|
||||
async function settlesWithinMicrotasks(pending: Promise<unknown>): Promise<boolean> {
|
||||
let settled = false;
|
||||
void pending.then(() => { settled = true; }, () => { settled = true; });
|
||||
for (let i = 0; i < MICROTASK_DRAIN_TICKS; i++) await Promise.resolve();
|
||||
return settled;
|
||||
}
|
||||
|
||||
const SITES = [
|
||||
["resume-metadata-refresher", refresherListProcesses],
|
||||
["codex-runtime-adapter", codexListProcesses],
|
||||
@@ -31,19 +48,14 @@ describe.each(SITES)("B12-T real async list_processes — %s", (_site, listProce
|
||||
it("hands control back to the event loop instead of blocking for the spawn (RED on the pre-B12 sync implementation)", async () => {
|
||||
// Note on what is NOT asserted: the invocation's synchronous-return time. Measured here, even
|
||||
// the async implementation spends 60-80ms in the call under load (child-process spawn setup),
|
||||
// so a wall-clock bound is environment-hostage. The deterministic discriminator is ORDER: the
|
||||
// pre-B12 execSync implementation finished ps inside the call, so its promise settles on the
|
||||
// first microtask — ahead of any timer — and `turnedBeforeResolve` reads false there, always.
|
||||
// so a wall-clock bound is environment-hostage. The pre-B12 execSync implementation finished ps
|
||||
// inside the call, so its promise settles within the microtask drain; the async one cannot.
|
||||
const pending = listProcesses();
|
||||
|
||||
// While ps runs, the loop must turn: a 0ms timer armed AFTER the call must fire BEFORE the
|
||||
// (much slower) spawn resolves.
|
||||
let loopTurnedFirst = false;
|
||||
setTimeout(() => { loopTurnedFirst = true; }, 0);
|
||||
const { rows, turnedBeforeResolve } = await pending.then((r) => ({ rows: r, turnedBeforeResolve: loopTurnedFirst }));
|
||||
const settledEarly = await settlesWithinMicrotasks(pending);
|
||||
const rows = await pending;
|
||||
|
||||
expect(rows.length).toBeGreaterThan(0); // the non-blocking return was not an empty-result shortcut
|
||||
expect(turnedBeforeResolve).toBe(true);
|
||||
expect(settledEarly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,13 +89,12 @@ describe("F1 real async resolve_home — codex-thread-id", () => {
|
||||
|
||||
it("hands control back to the event loop instead of blocking for the spawn (RED on the pre-F1 sync implementation)", async () => {
|
||||
const { defaultResolveHomeDirByPid } = await import("../src/domain/codex-thread-id.js");
|
||||
const { home, turnedBeforeResolve } = await withChild(async (pid) => {
|
||||
const pending = defaultResolveHomeDirByPid(pid);
|
||||
let loopTurnedFirst = false;
|
||||
setTimeout(() => { loopTurnedFirst = true; }, 0);
|
||||
return Promise.resolve(pending).then((h) => ({ home: h, turnedBeforeResolve: loopTurnedFirst }));
|
||||
const { home, settledEarly } = await withChild(async (pid) => {
|
||||
const pending = Promise.resolve(defaultResolveHomeDirByPid(pid));
|
||||
const settledEarly = await settlesWithinMicrotasks(pending);
|
||||
return { home: await pending, settledEarly };
|
||||
});
|
||||
expect(home).toBe("/tmp/f1-probe-home"); // the fast return was not an empty shortcut
|
||||
expect(turnedBeforeResolve).toBe(true);
|
||||
expect(settledEarly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,19 +7,27 @@ import type { RigSpec } from "../src/domain/types.js";
|
||||
|
||||
// -- Mock filesystem --
|
||||
|
||||
function mockFs(files: Record<string, string>): PodAssemblerFsOps {
|
||||
const written: Record<string, string> = {};
|
||||
function mockFs(files: Record<string, string | Uint8Array>): PodAssemblerFsOps {
|
||||
const written: Record<string, string | Uint8Array> = {};
|
||||
const dirs = new Set<string>();
|
||||
const read = (p: string): string | Uint8Array => {
|
||||
if (p in files) return files[p]!;
|
||||
if (p in written) return written[p]!;
|
||||
throw new Error(`File not found: ${p}`);
|
||||
};
|
||||
|
||||
return {
|
||||
readFile: (p: string) => {
|
||||
if (p in files) return files[p]!;
|
||||
if (p in written) return written[p]!;
|
||||
throw new Error(`File not found: ${p}`);
|
||||
const v = read(p);
|
||||
return typeof v === "string" ? v : Buffer.from(v).toString("utf8");
|
||||
},
|
||||
readFileBuffer: (p: string) => {
|
||||
const v = read(p);
|
||||
return typeof v === "string" ? Buffer.from(v, "utf8") : v;
|
||||
},
|
||||
exists: (p: string) => p in files || p in written,
|
||||
mkdirp: (p: string) => { dirs.add(p); },
|
||||
writeFile: (p: string, content: string) => { written[p] = content; },
|
||||
writeFile: (p: string, content: string | Uint8Array) => { written[p] = content; },
|
||||
copyDir: () => {},
|
||||
listFiles: (dirPath: string) => {
|
||||
const result: string[] = [];
|
||||
@@ -31,7 +39,7 @@ function mockFs(files: Record<string, string>): PodAssemblerFsOps {
|
||||
return result;
|
||||
},
|
||||
_written: written, // for test inspection
|
||||
} as PodAssemblerFsOps & { _written: Record<string, string> };
|
||||
} as PodAssemblerFsOps & { _written: Record<string, string | Uint8Array> };
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
@@ -463,6 +471,33 @@ describe("PodBundleAssembler", () => {
|
||||
});
|
||||
|
||||
// T11: integration: assemble -> verify manifest + file contents
|
||||
it("copies agent-package and rig files as bytes, never decoding them", () => {
|
||||
const spec = makeRigSpec({
|
||||
cultureFile: "culture.md",
|
||||
startup: { files: [{ path: "startup/blob.bin", deliveryHint: "auto", required: true, appliesOn: ["fresh_start", "restore"] }], actions: [] },
|
||||
});
|
||||
const binary = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x80, 0x00, 0xc3, 0x28, 0xff]);
|
||||
const culture = Uint8Array.from([0x23, 0x20, 0xa0, 0xa1, 0x0a]);
|
||||
const startup = Uint8Array.from([0xfe, 0xed, 0x00, 0xc0]);
|
||||
const files: Record<string, string | Uint8Array> = {
|
||||
[`${RIG_ROOT}/rig.yaml`]: rigSpecYaml(spec),
|
||||
[`${RIG_ROOT}/culture.md`]: culture,
|
||||
[`${RIG_ROOT}/startup/blob.bin`]: startup,
|
||||
[`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl"),
|
||||
[`${RIG_ROOT}/agents/impl/assets/logo.png`]: binary,
|
||||
};
|
||||
const fs = mockFs(files);
|
||||
new PodBundleAssembler({ fsOps: fs }).assemble({
|
||||
rigRoot: RIG_ROOT, rigSpecPath: `${RIG_ROOT}/rig.yaml`,
|
||||
outputDir: "/tmp/staging", bundleName: "bytes", bundleVersion: "1.0.0",
|
||||
});
|
||||
|
||||
const written = (fs as unknown as { _written: Record<string, string | Uint8Array> })._written;
|
||||
expect(Buffer.from(written["/tmp/staging/agents/impl/assets/logo.png"]!).equals(Buffer.from(binary))).toBe(true);
|
||||
expect(Buffer.from(written["/tmp/staging/culture.md"]!).equals(Buffer.from(culture))).toBe(true);
|
||||
expect(Buffer.from(written["/tmp/staging/startup/blob.bin"]!).equals(Buffer.from(startup))).toBe(true);
|
||||
});
|
||||
|
||||
it("integration: assembled bundle has correct manifest and files", () => {
|
||||
const spec = makeRigSpec({
|
||||
cultureFile: "culture.md",
|
||||
@@ -492,7 +527,7 @@ describe("PodBundleAssembler", () => {
|
||||
// Verify written files exist
|
||||
const written = (fs as unknown as { _written: Record<string, string> })._written;
|
||||
expect(written["/tmp/staging/rig.yaml"]).toBeDefined();
|
||||
expect(written["/tmp/staging/culture.md"]).toBe("# Culture doc");
|
||||
expect(Buffer.from(written["/tmp/staging/culture.md"]!).toString("utf8")).toBe("# Culture doc");
|
||||
expect(written["/tmp/staging/bundle.yaml"]).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user