@cairnkeep/cli 2.22.3 → 2.22.5
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/CHANGELOG.md +79 -1
- package/containers/healthcheck.mjs +27 -33
- package/containers/quadlet/cairnkeep.container +1 -1
- package/docs/containers.md +12 -0
- package/docs/domain-knowledge.md +15 -0
- package/docs/learning/CURRICULUM-MAP.md +1 -1
- package/docs/learning/FEATURE-GUIDE.md +1 -1
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +72 -2
- package/docs/learning/tracks/operator.md +6 -1
- package/docs/learning/video-scripts/V28-security-assurance.md +65 -0
- package/docs/native-windows.md +10 -0
- package/docs/operating.md +10 -0
- package/docs/privacy-and-data-flow.md +6 -0
- package/docs/releasing.md +61 -2
- package/docs/security-assurance.md +82 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/capability-registry.js +2 -15
- package/mcp-memory-server/dist/context-pack-retrieval.js +6 -6
- package/mcp-memory-server/dist/domain-retrieval-provider.js +100 -45
- package/mcp-memory-server/dist/eval-plan.js +15 -22
- package/mcp-memory-server/dist/eval-runner.js +17 -10
- package/mcp-memory-server/dist/eval-workspace.js +82 -23
- package/mcp-memory-server/dist/failure-signature.js +29 -8
- package/mcp-memory-server/dist/graph-cli.js +25 -10
- package/mcp-memory-server/dist/note-store.js +24 -4
- package/mcp-memory-server/dist/okf.js +4 -9
- package/mcp-memory-server/dist/playbook-receipt.js +3 -17
- package/mcp-memory-server/dist/skill-store.js +10 -26
- package/mcp-memory-server/dist/stable-file.js +85 -0
- package/mcp-memory-server/dist/work-evidence-store.js +7 -5
- package/omp/extensions/cairnkeep-capture.ts +29 -17
- package/package.json +1 -1
- package/scripts/lib/opencode-v2-plugin-harness.mjs +20 -3
- package/scripts/lib/release-verification.mjs +131 -0
- package/scripts/lib/stable-file.mjs +75 -0
- package/scripts/playbook-instructions.mjs +42 -20
- package/scripts/setup-core.mjs +5 -36
- package/scripts/setup-reconcile.mjs +32 -32
- package/scripts/setup.mjs +7 -31
- package/scripts/test-ci-sharding.sh +5 -0
- package/scripts/test-container-health.mjs +56 -0
- package/scripts/test-container.sh +8 -1
- package/scripts/test-learning-docs.sh +8 -0
- package/scripts/test-omp-staged-security.mjs +110 -0
- package/scripts/test-release-verification.mjs +109 -0
- package/scripts/test-release-verification.sh +4 -0
- package/scripts/test-runtime-file-security.mjs +311 -0
- package/scripts/test-setup-reconcile.mjs +4 -2
- package/scripts/test-windows-native.mjs +4 -0
- package/scripts/verify-omp-capture.mjs +1 -0
- package/scripts/verify-phase19-runtime-evidence.mjs +43 -3
- package/scripts/verify-published-release.mjs +138 -0
- package/scripts/windows-platform.mjs +54 -22
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync, readdirSync
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
|
|
6
7
|
// omp port of the claude/hooks memory-capture + memory-wakeup pair (D-03/D-04,
|
|
@@ -25,6 +26,7 @@ import { join } from "node:path";
|
|
|
25
26
|
// Fail-open everywhere: every guard is a cheap check and every path is
|
|
26
27
|
// try/caught so a throw can never reach the extension runner.
|
|
27
28
|
const CAIRN_ROOT = "@@INFRA_ROOT@@";
|
|
29
|
+
const { readStableText } = createRequire(import.meta.url)(join(CAIRN_ROOT, "scripts", "lib", "stable-file.mjs"));
|
|
28
30
|
const SERVER_ENTRY = join(CAIRN_ROOT, "mcp-memory-server", "dist", "index.js");
|
|
29
31
|
const STAGE_ENTRY = join(CAIRN_ROOT, "scripts", "lib", "omp-capture-stage.mjs");
|
|
30
32
|
|
|
@@ -87,21 +89,36 @@ type StagedCandidate = {
|
|
|
87
89
|
|
|
88
90
|
// Bounded staged-candidate scan: the STAGED_MAX_FILES most recent *.json
|
|
89
91
|
// files, each read capped, malformed JSON skipped (fail-open).
|
|
90
|
-
function
|
|
92
|
+
function stagedFiles(repo: string): string[] {
|
|
93
|
+
const planning = join(repo, ".planning");
|
|
91
94
|
const stagingDir = join(repo, ".planning", "memory-staging");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
try {
|
|
96
|
+
for (const path of [planning, stagingDir]) {
|
|
97
|
+
const info = lstatSync(path);
|
|
98
|
+
if (!info.isDirectory() || info.isSymbolicLink()) return [];
|
|
99
|
+
}
|
|
100
|
+
} catch { return []; }
|
|
101
|
+
return readdirSync(stagingDir)
|
|
102
|
+
.filter((name) => /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/.test(name))
|
|
103
|
+
.flatMap((name) => {
|
|
104
|
+
try {
|
|
105
|
+
const info = lstatSync(join(stagingDir, name));
|
|
106
|
+
return info.isFile() && !info.isSymbolicLink() && info.size <= STAGED_FILE_READ_CAP
|
|
107
|
+
? [{ name, mtime: info.mtimeMs }] : [];
|
|
108
|
+
} catch { return []; }
|
|
109
|
+
})
|
|
110
|
+
.sort((a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name, "en"))
|
|
97
111
|
.slice(0, STAGED_MAX_FILES)
|
|
98
112
|
.map(({ name }) => name);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stagedCandidates(repo: string): StagedCandidate[] {
|
|
116
|
+
const stagingDir = join(repo, ".planning", "memory-staging");
|
|
99
117
|
const staged: StagedCandidate[] = [];
|
|
100
|
-
for (const file of
|
|
118
|
+
for (const file of stagedFiles(repo)) {
|
|
101
119
|
try {
|
|
102
120
|
const path = join(stagingDir, file);
|
|
103
|
-
|
|
104
|
-
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
121
|
+
const parsed: unknown = JSON.parse(readStableText(path, { label: "Staged candidate", maxBytes: STAGED_FILE_READ_CAP }).text);
|
|
105
122
|
const count = Array.isArray((parsed as { candidates?: unknown })?.candidates)
|
|
106
123
|
? ((parsed as { candidates: unknown[] }).candidates.length)
|
|
107
124
|
: 0;
|
|
@@ -197,12 +214,7 @@ export default function cairnCaptureExtension(pi: ExtensionAPI): void {
|
|
|
197
214
|
if (ctx.hasUI) ctx.ui.notify("No staged memory candidates (.planning/memory-staging is absent).", "info");
|
|
198
215
|
return;
|
|
199
216
|
}
|
|
200
|
-
const files =
|
|
201
|
-
.filter((name) => name.endsWith(".json"))
|
|
202
|
-
.map((name) => ({ name, mtime: statSync(join(stagingDir, name)).mtimeMs }))
|
|
203
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
204
|
-
.slice(0, STAGED_MAX_FILES)
|
|
205
|
-
.map(({ name }) => name);
|
|
217
|
+
const files = stagedFiles(ctx.cwd);
|
|
206
218
|
if (files.length === 0) {
|
|
207
219
|
if (ctx.hasUI) ctx.ui.notify("No staged memory candidates.", "info");
|
|
208
220
|
return;
|
|
@@ -210,7 +222,7 @@ export default function cairnCaptureExtension(pi: ExtensionAPI): void {
|
|
|
210
222
|
const sections = files.map((file) => {
|
|
211
223
|
let body: string;
|
|
212
224
|
try {
|
|
213
|
-
body =
|
|
225
|
+
body = readStableText(join(stagingDir, file), { label: "Staged candidate", maxBytes: STAGED_FILE_READ_CAP }).text;
|
|
214
226
|
} catch {
|
|
215
227
|
body = "(unreadable)";
|
|
216
228
|
}
|
package/package.json
CHANGED
|
@@ -76,6 +76,9 @@ await beforeHook({ ...editEvent, tool: "read", input: { filePath: join(repo, "ot
|
|
|
76
76
|
|
|
77
77
|
const now = Date.now();
|
|
78
78
|
let subscribed = false;
|
|
79
|
+
let streamFinished = false;
|
|
80
|
+
let finishStream;
|
|
81
|
+
const streamCompletion = new Promise((resolve) => { finishStream = resolve; });
|
|
79
82
|
const captureContext = {
|
|
80
83
|
location: { directory: repo },
|
|
81
84
|
event: {
|
|
@@ -84,6 +87,11 @@ const captureContext = {
|
|
|
84
87
|
assert.equal(signal instanceof AbortSignal, true);
|
|
85
88
|
yield { type: "session.compacted", data: { sessionID: "v2-session" } };
|
|
86
89
|
yield { type: "session.idle", data: { sessionID: "v2-session" } };
|
|
90
|
+
// File creation is not capture completion. Keep that distinction
|
|
91
|
+
// deterministic even when the child writer is unusually fast.
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
93
|
+
streamFinished = true;
|
|
94
|
+
finishStream();
|
|
87
95
|
},
|
|
88
96
|
},
|
|
89
97
|
session: {
|
|
@@ -125,10 +133,19 @@ const captureContext = {
|
|
|
125
133
|
const cleanup = await capture.default.setup(captureContext);
|
|
126
134
|
assert.equal(typeof cleanup, "function");
|
|
127
135
|
const db = join(repo, ".agentfs", "trajectory.db");
|
|
128
|
-
|
|
129
|
-
|
|
136
|
+
let completionTimer;
|
|
137
|
+
try {
|
|
138
|
+
await Promise.race([
|
|
139
|
+
streamCompletion,
|
|
140
|
+
new Promise((_resolve, reject) => {
|
|
141
|
+
completionTimer = setTimeout(() => reject(new Error("V2 fixture capture did not complete within eight seconds")), 8000);
|
|
142
|
+
}),
|
|
143
|
+
]);
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(completionTimer);
|
|
146
|
+
cleanup();
|
|
130
147
|
}
|
|
131
|
-
|
|
148
|
+
assert.equal(streamFinished, true, "fixture readers must wait for completed capture, not database creation");
|
|
132
149
|
assert.equal(subscribed, true, "V2 capture must subscribe to the public event stream");
|
|
133
150
|
assert.equal(existsSync(db), true, "V2 idle event must create a trajectory record");
|
|
134
151
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function requireThat(condition, label) { if (!condition) throw new Error(`Release verification failed: ${label}.`); }
|
|
4
|
+
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
5
|
+
const digestPattern = /^sha256:[a-f0-9]{64}$/;
|
|
6
|
+
|
|
7
|
+
export function validateReleaseOptions(options) {
|
|
8
|
+
requireThat(/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(options.repo ?? ""), "repository");
|
|
9
|
+
requireThat(/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(options.version ?? ""), "stable version");
|
|
10
|
+
for (const field of ["commit", "tree"]) requireThat(/^[a-f0-9]{40}$/.test(options[field] ?? ""), `exact ${field}`);
|
|
11
|
+
for (const field of ["ciRun", "securityRun", "publishRun"]) requireThat(/^[1-9][0-9]*$/.test(options[field] ?? ""), field);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function releaseAttestationArguments(image, digest, options) {
|
|
15
|
+
validateReleaseOptions(options);
|
|
16
|
+
requireThat(digestPattern.test(digest), "attestation subject digest");
|
|
17
|
+
requireThat(image === options.repo || image === `${options.repo}-workspace`, "attestation image repository");
|
|
18
|
+
// Exact certificate identity binds repository, workflow and tag. The CLI
|
|
19
|
+
// makes this mutually exclusive with its broader signer-workflow selector.
|
|
20
|
+
return ["attestation", "verify", `oci://ghcr.io/${image}@${digest}`, "--repo", options.repo,
|
|
21
|
+
"--source-ref", `refs/tags/v${options.version}`, "--source-digest", options.commit,
|
|
22
|
+
"--cert-identity", `https://github.com/${options.repo}/.github/workflows/publish.yml@refs/tags/v${options.version}`,
|
|
23
|
+
"--cert-oidc-issuer", "https://token.actions.githubusercontent.com",
|
|
24
|
+
"--predicate-type", "https://slsa.dev/provenance/v1", "--deny-self-hosted-runners", "--format", "json"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function json(bytes, label) {
|
|
28
|
+
try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); }
|
|
29
|
+
catch { throw new Error(`Release verification failed: ${label} encoding or JSON.`); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function verifyAssets(options, candidate, released) {
|
|
33
|
+
const names = [`cairnkeep-cli-${options.version}.tgz`, `cairnkeep-cli-${options.version}.cdx.json`];
|
|
34
|
+
const exactKeys = (map, expected) => map instanceof Map && JSON.stringify([...map.keys()].sort()) === JSON.stringify(expected.sort());
|
|
35
|
+
requireThat(exactKeys(candidate, [...names, "SHA256SUMS", "RELEASE-CANDIDATE.json"]), "candidate file set");
|
|
36
|
+
requireThat(exactKeys(released, [...names, "SHA256SUMS"]), "release asset set");
|
|
37
|
+
const manifest = json(candidate.get("RELEASE-CANDIDATE.json"), "candidate manifest");
|
|
38
|
+
requireThat(Object.keys(manifest).sort().join(",") === "schema_version,tree,version"
|
|
39
|
+
&& manifest.schema_version === 1 && manifest.tree === options.tree && manifest.version === options.version, "candidate manifest binding");
|
|
40
|
+
const sums = new TextDecoder("utf-8", { fatal: true }).decode(candidate.get("SHA256SUMS")).trim().split("\n");
|
|
41
|
+
requireThat(sums.length === names.length, "checksum file count");
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const line of sums) {
|
|
44
|
+
const match = /^([a-f0-9]{64})\s+\*?(?:\.\/)?([A-Za-z0-9._-]+)$/.exec(line);
|
|
45
|
+
requireThat(match && names.includes(match[2]) && !seen.has(match[2]), "checksum paths and uniqueness");
|
|
46
|
+
seen.add(match[2]);
|
|
47
|
+
requireThat(hash(candidate.get(match[2])) === match[1], "candidate checksum");
|
|
48
|
+
requireThat(candidate.get(match[2]).equals(released.get(match[2])), "release/candidate exact bytes");
|
|
49
|
+
}
|
|
50
|
+
requireThat(candidate.get("SHA256SUMS").equals(released.get("SHA256SUMS")), "checksum asset exact bytes");
|
|
51
|
+
const sbom = json(candidate.get(names[1]), "SBOM");
|
|
52
|
+
requireThat(sbom.bomFormat === "CycloneDX" && sbom.specVersion === "1.6"
|
|
53
|
+
&& sbom.metadata?.component?.version === options.version, "CycloneDX release binding");
|
|
54
|
+
return candidate.get(names[0]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function verifyPublishedRelease(options, io) {
|
|
58
|
+
validateReleaseOptions(options);
|
|
59
|
+
const api = path => io.api(path);
|
|
60
|
+
const ci = await api(`actions/runs/${options.ciRun}`);
|
|
61
|
+
requireThat(ci.conclusion === "success" && ci.path === ".github/workflows/ci.yml", "successful maintained CI");
|
|
62
|
+
requireThat((await api(`git/commits/${ci.head_sha}`)).tree?.sha === options.tree, "tested tree");
|
|
63
|
+
requireThat((await api(`git/commits/${options.commit}`)).tree?.sha === options.tree, "release tree");
|
|
64
|
+
const security = await api(`actions/runs/${options.securityRun}`);
|
|
65
|
+
requireThat(security.conclusion === "success" && security.path === ".github/workflows/security.yml", "successful maintained security workflow");
|
|
66
|
+
requireThat((await api(`git/commits/${security.head_sha}`)).tree?.sha === options.tree, "security-tested tree");
|
|
67
|
+
let tag = (await api(`git/ref/tags/v${options.version}`)).object;
|
|
68
|
+
for (let depth = 0; tag?.type === "tag" && depth < 4; depth++) tag = (await api(`git/tags/${tag.sha}`)).object;
|
|
69
|
+
requireThat(tag?.type === "commit" && tag.sha === options.commit, "immutable tag source");
|
|
70
|
+
const publication = await api(`actions/runs/${options.publishRun}`);
|
|
71
|
+
requireThat(publication.conclusion === "success" && publication.path === ".github/workflows/publish.yml"
|
|
72
|
+
&& publication.event === "release" && publication.head_sha === options.commit, "successful exact-source publication");
|
|
73
|
+
const release = await api(`releases/tags/v${options.version}`);
|
|
74
|
+
requireThat(release.tag_name === `v${options.version}` && release.draft === false && release.prerelease === false, "published stable release");
|
|
75
|
+
const artifacts = (await api(`actions/runs/${options.ciRun}/artifacts?per_page=100`)).artifacts;
|
|
76
|
+
const matches = artifacts?.filter(a => a.name === `release-candidate-${options.tree}` && a.expired === false);
|
|
77
|
+
requireThat(matches?.length === 1, "one retained exact-tree candidate");
|
|
78
|
+
const tarball = verifyAssets(options, await io.candidate(matches[0]), await io.release());
|
|
79
|
+
const registry = await io.registry();
|
|
80
|
+
let url;
|
|
81
|
+
try { url = new URL(registry.dist?.tarball); } catch { /* fail below */ }
|
|
82
|
+
requireThat(registry.version === options.version && url?.protocol === "https:" && url.hostname === "registry.npmjs.org"
|
|
83
|
+
&& !url.username && !url.password && !url.port && !url.hash, "registry tarball authority");
|
|
84
|
+
const publishedTarball = await io.tarball(url.href);
|
|
85
|
+
requireThat(tarball.equals(publishedTarball), "registry/candidate exact bytes");
|
|
86
|
+
requireThat(registry.dist.integrity === `sha512-${createHash("sha512").update(tarball).digest("base64")}`, "registry integrity");
|
|
87
|
+
requireThat(await io.verifyNpm(tarball), "npm signatures and attestations");
|
|
88
|
+
|
|
89
|
+
const images = [];
|
|
90
|
+
const [owner, project] = options.repo.split("/");
|
|
91
|
+
for (const name of [project, `${project}-workspace`]) {
|
|
92
|
+
const image = `${owner}/${name}`;
|
|
93
|
+
const get = async (kind, ref) => {
|
|
94
|
+
requireThat([options.version, "latest"].includes(ref) || digestPattern.test(ref), "OCI reference");
|
|
95
|
+
const result = await io.oci(image, kind, ref);
|
|
96
|
+
requireThat(digestPattern.test(result.digest) && (!ref.startsWith("sha256:") || result.digest === ref), "OCI content digest");
|
|
97
|
+
return result;
|
|
98
|
+
};
|
|
99
|
+
const index = await get("manifests", options.version);
|
|
100
|
+
requireThat((await get("manifests", "latest")).digest === index.digest, "stable image channel");
|
|
101
|
+
const manifests = index.value.manifests;
|
|
102
|
+
requireThat(Array.isArray(manifests) && manifests.length <= 32, "bounded OCI index");
|
|
103
|
+
for (const arch of ["amd64", "arm64"]) {
|
|
104
|
+
const entries = manifests.filter(m => m.platform?.os === "linux" && m.platform?.architecture === arch);
|
|
105
|
+
requireThat(entries.length === 1, "one image per supported architecture");
|
|
106
|
+
const entry = entries[0];
|
|
107
|
+
const manifest = (await get("manifests", entry.digest)).value;
|
|
108
|
+
const config = (await get("blobs", manifest.config?.digest)).value;
|
|
109
|
+
requireThat(config.os === "linux" && config.architecture === arch
|
|
110
|
+
&& config.config?.Labels?.["org.opencontainers.image.revision"] === options.commit, "image platform/source binding");
|
|
111
|
+
const attestations = manifests.filter(m => m.annotations?.["vnd.docker.reference.digest"] === entry.digest
|
|
112
|
+
&& m.annotations?.["vnd.docker.reference.type"] === "attestation-manifest");
|
|
113
|
+
requireThat(attestations.length === 1, "bound build attestation");
|
|
114
|
+
const layers = (await get("manifests", attestations[0].digest)).value.layers;
|
|
115
|
+
requireThat(Array.isArray(layers) && layers.length <= 32, "bounded attestation layers");
|
|
116
|
+
let sbom = false;
|
|
117
|
+
for (const layer of layers) {
|
|
118
|
+
const statement = (await get("blobs", layer.digest)).value;
|
|
119
|
+
if (statement.predicateType === "https://spdx.dev/Document" && statement.predicate?.spdxVersion?.startsWith("SPDX-")
|
|
120
|
+
&& statement.subject?.some(s => s.digest?.sha256 === entry.digest.slice(7))) sbom = true;
|
|
121
|
+
}
|
|
122
|
+
requireThat(sbom, "per-architecture bound SPDX SBOM");
|
|
123
|
+
}
|
|
124
|
+
requireThat(await io.verifyAttestation(image, index.digest, options), "verified exact-source release-workflow provenance");
|
|
125
|
+
images.push({ image: `ghcr.io/${image}`, digest: index.digest, platforms: ["linux/amd64", "linux/arm64"] });
|
|
126
|
+
}
|
|
127
|
+
return { schema_version: 1, status: "verified", ...options, candidate_artifact: matches[0].id,
|
|
128
|
+
exact_bytes: true, cyclonedx: "1.6", npm_signatures_and_attestations: true, images,
|
|
129
|
+
scope: "release-artifacts-only", anonymous_oci_metadata_verified: true,
|
|
130
|
+
full_image_layer_pull_verified: false, deployment_verified: false };
|
|
131
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
function sameFile(left, right) {
|
|
5
|
+
return right.isFile() && !right.isSymbolicLink()
|
|
6
|
+
&& left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
7
|
+
&& left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs
|
|
8
|
+
&& left.uid === right.uid && left.gid === right.gid && left.mode === right.mode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Recheck an already descriptor-inspected mutation target. This is a
|
|
12
|
+
// point-in-time guard, not an operating-system compare-and-swap primitive.
|
|
13
|
+
export function assertFileUnchanged(path, expected, label) {
|
|
14
|
+
try {
|
|
15
|
+
const named = fs.lstatSync(path);
|
|
16
|
+
if (expected && sameFile(expected, named)) return;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (!expected && error?.code === "ENOENT") return;
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`${label} changed or is unsafe before publication.`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Bootstrap utilities must work before the TypeScript server has been built.
|
|
24
|
+
// Keep the descriptor-first contract aligned with src/stable-file.ts.
|
|
25
|
+
export function readStableFile(path, { label, maxBytes, privateMode, nonExecutable = false }) {
|
|
26
|
+
let descriptor;
|
|
27
|
+
try {
|
|
28
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error();
|
|
29
|
+
descriptor = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)
|
|
30
|
+
| (fs.constants.O_NOCTTY ?? 0) | (process.platform === "win32" ? 0 : fs.constants.O_NONBLOCK ?? 0));
|
|
31
|
+
const opened = fs.fstatSync(descriptor);
|
|
32
|
+
if (!opened.isFile() || opened.size > maxBytes) throw new Error();
|
|
33
|
+
if (process.platform !== "win32") {
|
|
34
|
+
if (nonExecutable && (opened.mode & 0o111) !== 0) throw new Error(`${label} has an unsafe executable mode.`);
|
|
35
|
+
if (privateMode !== undefined && ((opened.mode & 0o777) !== privateMode
|
|
36
|
+
|| (typeof process.getuid === "function" && opened.uid !== process.getuid() && opened.uid !== 0))) throw new Error();
|
|
37
|
+
}
|
|
38
|
+
const parent = fs.lstatSync(dirname(path));
|
|
39
|
+
const named = fs.lstatSync(path);
|
|
40
|
+
if (named.isSymbolicLink() || parent.isSymbolicLink()) throw new Error(`${label} crosses a symlink.`);
|
|
41
|
+
if (!parent.isDirectory() || !sameFile(opened, named)) throw new Error();
|
|
42
|
+
const buffer = Buffer.allocUnsafe(opened.size + 1);
|
|
43
|
+
let offset = 0;
|
|
44
|
+
while (offset < buffer.byteLength) {
|
|
45
|
+
const count = fs.readSync(descriptor, buffer, offset, buffer.byteLength - offset, null);
|
|
46
|
+
if (count === 0) break;
|
|
47
|
+
offset += count;
|
|
48
|
+
}
|
|
49
|
+
const after = fs.fstatSync(descriptor);
|
|
50
|
+
const parentAfter = fs.lstatSync(dirname(path));
|
|
51
|
+
if (!sameFile(opened, after) || offset !== opened.size
|
|
52
|
+
|| !parentAfter.isDirectory() || parentAfter.isSymbolicLink()
|
|
53
|
+
|| parent.dev !== parentAfter.dev || parent.ino !== parentAfter.ino
|
|
54
|
+
|| !sameFile(opened, fs.lstatSync(path))) throw new Error();
|
|
55
|
+
return { bytes: buffer.subarray(0, offset), stat: opened };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof Error && [`${label} crosses a symlink.`, `${label} has an unsafe executable mode.`].includes(error.message)) throw error;
|
|
58
|
+
if (error?.code === "ELOOP") throw new Error(`${label} crosses a symlink.`);
|
|
59
|
+
throw new Error(`${label} is unsafe, changed or unreadable.`);
|
|
60
|
+
} finally {
|
|
61
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readStableText(path, options) {
|
|
66
|
+
const { bytes, stat } = readStableFile(path, options);
|
|
67
|
+
try { return { text: new TextDecoder("utf-8", { fatal: true }).decode(bytes), stat }; }
|
|
68
|
+
catch { throw new Error(`${options.label} contains invalid UTF-8.`); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readStableJson(path, options) {
|
|
72
|
+
const { text } = readStableText(path, options);
|
|
73
|
+
try { return JSON.parse(text); }
|
|
74
|
+
catch { throw new Error(`${options.label} contains invalid JSON.`); }
|
|
75
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { closeSync,
|
|
3
|
+
import { closeSync, existsSync, fchmodSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, constants } from "node:fs";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { readStableText, assertFileUnchanged } from "./lib/stable-file.mjs";
|
|
6
7
|
|
|
7
8
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
9
|
const TEMPLATE = join(ROOT, "templates", "playbook-agent-instructions.md.template");
|
|
@@ -11,17 +12,21 @@ const END = "<!-- cairnkeep:playbook:v1:end -->";
|
|
|
11
12
|
const MAX_BYTES = 1024 * 1024;
|
|
12
13
|
|
|
13
14
|
function safeRead(path) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
const { text, stat } = readStableText(path, { label: "AGENTS.md", maxBytes: MAX_BYTES });
|
|
16
|
+
return { text, mode: stat.mode & 0o777, stat };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeRoot(projectRoot, allowMissing = false) {
|
|
20
|
+
const root = resolve(projectRoot);
|
|
21
|
+
let info;
|
|
22
|
+
try { info = lstatSync(root); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
// Planning a new project must remain read-only before setup creates it.
|
|
25
|
+
if (allowMissing && error?.code === "ENOENT") return root;
|
|
26
|
+
throw error;
|
|
24
27
|
}
|
|
28
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Playbook project directory is unsafe.");
|
|
29
|
+
return root;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
function count(text, marker) {
|
|
@@ -50,38 +55,55 @@ function removeBlock(current) {
|
|
|
50
55
|
return `${current.slice(0, start)}${current.slice(end + END.length)}`.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
function atomicWrite(path, text, mode) {
|
|
58
|
+
function atomicWrite(path, text, mode, expected = null) {
|
|
59
|
+
const parent = lstatSync(dirname(path));
|
|
60
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error("Instruction publication parent is unsafe.");
|
|
54
61
|
const temporary = join(dirname(path), `.AGENTS.md.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
62
|
+
let descriptor;
|
|
63
|
+
let ownsTemporary = false;
|
|
55
64
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), mode);
|
|
66
|
+
ownsTemporary = true;
|
|
67
|
+
writeFileSync(descriptor, text, { encoding: "utf8" });
|
|
68
|
+
fchmodSync(descriptor, mode);
|
|
69
|
+
fsyncSync(descriptor);
|
|
70
|
+
closeSync(descriptor);
|
|
71
|
+
descriptor = undefined;
|
|
72
|
+
const currentParent = lstatSync(dirname(path));
|
|
73
|
+
if (!currentParent.isDirectory() || currentParent.isSymbolicLink()
|
|
74
|
+
|| parent.dev !== currentParent.dev || parent.ino !== currentParent.ino) throw new Error("Instruction publication parent changed.");
|
|
75
|
+
if (readStableText(temporary, { label: "Instruction publication", maxBytes: MAX_BYTES }).text !== text) throw new Error("Instruction publication changed.");
|
|
76
|
+
assertFileUnchanged(path, expected, "AGENTS.md");
|
|
77
|
+
if (expected) renameSync(temporary, path);
|
|
78
|
+
else linkSync(temporary, path);
|
|
58
79
|
} finally {
|
|
59
|
-
|
|
80
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
81
|
+
if (ownsTemporary) rmSync(temporary, { force: true });
|
|
60
82
|
}
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
export function reconcilePlaybookInstructions(projectRoot = process.cwd(), options = {}) {
|
|
64
|
-
const root =
|
|
86
|
+
const root = safeRoot(projectRoot, Boolean(options.check));
|
|
65
87
|
const path = join(root, "AGENTS.md");
|
|
66
88
|
if (dirname(path) !== root) throw new Error("AGENTS.md path escapes the project root.");
|
|
67
89
|
const block = readFileSync(TEMPLATE, "utf8");
|
|
68
90
|
const existing = existsSync(path) ? safeRead(path) : { text: "", mode: 0o644 };
|
|
69
91
|
const desired = render(existing.text, block);
|
|
70
92
|
const status = !existsSync(path) ? "created" : desired === existing.text ? "unchanged" : "updated";
|
|
71
|
-
if (!options.check && status !== "unchanged") atomicWrite(path, desired, existing.mode || 0o644);
|
|
93
|
+
if (!options.check && status !== "unchanged") atomicWrite(path, desired, existing.mode || 0o644, existing.stat ?? null);
|
|
72
94
|
return Object.freeze({ schema_version: 1, path, status: options.check && status !== "unchanged" ? `would-${status}` : status });
|
|
73
95
|
}
|
|
74
96
|
|
|
75
97
|
export function removePlaybookInstructions(projectRoot = process.cwd(), options = {}) {
|
|
76
|
-
const root =
|
|
98
|
+
const root = safeRoot(projectRoot);
|
|
77
99
|
const path = join(root, "AGENTS.md");
|
|
78
100
|
if (!existsSync(path)) return Object.freeze({ schema_version: 1, path, status: "unchanged" });
|
|
79
101
|
const existing = safeRead(path);
|
|
80
102
|
const desired = removeBlock(existing.text);
|
|
81
103
|
const status = desired === existing.text ? "unchanged" : "removed";
|
|
82
104
|
if (!options.check && status === "removed") {
|
|
83
|
-
if (desired.trim()) atomicWrite(path, `${desired}\n`, existing.mode || 0o644);
|
|
84
|
-
else rmSync(path);
|
|
105
|
+
if (desired.trim()) atomicWrite(path, `${desired}\n`, existing.mode || 0o644, existing.stat);
|
|
106
|
+
else { assertFileUnchanged(path, existing.stat, "AGENTS.md"); rmSync(path); }
|
|
85
107
|
}
|
|
86
108
|
return Object.freeze({ schema_version: 1, path, status: options.check && status === "removed" ? "would-remove" : status });
|
|
87
109
|
}
|
package/scripts/setup-core.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
2
|
import { accessSync } from "node:fs";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { dirname, join, parse, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { HARNESS_IDS, harnessProjectAssets } from "./harness-registry.mjs";
|
|
7
|
+
import { readStableJson } from "./lib/stable-file.mjs";
|
|
7
8
|
|
|
8
9
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
10
|
const TEMPLATE_ROOT = join(ROOT, "templates");
|
|
@@ -111,45 +112,13 @@ function parsePolicy(value) {
|
|
|
111
112
|
return deepFreeze(policy);
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
export function readSetupPolicy(path
|
|
115
|
-
const lstat = options.lstat ?? lstatSync;
|
|
116
|
-
const read = options.readFile ?? readFileSync;
|
|
117
|
-
let info;
|
|
118
|
-
try {
|
|
119
|
-
info = lstat(path);
|
|
120
|
-
} catch (error) {
|
|
121
|
-
throw operational("unsafe-policy", `Setup policy could not be read: ${error instanceof Error ? error.message : String(error)}.`);
|
|
122
|
-
}
|
|
123
|
-
if (!info.isFile() || info.isSymbolicLink()) throw operational("unsafe-policy", "Setup policy must be a regular file, not a symbolic link or device.");
|
|
124
|
-
if (info.size > POLICY_LIMIT_BYTES) throw operational("unsafe-policy", "Setup policy exceeds the size limit.");
|
|
125
|
-
if (process.platform !== "win32" && (info.mode & 0o111) !== 0) throw operational("unsafe-policy", "Setup policy must not be executable.");
|
|
126
|
-
let descriptor;
|
|
127
|
-
let bytes;
|
|
115
|
+
export function readSetupPolicy(path) {
|
|
128
116
|
try {
|
|
129
|
-
|
|
130
|
-
else {
|
|
131
|
-
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
132
|
-
const opened = fstatSync(descriptor);
|
|
133
|
-
if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino) throw operational("unsafe-policy", "Setup policy changed during validation.");
|
|
134
|
-
if (opened.size > POLICY_LIMIT_BYTES || (process.platform !== "win32" && (opened.mode & 0o111) !== 0)) {
|
|
135
|
-
throw operational("unsafe-policy", "Setup policy type, size, or executable mode is unsafe.");
|
|
136
|
-
}
|
|
137
|
-
bytes = read(descriptor);
|
|
138
|
-
}
|
|
117
|
+
return parsePolicy(readStableJson(path, { label: "Setup policy", maxBytes: POLICY_LIMIT_BYTES, nonExecutable: true }));
|
|
139
118
|
} catch (error) {
|
|
140
119
|
if (error instanceof SetupInputError) throw error;
|
|
141
|
-
throw operational("unsafe-policy",
|
|
142
|
-
} finally {
|
|
143
|
-
if (descriptor !== undefined) closeSync(descriptor);
|
|
144
|
-
}
|
|
145
|
-
if (bytes.byteLength > POLICY_LIMIT_BYTES) throw operational("unsafe-policy", "Setup policy exceeds the size limit.");
|
|
146
|
-
let parsed;
|
|
147
|
-
try {
|
|
148
|
-
parsed = JSON.parse(bytes.toString("utf8"));
|
|
149
|
-
} catch {
|
|
150
|
-
throw operational("unsafe-policy", "Invalid setup policy JSON syntax.");
|
|
120
|
+
throw operational("unsafe-policy", error instanceof Error ? error.message : "Setup policy is unsafe or unreadable.");
|
|
151
121
|
}
|
|
152
|
-
return parsePolicy(parsed);
|
|
153
122
|
}
|
|
154
123
|
|
|
155
124
|
function parseHarnesses(raw) {
|
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
chmodSync,
|
|
4
|
-
closeSync,
|
|
5
|
-
constants,
|
|
6
4
|
existsSync,
|
|
7
|
-
fstatSync,
|
|
8
5
|
lstatSync,
|
|
9
6
|
mkdirSync,
|
|
10
|
-
openSync,
|
|
11
|
-
readFileSync,
|
|
12
7
|
rmSync,
|
|
13
|
-
statSync,
|
|
14
8
|
writeFileSync,
|
|
15
9
|
} from "node:fs";
|
|
16
10
|
import { open, rename } from "node:fs/promises";
|
|
17
11
|
import { spawnSync } from "node:child_process";
|
|
18
12
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
19
13
|
import { HARNESS_IDS } from "./harness-registry.mjs";
|
|
14
|
+
import { readStableFile, readStableJson, assertFileUnchanged } from "./lib/stable-file.mjs";
|
|
15
|
+
|
|
16
|
+
const MAX_SETUP_BYTES = 1024 * 1024;
|
|
20
17
|
|
|
21
18
|
export const SETUP_STATE_SCHEMA_VERSION = 1;
|
|
22
19
|
|
|
@@ -218,41 +215,37 @@ function hardenPrivatePath(path) {
|
|
|
218
215
|
if (result.status !== 0) throw new Error("Unable to restrict private setup state.");
|
|
219
216
|
}
|
|
220
217
|
|
|
221
|
-
async function atomicWrite(path, bytes, mode, atomicReplace = defaultAtomicReplace, privateState = false) {
|
|
218
|
+
async function atomicWrite(path, bytes, mode, atomicReplace = defaultAtomicReplace, privateState = false, expected = null) {
|
|
219
|
+
const parent = lstatSync(dirname(path));
|
|
220
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error("Setup publication parent is unsafe.");
|
|
222
221
|
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
223
222
|
let handle;
|
|
223
|
+
let ownsTemporary = false;
|
|
224
224
|
try {
|
|
225
225
|
handle = await open(temporary, "wx", mode);
|
|
226
|
+
ownsTemporary = true;
|
|
226
227
|
await handle.writeFile(bytes);
|
|
228
|
+
await handle.chmod(mode);
|
|
227
229
|
await handle.sync();
|
|
228
230
|
await handle.close();
|
|
229
231
|
handle = undefined;
|
|
230
|
-
chmodSync(temporary, mode);
|
|
231
232
|
if (privateState) hardenPrivatePath(temporary);
|
|
233
|
+
const currentParent = lstatSync(dirname(path));
|
|
234
|
+
if (!currentParent.isDirectory() || currentParent.isSymbolicLink()
|
|
235
|
+
|| parent.dev !== currentParent.dev || parent.ino !== currentParent.ino) throw new Error("Setup publication parent changed.");
|
|
236
|
+
if (!readStableFile(temporary, { label: "Setup publication", maxBytes: MAX_SETUP_BYTES }).bytes.equals(bytes)) throw new Error("Setup publication changed.");
|
|
237
|
+
assertFileUnchanged(path, expected, "Setup target");
|
|
232
238
|
await atomicReplace(temporary, path);
|
|
233
|
-
chmodSync(path, mode);
|
|
234
239
|
if (privateState) hardenPrivatePath(path);
|
|
235
240
|
} finally {
|
|
236
241
|
if (handle) await handle.close().catch(() => undefined);
|
|
237
|
-
rmSync(temporary, { force: true });
|
|
242
|
+
if (ownsTemporary) rmSync(temporary, { force: true });
|
|
238
243
|
}
|
|
239
244
|
}
|
|
240
245
|
|
|
241
246
|
function readPriorState(statePath) {
|
|
242
247
|
if (!existsSync(statePath)) return null;
|
|
243
|
-
|
|
244
|
-
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("Existing setup state is unsafe.");
|
|
245
|
-
let descriptor;
|
|
246
|
-
try {
|
|
247
|
-
descriptor = openSync(statePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
248
|
-
const opened = fstatSync(descriptor);
|
|
249
|
-
if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) {
|
|
250
|
-
throw new Error("Existing setup state changed during validation.");
|
|
251
|
-
}
|
|
252
|
-
return normalizeState(JSON.parse(readFileSync(descriptor, "utf8")));
|
|
253
|
-
} finally {
|
|
254
|
-
if (descriptor !== undefined) closeSync(descriptor);
|
|
255
|
-
}
|
|
248
|
+
return normalizeState(readStableJson(statePath, { label: "Existing setup state", maxBytes: MAX_SETUP_BYTES }));
|
|
256
249
|
}
|
|
257
250
|
|
|
258
251
|
export async function writeSetupStateAtomic(path, state, options = {}) {
|
|
@@ -265,8 +258,13 @@ export async function writeSetupStateAtomic(path, state, options = {}) {
|
|
|
265
258
|
safeMkdirChain(target, directory);
|
|
266
259
|
hardenPrivatePath(directory);
|
|
267
260
|
const bytes = Buffer.from(`${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
|
268
|
-
|
|
269
|
-
|
|
261
|
+
let expected = null;
|
|
262
|
+
if (existsSync(path)) {
|
|
263
|
+
const current = readStableFile(path, { label: "Existing setup state", maxBytes: MAX_SETUP_BYTES });
|
|
264
|
+
if (current.bytes.equals(bytes) && (current.stat.mode & 0o777) === 0o600) return normalized;
|
|
265
|
+
expected = current.stat;
|
|
266
|
+
}
|
|
267
|
+
await atomicWrite(path, bytes, 0o600, options.atomicReplace ?? defaultAtomicReplace, true, expected);
|
|
270
268
|
return normalized;
|
|
271
269
|
}
|
|
272
270
|
|
|
@@ -292,20 +290,22 @@ export async function reconcileSetupPlan(plan, options = {}) {
|
|
|
292
290
|
|
|
293
291
|
const decisions = selected.map((asset) => {
|
|
294
292
|
if (!existsSync(asset.destination)) return { ...asset, status: "created" };
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
293
|
+
const current = readStableFile(asset.destination, { label: "Setup asset", maxBytes: MAX_SETUP_BYTES });
|
|
294
|
+
const currentDigest = hashSetupAsset(current.bytes);
|
|
295
|
+
const currentMode = current.stat.mode & 0o777;
|
|
296
|
+
// Windows mode bits do not represent POSIX executability. Match diagnosis:
|
|
297
|
+
// retain digest ownership there, while writers enforce private state ACLs.
|
|
298
|
+
const modeMatches = (mode) => process.platform === "win32" || currentMode === mode;
|
|
299
|
+
if (currentDigest === asset.digest && modeMatches(asset.mode)) return { ...asset, status: "unchanged" };
|
|
300
300
|
const prior = previousState?.assets?.[asset.path];
|
|
301
|
-
if (prior && prior.digest === currentDigest && prior.mode
|
|
301
|
+
if (prior && prior.digest === currentDigest && modeMatches(prior.mode)) return { ...asset, status: "updated", expected: current.stat };
|
|
302
302
|
return { ...asset, status: "skipped" };
|
|
303
303
|
});
|
|
304
304
|
|
|
305
305
|
for (const decision of decisions) {
|
|
306
306
|
if (decision.status !== "created" && decision.status !== "updated") continue;
|
|
307
307
|
safeMkdirChain(target, dirname(decision.destination));
|
|
308
|
-
await atomicWrite(decision.destination, decision.bytes, decision.mode, options.atomicReplace ?? defaultAtomicReplace, decision.mode === 0o600);
|
|
308
|
+
await atomicWrite(decision.destination, decision.bytes, decision.mode, options.atomicReplace ?? defaultAtomicReplace, decision.mode === 0o600, decision.expected ?? null);
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
const assets = {};
|