mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(evidence): preserve source and authenticated observation identity
This commit is contained in:
1 parent
6336fe5bb5
commit
09acf217d9
5 files changed
+224
-23
No files matched your search
@@ -1,9 +1,13 @@
|
||||
import type { BigIntStats, Dir, Dirent } from "node:fs";
|
||||
import { opendir, readlink, realpath } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { opendir, readlink, realpath, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join, sep } from "node:path";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import type { ArtifactCleanupAttempt } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js";
|
||||
import {
|
||||
absoluteCallerPath,
|
||||
appendCallerPath,
|
||||
} from "../filesystem/callerPath.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
|
||||
import {
|
||||
@@ -455,18 +459,15 @@ const readSymlinkTarget = async (
|
||||
`Symlink target is not valid UTF-8 (raw target bytes: ${rawTarget.toString("hex")})`,
|
||||
);
|
||||
}
|
||||
const lexicalTarget = resolve(dirname(absolute), target);
|
||||
if (!isPathWithinRoot(root, lexicalTarget))
|
||||
return {
|
||||
status: "read",
|
||||
kind: "symlink",
|
||||
path,
|
||||
target: lexicalTarget,
|
||||
targetState: "external",
|
||||
};
|
||||
const nativeTarget = absoluteCallerPath(target, { cwd: dirname(absolute) });
|
||||
let canonicalTarget: string;
|
||||
try {
|
||||
canonicalTarget = await realpath(lexicalTarget);
|
||||
// The filesystem resolves parent segments after following symlinks. A
|
||||
// lexical containment check can both select a decoy and misclassify scope.
|
||||
// realpath can collapse file/.. on some hosts even though native lookup
|
||||
// rejects that traversal, so establish existence through stat first.
|
||||
await stat(nativeTarget);
|
||||
canonicalTarget = await realpath(nativeTarget);
|
||||
} catch (cause: unknown) {
|
||||
const code =
|
||||
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
|
||||
@@ -475,7 +476,7 @@ const readSymlinkTarget = async (
|
||||
status: "read",
|
||||
kind: "symlink",
|
||||
path,
|
||||
target: pathFromRoot(root, lexicalTarget),
|
||||
target: await missingSymlinkPath(root, nativeTarget),
|
||||
targetState: "missing",
|
||||
};
|
||||
}
|
||||
@@ -489,6 +490,30 @@ const readSymlinkTarget = async (
|
||||
};
|
||||
};
|
||||
|
||||
/** Preserve the native spelling when a missing parent prevents canonical lookup. */
|
||||
const missingSymlinkPath = async (
|
||||
root: string,
|
||||
target: string,
|
||||
): Promise<string> => {
|
||||
if (basename(target) === "..") return target;
|
||||
let parent: string;
|
||||
try {
|
||||
await stat(dirname(target));
|
||||
parent = await realpath(dirname(target));
|
||||
} catch (cause: unknown) {
|
||||
const code =
|
||||
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") return target;
|
||||
throw cause;
|
||||
}
|
||||
const candidate = appendCallerPath(parent, basename(target));
|
||||
// A trailing separator requires a directory; relative() would erase it.
|
||||
if (target.endsWith(sep)) return `${candidate}${sep}`;
|
||||
return isPathWithinRoot(root, candidate)
|
||||
? pathFromRoot(root, candidate)
|
||||
: candidate;
|
||||
};
|
||||
|
||||
const applyExclusion = (
|
||||
shouldExclude:
|
||||
| ((path: string, kind: ReferenceSourceExclusionKind) => boolean)
|
||||
|
||||
@@ -3,7 +3,8 @@ import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { evidenceSchema, type Evidence } from "../domain/evidence.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { safeParseEvidence } from "../domain/evidenceValidation.js";
|
||||
import {
|
||||
projectAnalysisError,
|
||||
type AnalysisErrorProjection,
|
||||
@@ -155,9 +156,9 @@ export class ToolResultDelivery {
|
||||
): CallToolResult {
|
||||
if (recordEvidence === undefined)
|
||||
return this.toErrorToolResult(error, targetContent);
|
||||
const partial = evidenceSchema.safeParse(error.partialObservation);
|
||||
if (!partial.success) return this.toErrorToolResult(error, targetContent);
|
||||
const recorded = recordEvidence(partial.data);
|
||||
const partial = safeParseEvidence(error.partialObservation);
|
||||
if (!partial.ok) return this.toErrorToolResult(error, targetContent);
|
||||
const recorded = recordEvidence(partial.value);
|
||||
if (!recorded.ok) return this.toErrorToolResult(error, targetContent);
|
||||
const projected = projectAnalysisError(error);
|
||||
return this.projectedErrorResult(
|
||||
@@ -167,7 +168,7 @@ export class ToolResultDelivery {
|
||||
...projected.details,
|
||||
partial_observation: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: partial.data.evidence_id,
|
||||
evidence_id: partial.value.evidence_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
readlink,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.skipIf(process.platform === "win32")(
|
||||
"resolves source symlink targets through native traversal across retargeting and missing files",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-reference-native-links-");
|
||||
const config = await createTestTempDirectory("rea-reference-link-config-");
|
||||
const outside = await createTestTempDirectory(
|
||||
"rea-reference-link-outside-",
|
||||
);
|
||||
for (const directory of [
|
||||
join(root, "first"),
|
||||
join(root, "second"),
|
||||
outside,
|
||||
]) {
|
||||
await mkdir(join(directory, "deep"), { recursive: true });
|
||||
await writeFile(
|
||||
join(directory, "source.js"),
|
||||
"export const selected = true;\n",
|
||||
);
|
||||
}
|
||||
await writeFile(join(root, "source.js"), "export const decoy = true;\n");
|
||||
const alias = join(root, "alias");
|
||||
await symlink(join(root, "first/deep"), alias);
|
||||
await symlink("alias/../source.js", join(root, "selected.js"));
|
||||
await symlink("alias/../../source.js", join(root, "parent.js"));
|
||||
await symlink("absent/../source.js", join(root, "missing-parent.js"));
|
||||
await symlink("source.js/", join(root, "directory-required.js"));
|
||||
await symlink("source.js/..", join(root, "file-parent.js"));
|
||||
await symlink("source.js/../source.js", join(root, "file-parent-child.js"));
|
||||
const run = async () => {
|
||||
const result = await cli.run({
|
||||
arguments: ["import-reference-source", root, "--json"],
|
||||
environment: {
|
||||
HOME: config,
|
||||
XDG_CONFIG_HOME: config,
|
||||
XDG_CACHE_HOME: config,
|
||||
},
|
||||
});
|
||||
expect(result.exitCode, result.stderr + JSON.stringify(result.json)).toBe(
|
||||
0,
|
||||
);
|
||||
return historicalSourceGraphSchema.parse(result.json);
|
||||
};
|
||||
const first = await run();
|
||||
expect(first.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "selected.js",
|
||||
target: "first/source.js",
|
||||
target_state: "internal",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "parent.js",
|
||||
target: "source.js",
|
||||
target_state: "internal",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "missing-parent.js",
|
||||
target: `${root}/absent/../source.js`,
|
||||
target_state: "missing",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "directory-required.js",
|
||||
target: `${root}/source.js/`,
|
||||
target_state: "missing",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "file-parent.js",
|
||||
target: `${root}/source.js/..`,
|
||||
target_state: "missing",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "file-parent-child.js",
|
||||
target: `${root}/source.js/../source.js`,
|
||||
target_state: "missing",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
await unlink(alias);
|
||||
await symlink(join(root, "second/deep"), alias);
|
||||
const second = await run();
|
||||
expect(second.entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "selected.js",
|
||||
target: "second/source.js",
|
||||
target_state: "internal",
|
||||
}),
|
||||
);
|
||||
await unlink(join(root, "second/source.js"));
|
||||
const missing = await run();
|
||||
expect(missing.entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "selected.js",
|
||||
target: "second/source.js",
|
||||
target_state: "missing",
|
||||
}),
|
||||
);
|
||||
await unlink(alias);
|
||||
await symlink(join(outside, "deep"), alias);
|
||||
const external = await run();
|
||||
expect(external.entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "selected.js",
|
||||
target: join(outside, "source.js"),
|
||||
target_state: "external",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
external.entries
|
||||
.filter((entry) => entry.kind === "file")
|
||||
.map((entry) => entry.path),
|
||||
).toEqual(["first/source.js", "source.js"]);
|
||||
expect(await readlink(join(root, "selected.js"))).toBe(
|
||||
"alias/../source.js",
|
||||
);
|
||||
expect(await readFile(join(root, "source.js"), "utf8")).toBe(
|
||||
"export const decoy = true;\n",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -317,7 +317,7 @@ it("does not manufacture links for empty or failed composed calls", async () =>
|
||||
expect(fixture.session.exportEvidenceBundle().records).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops an old cached ID rather than guessing after an ambiguous snapshot replacement", async () => {
|
||||
it("reruns an ambiguous snapshot binding before selecting composed Evidence links", async () => {
|
||||
const fixture = await composedWorkflowFixture();
|
||||
const target = await fixture.open();
|
||||
const initial = await fixture.session.execute(
|
||||
@@ -352,6 +352,7 @@ it("drops an old cached ID rather than guessing after an ambiguous snapshot repl
|
||||
);
|
||||
if (!before.ok) throw before.error;
|
||||
expect(before.value.evidenceId).toBe(initial.value.evidenceId);
|
||||
expect(replay.calls).not.toContain("read_binary_inventory_summary");
|
||||
expect(replay.session.importAnalysisSnapshot(ambiguous.value).ok).toBe(true);
|
||||
const after = await replay.session.execute(
|
||||
"read_binary_inventory_summary",
|
||||
@@ -359,8 +360,47 @@ it("drops an old cached ID rather than guessing after an ambiguous snapshot repl
|
||||
);
|
||||
if (!after.ok) throw after.error;
|
||||
expect(after.value.result).toEqual(fixture.summary);
|
||||
expect(after.value.evidenceId).toBeUndefined();
|
||||
expect(replay.calls).not.toContain("read_binary_inventory_summary");
|
||||
expect(
|
||||
replay.calls.filter(
|
||||
(operation) => operation === "read_binary_inventory_summary",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(after.value.limitations).not.toContain(
|
||||
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
|
||||
);
|
||||
if (after.value.evidenceId === undefined)
|
||||
throw new Error("Fresh provider observation was not committed");
|
||||
// Unchanged facts reproduce their deterministic identity. The provider call,
|
||||
// rather than a choice between historical records, establishes this binding.
|
||||
expect(after.value.evidenceId).toBe(initial.value.evidenceId);
|
||||
expect(after.value.evidenceId).not.toBe(alternative.evidence_id);
|
||||
const originalRecord = snapshot.value.evidence_bundle.records.find(
|
||||
({ evidence_id }) => evidence_id === initial.value.evidenceId,
|
||||
);
|
||||
expect(replay.session.evidenceById(after.value.evidenceId)).toEqual(
|
||||
originalRecord,
|
||||
);
|
||||
expect(replay.session.evidenceById(alternative.evidence_id)).toEqual(
|
||||
alternative,
|
||||
);
|
||||
|
||||
const composed = await executeComposedWorkflow(
|
||||
replay.session,
|
||||
async (observed) => {
|
||||
const execution = await observed.execute(
|
||||
"read_binary_inventory_summary",
|
||||
{},
|
||||
);
|
||||
return execution.ok ? ok(execution.value.result) : execution;
|
||||
},
|
||||
);
|
||||
if (!composed.ok) throw composed.error;
|
||||
expect(composed.value.evidenceLinks).toEqual([after.value.evidenceId]);
|
||||
expect(
|
||||
replay.calls.filter(
|
||||
(operation) => operation === "read_binary_inventory_summary",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("replaces provider-supplied identity with the record actually committed by the session", async () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ it.each(operations)(
|
||||
parameters: {},
|
||||
result: { completed: true },
|
||||
},
|
||||
{ immutable: true },
|
||||
);
|
||||
const evidence: Record<ObservationOperation, Evidence> = {
|
||||
list_browser_targets: makeEvidence("list_browser_targets"),
|
||||
@@ -154,7 +155,7 @@ it.each(operations)(
|
||||
},
|
||||
});
|
||||
expect(
|
||||
session.evidenceById(evidence[selectedOperation].evidence_id),
|
||||
).toEqual(evidence[selectedOperation]);
|
||||
session.evidenceForAnalysis(evidence[selectedOperation].evidence_id),
|
||||
).toBe(evidence[selectedOperation]);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user