@deksden-com/dd-flow-cli 0.9.0-beta.100 → 0.9.0-beta.103
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/dist/build-info.json +3 -3
- package/dist/cli/hook-ingress.js +5 -10
- package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
- package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
- package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
- package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +13 -2
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
- package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
- package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +32 -3
- package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
- package/dist/harness-runtime/lib/process-json.mjs +3 -2
- package/dist/services/code-checks.js +53 -16
- package/dist/services/controller-fanout.js +37 -6
- package/dist/services/eval-snapshots.js +148 -38
- package/dist/services/external-work-launch.js +60 -5
- package/dist/services/prompts.js +2 -0
- package/dist/services/review-copy.js +136 -0
- package/dist/services/run-control-worker.js +16 -11
- package/dist/services/run-controller-adapter.js +9 -0
- package/dist/services/runs.js +3 -0
- package/dist/services/runtime-scope-stop.js +66 -6
- package/dist/services/vnext-code-review.js +6 -2
- package/dist/services/vnext-code.js +1 -0
- package/dist/services/vnext-fanout.js +9 -1
- package/dist/services/vnext-merge.js +8 -4
- package/dist/services/vnext-plan-review.js +4 -15
- package/dist/services/vnext-plan.js +4 -0
- package/dist/services/vnext-protocolize.js +4 -2
- package/dist/services/vnext-specify.js +7 -0
- package/dist/services/work-registry.js +208 -22
- package/dist/services/workspace-files.js +83 -0
- package/dist/services/worktrees.js +3 -1
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@ import { captureRecoveryDatabase, readRecoveryResourceEvidence } from "./recover
|
|
|
15
15
|
import { resourceHome } from "./managed-processes.js";
|
|
16
16
|
import { codexHomeSharedEntries } from "./hooks.js";
|
|
17
17
|
import { prepareOutputFile } from "../cli/input-preparation.js";
|
|
18
|
+
import { localEnvironmentAllowed, projectServicePathFilter } from "./workspace-files.js";
|
|
18
19
|
/** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
|
|
19
20
|
export function prepareEvalBootstrapSnapshot(context, input) {
|
|
20
21
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
@@ -38,17 +39,42 @@ export function prepareEvalBootstrapSnapshot(context, input) {
|
|
|
38
39
|
}
|
|
39
40
|
export function createEvalBootstrapSnapshot(context, input) {
|
|
40
41
|
const { projectRoot, output } = prepareEvalBootstrapSnapshot(context, input);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
42
|
+
const temporary = `${output}.partial-${crypto.randomUUID()}`;
|
|
43
|
+
const before = bootstrapSourceVersion(projectRoot);
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(temporary, { recursive: true });
|
|
46
|
+
fs.cpSync(projectRoot, path.join(temporary, "project"), { recursive: true, verbatimSymlinks: true, filter: projectPayloadFilter(projectRoot) });
|
|
47
|
+
const after = bootstrapSourceVersion(projectRoot);
|
|
48
|
+
if (before !== after)
|
|
49
|
+
throw new AppError("snapshot_source_changed", "Bootstrap source changed during snapshot capture", 1, { phase: "bootstrap", recoverable: true });
|
|
50
|
+
const manifest = {
|
|
51
|
+
schema_id: "dd-eval/bootstrap-snapshot@1",
|
|
52
|
+
stage: "specify",
|
|
53
|
+
project_root: projectRoot,
|
|
54
|
+
project_sha256: treeHash(path.join(temporary, "project")),
|
|
55
|
+
project_git: snapshotProjectGit(projectRoot, temporary)
|
|
56
|
+
};
|
|
57
|
+
writeJson(path.join(temporary, "bootstrap.json"), manifest);
|
|
58
|
+
if (after !== bootstrapSourceVersion(projectRoot))
|
|
59
|
+
throw new AppError("snapshot_source_changed", "Bootstrap source changed while preserving Git evidence", 1, { phase: "bootstrap", recoverable: true });
|
|
60
|
+
syncSnapshotTree(temporary);
|
|
61
|
+
fs.renameSync(temporary, output);
|
|
62
|
+
syncPath(path.dirname(output));
|
|
63
|
+
return { ok: true, snapshot: output, ...manifest };
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function bootstrapSourceVersion(projectRoot) {
|
|
71
|
+
return JSON.stringify([
|
|
72
|
+
treeHash(projectRoot, true),
|
|
73
|
+
treeModesHash(projectRoot, true),
|
|
74
|
+
git(projectRoot, ["symbolic-ref", "--quiet", "HEAD"]),
|
|
75
|
+
gitBytes(projectRoot, ["show-ref", "--head"]).toString("base64"),
|
|
76
|
+
indexEntries(projectRoot).toString("base64")
|
|
77
|
+
]);
|
|
52
78
|
}
|
|
53
79
|
/** Restore a pre-RUN project state; `stage start --bootstrap` creates runtime state afterwards. */
|
|
54
80
|
export function prepareEvalBootstrapRestore(input) {
|
|
@@ -185,7 +211,7 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
185
211
|
}
|
|
186
212
|
}
|
|
187
213
|
else
|
|
188
|
-
copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions]);
|
|
214
|
+
copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions], grokProviderHomes(context.ddFlowHome));
|
|
189
215
|
// VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
|
|
190
216
|
// pages. Copying the live database and its WAL separately is not atomic.
|
|
191
217
|
const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
|
|
@@ -217,7 +243,7 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
217
243
|
fs.cpSync(projectRoot, path.join(output, "project"), {
|
|
218
244
|
recursive: true,
|
|
219
245
|
verbatimSymlinks: true,
|
|
220
|
-
filter:
|
|
246
|
+
filter: projectPayloadFilter(projectRoot)
|
|
221
247
|
});
|
|
222
248
|
const projectGit = snapshotProjectGit(projectRoot, output);
|
|
223
249
|
const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
|
|
@@ -244,6 +270,8 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
244
270
|
created_at: context.now(),
|
|
245
271
|
runtime_sha256: treeHash(path.join(output, "runtime")),
|
|
246
272
|
project_sha256: treeHash(path.join(output, "project")),
|
|
273
|
+
project_payload_policy: "service-files@1",
|
|
274
|
+
...(!recovery ? { runtime_payload_policy: "flow-evidence@1" } : {}),
|
|
247
275
|
project_git: projectGit,
|
|
248
276
|
workspace,
|
|
249
277
|
payload_modes: { project: treeModesHash(path.join(output, "project")), runtime: treeModesHash(path.join(output, "runtime")), ...(workspace.kind === "git_worktree" ? { workspace: treeModesHash(path.join(output, "workspace")) } : {}) },
|
|
@@ -608,7 +636,7 @@ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
|
|
|
608
636
|
const { branch, baseRef } = prepared;
|
|
609
637
|
const workspace = path.join(output, "workspace");
|
|
610
638
|
const bundle = path.join(output, "workspace.bundle");
|
|
611
|
-
fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter:
|
|
639
|
+
fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: projectPayloadFilter(workspaceRoot) });
|
|
612
640
|
runGit(projectRoot, ["bundle", "create", bundle, branch]);
|
|
613
641
|
return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle), ...snapshotIndex(workspaceRoot, output, "workspace") };
|
|
614
642
|
}
|
|
@@ -675,7 +703,7 @@ function restoreProjectGit(snapshot, source, projectRoot) {
|
|
|
675
703
|
const mergeStateFiles = ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "ORIG_HEAD", "AUTO_MERGE"];
|
|
676
704
|
function indexEntries(root) {
|
|
677
705
|
const entries = Buffer.from(gitBytes(root, ["ls-files", "--stage", "-z"])
|
|
678
|
-
.toString("utf8").split("\0").filter(entry => entry &&
|
|
706
|
+
.toString("utf8").split("\0").filter(entry => entry && localEnvironmentAllowed(entry.slice(entry.indexOf("\t") + 1)))
|
|
679
707
|
.map(entry => `${entry}\0`).join(""));
|
|
680
708
|
const merge = {};
|
|
681
709
|
for (const name of mergeStateFiles) {
|
|
@@ -764,10 +792,11 @@ export function verifyRecoverySnapshot(snapshot, input, checkWorkingState = true
|
|
|
764
792
|
];
|
|
765
793
|
verifySnapshotPayload(snapshot, manifest);
|
|
766
794
|
for (const entry of entries) {
|
|
795
|
+
const allowed = manifest.project_payload_policy === "service-files@1" ? projectPayloadFilter(entry.root) : snapshotPayloadAllowed;
|
|
767
796
|
const expectedModes = manifest.payload_modes?.[entry.name];
|
|
768
|
-
if (checkWorkingState && expectedModes && treeModesHash(entry.root,
|
|
797
|
+
if (checkWorkingState && expectedModes && treeModesHash(entry.root, allowed) !== expectedModes)
|
|
769
798
|
throw new AppError("recovery_workspace_drift", "Executable file modes changed after recovery capture", 1, { payload: entry.name });
|
|
770
|
-
if (checkWorkingState && (treeHash(entry.root,
|
|
799
|
+
if (checkWorkingState && (treeHash(entry.root, allowed) !== entry.hash || git(entry.root, ["rev-parse", "HEAD"]) !== entry.head || git(entry.root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== entry.git.branch || (entry.git.index_sha256 && crypto.createHash("sha256").update(entry.git.index_format === "entries@1" ? indexEntries(entry.root) : indexPatch(entry.root)).digest("hex") !== entry.git.index_sha256)))
|
|
771
800
|
throw new AppError("recovery_workspace_drift", "Working files or Git state changed after recovery capture", 1, { payload: entry.name });
|
|
772
801
|
}
|
|
773
802
|
}
|
|
@@ -805,18 +834,48 @@ function runGit(root, args) {
|
|
|
805
834
|
throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
|
|
806
835
|
}
|
|
807
836
|
function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
|
|
808
|
-
function snapshotEnvironmentAllowed(source) {
|
|
809
|
-
const name = path.basename(source);
|
|
810
|
-
// Public configuration template, not a local environment/credential file.
|
|
811
|
-
return name === ".env.example" || (name !== ".env" && !name.startsWith(".env."));
|
|
812
|
-
}
|
|
813
837
|
function snapshotPayloadAllowed(source) {
|
|
814
838
|
const name = path.basename(source);
|
|
815
839
|
return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
|
|
816
|
-
&&
|
|
840
|
+
&& localEnvironmentAllowed(source);
|
|
841
|
+
}
|
|
842
|
+
function projectPayloadFilter(root) {
|
|
843
|
+
const serviceAllowed = projectServicePathFilter(root);
|
|
844
|
+
return file => snapshotPayloadAllowed(file) && serviceAllowed(file);
|
|
817
845
|
}
|
|
818
846
|
function runtimePayloadAllowed(source) {
|
|
819
|
-
return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));
|
|
847
|
+
return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "auth.encrypted", "auth.v2.key", "auth.v2.file", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));
|
|
848
|
+
}
|
|
849
|
+
/** Only a Grok daemon's verified provider root is excluded from flow evidence.
|
|
850
|
+
* An unrelated directory named grok-home remains ordinary project data. */
|
|
851
|
+
function grokProviderHomes(runtimeRoot) {
|
|
852
|
+
const homes = [];
|
|
853
|
+
const visit = (directory) => {
|
|
854
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
855
|
+
const file = path.join(directory, entry.name);
|
|
856
|
+
if (entry.isDirectory()) {
|
|
857
|
+
if (![".git", "node_modules", "grok-home"].includes(entry.name))
|
|
858
|
+
visit(file);
|
|
859
|
+
}
|
|
860
|
+
else if (entry.isFile() && entry.name === "daemon.json") {
|
|
861
|
+
let state;
|
|
862
|
+
try {
|
|
863
|
+
state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (state.schema_id !== "dd-grok/daemon-state@1")
|
|
869
|
+
continue;
|
|
870
|
+
const expected = path.join(directory, "grok-home");
|
|
871
|
+
if (state.config?.grokHome !== expected || state.config?.ddFlowHome !== runtimeRoot)
|
|
872
|
+
throw new AppError("snapshot_native_scope_invalid", "Grok provider home differs from its retained daemon binding", 1, { state_dir: directory });
|
|
873
|
+
homes.push(expected);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
visit(runtimeRoot);
|
|
878
|
+
return homes;
|
|
820
879
|
}
|
|
821
880
|
function sqliteFile(file) {
|
|
822
881
|
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
@@ -830,9 +889,55 @@ function sqliteFile(file) {
|
|
|
830
889
|
fs.closeSync(descriptor);
|
|
831
890
|
}
|
|
832
891
|
}
|
|
892
|
+
function sqliteIdentifier(value) { return `"${value.replaceAll('"', '""')}"`; }
|
|
893
|
+
function sqliteValue(value) {
|
|
894
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
895
|
+
return value;
|
|
896
|
+
if (typeof value === "number")
|
|
897
|
+
return Number.isFinite(value) ? value : String(value);
|
|
898
|
+
if (typeof value === "bigint")
|
|
899
|
+
return `${value}n`;
|
|
900
|
+
if (Buffer.isBuffer(value))
|
|
901
|
+
return `blob:${value.toString("base64")}`;
|
|
902
|
+
if (value instanceof Uint8Array)
|
|
903
|
+
return `blob:${Buffer.from(value).toString("base64")}`;
|
|
904
|
+
return String(value);
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* SQLite page layout is not semantic identity: VACUUM, checkpoint and a
|
|
908
|
+
* delete/insert rewrite can change serialized bytes without changing the
|
|
909
|
+
* logical database. Snapshot only the schema and ordered logical row values.
|
|
910
|
+
* This intentionally applies to the small set of managed SQLite files, not
|
|
911
|
+
* to arbitrary provider databases.
|
|
912
|
+
*/
|
|
913
|
+
function sqliteSemanticFingerprint(db, resourceRegistry = false) {
|
|
914
|
+
db.exec("PRAGMA busy_timeout = 4000");
|
|
915
|
+
db.exec("BEGIN");
|
|
916
|
+
try {
|
|
917
|
+
const schema = db.prepare("SELECT type, name, tbl_name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name, tbl_name, sql").all();
|
|
918
|
+
const tables = schema.filter(row => row.type === "table").map(row => {
|
|
919
|
+
const columns = db.prepare(`PRAGMA table_info(${sqliteIdentifier(row.name)})`).all()
|
|
920
|
+
.sort((left, right) => Number(left.cid ?? 0) - Number(right.cid ?? 0))
|
|
921
|
+
.map(column => ({ name: String(column.name ?? ""), type: String(column.type ?? ""), notnull: Number(column.notnull ?? 0), default: sqliteValue(column.dflt_value ?? null), pk: Number(column.pk ?? 0) }));
|
|
922
|
+
const names = columns.map(column => column.name);
|
|
923
|
+
const stableNames = resourceRegistry && row.name === "managed_processes" ? names.filter(name => name !== "lease_expires_at" && name !== "updated_at") : names;
|
|
924
|
+
const rows = db.prepare(`SELECT * FROM ${sqliteIdentifier(row.name)}`).all()
|
|
925
|
+
.map(record => stableNames.map(name => [name, sqliteValue(record[name])]))
|
|
926
|
+
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
927
|
+
return { name: row.name, columns, rows };
|
|
928
|
+
});
|
|
929
|
+
return crypto.createHash("sha256").update(JSON.stringify({ schema, tables })).digest("hex");
|
|
930
|
+
}
|
|
931
|
+
finally {
|
|
932
|
+
try {
|
|
933
|
+
db.exec("ROLLBACK");
|
|
934
|
+
}
|
|
935
|
+
catch { /* The read transaction may already have closed on provider error. */ }
|
|
936
|
+
}
|
|
937
|
+
}
|
|
833
938
|
/** A WAL is copied through its SQLite read snapshot, not as raw bytes. */
|
|
834
|
-
function runtimeSnapshotKind(file, excluded) {
|
|
835
|
-
if (!runtimePayloadAllowed(file) || excluded.includes(file))
|
|
939
|
+
function runtimeSnapshotKind(file, excluded, providerHomes = []) {
|
|
940
|
+
if (!runtimePayloadAllowed(file) || providerHomes.some(home => file === home || isInside(home, file)) || excluded.includes(file))
|
|
836
941
|
return "excluded";
|
|
837
942
|
if (fs.lstatSync(file).isFile()) {
|
|
838
943
|
if (/-(?:wal|shm|journal)$/.test(file) && sqliteFile(file.replace(/-(?:wal|shm|journal)$/, "")))
|
|
@@ -854,10 +959,11 @@ function snapshotSourceVersion(context, input, retainedDatabases) {
|
|
|
854
959
|
throw new AppError("not_found", "Snapshot RUN is not registered", 1);
|
|
855
960
|
const entries = new Map();
|
|
856
961
|
const excluded = codexHomeSnapshotExclusions(context, project.id, input.runId);
|
|
962
|
+
const providerHomes = input.recoveryId === undefined ? grokProviderHomes(context.ddFlowHome) : [];
|
|
857
963
|
const visit = (directory) => {
|
|
858
964
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
859
965
|
const file = path.join(directory, entry.name);
|
|
860
|
-
const kind = runtimeSnapshotKind(file, excluded);
|
|
966
|
+
const kind = runtimeSnapshotKind(file, excluded, providerHomes);
|
|
861
967
|
if (kind === "excluded" || kind === "sidecar")
|
|
862
968
|
continue;
|
|
863
969
|
const key = `runtime/${path.relative(context.ddFlowHome, file)}`;
|
|
@@ -869,18 +975,18 @@ function snapshotSourceVersion(context, input, retainedDatabases) {
|
|
|
869
975
|
else if (stat.isSymbolicLink())
|
|
870
976
|
entries.set(key, `link:${fs.readlinkSync(file)}`);
|
|
871
977
|
else if (kind === "sqlite") {
|
|
872
|
-
// Resource heartbeats are copied consistently but are not productive
|
|
873
|
-
// RUN writers; owner identity is fenced by boundary.verify().
|
|
874
|
-
if (file === path.join(context.ddFlowHome, "runtime.sqlite")) {
|
|
875
|
-
entries.set(key, `resource-sqlite:${stat.dev}:${stat.ino}:${stat.mode & 0o111}`);
|
|
876
|
-
continue;
|
|
877
|
-
}
|
|
878
978
|
let db = databases.get(file);
|
|
879
979
|
if (!db) {
|
|
880
980
|
db = new DatabaseSync(file, { readOnly: true });
|
|
881
981
|
databases.set(file, db);
|
|
882
982
|
}
|
|
883
|
-
|
|
983
|
+
// Resource heartbeats are copied consistently but are not productive
|
|
984
|
+
// RUN writers; owner identity is fenced by boundary.verify().
|
|
985
|
+
if (file === path.join(resourceHome(context), "runtime.sqlite")) {
|
|
986
|
+
entries.set(key, `resource-sqlite:${sqliteSemanticFingerprint(db, true)}:${stat.mode & 0o111}`);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
entries.set(key, `sqlite:${sqliteSemanticFingerprint(db)}:${stat.mode & 0o111}`);
|
|
884
990
|
}
|
|
885
991
|
else if (stat.isFile())
|
|
886
992
|
entries.set(key, `file:${crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}:${stat.mode & 0o111}`);
|
|
@@ -944,7 +1050,7 @@ function codexHomeSnapshotExclusions(context, projectId, runId) {
|
|
|
944
1050
|
return excluded;
|
|
945
1051
|
}
|
|
946
1052
|
/** Copy a dedicated home or scoped recovery tree, preserving SQLite WAL commits. */
|
|
947
|
-
function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
|
|
1053
|
+
function copyRuntimeTree(source, destination, scoped = true, excluded = [], providerHomes = []) {
|
|
948
1054
|
if (fs.lstatSync(source).isSymbolicLink())
|
|
949
1055
|
throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact roots must not be symbolic links", 1, { path: source });
|
|
950
1056
|
const root = physicalPath(source);
|
|
@@ -955,7 +1061,7 @@ function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
|
|
|
955
1061
|
fs.cpSync(source, destination, {
|
|
956
1062
|
recursive: true, verbatimSymlinks: true,
|
|
957
1063
|
filter: file => {
|
|
958
|
-
const kind = runtimeSnapshotKind(file, excluded);
|
|
1064
|
+
const kind = runtimeSnapshotKind(file, excluded, providerHomes);
|
|
959
1065
|
if (kind === "excluded" || kind === "sidecar")
|
|
960
1066
|
return false;
|
|
961
1067
|
const stat = fs.lstatSync(file);
|
|
@@ -1057,11 +1163,12 @@ function captureRecoveryAuxiliary(context, captured, input) {
|
|
|
1057
1163
|
}
|
|
1058
1164
|
function treeHash(root, filtered = false) {
|
|
1059
1165
|
const hash = crypto.createHash("sha256");
|
|
1166
|
+
const allowed = typeof filtered === "function" ? filtered : filtered ? projectPayloadFilter(root) : () => true;
|
|
1060
1167
|
const visit = (dir, relative) => {
|
|
1061
1168
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1062
1169
|
const file = path.join(dir, entry.name);
|
|
1063
1170
|
const rel = path.join(relative, entry.name);
|
|
1064
|
-
if (
|
|
1171
|
+
if (!allowed(file))
|
|
1065
1172
|
continue;
|
|
1066
1173
|
if (entry.isDirectory())
|
|
1067
1174
|
visit(file, rel);
|
|
@@ -1080,10 +1187,11 @@ function treeHash(root, filtered = false) {
|
|
|
1080
1187
|
}
|
|
1081
1188
|
function treeModesHash(root, filtered = false) {
|
|
1082
1189
|
const entries = [];
|
|
1190
|
+
const allowed = typeof filtered === "function" ? filtered : filtered ? projectPayloadFilter(root) : () => true;
|
|
1083
1191
|
const visit = (directory) => {
|
|
1084
1192
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1085
1193
|
const file = path.join(directory, entry.name);
|
|
1086
|
-
if (
|
|
1194
|
+
if (!allowed(file))
|
|
1087
1195
|
continue;
|
|
1088
1196
|
if (entry.isDirectory())
|
|
1089
1197
|
visit(file);
|
|
@@ -1131,6 +1239,8 @@ function readManifest(snapshot) {
|
|
|
1131
1239
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1132
1240
|
if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete", "recovery"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
|
|
1133
1241
|
throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
|
|
1242
|
+
if (value.runtime_payload_policy !== undefined && (value.runtime_payload_policy !== "flow-evidence@1" || value.purpose === "recovery"))
|
|
1243
|
+
throw new AppError("snapshot_invalid", "Snapshot runtime payload policy is invalid for its purpose", 1, { snapshot });
|
|
1134
1244
|
if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
|
|
1135
1245
|
throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });
|
|
1136
1246
|
if (value.workspace.kind === "git_worktree" && (!value.workspace.branch || !value.workspace.base_ref || !value.workspace.bundle_sha256))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { ensureWorkLaunchStorage } from "../storage/database.js";
|
|
5
6
|
import { AppError } from "../shared/errors.js";
|
|
6
7
|
import { runtimeBudgetForRun } from "./runtime-budget.js";
|
|
@@ -8,9 +9,12 @@ import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } fro
|
|
|
8
9
|
import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
|
|
9
10
|
import { resolveExecutionPolicy, runtimeHarness, validateExecutionOverride } from "./execution-policy.js";
|
|
10
11
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
11
|
-
import { assertWorkLaunchReady, workStartCommand } from "./work-registry.js";
|
|
12
|
+
import { assertWorkLaunchReady, reviewSourceInputs, workStartCommand } from "./work-registry.js";
|
|
12
13
|
import { initCodexHome } from "./hooks.js";
|
|
13
14
|
import { managedInvocationContext } from "./lifecycle-invocations.js";
|
|
15
|
+
import { reviewExecutionCopyHash, reviewSourceFilter, reviewSourceHash, reviewSourcePaths } from "./review-copy.js";
|
|
16
|
+
import { workspaceGitFiles } from "./workspace-files.js";
|
|
17
|
+
import { qualifyAgyHooks } from "../harness-runtime/lib/dd-agy-daemon.mjs";
|
|
14
18
|
/**
|
|
15
19
|
* Creates an external provider root for one already-created Work. The launch
|
|
16
20
|
* is not a Work claim: only the worker's trusted `work start` hook binds it.
|
|
@@ -43,13 +47,34 @@ export async function launchExternalWork(context, input) {
|
|
|
43
47
|
const resolved = resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy, profiles: snapshot.execution_profile.agent_profiles, ...(stage ? { stage } : {}), ...(override ? { work: override } : {}) });
|
|
44
48
|
if (resolved.delegation.mode !== "external")
|
|
45
49
|
throw new AppError("external_launch_not_selected", "This Work's frozen policy selects native delegation", 2, { work_id: work.work_id });
|
|
50
|
+
const agyWorker = runtimeHarness(resolved.worker) === "antigravity-cli";
|
|
51
|
+
if (agyWorker)
|
|
52
|
+
await qualifyAgyHooks(run.workspace_root);
|
|
46
53
|
const operationId = `OPR-${crypto.randomUUID()}`;
|
|
47
54
|
const root = path.join(run.run_root, "external-launches", operationId);
|
|
48
55
|
const journal = path.join(root, "adapter.events.jsonl");
|
|
49
56
|
fs.mkdirSync(root, { recursive: true });
|
|
57
|
+
const isolatedWorkspace = isReadOnlyWork(work.payload_json) ? path.join(root, "read-only-workspace") : null;
|
|
58
|
+
const reviewInputs = isolatedWorkspace ? reviewSourceInputs(context, work.work_id) : [];
|
|
59
|
+
const reviewBase = snapshot.workspace?.base && snapshot.workspace.base !== "unknown" ? snapshot.workspace.base : "HEAD";
|
|
60
|
+
let reviewCopy = null;
|
|
61
|
+
if (isolatedWorkspace) {
|
|
62
|
+
const allowed = reviewSourceFilter(run.workspace_root, reviewInputs, reviewBase);
|
|
63
|
+
const before = reviewSourceHash(run.workspace_root, reviewInputs, reviewBase);
|
|
64
|
+
fs.cpSync(run.workspace_root, isolatedWorkspace, { recursive: true, verbatimSymlinks: true, filter: allowed });
|
|
65
|
+
if (agyWorker)
|
|
66
|
+
await qualifyAgyHooks(isolatedWorkspace);
|
|
67
|
+
const after = reviewSourceHash(run.workspace_root, reviewInputs, reviewBase), copy = reviewExecutionCopyHash(isolatedWorkspace, run.workspace_root, false, reviewInputs, reviewBase);
|
|
68
|
+
if (before !== after || after !== copy)
|
|
69
|
+
throw new AppError("read_only_input_changed", "Read-only execution copy differs from its authoritative source", 2, { work_id: work.work_id, before, after, copy });
|
|
70
|
+
writeReviewGitEvidence(run.workspace_root, isolatedWorkspace, snapshot.workspace?.base, reviewInputs);
|
|
71
|
+
if (reviewSourceHash(run.workspace_root, reviewInputs, reviewBase) !== after)
|
|
72
|
+
throw new AppError("read_only_input_changed", "Authoritative review workspace changed while collecting Git evidence", 2, { work_id: work.work_id });
|
|
73
|
+
reviewCopy = { root: path.relative(run.run_root, isolatedWorkspace).split(path.sep).join("/"), sha256: reviewExecutionCopyHash(isolatedWorkspace, run.workspace_root, true, reviewInputs, reviewBase), source_sha256: after };
|
|
74
|
+
}
|
|
50
75
|
const now = context.now();
|
|
51
76
|
const flowExecutable = prepareHarnessFlowExecutable(context, root);
|
|
52
|
-
const env = { ...context.env, DD_FLOW_BIN: flowExecutable, DD_FLOW_INVOCATION_SCOPE: undefined, DD_FLOW_CURRENT_INVOCATION: undefined };
|
|
77
|
+
const env = { ...context.env, DD_FLOW_BIN: flowExecutable, DD_FLOW_INVOCATION_SCOPE: undefined, DD_FLOW_CURRENT_INVOCATION: undefined, ...(isolatedWorkspace ? { DD_FLOW_READ_ONLY_WORKSPACE: isolatedWorkspace } : {}) };
|
|
53
78
|
// An external Codex root is a fresh thread, so it does not inherit the
|
|
54
79
|
// coordinator's managed PreToolUse hook. Its first `work start` must emit
|
|
55
80
|
// that hook event to bind the Work to the physical session.
|
|
@@ -61,8 +86,8 @@ export async function launchExternalWork(context, input) {
|
|
|
61
86
|
let runtimeContext = { ...context, env };
|
|
62
87
|
const runtimeOwner = { schema_id: "dd-flow/runtime-owner@1", owner_id: `run:${work.run_id}:work:${work.work_id}`, role: "worker", project_id: work.project_id, run_id: work.run_id, work_id: work.work_id, operation_id: operationId, state_dir: root, dd_flow_home: context.ddFlowHome, dd_flow_bin: flowExecutable, resource_home: context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, budget: runtimeBudgetForRun(context, work.project_id, work.run_id) };
|
|
63
88
|
const executable = resolveHarnessCommand(context.ddFlowHome, runtimeHarness(resolved.worker), "adapter");
|
|
64
|
-
context.db.run("INSERT INTO work_launches (operation_id, work_id, profile_json, profile_hash, status, state_dir, journal_path, provider_session_id, adapter_receipt_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'launching', ?, ?, NULL, NULL, ?, ?)", [operationId, work.work_id, JSON.stringify({ ...resolved, runtime_adapter: { executable, owner: runtimeOwner } }), resolved.profile_hash, root, journal, now, now]);
|
|
65
|
-
const common = ["--dd-flow-bin", flowExecutable, "--state-dir", root, "--journal", journal, "--cwd", run.workspace_root, "--provider", resolved.worker.provider, "--model", resolved.worker.model, "--reasoning", resolved.worker.reasoning, "--mode", resolved.worker.mode, "--permission", resolved.worker.permission, "--project-root", run.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, runtimeHarness(resolved.worker)), "--json"];
|
|
89
|
+
context.db.run("INSERT INTO work_launches (operation_id, work_id, profile_json, profile_hash, status, state_dir, journal_path, provider_session_id, adapter_receipt_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'launching', ?, ?, NULL, NULL, ?, ?)", [operationId, work.work_id, JSON.stringify({ ...resolved, runtime_adapter: { executable, owner: runtimeOwner }, ...(reviewCopy ? { review_copy: reviewCopy } : {}) }), resolved.profile_hash, root, journal, now, now]);
|
|
90
|
+
const common = ["--dd-flow-bin", flowExecutable, "--state-dir", root, "--journal", journal, "--cwd", isolatedWorkspace ?? run.workspace_root, "--provider", resolved.worker.provider, "--model", resolved.worker.model, "--reasoning", resolved.worker.reasoning, "--mode", resolved.worker.mode, "--permission", resolved.worker.permission, "--project-root", run.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, runtimeHarness(resolved.worker)), "--json"];
|
|
66
91
|
const evidence = (value) => fs.appendFileSync(journal, `${JSON.stringify({ type: "adapter_call", at: context.now(), ...value })}\n`);
|
|
67
92
|
try {
|
|
68
93
|
const call = (args, timeoutMs) => runHarnessAdapter({ executable, args, env: runtimeContext.env, operationId: `${operationId}:${args[0]}.${args[1]}`, runtimeOwner, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), onEvidence: evidence });
|
|
@@ -78,8 +103,10 @@ export async function launchExternalWork(context, input) {
|
|
|
78
103
|
projectRoot: run.project_root, runId: work.run_id, generation });
|
|
79
104
|
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
80
105
|
assertWorkLaunchReady(context, work.work_id);
|
|
106
|
+
if (reviewCopy && reviewSourceHash(run.workspace_root, reviewInputs, reviewBase) !== reviewCopy.source_sha256)
|
|
107
|
+
throw new AppError("read_only_input_changed", "Authoritative review workspace changed after the execution copy was made", 2, { work_id: work.work_id });
|
|
81
108
|
const promptFile = path.join(root, "launch.md");
|
|
82
|
-
fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(runtimeContext, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.\n`);
|
|
109
|
+
fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(runtimeContext, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.${isolatedWorkspace ? `\n\nThe review copy has no .git. Read the captured status/diff or gitless note in ${path.join(isolatedWorkspace, ".dd-flow", "review-evidence")}; do not run Git inside the copy.\n` : "\n"}`);
|
|
83
110
|
const prompted = await call(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60_000);
|
|
84
111
|
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
85
112
|
context.db.run("UPDATE work_launches SET status = 'start_requested', provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [sessionId, JSON.stringify({ create: created, prompt: prompted }), context.now(), operationId]);
|
|
@@ -90,6 +117,24 @@ export async function launchExternalWork(context, input) {
|
|
|
90
117
|
throw error;
|
|
91
118
|
}
|
|
92
119
|
}
|
|
120
|
+
export function writeReviewGitEvidence(source, copy, base, explicitInputs = []) {
|
|
121
|
+
const directory = path.join(copy, ".dd-flow", "review-evidence");
|
|
122
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
123
|
+
if (workspaceGitFiles(source, ["--cached"]) === null) {
|
|
124
|
+
fs.writeFileSync(path.join(directory, "gitless.txt"), "Source workspace is not a Git repository. Review the copied files and accepted RUN evidence.\n");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!base || base === "unknown")
|
|
128
|
+
throw new AppError("review_git_evidence_missing", "Git review source has no frozen base commit", 1, { workspace_root: source });
|
|
129
|
+
const paths = (reviewSourcePaths(source, explicitInputs, base) ?? []).map(file => `:(literal)${file}`);
|
|
130
|
+
for (const [name, args] of [["status.txt", ["status", "--short", "--untracked-files=all"]], ["diff.patch", ["diff", "--no-ext-diff", "--no-renames", "--binary", base]]]) {
|
|
131
|
+
const result = paths.length ? spawnSync("git", ["-C", source, ...args, "--", ...paths], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }) : { status: 0, stdout: "", stderr: "", error: undefined };
|
|
132
|
+
if (result.status !== 0 || result.error)
|
|
133
|
+
throw new AppError("review_git_evidence_failed", "Could not collect read-only Git evidence for reviewer", 1, { workspace_root: source, command: name, cause: result.error?.message ?? result.stderr });
|
|
134
|
+
fs.writeFileSync(path.join(directory, name), result.stdout);
|
|
135
|
+
}
|
|
136
|
+
fs.writeFileSync(path.join(directory, "base.txt"), `${base}\n`);
|
|
137
|
+
}
|
|
93
138
|
function workOverride(payload) {
|
|
94
139
|
if (!payload)
|
|
95
140
|
return undefined;
|
|
@@ -108,3 +153,13 @@ function workOverride(payload) {
|
|
|
108
153
|
validateExecutionOverride(execution);
|
|
109
154
|
return execution;
|
|
110
155
|
}
|
|
156
|
+
function isReadOnlyWork(payload) {
|
|
157
|
+
if (!payload)
|
|
158
|
+
return false;
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(payload).read_only === true;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
package/dist/services/prompts.js
CHANGED
|
@@ -300,6 +300,8 @@ function renderPrompt(input) {
|
|
|
300
300
|
`- Acceptance contribution: ${item.semantic_spine?.acceptance_contribution ?? "not_applicable"}`,
|
|
301
301
|
"",
|
|
302
302
|
"## Hard Write Boundary",
|
|
303
|
+
"Before editing, change into the project workspace shown below (cd -- WORKSPACE) or use its absolute path when the tool cannot persist cwd.",
|
|
304
|
+
"Write only the project workspace and the named report directory; never write another RUN/native home or Git/worktree control data.",
|
|
303
305
|
`- All project writes must stay under: \`${input.workspaceRoot}\``,
|
|
304
306
|
"- Do not write outside this workspace, to another RUN, or into Git/worktree control data.",
|
|
305
307
|
"- Accepted requirements and non-goals remain binding; planned write areas do not.",
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { ignoredWorkspacePaths } from "./code-checks.js";
|
|
7
|
+
import { localEnvironmentAllowed, ownedWorkspaceHookPath, projectServicePathFilter, workspaceGitFiles } from "./workspace-files.js";
|
|
8
|
+
export function reviewEnvironmentAllowed(file) {
|
|
9
|
+
return localEnvironmentAllowed(file);
|
|
10
|
+
}
|
|
11
|
+
export function reviewSourcePaths(root, explicitInputs = [], base = "HEAD") {
|
|
12
|
+
const inventory = workspaceGitFiles(root, ["--cached", "--others", "--exclude-standard"]);
|
|
13
|
+
if (inventory === null)
|
|
14
|
+
return null;
|
|
15
|
+
const serviceAllowed = projectServicePathFilter(root, explicitInputs);
|
|
16
|
+
const hasBase = base !== "HEAD" || spawnSync("git", ["-C", root, "rev-parse", "--verify", "HEAD"], { encoding: "utf8" }).status === 0;
|
|
17
|
+
const changes = hasBase ? spawnSync("git", ["-C", root, "diff", "--name-status", "-z", "--find-renames", base, "--"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }) : null;
|
|
18
|
+
if (changes && (changes.status !== 0 || changes.error))
|
|
19
|
+
throw new AppError("review_git_evidence_failed", "Could not classify renamed review paths", 1, { workspace_root: root, cause: changes.error?.message ?? changes.stderr });
|
|
20
|
+
const fields = changes?.stdout.split("\0").filter(Boolean) ?? [], tainted = new Set();
|
|
21
|
+
for (let index = 0; index < fields.length;) {
|
|
22
|
+
const status = fields[index++];
|
|
23
|
+
if (/^[RC]/.test(status)) {
|
|
24
|
+
const before = fields[index++], after = fields[index++];
|
|
25
|
+
if (!reviewEnvironmentAllowed(before) || !reviewEnvironmentAllowed(after)) {
|
|
26
|
+
tainted.add(before);
|
|
27
|
+
tainted.add(after);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else
|
|
31
|
+
index += 1;
|
|
32
|
+
}
|
|
33
|
+
return [...new Set([...inventory, ...explicitInputs])]
|
|
34
|
+
.filter(relative => reviewEnvironmentAllowed(relative) && !tainted.has(relative) && serviceAllowed(path.join(root, relative)))
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
export function reviewSourceFilter(root, explicitInputs = [], base = "HEAD") {
|
|
38
|
+
const selected = reviewSourcePaths(root, explicitInputs, base);
|
|
39
|
+
const serviceAllowed = projectServicePathFilter(root, explicitInputs);
|
|
40
|
+
const retained = new Set(selected?.map(relative => path.resolve(root, relative)) ?? []);
|
|
41
|
+
const branches = new Set();
|
|
42
|
+
for (const file of retained)
|
|
43
|
+
for (let directory = path.dirname(file); directory !== path.dirname(directory); directory = path.dirname(directory))
|
|
44
|
+
branches.add(directory);
|
|
45
|
+
return file => {
|
|
46
|
+
const parts = path.relative(root, file).split(path.sep);
|
|
47
|
+
if (parts.some(part => [".git", "node_modules", ".dd-flow"].includes(part)) || !reviewEnvironmentAllowed(file))
|
|
48
|
+
return false;
|
|
49
|
+
return selected === null ? serviceAllowed(file) : retained.has(path.resolve(file)) || branches.has(path.resolve(file));
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function reviewSourceHash(root, explicitInputs = [], base = "HEAD") {
|
|
53
|
+
const allowed = reviewSourceFilter(root, explicitInputs, base), ownedHook = ownedWorkspaceHookPath(root);
|
|
54
|
+
return reviewCopyHash(root, file => path.relative(root, file) !== ownedHook && allowed(file));
|
|
55
|
+
}
|
|
56
|
+
/** The copied project has no Git index. Apply the source's exact service-file
|
|
57
|
+
* policy to it, while retaining only our frozen Git evidence under .dd-flow. */
|
|
58
|
+
export function reviewExecutionCopyHash(copyRoot, sourceRoot, includeEvidence = true, explicitInputs = [], base = "HEAD") {
|
|
59
|
+
if (ownedWorkspaceHookPath(sourceRoot) && !ownedWorkspaceHookPath(copyRoot))
|
|
60
|
+
throw new AppError("read_only_input_changed", "Qualified review hook differs between execution copy and source", 2, { copy_root: copyRoot });
|
|
61
|
+
const sourceAllowed = reviewSourceFilter(sourceRoot, explicitInputs, base);
|
|
62
|
+
const serviceAllowed = projectServicePathFilter(sourceRoot, explicitInputs);
|
|
63
|
+
const ownedHook = ownedWorkspaceHookPath(sourceRoot);
|
|
64
|
+
const verifySelection = (directory) => {
|
|
65
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
66
|
+
const file = path.join(directory, entry.name), relative = path.relative(copyRoot, file);
|
|
67
|
+
const parts = relative.split(path.sep);
|
|
68
|
+
if (parts[0] === ".dd-flow" || parts.some(part => [".git", "node_modules"].includes(part)))
|
|
69
|
+
continue;
|
|
70
|
+
if (!reviewEnvironmentAllowed(file))
|
|
71
|
+
throw new AppError("read_only_input_changed", "Read-only execution copy contains a local environment file", 2, { path: relative });
|
|
72
|
+
if (!serviceAllowed(path.join(sourceRoot, relative)))
|
|
73
|
+
continue;
|
|
74
|
+
if (!sourceAllowed(path.join(sourceRoot, relative)) && ignoredWorkspacePaths(sourceRoot, [relative]).includes(relative.split(path.sep).join("/")))
|
|
75
|
+
continue;
|
|
76
|
+
if (!sourceAllowed(path.join(sourceRoot, relative)))
|
|
77
|
+
throw new AppError("read_only_input_changed", "Read-only execution copy contains an unselected product input", 2, { path: relative });
|
|
78
|
+
if (entry.isDirectory())
|
|
79
|
+
verifySelection(file);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
verifySelection(copyRoot);
|
|
83
|
+
return reviewCopyHash(copyRoot, file => {
|
|
84
|
+
const relative = path.relative(copyRoot, file);
|
|
85
|
+
const parts = relative.split(path.sep);
|
|
86
|
+
if (parts[0] === ".dd-flow")
|
|
87
|
+
return includeEvidence && (parts.length === 1 || parts[1] === "review-evidence");
|
|
88
|
+
return relative !== ownedHook && sourceAllowed(path.join(sourceRoot, relative));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Exact bytes visible in a read-only execution copy, including symlink targets. */
|
|
92
|
+
export function reviewCopyHash(root, allowed = () => true) {
|
|
93
|
+
const hash = crypto.createHash("sha256");
|
|
94
|
+
let canonicalRoot;
|
|
95
|
+
try {
|
|
96
|
+
canonicalRoot = fs.realpathSync(root);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new AppError("read_only_input_changed", "Read-only execution copy is missing", 2, { root });
|
|
100
|
+
}
|
|
101
|
+
const visit = (directory) => {
|
|
102
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
103
|
+
const file = path.join(directory, entry.name);
|
|
104
|
+
if (!allowed(file))
|
|
105
|
+
continue;
|
|
106
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
107
|
+
const stat = fs.lstatSync(file);
|
|
108
|
+
const record = (kind, value = "") => hash.update(`${JSON.stringify([relative, kind, stat.mode & 0o111, value])}\n`);
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
record("directory");
|
|
111
|
+
visit(file);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (entry.isSymbolicLink()) {
|
|
115
|
+
const link = fs.readlinkSync(file);
|
|
116
|
+
let target;
|
|
117
|
+
try {
|
|
118
|
+
target = fs.realpathSync(file);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new AppError("read_only_workspace_escape", "Read-only copy contains an unresolved symlink", 1, { path: relative });
|
|
122
|
+
}
|
|
123
|
+
const inside = path.relative(canonicalRoot, target);
|
|
124
|
+
if (path.isAbsolute(link) || inside === ".." || inside.startsWith(`..${path.sep}`))
|
|
125
|
+
throw new AppError("read_only_workspace_escape", "Read-only copy contains a symlink outside its workspace", 1, { path: relative });
|
|
126
|
+
record("symlink", link);
|
|
127
|
+
}
|
|
128
|
+
else if (entry.isFile())
|
|
129
|
+
record("file", crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
|
|
130
|
+
else
|
|
131
|
+
throw new AppError("read_only_workspace_unsupported", "Read-only copy contains an unsupported file type", 1, { path: relative });
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
visit(root);
|
|
135
|
+
return hash.digest("hex");
|
|
136
|
+
}
|