yam-flow 2.5.0 → 2.5.1
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 +26 -0
- package/README.md +6 -2
- package/ROADMAP.md +4 -1
- package/dist/bin/yam.js +17 -17
- package/dist/lib/bounded-input.d.ts +12 -0
- package/dist/lib/bounded-input.js +48 -0
- package/dist/lib/external-updates.js +67 -2
- package/dist/lib/skill-installation.js +23 -5
- package/dist/lib/stable-order.d.ts +1 -0
- package/dist/lib/stable-order.js +5 -0
- package/dist/lib/ueye-artifacts.js +3 -2
- package/package.json +4 -2
- package/src/bin/yam.ts +21 -13
- package/src/lib/bounded-input.ts +59 -0
- package/src/lib/external-updates.ts +72 -2
- package/src/lib/skill-installation.ts +24 -5
- package/src/lib/stable-order.ts +4 -0
- package/src/lib/ueye-artifacts.ts +3 -2
- package/yam.manifest.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to yam-flow are documented here.
|
|
4
4
|
|
|
5
|
+
## v2.5.1 - 2026-07-31
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added bounded stdin readers with a 1 MiB hook limit and a 4 MiB general CLI limit. Malformed or oversized hook payloads now return a valid fail-open response without echoing or acting on rejected input.
|
|
10
|
+
- Added a final read-only `yam doctor --json` verification to yam updates and automatic rollbacks. The updater accepts success only when the command exits cleanly and returns the expected healthy Doctor contract.
|
|
11
|
+
- Added a release-time secret guard over the actual npm dry-run packlist. It scans regular UTF-8 text files for high-signal private-key and provider credential patterns while reporting only pattern, path, and line metadata.
|
|
12
|
+
|
|
13
|
+
### Improved
|
|
14
|
+
|
|
15
|
+
- Install and Ueye integrity manifests now use locale-independent ordinal ordering. Existing yam 2.5.0-and-earlier receipts retain compatibility with their stored legacy order while new digests are deterministic across locales.
|
|
16
|
+
|
|
17
|
+
### Security
|
|
18
|
+
|
|
19
|
+
- Rejected stdin payloads are byte-bounded, are not included in diagnostics, and cannot supply hook context or trigger workspace mutation.
|
|
20
|
+
- `verify:self`, `prepack`, `prepublishOnly`, and `release:check` now inherit the npm packlist secret guard; the inner pack inspection disables lifecycle scripts to avoid recursive execution.
|
|
21
|
+
- Updater Doctor output recorded in receipts remains redacted and bounded, and an unhealthy rollback is not reported as an automatic recovery.
|
|
22
|
+
|
|
23
|
+
### Verification
|
|
24
|
+
|
|
25
|
+
- TypeScript typecheck and build
|
|
26
|
+
- Bounded hook latency/read-only smoke and packaged CLI smoke
|
|
27
|
+
- Locale-independent install/Ueye manifest regression smokes
|
|
28
|
+
- External update/rollback Doctor smoke
|
|
29
|
+
- Npm packlist secret-pattern and redaction smoke
|
|
30
|
+
|
|
5
31
|
## v2.5.0 - 2026-07-30
|
|
6
32
|
|
|
7
33
|
### Added
|
package/README.md
CHANGED
|
@@ -99,6 +99,8 @@ yam hook status --global
|
|
|
99
99
|
|
|
100
100
|
The profile installs `UserPromptSubmit` and `Stop` handlers. At `Stop`, yam checks the latest assistant message with the same read-only Study Note Guard used by `yam study-note check`. If changed artifacts lack the required role, execution point, before/after, expected behavior, syntax/structure, verification, limits, or relevant architecture hygiene, Codex receives one correction prompt. A second failed check warns but does not loop forever. If Git change scope is unavailable, the guard reports partial truth and keeps a manual-inspection warning visible instead of claiming the project is clean. The hook never writes the Study Note or runs verification for the agent.
|
|
101
101
|
|
|
102
|
+
Hook stdin is limited to 1 MiB. Malformed JSON or an oversized payload produces a valid fail-open response without echoing the rejected content, deriving hook context from it, or mutating the workspace. Other CLI commands that read piped text use a 4 MiB limit and fail closed when it is exceeded.
|
|
103
|
+
|
|
102
104
|
`yam hook status` reports stale or missing command targets as `broken` and exits nonzero. Run `yam hook enable <profile> --global` again to create a timestamped backup, preserve unrelated hooks, and migrate that profile to the current installed path and event coverage. Restart Codex or open a new task after changing hooks.
|
|
103
105
|
|
|
104
106
|
## Install
|
|
@@ -120,7 +122,7 @@ yam install --replace-user-skill quick
|
|
|
120
122
|
|
|
121
123
|
Repeat `--replace-user-skill <name>` for each reviewed conflict. Unproven legacy, retired, and Codex mirror directories are preserved rather than deleted by name.
|
|
122
124
|
|
|
123
|
-
A successful install writes `~/.agents/skills/.yam-flow-install-receipt.json`. The receipt records the package version, source identity, install timestamp, destination, managed skill inventory, and per-file hashes. `yam status` independently recomputes source and installed hashes, so a stale package install, local file change, unexpected file, or modified receipt is reported as drift.
|
|
125
|
+
A successful install writes `~/.agents/skills/.yam-flow-install-receipt.json`. The receipt records the package version, source identity, install timestamp, destination, managed skill inventory, and per-file hashes. Integrity paths and digests use locale-independent ordinal ordering; receipts written by yam 2.5.0 and earlier are also checked against their stored legacy order during upgrade. `yam status` independently recomputes source and installed hashes, so a stale package install, local file change, unexpected file, or modified receipt is reported as drift.
|
|
124
126
|
|
|
125
127
|
The receipt is local integrity provenance, not a cryptographic signature or a security boundary against another process running as the same OS user. An actor that can rewrite both the receipt and every recorded file can forge local ownership evidence.
|
|
126
128
|
|
|
@@ -148,7 +150,7 @@ yam update apply --component yam
|
|
|
148
150
|
yam update apply --all --json
|
|
149
151
|
```
|
|
150
152
|
|
|
151
|
-
`--all` processes Scrapling, then Insane Search, then yam so the running updater replaces itself last. It stops at the first failed or manual-only component. Each attempt writes a component receipt under `~/.local/state/yam/update-receipts/` with the old/new version, source revision, checks, side effects, outcome, and rollback guidance. A lock prevents concurrent apply runs.
|
|
153
|
+
`--all` processes Scrapling, then Insane Search, then yam so the running updater replaces itself last. It stops at the first failed or manual-only component. A yam update is successful only after the installed version and status checks plus a final read-only `yam doctor --json` return the expected healthy contract. Doctor failure triggers the existing rollback path, and rollback is considered automatic only when its own version, status, and Doctor checks pass. Each attempt writes a component receipt under `~/.local/state/yam/update-receipts/` with the old/new version, source revision, checks, side effects, outcome, and rollback guidance. A lock prevents concurrent apply runs.
|
|
152
154
|
|
|
153
155
|
Scrapling updates install an exact stable PyPI version into a new versioned virtual environment. `pip check`, ordinary HTTP extraction, and headless browser extraction must pass before the executable symlink changes atomically; the previous environment is retained. An existing environment must have a matching `.yam-scrapling-install.json` ownership marker before yam will replace its link. Insane Search uses only the official `codex plugin` commands. yam never edits `.codex/plugins/cache`, never removes the plugin first, and returns `manual_plugin_update_required` when the installed Codex CLI cannot update it safely in place.
|
|
154
156
|
|
|
@@ -255,6 +257,8 @@ yam status
|
|
|
255
257
|
|
|
256
258
|
After `yam status` reports all skills and the install receipt as `ok`, restart Codex so the refreshed skills reload.
|
|
257
259
|
|
|
260
|
+
Before packaging or publishing, `verify:self` inspects the exact file list returned by `npm pack --dry-run --json --ignore-scripts`. The release gate scans regular UTF-8 text files up to 2 MiB for high-signal private-key, npm, GitHub, OpenAI, Anthropic, and AWS credential patterns. Findings expose only the pattern id, package-relative path, and line number; matched text is never printed. Binary, symlink, non-regular, unreadable, outside-root, and oversized entries are reported as skipped rather than silently represented as scanned.
|
|
261
|
+
|
|
258
262
|
Publishing still requires confirming the final npm package name and account access.
|
|
259
263
|
|
|
260
264
|
## Trust Kernel
|
package/ROADMAP.md
CHANGED
|
@@ -172,7 +172,7 @@ Tasks:
|
|
|
172
172
|
|
|
173
173
|
- Add `.yam/memory/records/*.json` storage. Done.
|
|
174
174
|
- Add `yam memory init/add/list/summary/resolve`. Done.
|
|
175
|
-
- Add lightweight secret-pattern blocking. Done.
|
|
175
|
+
- Add lightweight secret-pattern blocking, including an actual npm dry-run packlist release guard with redacted findings. Done.
|
|
176
176
|
- Teach context reuse to prefer `summary.md`, not full record reads. Done.
|
|
177
177
|
- Keep memory opt-in only. Done.
|
|
178
178
|
|
|
@@ -308,6 +308,7 @@ Tasks:
|
|
|
308
308
|
- Keep `yam-lite` advisory-only through `UserPromptSubmit`. Done.
|
|
309
309
|
- Add Study Note `UserPromptSubmit` reminder plus one-pass `Stop` completion gate. Done.
|
|
310
310
|
- Detect broken hook targets and migrate stale paths without replacing unrelated hooks. Done.
|
|
311
|
+
- Bound hook/general stdin and fail open safely for rejected hook payloads. Done.
|
|
311
312
|
- Add `references/tool-trust-layer.md`. Done.
|
|
312
313
|
- Add read-only `yam tools doctor`. Done.
|
|
313
314
|
- Add `yam proof` summary command. Done.
|
|
@@ -318,6 +319,8 @@ Tasks:
|
|
|
318
319
|
- Add shallow `.sql` destructive keyword scan. Done.
|
|
319
320
|
- Add `yam proof write`. Done.
|
|
320
321
|
- Prepare npm/npx package metadata and dry-run packaging. Done.
|
|
322
|
+
- Make integrity-critical install and Ueye manifest ordering locale-independent. Done.
|
|
323
|
+
- Require a final read-only Doctor contract for yam update and rollback success. Done.
|
|
321
324
|
- Add small route-scoped trust kernel: truth caps, fake/real policy, runtime truth matrix, and completion proof object. Done.
|
|
322
325
|
|
|
323
326
|
### 16. Deferred Triggers
|
package/dist/bin/yam.js
CHANGED
|
@@ -12,6 +12,7 @@ import { TRUTH_STATUSES, buildLoopReport, buildMediaGenerationProof, buildMissio
|
|
|
12
12
|
import { archiveUeyeRevision, inspectImageFile, upsertUeyeAsset, verifyUeyeAssetManifest, verifyUeyeRevisionHistory } from '../lib/ueye-artifacts.js';
|
|
13
13
|
import { inspectSkillInstallation, installSkillSetTransactional, uninstallSkillSetSafely } from '../lib/skill-installation.js';
|
|
14
14
|
import { EXTERNAL_UPDATE_COMPONENTS, applyExternalUpdates, checkExternalUpdates } from '../lib/external-updates.js';
|
|
15
|
+
import { BoundedInputError, GENERAL_STDIN_MAX_BYTES, HOOK_STDIN_MAX_BYTES, readBoundedStdinJson, readBoundedStdinText } from '../lib/bounded-input.js';
|
|
15
16
|
function errorMessage(error) {
|
|
16
17
|
return error instanceof Error ? error.message : String(error);
|
|
17
18
|
}
|
|
@@ -2008,7 +2009,20 @@ async function hookRun(args = []) {
|
|
|
2008
2009
|
console.log(JSON.stringify({ continue: true }));
|
|
2009
2010
|
return;
|
|
2010
2011
|
}
|
|
2011
|
-
|
|
2012
|
+
let input;
|
|
2013
|
+
try {
|
|
2014
|
+
input = await readStdinJson();
|
|
2015
|
+
}
|
|
2016
|
+
catch (error) {
|
|
2017
|
+
if (!(error instanceof BoundedInputError))
|
|
2018
|
+
throw error;
|
|
2019
|
+
const code = error.code === 'invalid_json' ? 'invalid_hook_json' : error.code;
|
|
2020
|
+
console.log(JSON.stringify({
|
|
2021
|
+
continue: true,
|
|
2022
|
+
systemMessage: `yam-${profile} hook input rejected: ${code}; continuing without using or echoing the payload.`
|
|
2023
|
+
}));
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2012
2026
|
const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
|
|
2013
2027
|
const cwd = String(input?.cwd || process.cwd());
|
|
2014
2028
|
if (profile === 'study-note' && event === 'Stop') {
|
|
@@ -2029,18 +2043,7 @@ async function hookRun(args = []) {
|
|
|
2029
2043
|
console.log(JSON.stringify(output));
|
|
2030
2044
|
}
|
|
2031
2045
|
async function readStdinJson() {
|
|
2032
|
-
|
|
2033
|
-
for await (const chunk of process.stdin)
|
|
2034
|
-
chunks.push(Buffer.from(chunk));
|
|
2035
|
-
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
2036
|
-
if (!text)
|
|
2037
|
-
return {};
|
|
2038
|
-
try {
|
|
2039
|
-
return JSON.parse(text);
|
|
2040
|
-
}
|
|
2041
|
-
catch {
|
|
2042
|
-
return {};
|
|
2043
|
-
}
|
|
2046
|
+
return readBoundedStdinJson(process.stdin, HOOK_STDIN_MAX_BYTES);
|
|
2044
2047
|
}
|
|
2045
2048
|
function extractPrompt(input = {}) {
|
|
2046
2049
|
const candidates = [
|
|
@@ -2671,10 +2674,7 @@ function redactSensitiveText(text = '') {
|
|
|
2671
2674
|
async function readStdinTextIfAvailable() {
|
|
2672
2675
|
if (process.stdin.isTTY)
|
|
2673
2676
|
return '';
|
|
2674
|
-
|
|
2675
|
-
for await (const chunk of process.stdin)
|
|
2676
|
-
chunks.push(Buffer.from(chunk));
|
|
2677
|
-
return Buffer.concat(chunks).toString('utf8').trim();
|
|
2677
|
+
return (await readBoundedStdinText(process.stdin, GENERAL_STDIN_MAX_BYTES)).trim();
|
|
2678
2678
|
}
|
|
2679
2679
|
async function loop(args = []) {
|
|
2680
2680
|
const subcommand = args[0] || 'help';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const HOOK_STDIN_MAX_BYTES: number;
|
|
2
|
+
export declare const GENERAL_STDIN_MAX_BYTES: number;
|
|
3
|
+
export type BoundedInputErrorCode = 'input_too_large' | 'invalid_json';
|
|
4
|
+
export declare class BoundedInputError extends Error {
|
|
5
|
+
readonly code: BoundedInputErrorCode;
|
|
6
|
+
readonly maxBytes?: number;
|
|
7
|
+
constructor(code: BoundedInputErrorCode, options?: {
|
|
8
|
+
maxBytes?: number;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export declare function readBoundedStdinText(input: NodeJS.ReadableStream & AsyncIterable<string | Buffer>, maxBytes: number): Promise<string>;
|
|
12
|
+
export declare function readBoundedStdinJson(input: NodeJS.ReadableStream & AsyncIterable<string | Buffer>, maxBytes: number): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const HOOK_STDIN_MAX_BYTES = 1024 * 1024;
|
|
2
|
+
export const GENERAL_STDIN_MAX_BYTES = 4 * 1024 * 1024;
|
|
3
|
+
export class BoundedInputError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
maxBytes;
|
|
6
|
+
constructor(code, options = {}) {
|
|
7
|
+
const detail = code === 'input_too_large' && options.maxBytes
|
|
8
|
+
? `stdin input exceeds the ${options.maxBytes}-byte limit`
|
|
9
|
+
: 'stdin input is not a valid JSON object';
|
|
10
|
+
super(`[${code}] ${detail}`);
|
|
11
|
+
this.name = 'BoundedInputError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.maxBytes = options.maxBytes;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function readBoundedStdinText(input, maxBytes) {
|
|
17
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
18
|
+
throw new Error('stdin byte limit must be a positive safe integer');
|
|
19
|
+
}
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let totalBytes = 0;
|
|
22
|
+
for await (const chunk of input) {
|
|
23
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
24
|
+
totalBytes += bytes.byteLength;
|
|
25
|
+
if (totalBytes > maxBytes) {
|
|
26
|
+
throw new BoundedInputError('input_too_large', { maxBytes });
|
|
27
|
+
}
|
|
28
|
+
chunks.push(bytes);
|
|
29
|
+
}
|
|
30
|
+
return Buffer.concat(chunks, totalBytes).toString('utf8');
|
|
31
|
+
}
|
|
32
|
+
export async function readBoundedStdinJson(input, maxBytes) {
|
|
33
|
+
const text = (await readBoundedStdinText(input, maxBytes)).trim();
|
|
34
|
+
if (!text)
|
|
35
|
+
return {};
|
|
36
|
+
try {
|
|
37
|
+
const value = JSON.parse(text);
|
|
38
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
39
|
+
throw new BoundedInputError('invalid_json');
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof BoundedInputError)
|
|
45
|
+
throw error;
|
|
46
|
+
throw new BoundedInputError('invalid_json');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -354,6 +354,13 @@ async function applyYam(currentYamVersion, deps) {
|
|
|
354
354
|
break;
|
|
355
355
|
}
|
|
356
356
|
}
|
|
357
|
+
if (!postFailure) {
|
|
358
|
+
const doctor = await deps.run('yam', ['doctor', '--json'], { env: deps.env, timeout: 180000 });
|
|
359
|
+
const validation = doctorValidation('yam_finalize_doctor', doctor);
|
|
360
|
+
checks.push(validation.check);
|
|
361
|
+
if (!validation.passed)
|
|
362
|
+
postFailure = `yam_finalize_doctor failed: ${validation.error}`;
|
|
363
|
+
}
|
|
357
364
|
if (postFailure) {
|
|
358
365
|
const rollback = await rollbackYam(check.installed_version, deps, checks);
|
|
359
366
|
return persistReceipt(baseReceipt(check, 'failed', checks, {
|
|
@@ -392,10 +399,17 @@ async function rollbackYam(previousVersion, deps, checks) {
|
|
|
392
399
|
const install = await deps.run('yam', ['install'], { env: deps.env, timeout: 180000 });
|
|
393
400
|
const version = await deps.run('yam', ['version'], { env: deps.env, timeout: 30000 });
|
|
394
401
|
const status = await deps.run('yam', ['status'], { env: deps.env, timeout: 120000 });
|
|
402
|
+
const doctor = await deps.run('yam', ['doctor', '--json'], { env: deps.env, timeout: 180000 });
|
|
403
|
+
const doctorResult = doctorValidation('automatic_yam_rollback_doctor', doctor);
|
|
395
404
|
checks.push(commandCheck('automatic_yam_rollback_skills', install));
|
|
396
405
|
checks.push(commandCheck('automatic_yam_rollback_version', version));
|
|
397
406
|
checks.push(commandCheck('automatic_yam_rollback_status', status));
|
|
398
|
-
|
|
407
|
+
checks.push(doctorResult.check);
|
|
408
|
+
return (install.ok
|
|
409
|
+
&& version.ok
|
|
410
|
+
&& extractVersion(version.stdout) === previousVersion
|
|
411
|
+
&& status.ok
|
|
412
|
+
&& doctorResult.passed);
|
|
399
413
|
}
|
|
400
414
|
async function applyScrapling(deps) {
|
|
401
415
|
const check = await checkScrapling(deps);
|
|
@@ -820,7 +834,7 @@ function yamRollback(previousVersion) {
|
|
|
820
834
|
automatic: false,
|
|
821
835
|
previous_target: previousVersion,
|
|
822
836
|
guidance: previousVersion
|
|
823
|
-
? `Reinstall the exact prior package with \`npm install -g yam-flow@${previousVersion}\`, then run \`yam install\`, \`yam version\`, and \`yam
|
|
837
|
+
? `Reinstall the exact prior package with \`npm install -g yam-flow@${previousVersion}\`, then run \`yam install\`, \`yam version\`, \`yam status\`, and \`yam doctor --json\`.`
|
|
824
838
|
: 'Inspect the global npm installation before attempting a manual rollback.'
|
|
825
839
|
};
|
|
826
840
|
}
|
|
@@ -858,6 +872,57 @@ function commandCheck(id, result) {
|
|
|
858
872
|
note: commandNote(result)
|
|
859
873
|
};
|
|
860
874
|
}
|
|
875
|
+
function doctorValidation(id, result) {
|
|
876
|
+
if (!result.ok) {
|
|
877
|
+
const note = boundedNote(`command failed: ${commandNote(result)}`);
|
|
878
|
+
return {
|
|
879
|
+
passed: false,
|
|
880
|
+
error: note,
|
|
881
|
+
check: { id, status: 'failed', note }
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
let report;
|
|
885
|
+
try {
|
|
886
|
+
const parsed = JSON.parse(String(result.stdout || ''));
|
|
887
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
888
|
+
throw new Error('not an object');
|
|
889
|
+
}
|
|
890
|
+
report = parsed;
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
const note = 'yam doctor returned invalid JSON';
|
|
894
|
+
return {
|
|
895
|
+
passed: false,
|
|
896
|
+
error: note,
|
|
897
|
+
check: { id, status: 'failed', note }
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
if (report.schema !== 'yam.doctor.v1') {
|
|
901
|
+
const note = 'yam doctor returned an unexpected schema';
|
|
902
|
+
return {
|
|
903
|
+
passed: false,
|
|
904
|
+
error: note,
|
|
905
|
+
check: { id, status: 'failed', note }
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
if (report.ok !== true) {
|
|
909
|
+
const note = 'yam.doctor.v1 reported ok=false';
|
|
910
|
+
return {
|
|
911
|
+
passed: false,
|
|
912
|
+
error: note,
|
|
913
|
+
check: { id, status: 'failed', note }
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
const note = 'yam.doctor.v1 reported ok=true';
|
|
917
|
+
return {
|
|
918
|
+
passed: true,
|
|
919
|
+
error: '',
|
|
920
|
+
check: { id, status: 'passed', note }
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
function boundedNote(text) {
|
|
924
|
+
return redactSensitiveText(String(text || '')).slice(0, 800);
|
|
925
|
+
}
|
|
861
926
|
function commandNote(result) {
|
|
862
927
|
const text = redactSensitiveText([result.stdout, result.stderr].filter(Boolean).join('\n'));
|
|
863
928
|
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { compareStableText } from './stable-order.js';
|
|
4
5
|
export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
|
|
5
6
|
export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
|
|
6
7
|
export const INSTALL_RECEIPT_SCHEMA = 'yam.install-receipt.v1';
|
|
@@ -70,7 +71,7 @@ async function collectTreeManifest(root, prefix = '') {
|
|
|
70
71
|
const files = [];
|
|
71
72
|
async function walk(current, relative) {
|
|
72
73
|
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
73
|
-
entries.sort((left, right) => left.name
|
|
74
|
+
entries.sort((left, right) => compareStableText(left.name, right.name));
|
|
74
75
|
for (const entry of entries) {
|
|
75
76
|
const absolute = path.join(current, entry.name);
|
|
76
77
|
const nextRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
@@ -86,7 +87,7 @@ async function collectTreeManifest(root, prefix = '') {
|
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
await walk(root, '');
|
|
89
|
-
return files.sort((left, right) => left.path
|
|
90
|
+
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
90
91
|
}
|
|
91
92
|
async function expectedInstallManifest(sourceRoot, skills) {
|
|
92
93
|
const referencesRoot = path.join(sourceRoot, 'references');
|
|
@@ -112,7 +113,7 @@ async function expectedInstallManifest(sourceRoot, skills) {
|
|
|
112
113
|
});
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
|
-
return files.sort((left, right) => left.path
|
|
116
|
+
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
116
117
|
}
|
|
117
118
|
async function copyTree(source, target) {
|
|
118
119
|
const stat = await fsp.lstat(source);
|
|
@@ -137,8 +138,11 @@ async function copyTree(source, target) {
|
|
|
137
138
|
}
|
|
138
139
|
}
|
|
139
140
|
function manifestDigest(files) {
|
|
141
|
+
return digestManifestFiles([...files].sort((left, right) => compareStableText(left.path, right.path)));
|
|
142
|
+
}
|
|
143
|
+
function digestManifestFiles(files) {
|
|
140
144
|
const digest = createHash('sha256');
|
|
141
|
-
for (const file of
|
|
145
|
+
for (const file of files) {
|
|
142
146
|
digest.update(file.path);
|
|
143
147
|
digest.update('\0');
|
|
144
148
|
digest.update(String(file.bytes));
|
|
@@ -148,6 +152,20 @@ function manifestDigest(files) {
|
|
|
148
152
|
}
|
|
149
153
|
return digest.digest('hex');
|
|
150
154
|
}
|
|
155
|
+
function allowsLegacyManifestOrder(version) {
|
|
156
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(String(version || ''));
|
|
157
|
+
if (!match)
|
|
158
|
+
return false;
|
|
159
|
+
const major = Number(match[1]);
|
|
160
|
+
const minor = Number(match[2]);
|
|
161
|
+
const patch = Number(match[3]);
|
|
162
|
+
return major < 2 || (major === 2 && (minor < 5 || (minor === 5 && patch === 0)));
|
|
163
|
+
}
|
|
164
|
+
function receiptManifestDigestMatches(files, digest, version) {
|
|
165
|
+
if (digest === manifestDigest(files))
|
|
166
|
+
return true;
|
|
167
|
+
return allowsLegacyManifestOrder(version) && digest === digestManifestFiles(files);
|
|
168
|
+
}
|
|
151
169
|
function manifestIssues(expected, actual, label) {
|
|
152
170
|
const issues = [];
|
|
153
171
|
const expectedByPath = new Map(expected.map((file) => [file.path, file]));
|
|
@@ -236,7 +254,7 @@ function ownershipReceiptIssues(receipt, packageName, destination) {
|
|
|
236
254
|
if (value.integrity?.file_count !== files.length) {
|
|
237
255
|
issues.push('install receipt file count does not match its manifest');
|
|
238
256
|
}
|
|
239
|
-
if (value.integrity?.source_digest
|
|
257
|
+
if (!receiptManifestDigestMatches(files, value.integrity?.source_digest, value.package?.version)) {
|
|
240
258
|
issues.push('install receipt source digest does not match its manifest');
|
|
241
259
|
}
|
|
242
260
|
if (Array.isArray(value.skills)) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function compareStableText(left: string, right: string): number;
|
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { constants as fsConstants } from 'node:fs';
|
|
3
3
|
import fsp from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { compareStableText } from './stable-order.js';
|
|
5
6
|
export async function inspectImageFile(file) {
|
|
6
7
|
const absolute = path.resolve(file);
|
|
7
8
|
const buffer = await fsp.readFile(absolute);
|
|
@@ -46,7 +47,7 @@ export async function upsertUeyeAsset(input) {
|
|
|
46
47
|
manifest.assets[existingIndex] = entry;
|
|
47
48
|
else
|
|
48
49
|
manifest.assets.push(entry);
|
|
49
|
-
manifest.assets.sort((left, right) => left.id
|
|
50
|
+
manifest.assets.sort((left, right) => compareStableText(left.id, right.id));
|
|
50
51
|
manifest.updated_at = new Date().toISOString();
|
|
51
52
|
await writeJsonAtomic(manifestPath, manifest);
|
|
52
53
|
const warnings = assetWarnings(entry);
|
|
@@ -152,7 +153,7 @@ export async function archiveUeyeRevision(input) {
|
|
|
152
153
|
else {
|
|
153
154
|
history.revisions.push(entry);
|
|
154
155
|
}
|
|
155
|
-
history.revisions.sort((left, right) => left.round - right.round || left.artifact_id
|
|
156
|
+
history.revisions.sort((left, right) => left.round - right.round || compareStableText(left.artifact_id, right.artifact_id));
|
|
156
157
|
history.updated_at = new Date().toISOString();
|
|
157
158
|
await writeJsonAtomic(manifestPath, history);
|
|
158
159
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yam-flow",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Progressive proof-first agent flow: start fast, deepen deliberately, stay honest by design.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "0kim0bos",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"doctor": "node ./dist/bin/yam.js doctor",
|
|
53
53
|
"list": "node ./dist/bin/yam.js list",
|
|
54
54
|
"examples": "node ./dist/bin/yam.js examples",
|
|
55
|
-
"verify:self": "npm run build && node --check ./dist/bin/yam.js && node --check ./dist/lib/trust-kernel.js && node --check ./dist/lib/skill-installation.js && node --check ./dist/lib/external-updates.js && npm run hook-latency-smoke && node ./scripts/external-updates-smoke.mjs && node ./scripts/install-transaction-smoke.mjs && node ./dist/bin/yam.js verify && node ./dist/bin/yam.js doctor",
|
|
55
|
+
"verify:self": "npm run build && node --check ./dist/bin/yam.js && node --check ./dist/lib/trust-kernel.js && node --check ./dist/lib/skill-installation.js && node --check ./dist/lib/external-updates.js && npm run hook-latency-smoke && node ./scripts/external-updates-smoke.mjs && node ./scripts/install-transaction-smoke.mjs && npm run package-secrets:check && npm run package-secrets-smoke && node ./dist/bin/yam.js verify && node ./dist/bin/yam.js doctor",
|
|
56
56
|
"prepare": "npm run build",
|
|
57
57
|
"prepack": "npm run verify:self",
|
|
58
58
|
"prepublishOnly": "npm run verify:self",
|
|
@@ -61,6 +61,8 @@
|
|
|
61
61
|
"forbidden-names:check": "node ./scripts/check-forbidden-names.mjs",
|
|
62
62
|
"study-note:check": "node ./dist/bin/yam.js study-note check --json",
|
|
63
63
|
"package-boundary:check": "node ./scripts/check-package-boundary.mjs",
|
|
64
|
+
"package-secrets:check": "node ./scripts/check-package-secrets.mjs",
|
|
65
|
+
"package-secrets-smoke": "node ./scripts/package-secrets-smoke.mjs",
|
|
64
66
|
"dist:freshness": "node ./scripts/check-dist-freshness.mjs",
|
|
65
67
|
"registry:check": "node ./scripts/check-registry-version.mjs",
|
|
66
68
|
"cli-smoke": "node ./scripts/cli-smoke.mjs",
|
package/src/bin/yam.ts
CHANGED
|
@@ -45,6 +45,13 @@ import {
|
|
|
45
45
|
checkExternalUpdates
|
|
46
46
|
} from '../lib/external-updates.js';
|
|
47
47
|
import type { ExternalUpdateComponent } from '../lib/external-updates.js';
|
|
48
|
+
import {
|
|
49
|
+
BoundedInputError,
|
|
50
|
+
GENERAL_STDIN_MAX_BYTES,
|
|
51
|
+
HOOK_STDIN_MAX_BYTES,
|
|
52
|
+
readBoundedStdinJson,
|
|
53
|
+
readBoundedStdinText
|
|
54
|
+
} from '../lib/bounded-input.js';
|
|
48
55
|
|
|
49
56
|
type AnyRecord = Record<string, any>;
|
|
50
57
|
|
|
@@ -2022,7 +2029,18 @@ async function hookRun(args = []) {
|
|
|
2022
2029
|
console.log(JSON.stringify({ continue: true }));
|
|
2023
2030
|
return;
|
|
2024
2031
|
}
|
|
2025
|
-
|
|
2032
|
+
let input: AnyRecord;
|
|
2033
|
+
try {
|
|
2034
|
+
input = await readStdinJson();
|
|
2035
|
+
} catch (error) {
|
|
2036
|
+
if (!(error instanceof BoundedInputError)) throw error;
|
|
2037
|
+
const code = error.code === 'invalid_json' ? 'invalid_hook_json' : error.code;
|
|
2038
|
+
console.log(JSON.stringify({
|
|
2039
|
+
continue: true,
|
|
2040
|
+
systemMessage: `yam-${profile} hook input rejected: ${code}; continuing without using or echoing the payload.`
|
|
2041
|
+
}));
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2026
2044
|
const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
|
|
2027
2045
|
const cwd = String(input?.cwd || process.cwd());
|
|
2028
2046
|
if (profile === 'study-note' && event === 'Stop') {
|
|
@@ -2044,15 +2062,7 @@ async function hookRun(args = []) {
|
|
|
2044
2062
|
}
|
|
2045
2063
|
|
|
2046
2064
|
async function readStdinJson() {
|
|
2047
|
-
|
|
2048
|
-
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
2049
|
-
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
2050
|
-
if (!text) return {};
|
|
2051
|
-
try {
|
|
2052
|
-
return JSON.parse(text);
|
|
2053
|
-
} catch {
|
|
2054
|
-
return {};
|
|
2055
|
-
}
|
|
2065
|
+
return readBoundedStdinJson(process.stdin, HOOK_STDIN_MAX_BYTES);
|
|
2056
2066
|
}
|
|
2057
2067
|
|
|
2058
2068
|
function extractPrompt(input: AnyRecord = {}) {
|
|
@@ -2685,9 +2695,7 @@ function redactSensitiveText(text = '') {
|
|
|
2685
2695
|
|
|
2686
2696
|
async function readStdinTextIfAvailable() {
|
|
2687
2697
|
if (process.stdin.isTTY) return '';
|
|
2688
|
-
|
|
2689
|
-
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
2690
|
-
return Buffer.concat(chunks).toString('utf8').trim();
|
|
2698
|
+
return (await readBoundedStdinText(process.stdin, GENERAL_STDIN_MAX_BYTES)).trim();
|
|
2691
2699
|
}
|
|
2692
2700
|
|
|
2693
2701
|
async function loop(args = []) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const HOOK_STDIN_MAX_BYTES = 1024 * 1024;
|
|
2
|
+
export const GENERAL_STDIN_MAX_BYTES = 4 * 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
export type BoundedInputErrorCode = 'input_too_large' | 'invalid_json';
|
|
5
|
+
|
|
6
|
+
export class BoundedInputError extends Error {
|
|
7
|
+
readonly code: BoundedInputErrorCode;
|
|
8
|
+
readonly maxBytes?: number;
|
|
9
|
+
|
|
10
|
+
constructor(code: BoundedInputErrorCode, options: { maxBytes?: number } = {}) {
|
|
11
|
+
const detail = code === 'input_too_large' && options.maxBytes
|
|
12
|
+
? `stdin input exceeds the ${options.maxBytes}-byte limit`
|
|
13
|
+
: 'stdin input is not a valid JSON object';
|
|
14
|
+
super(`[${code}] ${detail}`);
|
|
15
|
+
this.name = 'BoundedInputError';
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.maxBytes = options.maxBytes;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function readBoundedStdinText(
|
|
22
|
+
input: NodeJS.ReadableStream & AsyncIterable<string | Buffer>,
|
|
23
|
+
maxBytes: number
|
|
24
|
+
): Promise<string> {
|
|
25
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
26
|
+
throw new Error('stdin byte limit must be a positive safe integer');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const chunks: Buffer[] = [];
|
|
30
|
+
let totalBytes = 0;
|
|
31
|
+
for await (const chunk of input) {
|
|
32
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
33
|
+
totalBytes += bytes.byteLength;
|
|
34
|
+
if (totalBytes > maxBytes) {
|
|
35
|
+
throw new BoundedInputError('input_too_large', { maxBytes });
|
|
36
|
+
}
|
|
37
|
+
chunks.push(bytes);
|
|
38
|
+
}
|
|
39
|
+
return Buffer.concat(chunks, totalBytes).toString('utf8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readBoundedStdinJson(
|
|
43
|
+
input: NodeJS.ReadableStream & AsyncIterable<string | Buffer>,
|
|
44
|
+
maxBytes: number
|
|
45
|
+
): Promise<Record<string, unknown>> {
|
|
46
|
+
const text = (await readBoundedStdinText(input, maxBytes)).trim();
|
|
47
|
+
if (!text) return {};
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const value = JSON.parse(text);
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
52
|
+
throw new BoundedInputError('invalid_json');
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof BoundedInputError) throw error;
|
|
57
|
+
throw new BoundedInputError('invalid_json');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -479,6 +479,12 @@ async function applyYam(currentYamVersion: string, deps: ResolvedDependencies) {
|
|
|
479
479
|
break;
|
|
480
480
|
}
|
|
481
481
|
}
|
|
482
|
+
if (!postFailure) {
|
|
483
|
+
const doctor = await deps.run('yam', ['doctor', '--json'], { env: deps.env, timeout: 180000 });
|
|
484
|
+
const validation = doctorValidation('yam_finalize_doctor', doctor);
|
|
485
|
+
checks.push(validation.check);
|
|
486
|
+
if (!validation.passed) postFailure = `yam_finalize_doctor failed: ${validation.error}`;
|
|
487
|
+
}
|
|
482
488
|
if (postFailure) {
|
|
483
489
|
const rollback = await rollbackYam(check.installed_version, deps, checks);
|
|
484
490
|
return persistReceipt(baseReceipt(check, 'failed', checks, {
|
|
@@ -521,10 +527,19 @@ async function rollbackYam(
|
|
|
521
527
|
const install = await deps.run('yam', ['install'], { env: deps.env, timeout: 180000 });
|
|
522
528
|
const version = await deps.run('yam', ['version'], { env: deps.env, timeout: 30000 });
|
|
523
529
|
const status = await deps.run('yam', ['status'], { env: deps.env, timeout: 120000 });
|
|
530
|
+
const doctor = await deps.run('yam', ['doctor', '--json'], { env: deps.env, timeout: 180000 });
|
|
531
|
+
const doctorResult = doctorValidation('automatic_yam_rollback_doctor', doctor);
|
|
524
532
|
checks.push(commandCheck('automatic_yam_rollback_skills', install));
|
|
525
533
|
checks.push(commandCheck('automatic_yam_rollback_version', version));
|
|
526
534
|
checks.push(commandCheck('automatic_yam_rollback_status', status));
|
|
527
|
-
|
|
535
|
+
checks.push(doctorResult.check);
|
|
536
|
+
return (
|
|
537
|
+
install.ok
|
|
538
|
+
&& version.ok
|
|
539
|
+
&& extractVersion(version.stdout) === previousVersion
|
|
540
|
+
&& status.ok
|
|
541
|
+
&& doctorResult.passed
|
|
542
|
+
);
|
|
528
543
|
}
|
|
529
544
|
|
|
530
545
|
async function applyScrapling(deps: ResolvedDependencies) {
|
|
@@ -967,7 +982,7 @@ function yamRollback(previousVersion: string) {
|
|
|
967
982
|
automatic: false,
|
|
968
983
|
previous_target: previousVersion,
|
|
969
984
|
guidance: previousVersion
|
|
970
|
-
? `Reinstall the exact prior package with \`npm install -g yam-flow@${previousVersion}\`, then run \`yam install\`, \`yam version\`, and \`yam
|
|
985
|
+
? `Reinstall the exact prior package with \`npm install -g yam-flow@${previousVersion}\`, then run \`yam install\`, \`yam version\`, \`yam status\`, and \`yam doctor --json\`.`
|
|
971
986
|
: 'Inspect the global npm installation before attempting a manual rollback.'
|
|
972
987
|
};
|
|
973
988
|
}
|
|
@@ -1008,6 +1023,61 @@ function commandCheck(id: string, result: ExternalCommandResult): ExternalCompon
|
|
|
1008
1023
|
};
|
|
1009
1024
|
}
|
|
1010
1025
|
|
|
1026
|
+
function doctorValidation(id: string, result: ExternalCommandResult) {
|
|
1027
|
+
if (!result.ok) {
|
|
1028
|
+
const note = boundedNote(`command failed: ${commandNote(result)}`);
|
|
1029
|
+
return {
|
|
1030
|
+
passed: false,
|
|
1031
|
+
error: note,
|
|
1032
|
+
check: { id, status: 'failed' as const, note }
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
let report: JsonRecord;
|
|
1037
|
+
try {
|
|
1038
|
+
const parsed = JSON.parse(String(result.stdout || ''));
|
|
1039
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1040
|
+
throw new Error('not an object');
|
|
1041
|
+
}
|
|
1042
|
+
report = parsed;
|
|
1043
|
+
} catch {
|
|
1044
|
+
const note = 'yam doctor returned invalid JSON';
|
|
1045
|
+
return {
|
|
1046
|
+
passed: false,
|
|
1047
|
+
error: note,
|
|
1048
|
+
check: { id, status: 'failed' as const, note }
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (report.schema !== 'yam.doctor.v1') {
|
|
1053
|
+
const note = 'yam doctor returned an unexpected schema';
|
|
1054
|
+
return {
|
|
1055
|
+
passed: false,
|
|
1056
|
+
error: note,
|
|
1057
|
+
check: { id, status: 'failed' as const, note }
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
if (report.ok !== true) {
|
|
1061
|
+
const note = 'yam.doctor.v1 reported ok=false';
|
|
1062
|
+
return {
|
|
1063
|
+
passed: false,
|
|
1064
|
+
error: note,
|
|
1065
|
+
check: { id, status: 'failed' as const, note }
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const note = 'yam.doctor.v1 reported ok=true';
|
|
1070
|
+
return {
|
|
1071
|
+
passed: true,
|
|
1072
|
+
error: '',
|
|
1073
|
+
check: { id, status: 'passed' as const, note }
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function boundedNote(text: string) {
|
|
1078
|
+
return redactSensitiveText(String(text || '')).slice(0, 800);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1011
1081
|
function commandNote(result: ExternalCommandResult) {
|
|
1012
1082
|
const text = redactSensitiveText([result.stdout, result.stderr].filter(Boolean).join('\n'));
|
|
1013
1083
|
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { compareStableText } from './stable-order.js';
|
|
4
5
|
|
|
5
6
|
export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
|
|
6
7
|
export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
|
|
@@ -211,7 +212,7 @@ async function collectTreeManifest(root: string, prefix = ''): Promise<InstallFi
|
|
|
211
212
|
const files: InstallFileDigest[] = [];
|
|
212
213
|
async function walk(current: string, relative: string): Promise<void> {
|
|
213
214
|
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
214
|
-
entries.sort((left, right) => left.name
|
|
215
|
+
entries.sort((left, right) => compareStableText(left.name, right.name));
|
|
215
216
|
for (const entry of entries) {
|
|
216
217
|
const absolute = path.join(current, entry.name);
|
|
217
218
|
const nextRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
@@ -226,7 +227,7 @@ async function collectTreeManifest(root: string, prefix = ''): Promise<InstallFi
|
|
|
226
227
|
}
|
|
227
228
|
|
|
228
229
|
await walk(root, '');
|
|
229
|
-
return files.sort((left, right) => left.path
|
|
230
|
+
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
230
231
|
}
|
|
231
232
|
|
|
232
233
|
async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
|
|
@@ -253,7 +254,7 @@ async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
|
|
|
253
254
|
});
|
|
254
255
|
}
|
|
255
256
|
}
|
|
256
|
-
return files.sort((left, right) => left.path
|
|
257
|
+
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
async function copyTree(source: string, target: string): Promise<void> {
|
|
@@ -278,8 +279,12 @@ async function copyTree(source: string, target: string): Promise<void> {
|
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
function manifestDigest(files: InstallFileDigest[]) {
|
|
282
|
+
return digestManifestFiles([...files].sort((left, right) => compareStableText(left.path, right.path)));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function digestManifestFiles(files: InstallFileDigest[]) {
|
|
281
286
|
const digest = createHash('sha256');
|
|
282
|
-
for (const file of
|
|
287
|
+
for (const file of files) {
|
|
283
288
|
digest.update(file.path);
|
|
284
289
|
digest.update('\0');
|
|
285
290
|
digest.update(String(file.bytes));
|
|
@@ -290,6 +295,20 @@ function manifestDigest(files: InstallFileDigest[]) {
|
|
|
290
295
|
return digest.digest('hex');
|
|
291
296
|
}
|
|
292
297
|
|
|
298
|
+
function allowsLegacyManifestOrder(version: unknown) {
|
|
299
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(String(version || ''));
|
|
300
|
+
if (!match) return false;
|
|
301
|
+
const major = Number(match[1]);
|
|
302
|
+
const minor = Number(match[2]);
|
|
303
|
+
const patch = Number(match[3]);
|
|
304
|
+
return major < 2 || (major === 2 && (minor < 5 || (minor === 5 && patch === 0)));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function receiptManifestDigestMatches(files: InstallFileDigest[], digest: unknown, version: unknown) {
|
|
308
|
+
if (digest === manifestDigest(files)) return true;
|
|
309
|
+
return allowsLegacyManifestOrder(version) && digest === digestManifestFiles(files);
|
|
310
|
+
}
|
|
311
|
+
|
|
293
312
|
function manifestIssues(expected: InstallFileDigest[], actual: InstallFileDigest[], label: string) {
|
|
294
313
|
const issues: string[] = [];
|
|
295
314
|
const expectedByPath = new Map(expected.map((file) => [file.path, file]));
|
|
@@ -379,7 +398,7 @@ function ownershipReceiptIssues(
|
|
|
379
398
|
if (value.integrity?.file_count !== files.length) {
|
|
380
399
|
issues.push('install receipt file count does not match its manifest');
|
|
381
400
|
}
|
|
382
|
-
if (value.integrity?.source_digest
|
|
401
|
+
if (!receiptManifestDigestMatches(files, value.integrity?.source_digest, value.package?.version)) {
|
|
383
402
|
issues.push('install receipt source digest does not match its manifest');
|
|
384
403
|
}
|
|
385
404
|
if (Array.isArray(value.skills)) {
|
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { constants as fsConstants } from 'node:fs';
|
|
3
3
|
import fsp from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { compareStableText } from './stable-order.js';
|
|
5
6
|
|
|
6
7
|
export interface InspectedImageFile {
|
|
7
8
|
path: string;
|
|
@@ -102,7 +103,7 @@ export async function upsertUeyeAsset(input: {
|
|
|
102
103
|
};
|
|
103
104
|
if (existingIndex >= 0) manifest.assets[existingIndex] = entry;
|
|
104
105
|
else manifest.assets.push(entry);
|
|
105
|
-
manifest.assets.sort((left, right) => left.id
|
|
106
|
+
manifest.assets.sort((left, right) => compareStableText(left.id, right.id));
|
|
106
107
|
manifest.updated_at = new Date().toISOString();
|
|
107
108
|
await writeJsonAtomic(manifestPath, manifest);
|
|
108
109
|
const warnings = assetWarnings(entry);
|
|
@@ -205,7 +206,7 @@ export async function archiveUeyeRevision(input: {
|
|
|
205
206
|
} else {
|
|
206
207
|
history.revisions.push(entry);
|
|
207
208
|
}
|
|
208
|
-
history.revisions.sort((left, right) => left.round - right.round || left.artifact_id
|
|
209
|
+
history.revisions.sort((left, right) => left.round - right.round || compareStableText(left.artifact_id, right.artifact_id));
|
|
209
210
|
history.updated_at = new Date().toISOString();
|
|
210
211
|
await writeJsonAtomic(manifestPath, history);
|
|
211
212
|
return {
|