@clossys/launcher 0.3.0 → 0.4.0
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/README.md +1328 -60
- package/contracts/conversation-contract.md +2 -1
- package/contracts/product-ci-workflow.yml +74 -0
- package/contracts/repository-inventory.json +53 -0
- package/dist/admission-fixture.d.ts +168 -0
- package/dist/admission-fixture.d.ts.map +1 -0
- package/dist/admission-fixture.js +453 -0
- package/dist/admission-fixture.js.map +1 -0
- package/dist/admission.d.ts +124 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +799 -0
- package/dist/admission.js.map +1 -0
- package/dist/agents-guide.d.ts +9 -0
- package/dist/agents-guide.d.ts.map +1 -0
- package/dist/agents-guide.js +26 -0
- package/dist/agents-guide.js.map +1 -0
- package/dist/apply-command-options.check.d.ts +12 -0
- package/dist/apply-command-options.check.d.ts.map +1 -0
- package/dist/apply-command-options.check.js +20 -0
- package/dist/apply-command-options.check.js.map +1 -0
- package/dist/apply-plan-cli.d.ts +39 -1
- package/dist/apply-plan-cli.d.ts.map +1 -1
- package/dist/apply-plan-cli.js +432 -15
- package/dist/apply-plan-cli.js.map +1 -1
- package/dist/apply-plan.d.ts +46 -59
- package/dist/apply-plan.d.ts.map +1 -1
- package/dist/apply-plan.js +112 -97
- package/dist/apply-plan.js.map +1 -1
- package/dist/apply-step-fixture.d.ts +87 -0
- package/dist/apply-step-fixture.d.ts.map +1 -0
- package/dist/apply-step-fixture.js +199 -0
- package/dist/apply-step-fixture.js.map +1 -0
- package/dist/apply-store.d.ts +93 -0
- package/dist/apply-store.d.ts.map +1 -0
- package/dist/apply-store.js +625 -0
- package/dist/apply-store.js.map +1 -0
- package/dist/approval-sheet.d.ts +21 -0
- package/dist/approval-sheet.d.ts.map +1 -0
- package/dist/approval-sheet.js +157 -0
- package/dist/approval-sheet.js.map +1 -0
- package/dist/body-command.d.ts +42 -0
- package/dist/body-command.d.ts.map +1 -0
- package/dist/body-command.js +143 -0
- package/dist/body-command.js.map +1 -0
- package/dist/change-set-contract.d.ts +381 -0
- package/dist/change-set-contract.d.ts.map +1 -0
- package/dist/change-set-contract.js +738 -0
- package/dist/change-set-contract.js.map +1 -0
- package/dist/change-set-digest.d.ts +28 -0
- package/dist/change-set-digest.d.ts.map +1 -0
- package/dist/change-set-digest.js +65 -0
- package/dist/change-set-digest.js.map +1 -0
- package/dist/check-cli.d.ts.map +1 -1
- package/dist/check-cli.js +14 -3
- package/dist/check-cli.js.map +1 -1
- package/dist/cli.d.ts +17 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +84 -23
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +79 -22
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +843 -268
- package/dist/core.js.map +1 -1
- package/dist/dry-materialize.d.ts +63 -0
- package/dist/dry-materialize.d.ts.map +1 -0
- package/dist/dry-materialize.js +330 -0
- package/dist/dry-materialize.js.map +1 -0
- package/dist/generated/contract-schema.generated.d.ts +97 -0
- package/dist/generated/contract-schema.generated.d.ts.map +1 -0
- package/dist/generated/contract-schema.generated.js +496 -0
- package/dist/generated/contract-schema.generated.js.map +1 -0
- package/dist/generated/package-scope.generated.d.ts +6 -0
- package/dist/generated/package-scope.generated.d.ts.map +1 -0
- package/dist/generated/package-scope.generated.js +10 -0
- package/dist/generated/package-scope.generated.js.map +1 -0
- package/dist/generated/plan-contracts.generated.d.ts +3 -0
- package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
- package/dist/generated/plan-contracts.generated.js +2840 -0
- package/dist/generated/plan-contracts.generated.js.map +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +11 -0
- package/dist/host.js.map +1 -1
- package/dist/identity.d.ts +15 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +48 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +34 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/inventory-adoption.d.ts +24 -5
- package/dist/inventory-adoption.d.ts.map +1 -1
- package/dist/inventory-adoption.js +70 -25
- package/dist/inventory-adoption.js.map +1 -1
- package/dist/inventory-choice.d.ts +40 -0
- package/dist/inventory-choice.d.ts.map +1 -0
- package/dist/inventory-choice.js +156 -0
- package/dist/inventory-choice.js.map +1 -0
- package/dist/inventory-contract.d.ts +89 -0
- package/dist/inventory-contract.d.ts.map +1 -0
- package/dist/inventory-contract.js +121 -0
- package/dist/inventory-contract.js.map +1 -0
- package/dist/key-editor.d.ts +30 -0
- package/dist/key-editor.d.ts.map +1 -0
- package/dist/key-editor.js +445 -0
- package/dist/key-editor.js.map +1 -0
- package/dist/ledger-contract.d.ts +187 -0
- package/dist/ledger-contract.d.ts.map +1 -0
- package/dist/ledger-contract.js +532 -0
- package/dist/ledger-contract.js.map +1 -0
- package/dist/ledger-trust.d.ts +90 -0
- package/dist/ledger-trust.d.ts.map +1 -0
- package/dist/ledger-trust.js +198 -0
- package/dist/ledger-trust.js.map +1 -0
- package/dist/lockfile-invariants.d.ts +48 -0
- package/dist/lockfile-invariants.d.ts.map +1 -0
- package/dist/lockfile-invariants.js +375 -0
- package/dist/lockfile-invariants.js.map +1 -0
- package/dist/lockfile-readers.d.ts +72 -0
- package/dist/lockfile-readers.d.ts.map +1 -0
- package/dist/lockfile-readers.js +713 -0
- package/dist/lockfile-readers.js.map +1 -0
- package/dist/lockfile-regen.d.ts +106 -0
- package/dist/lockfile-regen.d.ts.map +1 -0
- package/dist/lockfile-regen.js +760 -0
- package/dist/lockfile-regen.js.map +1 -0
- package/dist/lockfile-tool-env.d.ts +29 -0
- package/dist/lockfile-tool-env.d.ts.map +1 -0
- package/dist/lockfile-tool-env.js +111 -0
- package/dist/lockfile-tool-env.js.map +1 -0
- package/dist/materialize.d.ts +113 -0
- package/dist/materialize.d.ts.map +1 -0
- package/dist/materialize.js +840 -0
- package/dist/materialize.js.map +1 -0
- package/dist/observe-repository.d.ts +90 -0
- package/dist/observe-repository.d.ts.map +1 -0
- package/dist/observe-repository.js +1367 -0
- package/dist/observe-repository.js.map +1 -0
- package/dist/plan-bundle-setup-fixture.d.ts +68 -0
- package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
- package/dist/plan-bundle-setup-fixture.js +167 -0
- package/dist/plan-bundle-setup-fixture.js.map +1 -0
- package/dist/plan-bundle.d.ts +250 -0
- package/dist/plan-bundle.d.ts.map +1 -0
- package/dist/plan-bundle.js +827 -0
- package/dist/plan-bundle.js.map +1 -0
- package/dist/plan-command.d.ts +29 -0
- package/dist/plan-command.d.ts.map +1 -0
- package/dist/plan-command.js +493 -0
- package/dist/plan-command.js.map +1 -0
- package/dist/plan-contract.d.ts +153 -0
- package/dist/plan-contract.d.ts.map +1 -0
- package/dist/plan-contract.js +61 -0
- package/dist/plan-contract.js.map +1 -0
- package/dist/plan-digest.d.ts +25 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plan-digest.js +106 -0
- package/dist/plan-digest.js.map +1 -0
- package/dist/plan-rules.d.ts +23 -0
- package/dist/plan-rules.d.ts.map +1 -0
- package/dist/plan-rules.js +177 -0
- package/dist/plan-rules.js.map +1 -0
- package/dist/planned-bundle.d.ts +20 -0
- package/dist/planned-bundle.d.ts.map +1 -0
- package/dist/planned-bundle.js +191 -0
- package/dist/planned-bundle.js.map +1 -0
- package/dist/product-repository.d.ts +4 -0
- package/dist/product-repository.d.ts.map +1 -1
- package/dist/product-repository.js +9 -1
- package/dist/product-repository.js.map +1 -1
- package/dist/provenance-gate.d.ts +48 -0
- package/dist/provenance-gate.d.ts.map +1 -0
- package/dist/provenance-gate.js +324 -0
- package/dist/provenance-gate.js.map +1 -0
- package/dist/pull-request-body.d.ts +45 -0
- package/dist/pull-request-body.d.ts.map +1 -0
- package/dist/pull-request-body.js +232 -0
- package/dist/pull-request-body.js.map +1 -0
- package/dist/registry-snapshot.d.ts +141 -0
- package/dist/registry-snapshot.d.ts.map +1 -0
- package/dist/registry-snapshot.js +483 -0
- package/dist/registry-snapshot.js.map +1 -0
- package/dist/release-age-edit.d.ts +52 -0
- package/dist/release-age-edit.d.ts.map +1 -0
- package/dist/release-age-edit.js +413 -0
- package/dist/release-age-edit.js.map +1 -0
- package/dist/root-entries.d.ts +36 -0
- package/dist/root-entries.d.ts.map +1 -0
- package/dist/root-entries.js +80 -0
- package/dist/root-entries.js.map +1 -0
- package/dist/setup-template-scripts.d.ts +34 -0
- package/dist/setup-template-scripts.d.ts.map +1 -0
- package/dist/setup-template-scripts.js +557 -0
- package/dist/setup-template-scripts.js.map +1 -0
- package/dist/setup-templates.d.ts +54 -0
- package/dist/setup-templates.d.ts.map +1 -0
- package/dist/setup-templates.js +427 -0
- package/dist/setup-templates.js.map +1 -0
- package/dist/skills.d.ts +34 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +129 -17
- package/dist/skills.js.map +1 -1
- package/dist/status.d.ts +63 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +539 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +151 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -5
- package/skeleton/README.md +14 -9
- package/skeleton/package.json +2 -1
- package/skill/SKILL.md +20 -16
- package/skill-catalogue/advisor/SKILL.md +100 -11
- package/skill-catalogue/architect/SKILL.md +2 -13
- package/skill-catalogue/bouncer/SKILL.md +2 -13
- package/skill-catalogue/builder/SKILL.md +2 -13
- package/skill-catalogue/butler/SKILL.md +2 -13
- package/skill-catalogue/controller/SKILL.md +2 -13
- package/skill-catalogue/customer/SKILL.md +4 -13
- package/skill-catalogue/designer/SKILL.md +9 -14
- package/skill-catalogue/giver/SKILL.md +2 -13
- package/skill-catalogue/influencer/SKILL.md +2 -13
- package/skill-catalogue/inspector/SKILL.md +2 -13
- package/skill-catalogue/integrator/SKILL.md +2 -13
- package/skill-catalogue/keeper/SKILL.md +2 -13
- package/skill-catalogue/launcher/SKILL.md +20 -16
- package/skill-catalogue/locksmith/SKILL.md +2 -13
- package/skill-catalogue/messenger/SKILL.md +2 -13
- package/skill-catalogue/observer/SKILL.md +2 -13
- package/skill-catalogue/publisher/SKILL.md +11 -17
- package/skill-catalogue/starter/SKILL.md +3 -13
- package/skill-catalogue/strategist/SKILL.md +14 -17
- package/skill-catalogue/writer/SKILL.md +7 -14
- package/src/admission-fixture.ts +572 -0
- package/src/admission.ts +816 -0
- package/src/agents-guide.ts +29 -0
- package/src/apply-command-options.check.ts +27 -0
- package/src/apply-plan-cli.ts +454 -14
- package/src/apply-plan.ts +112 -124
- package/src/apply-step-fixture.ts +236 -0
- package/src/apply-store.ts +584 -0
- package/src/approval-sheet.ts +164 -0
- package/src/body-command.ts +162 -0
- package/src/change-set-contract.ts +937 -0
- package/src/change-set-digest.ts +70 -0
- package/src/check-cli.ts +14 -3
- package/src/cli.ts +90 -22
- package/src/core.ts +973 -275
- package/src/dry-materialize.ts +353 -0
- package/src/generated/contract-schema.generated.ts +520 -0
- package/src/generated/package-scope.generated.ts +10 -0
- package/src/generated/plan-contracts.generated.ts +2840 -0
- package/src/host.ts +10 -0
- package/src/identity.ts +51 -0
- package/src/index.ts +72 -3
- package/src/inventory-adoption.ts +107 -29
- package/src/inventory-choice.ts +172 -0
- package/src/inventory-contract.ts +166 -0
- package/src/key-editor.ts +446 -0
- package/src/ledger-contract.ts +637 -0
- package/src/ledger-trust.ts +267 -0
- package/src/lockfile-invariants.ts +421 -0
- package/src/lockfile-readers.ts +749 -0
- package/src/lockfile-regen.ts +851 -0
- package/src/lockfile-tool-env.ts +131 -0
- package/src/materialize.ts +886 -0
- package/src/observe-repository.ts +1365 -0
- package/src/plan-bundle-setup-fixture.ts +200 -0
- package/src/plan-bundle.ts +964 -0
- package/src/plan-command.ts +509 -0
- package/src/plan-contract.ts +179 -0
- package/src/plan-digest.ts +102 -0
- package/src/plan-rules.ts +188 -0
- package/src/planned-bundle.ts +211 -0
- package/src/product-repository.ts +10 -1
- package/src/provenance-gate.ts +352 -0
- package/src/pull-request-body.ts +261 -0
- package/src/registry-snapshot.ts +534 -0
- package/src/release-age-edit.ts +430 -0
- package/src/root-entries.ts +81 -0
- package/src/setup-template-scripts.ts +571 -0
- package/src/setup-templates.ts +471 -0
- package/src/skills.ts +161 -18
- package/src/status.ts +557 -0
- package/src/types.ts +148 -13
- package/CHANGELOG.md +0 -131
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
// Decide a repository change set's ApprovalBinding from the hub alone (issue
|
|
2
|
+
// #1178; the apply RFC's check V3 and decisions D22 and D26, in the public
|
|
3
|
+
// repository, not shipped in this package). A change set is bound in exactly two ways: **approved**, when its
|
|
4
|
+
// digest is a member of the bundle the hub's latest committed decision
|
|
5
|
+
// approved, or **admitted**, when it is the apply set that follows an approved
|
|
6
|
+
// setup set and changes nothing the founder did not already approve (D26's
|
|
7
|
+
// three conditions). Anything else waits for a new approval.
|
|
8
|
+
//
|
|
9
|
+
// This is a security decision, so every ambiguity refuses:
|
|
10
|
+
//
|
|
11
|
+
// - The plan is read as a git object at the hub's HEAD, never from the
|
|
12
|
+
// worktree, and only when HEAD is attached to a branch: an uncommitted edit
|
|
13
|
+
// approves nothing, and a detached HEAD at an old commit cannot revive an
|
|
14
|
+
// approval a later commit revoked (decisions are append-only and outside the
|
|
15
|
+
// plan digest).
|
|
16
|
+
// - That HEAD must also be its branch's upstream (H1, local refs, no fetch): a
|
|
17
|
+
// local commit nobody pushed, or a clone that fell behind, is not the hub's
|
|
18
|
+
// word. HEAD is resolved to one commit id, and every later read of the hub
|
|
19
|
+
// (the plan, the assessment) is made at that id, never at `HEAD` again.
|
|
20
|
+
// - Bundles and setup sets are read only from the hub's own stores, by digest,
|
|
21
|
+
// through the store readers that recompute each digest. Nothing the caller
|
|
22
|
+
// holds in memory stands in for them.
|
|
23
|
+
// - Membership uses only (exact id, change-set digest): the rest of a bundle
|
|
24
|
+
// entry lies outside the bundle's digest and is never believed.
|
|
25
|
+
// - What the base holds is read as git objects at the set's own base commit:
|
|
26
|
+
// raw blob bytes, the tree's own mode, exact-case paths. Never the worktree,
|
|
27
|
+
// never ancestry, so a squash or rebase merge is admitted and a repository
|
|
28
|
+
// whose history was rewritten is judged by content alone.
|
|
29
|
+
// - The last word is RENDER: the ledger the admitted set would write must be
|
|
30
|
+
// exactly one admitted generation over the base ledger, by the ledger
|
|
31
|
+
// contract's own succession rules.
|
|
32
|
+
//
|
|
33
|
+
// A refusal carries an exit code, a reason and a fixed token, never a path, a
|
|
34
|
+
// digest or an id. Exit 1 means "violated"; exit 2 means "indeterminate".
|
|
35
|
+
// The caller maps a refusal to an ApplyStepResult.
|
|
36
|
+
import { spawnSync } from "node:child_process";
|
|
37
|
+
import { createHash } from "node:crypto";
|
|
38
|
+
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
39
|
+
import { tmpdir } from "node:os";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { AGENTS_GUIDE_PATH, AGENTS_GUIDE_TEXT } from "./agents-guide.js";
|
|
42
|
+
import { approvedSubject } from "./apply-plan.js";
|
|
43
|
+
import { readStoredApplyBundle, readStoredChangeSet } from "./apply-store.js";
|
|
44
|
+
import { LEDGER_PATH, dependencyPointer, lockfilePath, validateRepositoryChangeSet } from "./change-set-contract.js";
|
|
45
|
+
import { changeSetDigest } from "./change-set-digest.js";
|
|
46
|
+
import { readContractDocument } from "./generated/contract-schema.generated.js";
|
|
47
|
+
import { valueAtJsonPointer } from "./key-editor.js";
|
|
48
|
+
import { ledgerSuccession, renderInstalledLedger, serializeInstalledLedger } from "./ledger-contract.js";
|
|
49
|
+
import { isUnreadable, readLockfile } from "./lockfile-readers.js";
|
|
50
|
+
import { validateAdvisorPlan } from "./plan-contract.js";
|
|
51
|
+
import { canonicalJson, planDigest } from "./plan-digest.js";
|
|
52
|
+
const PLAN_PATH = "clossys/advisor/plan.json";
|
|
53
|
+
const ASSESSMENT_PATH = "clossys/advisor/assessment-input.json";
|
|
54
|
+
const READINESS_BIN = "node_modules/.bin/advisor-execution-readiness";
|
|
55
|
+
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
|
|
56
|
+
const SHA256 = /^sha256:[0-9a-f]{64}$/u;
|
|
57
|
+
function refuse(detail) {
|
|
58
|
+
return { state: "refused", exitCode: 2, reason: "awaiting-approval", detail };
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// git
|
|
62
|
+
const DROPPED_GIT_ENV = [
|
|
63
|
+
"GIT_DIR",
|
|
64
|
+
"GIT_WORK_TREE",
|
|
65
|
+
"GIT_INDEX_FILE",
|
|
66
|
+
"GIT_OBJECT_DIRECTORY",
|
|
67
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
68
|
+
"GIT_COMMON_DIR",
|
|
69
|
+
"GIT_NAMESPACE",
|
|
70
|
+
"GIT_PREFIX",
|
|
71
|
+
"GIT_SHALLOW_FILE",
|
|
72
|
+
"GIT_GRAFT_FILE",
|
|
73
|
+
];
|
|
74
|
+
/** Runs git with no repository named by the environment, no replace refs, and every pathspec literal. Never throws. */
|
|
75
|
+
function runGit(cwd, args) {
|
|
76
|
+
const env = { ...process.env };
|
|
77
|
+
for (const name of DROPPED_GIT_ENV)
|
|
78
|
+
delete env[name];
|
|
79
|
+
env.GIT_LITERAL_PATHSPECS = "1";
|
|
80
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
81
|
+
env.GIT_OPTIONAL_LOCKS = "0";
|
|
82
|
+
const run = spawnSync("git", ["--no-replace-objects", ...args], { cwd, env, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024 });
|
|
83
|
+
if (run.error !== undefined || run.stdout === null || run.stdout === undefined)
|
|
84
|
+
return { status: null, stdout: Buffer.alloc(0) };
|
|
85
|
+
return { status: run.status, stdout: run.stdout };
|
|
86
|
+
}
|
|
87
|
+
function parseTreeRows(stdout) {
|
|
88
|
+
const rows = [];
|
|
89
|
+
for (const record of stdout.toString("utf8").split("\0")) {
|
|
90
|
+
if (record === "")
|
|
91
|
+
continue;
|
|
92
|
+
const tab = record.indexOf("\t");
|
|
93
|
+
if (tab === -1)
|
|
94
|
+
return null;
|
|
95
|
+
const [mode, type, oid, ...rest] = record.slice(0, tab).split(" ");
|
|
96
|
+
if (mode === undefined || type === undefined || oid === undefined || rest.length > 0 || !OBJECT_ID.test(oid))
|
|
97
|
+
return null;
|
|
98
|
+
rows.push({ mode, type, oid, name: record.slice(tab + 1) });
|
|
99
|
+
}
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* H1. Resolves the hub's HEAD to one commit id, and requires the branch's
|
|
104
|
+
* configured upstream to resolve to that same commit, and to be a remote-tracking
|
|
105
|
+
* ref (`refs/remotes/`), not a local branch. One `rev-parse` reads all three, so
|
|
106
|
+
* the read is atomic. Local refs only: nothing here fetches, so a hub whose
|
|
107
|
+
* upstream is stale is judged by what it holds.
|
|
108
|
+
* HEAD must be attached to a branch (a detached HEAD may sit on an old commit
|
|
109
|
+
* that still carries a since-revoked approval). No upstream, ahead and behind
|
|
110
|
+
* are all `not-upstream`.
|
|
111
|
+
*/
|
|
112
|
+
function resolveHubHead(hub) {
|
|
113
|
+
// One read, so HEAD, its commit and its upstream come from one moment: the commit, the upstream's commit, then the full names of HEAD and of the upstream.
|
|
114
|
+
const read = runGit(hub, ["rev-parse", "HEAD^{commit}", "@{upstream}^{commit}", "--symbolic-full-name", "HEAD", "@{upstream}"]);
|
|
115
|
+
const lines = read.stdout.toString("utf8").split("\n");
|
|
116
|
+
const commit = lines[0] ?? "";
|
|
117
|
+
if (read.status !== 0) {
|
|
118
|
+
// Refused either way. A readable commit on an attached HEAD means the upstream is the part that failed.
|
|
119
|
+
if (!OBJECT_ID.test(commit))
|
|
120
|
+
return { state: "unreadable" };
|
|
121
|
+
const symbolic = runGit(hub, ["symbolic-ref", "-q", "HEAD"]);
|
|
122
|
+
return symbolic.status === 0 && symbolic.stdout.toString("utf8").trim().startsWith("refs/heads/") ? { state: "not-upstream" } : { state: "unreadable" };
|
|
123
|
+
}
|
|
124
|
+
const [head, upstreamCommit, headRef, upstreamRef, ...rest] = lines;
|
|
125
|
+
if (head === undefined || upstreamCommit === undefined || headRef === undefined || upstreamRef === undefined || rest.some((line) => line !== ""))
|
|
126
|
+
return { state: "unreadable" };
|
|
127
|
+
if (!headRef.startsWith("refs/heads/") || !OBJECT_ID.test(head))
|
|
128
|
+
return { state: "unreadable" };
|
|
129
|
+
// The upstream must be a remote-tracking ref: a local branch (remote `.`) is this repository's own word, not the remote's.
|
|
130
|
+
if (!upstreamRef.startsWith("refs/remotes/") || upstreamCommit !== head)
|
|
131
|
+
return { state: "not-upstream" };
|
|
132
|
+
return { state: "upstream", commit: head };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The bytes of a regular file at `relPath` in the tree of one commit of the
|
|
136
|
+
* hub, or null. The commit is the id resolveHubHead returned, never `HEAD`, so
|
|
137
|
+
* two reads cannot straddle a moved head. The path must be one regular,
|
|
138
|
+
* non-executable blob (mode 100644: a symbolic link is refused), and the read
|
|
139
|
+
* goes through the blob's object id, so it never sees the worktree. The path
|
|
140
|
+
* is relative to the hub, so a hub inside a larger repository works.
|
|
141
|
+
*/
|
|
142
|
+
function readCommittedBlob(hub, commit, relPath) {
|
|
143
|
+
if (!OBJECT_ID.test(commit))
|
|
144
|
+
return null;
|
|
145
|
+
const listed = runGit(hub, ["ls-tree", "-z", commit, "--", relPath]);
|
|
146
|
+
if (listed.status !== 0)
|
|
147
|
+
return null;
|
|
148
|
+
const rows = parseTreeRows(listed.stdout);
|
|
149
|
+
if (rows === null || rows.length !== 1)
|
|
150
|
+
return null;
|
|
151
|
+
const [row] = rows;
|
|
152
|
+
if (row === undefined || row.name !== relPath || row.type !== "blob" || row.mode !== "100644")
|
|
153
|
+
return null;
|
|
154
|
+
const blob = runGit(hub, ["cat-file", "blob", row.oid]);
|
|
155
|
+
return blob.status === 0 ? blob.stdout : null;
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// K1: the plan
|
|
159
|
+
/**
|
|
160
|
+
* K1. The plan committed at the hub's HEAD, and the bundle digest its latest
|
|
161
|
+
* decision approves. Exit 2, `awaiting-approval`, with `hub-not-upstream` when
|
|
162
|
+
* the attached HEAD is not its branch's upstream (H1: no upstream, ahead or
|
|
163
|
+
* behind), `plan-unreadable` when the plan cannot be read as one strict-JSON
|
|
164
|
+
* regular blob at an attached HEAD, and `plan-not-approved` when it does not
|
|
165
|
+
* validate, or nothing in it is approved with a subject (see approvedSubject).
|
|
166
|
+
* HEAD is resolved once (H2): `head` is that commit id, and the plan is read
|
|
167
|
+
* from it.
|
|
168
|
+
*/
|
|
169
|
+
export function readHubAuthority(hub) {
|
|
170
|
+
const hubHead = resolveHubHead(hub);
|
|
171
|
+
if (hubHead.state === "unreadable")
|
|
172
|
+
return refuse("plan-unreadable");
|
|
173
|
+
if (hubHead.state === "not-upstream")
|
|
174
|
+
return refuse("hub-not-upstream");
|
|
175
|
+
const { commit } = hubHead;
|
|
176
|
+
const bytes = readCommittedBlob(hub, commit, PLAN_PATH);
|
|
177
|
+
if (bytes === null)
|
|
178
|
+
return refuse("plan-unreadable");
|
|
179
|
+
let document;
|
|
180
|
+
try {
|
|
181
|
+
document = readContractDocument(bytes);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return refuse("plan-unreadable");
|
|
185
|
+
}
|
|
186
|
+
if (!validateAdvisorPlan(document).valid)
|
|
187
|
+
return refuse("plan-not-approved");
|
|
188
|
+
const plan = document;
|
|
189
|
+
const subject = approvedSubject(plan);
|
|
190
|
+
if (subject === null)
|
|
191
|
+
return refuse("plan-not-approved");
|
|
192
|
+
let digest;
|
|
193
|
+
try {
|
|
194
|
+
digest = planDigest(plan);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return refuse("plan-not-approved");
|
|
198
|
+
}
|
|
199
|
+
return { plan, planDigest: digest, subject, head: commit };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The plan's identity for each package act of one repository (exact id), in
|
|
203
|
+
* the shape renderInstalledLedger and trustInstalledLedger take. Trust and
|
|
204
|
+
* render use the committed plan's acts, never the acts of the set being
|
|
205
|
+
* admitted: a set cannot vouch for itself.
|
|
206
|
+
*/
|
|
207
|
+
export function planPackagesFor(authority, repositoryId) {
|
|
208
|
+
return (authority.plan.packages ?? [])
|
|
209
|
+
.filter((act) => act.repository === repositoryId)
|
|
210
|
+
.map(({ planItem, act, name, version, integrity, placement }) => ({ planItem, act, name, version, integrity, placement }));
|
|
211
|
+
}
|
|
212
|
+
const isPackageItem = (item) => item.act === "install" || item.act === "pin-starter";
|
|
213
|
+
const isWhole = (file) => !("derived" in file);
|
|
214
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
215
|
+
function same(left, right) {
|
|
216
|
+
try {
|
|
217
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const sha256Of = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
224
|
+
/** Runs a reader, reading a throw as its own failure value. */
|
|
225
|
+
function guarded(read, failure) {
|
|
226
|
+
try {
|
|
227
|
+
return read();
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return failure;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Whether a bundle lists a change set under exactly this id. */
|
|
234
|
+
function holds(bundle, id, changeSet) {
|
|
235
|
+
return bundle.repositories.some((entry) => "changeSet" in entry && entry.id === id && entry.changeSet === changeSet);
|
|
236
|
+
}
|
|
237
|
+
const identityOf = (item) => ({
|
|
238
|
+
planItem: item.planItem,
|
|
239
|
+
act: item.act,
|
|
240
|
+
name: item.package.name,
|
|
241
|
+
version: item.package.version,
|
|
242
|
+
integrity: item.package.integrity,
|
|
243
|
+
placement: item.placement,
|
|
244
|
+
});
|
|
245
|
+
/** A deferred install of the setup set, by planItem. */
|
|
246
|
+
function deferredInstall(item, setup) {
|
|
247
|
+
return item !== undefined && item.act === "install" && setup.deferred.some((deferral) => deferral.planItem === item.planItem);
|
|
248
|
+
}
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// K10: succession
|
|
251
|
+
/** K10's last word on a pair of ledgers: exactly one admitted next generation, with no violation. Null when it holds. */
|
|
252
|
+
export function checkSuccession(baseBytes, headBytes) {
|
|
253
|
+
let succession;
|
|
254
|
+
try {
|
|
255
|
+
succession = ledgerSuccession(baseBytes, headBytes);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return refuse("succession-refused");
|
|
259
|
+
}
|
|
260
|
+
if (succession.change !== "next-generation" || succession.admission !== "admitted" || succession.violations.length > 0)
|
|
261
|
+
return refuse("succession-refused");
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* K10. Renders the ledger the admitted set would write over the base ledger,
|
|
266
|
+
* with the committed plan's package identities, and requires it to be exactly
|
|
267
|
+
* one admitted generation over the base's bytes. A render that throws is
|
|
268
|
+
* `render-refused`; a pair the succession rules refuse is `succession-refused`.
|
|
269
|
+
*/
|
|
270
|
+
export function verifyAdmittedSuccession(input) {
|
|
271
|
+
const binding = { kind: "admitted", subjectDigest: input.authority.subject, setupChangeSet: input.setupChangeSet };
|
|
272
|
+
let rendered;
|
|
273
|
+
try {
|
|
274
|
+
rendered = renderInstalledLedger(input.baseLedger, input.set, binding, planPackagesFor(input.authority, input.set.repository.id));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return refuse("render-refused");
|
|
278
|
+
}
|
|
279
|
+
const refused = checkSuccession(input.baseLedgerBytes, Buffer.from(rendered, "utf8"));
|
|
280
|
+
return refused ?? { state: "bound", binding };
|
|
281
|
+
}
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// K8: condition 2
|
|
284
|
+
/** D26 condition 2: the apply set's acts are the setup set's and nothing else changes. Null when it holds. */
|
|
285
|
+
function checkCondition2(set, setup, authority, readers) {
|
|
286
|
+
// U11: a refusal in either set means a set that could never have been materialized.
|
|
287
|
+
if (setup.refused.length > 0 || set.refused.length > 0)
|
|
288
|
+
return refuse("refusal-present");
|
|
289
|
+
if (set.deferred.length > 0)
|
|
290
|
+
return refuse("deferred-present");
|
|
291
|
+
if (!same(set.producer, setup.producer))
|
|
292
|
+
return refuse("producer-differs");
|
|
293
|
+
if (!same(set.engine, setup.engine) || !same(set.integrator, setup.integrator))
|
|
294
|
+
return refuse("engine-differs");
|
|
295
|
+
const repository = (value) => ({ id: value.id, nodeId: value.nodeId, visibility: value.visibility, defaultBranch: value.defaultBranch });
|
|
296
|
+
if (!same(repository(set.repository), repository(setup.repository)))
|
|
297
|
+
return refuse("repository-differs");
|
|
298
|
+
const observed = (value) => ({ packageManager: value.packageManager, lockfile: lockfilePath(value) });
|
|
299
|
+
if (!same(observed(set.observed), observed(setup.observed)))
|
|
300
|
+
return refuse("observed-differs");
|
|
301
|
+
// The acts: the setup's own package items, and its deferred installs with the committed plan's identity.
|
|
302
|
+
const planned = planPackagesFor(authority, set.repository.id);
|
|
303
|
+
const expected = new Map();
|
|
304
|
+
for (const item of setup.items)
|
|
305
|
+
if (isPackageItem(item))
|
|
306
|
+
expected.set(item.planItem, identityOf(item));
|
|
307
|
+
for (const deferral of setup.deferred) {
|
|
308
|
+
const act = planned.find((candidate) => candidate.planItem === deferral.planItem);
|
|
309
|
+
if (act === undefined || expected.has(deferral.planItem))
|
|
310
|
+
return refuse("package-acts-differ");
|
|
311
|
+
if (act.act !== "install")
|
|
312
|
+
return refuse("deferred-act-not-install");
|
|
313
|
+
expected.set(deferral.planItem, act);
|
|
314
|
+
}
|
|
315
|
+
const actual = new Map();
|
|
316
|
+
for (const item of set.items)
|
|
317
|
+
if (isPackageItem(item))
|
|
318
|
+
actual.set(item.planItem, identityOf(item));
|
|
319
|
+
if (actual.size !== expected.size)
|
|
320
|
+
return refuse("package-acts-differ");
|
|
321
|
+
for (const [planItem, identity] of expected) {
|
|
322
|
+
const found = actual.get(planItem);
|
|
323
|
+
if (found === undefined || !same(found, identity))
|
|
324
|
+
return refuse("package-acts-differ");
|
|
325
|
+
}
|
|
326
|
+
// satisfiedInBase describes the base, not the act, so it is never compared; a deferred install claimed satisfied is refused outright,
|
|
327
|
+
// because nothing here verifies that claim by content.
|
|
328
|
+
for (const item of set.items) {
|
|
329
|
+
if (isPackageItem(item) && setup.deferred.some((deferral) => deferral.planItem === item.planItem) && item.satisfiedInBase)
|
|
330
|
+
return refuse("satisfied-unverified");
|
|
331
|
+
}
|
|
332
|
+
// The one addition: an install set up before the Launcher guide existed has a setup set that wrote none, so the apply set may add it.
|
|
333
|
+
const wrote = new Map(setup.files.filter(isWhole).flatMap((file) => (file.after === null ? [] : [[file.path, file]])));
|
|
334
|
+
// The guide item is matched whole, not by id: an item with the guide's id and another act or source is compared like any other item.
|
|
335
|
+
const mayAddGuide = !setup.items.some((item) => item.id === GUIDE_ITEM_ID) && !wrote.has(AGENTS_GUIDE_PATH);
|
|
336
|
+
const others = (value) => value.items.filter((item) => !isPackageItem(item) && !(mayAddGuide && value === set && isGuideItem(item)));
|
|
337
|
+
if (!same(others(set), others(setup)))
|
|
338
|
+
return refuse("items-differ");
|
|
339
|
+
// Every whole file is a no-op over bytes the setup wrote, except the guide add: absent before, the constant text after.
|
|
340
|
+
for (const file of set.files) {
|
|
341
|
+
if (!isWhole(file))
|
|
342
|
+
continue;
|
|
343
|
+
if (mayAddGuide && file.path === AGENTS_GUIDE_PATH) {
|
|
344
|
+
if (file.before !== null || file.mode !== "100644" || file.after !== sha256Of(Buffer.from(AGENTS_GUIDE_TEXT, "utf8")) || file.item !== GUIDE_ITEM_ID)
|
|
345
|
+
return refuse("file-not-setup");
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (file.after === null || file.before !== file.after)
|
|
349
|
+
return refuse("file-not-noop");
|
|
350
|
+
const original = wrote.get(file.path);
|
|
351
|
+
if (original === undefined || original.after !== file.after || original.mode !== file.mode)
|
|
352
|
+
return refuse("file-not-setup");
|
|
353
|
+
}
|
|
354
|
+
const byId = new Map(set.items.map((item) => [item.id, item]));
|
|
355
|
+
for (const key of set.keys) {
|
|
356
|
+
const item = byId.get(key.item);
|
|
357
|
+
if (!deferredInstall(item, setup) || key.before !== null)
|
|
358
|
+
return refuse("key-not-deferred");
|
|
359
|
+
}
|
|
360
|
+
for (const file of set.files) {
|
|
361
|
+
if (isWhole(file) || file.path === LEDGER_PATH)
|
|
362
|
+
continue;
|
|
363
|
+
for (const invariant of file.invariants) {
|
|
364
|
+
if ("name" in invariant && !deferredInstall(byId.get(invariant.item), setup))
|
|
365
|
+
return refuse("lockfile-not-deferred");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// The setup's own packages are satisfied in the base, or they would be written here.
|
|
369
|
+
for (const item of set.items) {
|
|
370
|
+
if (isPackageItem(item) && !deferredInstall(item, setup) && !item.satisfiedInBase)
|
|
371
|
+
return refuse("key-not-deferred");
|
|
372
|
+
}
|
|
373
|
+
// U14: the bundle the set names is published in the ledger, so it must be a stored bundle that holds this set, and never the approved one.
|
|
374
|
+
const named = guarded(() => readers.bundle(set.bundle), null);
|
|
375
|
+
if (named === null || set.bundle === authority.subject || !holds(named, set.repository.id, set.changeSetDigest))
|
|
376
|
+
return refuse("apply-bundle-unrecorded");
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
const GUIDE_ITEM_ID = "agents-guide";
|
|
380
|
+
/** The one item an apply set may add: `{ id: "agents-guide", act: "write-record", source: "agents-guide" }`, and no other field. */
|
|
381
|
+
const isGuideItem = (item) => same(item, { id: GUIDE_ITEM_ID, act: "write-record", source: "agents-guide" });
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// K9: condition 3, the base tree
|
|
384
|
+
const normalizeName = (name) => name.normalize("NFC").toLowerCase();
|
|
385
|
+
/**
|
|
386
|
+
* The guide an apply set adds is added only where the base has nothing there in any letter case, as the planner reads it: every root
|
|
387
|
+
* entry whose name is `clossys` in any case is listed, and none may hold an entry named `agents.md` in any case, file or directory. Null
|
|
388
|
+
* when the set adds no guide, or the path is clear.
|
|
389
|
+
*/
|
|
390
|
+
function checkGuideAbsent(set, readers) {
|
|
391
|
+
if (!set.files.some((file) => isWhole(file) && file.path === AGENTS_GUIDE_PATH && file.before === null))
|
|
392
|
+
return null;
|
|
393
|
+
const entry = guarded(() => readers.baseEntry(AGENTS_GUIDE_PATH), "unreadable");
|
|
394
|
+
if (entry === "unreadable")
|
|
395
|
+
return refuse("base-unreadable");
|
|
396
|
+
if (entry !== null)
|
|
397
|
+
return refuse("guide-not-absent");
|
|
398
|
+
const slash = AGENTS_GUIDE_PATH.lastIndexOf("/");
|
|
399
|
+
const [directory, base] = [normalizeName(AGENTS_GUIDE_PATH.slice(0, slash)), normalizeName(AGENTS_GUIDE_PATH.slice(slash + 1))];
|
|
400
|
+
const roots = guarded(() => readers.baseDirectory(""), "unreadable");
|
|
401
|
+
if (roots === "unreadable" || roots === null)
|
|
402
|
+
return refuse("base-unreadable");
|
|
403
|
+
for (const root of roots.filter((name) => normalizeName(name) === directory)) {
|
|
404
|
+
const names = guarded(() => readers.baseDirectory(root), "unreadable");
|
|
405
|
+
if (names === "unreadable")
|
|
406
|
+
return refuse("base-unreadable");
|
|
407
|
+
if (names !== null && names.some((name) => normalizeName(name) === base))
|
|
408
|
+
return refuse("guide-not-absent");
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
/** Every byte the setup wrote is in the base tree, by content: whole files, package.json keys, and its lockfile invariants. Null when it holds. */
|
|
413
|
+
function checkBaseTree(setup, readers) {
|
|
414
|
+
const listings = new Map();
|
|
415
|
+
const listing = (dir) => {
|
|
416
|
+
let known = listings.get(dir);
|
|
417
|
+
if (known === undefined) {
|
|
418
|
+
const names = guarded(() => readers.baseDirectory(dir), "unreadable");
|
|
419
|
+
known = names === null || names === "unreadable" ? "unreadable" : names;
|
|
420
|
+
listings.set(dir, known);
|
|
421
|
+
}
|
|
422
|
+
return known;
|
|
423
|
+
};
|
|
424
|
+
const entryAt = (path) => guarded(() => readers.baseEntry(path), "unreadable");
|
|
425
|
+
for (const file of setup.files) {
|
|
426
|
+
if (!isWhole(file) || file.after === null)
|
|
427
|
+
continue;
|
|
428
|
+
const entry = entryAt(file.path);
|
|
429
|
+
if (entry === "unreadable")
|
|
430
|
+
return refuse("base-unreadable");
|
|
431
|
+
if (entry === null)
|
|
432
|
+
return refuse("base-content-missing");
|
|
433
|
+
if (entry.mode !== file.mode)
|
|
434
|
+
return refuse("base-mode-mismatch");
|
|
435
|
+
if (sha256Of(entry.bytes) !== file.after)
|
|
436
|
+
return refuse("base-content-mismatch");
|
|
437
|
+
// The entry is there, so every directory above it is too: none may have a sibling that differs only in case.
|
|
438
|
+
const segments = file.path.split("/");
|
|
439
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
440
|
+
const names = listing(segments.slice(0, index).join("/"));
|
|
441
|
+
if (names === "unreadable")
|
|
442
|
+
return refuse("base-unreadable");
|
|
443
|
+
const wanted = normalizeName(segments[index]);
|
|
444
|
+
if (names.some((name) => name !== segments[index] && normalizeName(name) === wanted))
|
|
445
|
+
return refuse("base-case-variant");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const packages = setup.items.filter(isPackageItem);
|
|
449
|
+
if (packages.length === 0)
|
|
450
|
+
return null;
|
|
451
|
+
const manifest = entryAt("package.json");
|
|
452
|
+
if (manifest === "unreadable" || manifest === null || manifest.mode !== "100644")
|
|
453
|
+
return refuse("base-unreadable");
|
|
454
|
+
let manifestText;
|
|
455
|
+
try {
|
|
456
|
+
readContractDocument(manifest.bytes);
|
|
457
|
+
manifestText = new TextDecoder("utf-8", { fatal: true }).decode(manifest.bytes);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return refuse("base-unreadable");
|
|
461
|
+
}
|
|
462
|
+
const placements = ["dependencies", "devDependencies"];
|
|
463
|
+
for (const item of packages) {
|
|
464
|
+
const found = [];
|
|
465
|
+
for (const placement of placements) {
|
|
466
|
+
let at;
|
|
467
|
+
try {
|
|
468
|
+
at = valueAtJsonPointer(manifestText, dependencyPointer(placement, item.package.name));
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return refuse("base-unreadable");
|
|
472
|
+
}
|
|
473
|
+
if (at.found)
|
|
474
|
+
found.push({ placement, value: at.value });
|
|
475
|
+
}
|
|
476
|
+
if (found.length > 1)
|
|
477
|
+
return refuse("base-key-ambiguous");
|
|
478
|
+
const only = found[0];
|
|
479
|
+
if (only === undefined || only.placement !== item.placement)
|
|
480
|
+
return refuse("base-key-missing");
|
|
481
|
+
if (only.value !== item.package.version)
|
|
482
|
+
return refuse("base-key-mismatch");
|
|
483
|
+
}
|
|
484
|
+
const lockPath = lockfilePath(setup.observed);
|
|
485
|
+
const format = lockPath === "package-lock.json" ? "npm" : lockPath === "pnpm-lock.yaml" ? "pnpm" : null;
|
|
486
|
+
if (lockPath === null || format === null)
|
|
487
|
+
return refuse("base-unreadable");
|
|
488
|
+
const lock = entryAt(lockPath);
|
|
489
|
+
if (lock === "unreadable" || lock === null || lock.mode !== "100644")
|
|
490
|
+
return refuse("base-unreadable");
|
|
491
|
+
let lockText;
|
|
492
|
+
try {
|
|
493
|
+
lockText = new TextDecoder("utf-8", { fatal: true }).decode(lock.bytes);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return refuse("base-unreadable");
|
|
497
|
+
}
|
|
498
|
+
const view = readLockfile(format, lockText);
|
|
499
|
+
if (isUnreadable(view))
|
|
500
|
+
return refuse("base-unreadable");
|
|
501
|
+
for (const item of packages) {
|
|
502
|
+
const resolved = view.root.find((row) => row.name === item.package.name && row.placement === item.placement);
|
|
503
|
+
if (resolved === undefined || resolved.link || resolved.version !== item.package.version || resolved.integrity !== item.package.integrity)
|
|
504
|
+
return refuse("base-lockfile-mismatch");
|
|
505
|
+
}
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// K2 to K10
|
|
510
|
+
/**
|
|
511
|
+
* The pure core. `set` is bound approved when the hub's approved bundle holds
|
|
512
|
+
* it, admitted when it is the apply set that follows the approved, merged
|
|
513
|
+
* setup set (D26), and refused otherwise. Every read goes through `readers`;
|
|
514
|
+
* nothing here touches a filesystem or runs git. Never throws: a failure it
|
|
515
|
+
* did not foresee is `admission-failed`.
|
|
516
|
+
*/
|
|
517
|
+
export function decideBinding(input) {
|
|
518
|
+
try {
|
|
519
|
+
return decide(input);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return refuse("admission-failed");
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
function decide(input) {
|
|
526
|
+
const { set, authority, readers, baseLedger, baseLedgerBytes } = input;
|
|
527
|
+
const invalid = { state: "refused", exitCode: 2, reason: "change-set-invalid" };
|
|
528
|
+
if (!validateRepositoryChangeSet(set).valid || changeSetDigest(set) !== set.changeSetDigest)
|
|
529
|
+
return invalid;
|
|
530
|
+
// K2: the approved bundle, read from the hub's store.
|
|
531
|
+
const bundle = guarded(() => readers.bundle(authority.subject), null);
|
|
532
|
+
if (bundle === null || bundle.bundleDigest !== authority.subject)
|
|
533
|
+
return refuse("bundle-unreadable");
|
|
534
|
+
if (bundle.plan.digest !== authority.planDigest)
|
|
535
|
+
return refuse("bundle-plan-mismatch");
|
|
536
|
+
// K3: the set is computed from the approved plan.
|
|
537
|
+
if (set.planDigest !== authority.planDigest)
|
|
538
|
+
return refuse("plan-digest-mismatch");
|
|
539
|
+
// K4: membership, by (exact id, digest) only.
|
|
540
|
+
if (holds(bundle, set.repository.id, set.changeSetDigest))
|
|
541
|
+
return { state: "bound", binding: { kind: "approved", subjectDigest: authority.subject } };
|
|
542
|
+
// K5: only an apply set can be admitted.
|
|
543
|
+
if (set.phase !== "apply")
|
|
544
|
+
return refuse("not-member");
|
|
545
|
+
// K6: the base ledger ends with the approved setup set, exactly as RENDER writes it.
|
|
546
|
+
if ((baseLedger === null) !== (baseLedgerBytes === null))
|
|
547
|
+
return refuse("base-ledger-inconsistent");
|
|
548
|
+
if (baseLedger === null || baseLedgerBytes === null)
|
|
549
|
+
return refuse("ledger-absent");
|
|
550
|
+
let reserialized;
|
|
551
|
+
try {
|
|
552
|
+
reserialized = serializeInstalledLedger(baseLedger);
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
return refuse("base-ledger-inconsistent");
|
|
556
|
+
}
|
|
557
|
+
if (!Buffer.from(reserialized, "utf8").equals(Buffer.from(baseLedgerBytes)))
|
|
558
|
+
return refuse("base-ledger-inconsistent");
|
|
559
|
+
const last = baseLedger.history.at(-1);
|
|
560
|
+
if (last === undefined || last.phase !== "setup")
|
|
561
|
+
return refuse("ledger-not-setup");
|
|
562
|
+
const setup = guarded(() => readers.setupSet(last.changeSet), null);
|
|
563
|
+
if (setup === null || setup.changeSetDigest !== last.changeSet)
|
|
564
|
+
return refuse("setup-unstored");
|
|
565
|
+
if (last.binding.kind !== "approved" || last.binding.subjectDigest !== authority.subject)
|
|
566
|
+
return refuse("setup-subject-mismatch");
|
|
567
|
+
if (setup.planDigest !== authority.planDigest || last.planDigest !== authority.planDigest)
|
|
568
|
+
return refuse("setup-plan-mismatch");
|
|
569
|
+
if (setup.phase !== "setup" || setup.repository.id !== set.repository.id || setup.repository.nodeId !== set.repository.nodeId)
|
|
570
|
+
return refuse("setup-unstored");
|
|
571
|
+
if (setup.ledger.generation !== 0 || baseLedger.generation !== 1)
|
|
572
|
+
return refuse("setup-not-first");
|
|
573
|
+
let expectedBytes;
|
|
574
|
+
try {
|
|
575
|
+
// `bundle` lies outside the set's digest: the ledger names the run that computed the set, which a first-stored copy may not.
|
|
576
|
+
expectedBytes = Buffer.from(renderInstalledLedger(null, { ...setup, bundle: last.bundle }, { kind: "approved", subjectDigest: authority.subject }, planPackagesFor(authority, setup.repository.id)), "utf8");
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return refuse("setup-ledger-not-rendered");
|
|
580
|
+
}
|
|
581
|
+
if (!expectedBytes.equals(Buffer.from(baseLedgerBytes)))
|
|
582
|
+
return refuse("setup-ledger-not-rendered");
|
|
583
|
+
// K7: the setup set is itself a member of the approved bundle.
|
|
584
|
+
if (!holds(bundle, setup.repository.id, setup.changeSetDigest))
|
|
585
|
+
return refuse("setup-not-member");
|
|
586
|
+
// K8: nothing but the authorized package acts differs from the setup.
|
|
587
|
+
const differs = checkCondition2(set, setup, authority, readers);
|
|
588
|
+
if (differs !== null)
|
|
589
|
+
return differs;
|
|
590
|
+
// K9: every byte the setup wrote is in the base, by content.
|
|
591
|
+
const missing = checkBaseTree(setup, readers);
|
|
592
|
+
if (missing !== null)
|
|
593
|
+
return missing;
|
|
594
|
+
const occupied = checkGuideAbsent(set, readers);
|
|
595
|
+
if (occupied !== null)
|
|
596
|
+
return occupied;
|
|
597
|
+
// K10: the ledger the admitted set writes is exactly one admitted generation over the base's.
|
|
598
|
+
return verifyAdmittedSuccession({ baseLedger, baseLedgerBytes, set, authority, setupChangeSet: setup.changeSetDigest });
|
|
599
|
+
}
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
// the hub and the clone
|
|
602
|
+
function gitBaseReaders(clone, commit) {
|
|
603
|
+
const valid = OBJECT_ID.test(commit);
|
|
604
|
+
return {
|
|
605
|
+
baseEntry: (path) => {
|
|
606
|
+
if (!valid)
|
|
607
|
+
return "unreadable";
|
|
608
|
+
const listed = runGit(clone, ["ls-tree", "-z", commit, "--", path]);
|
|
609
|
+
if (listed.status !== 0)
|
|
610
|
+
return "unreadable";
|
|
611
|
+
const rows = parseTreeRows(listed.stdout);
|
|
612
|
+
if (rows === null || rows.length > 1)
|
|
613
|
+
return "unreadable";
|
|
614
|
+
const [row] = rows;
|
|
615
|
+
if (row === undefined)
|
|
616
|
+
return null;
|
|
617
|
+
if (row.name !== path)
|
|
618
|
+
return "unreadable";
|
|
619
|
+
if (row.type !== "blob")
|
|
620
|
+
return { mode: row.mode, bytes: new Uint8Array(0) };
|
|
621
|
+
const blob = runGit(clone, ["cat-file", "blob", row.oid]);
|
|
622
|
+
if (blob.status !== 0)
|
|
623
|
+
return "unreadable";
|
|
624
|
+
return { mode: row.mode, bytes: new Uint8Array(blob.stdout) };
|
|
625
|
+
},
|
|
626
|
+
baseDirectory: (dir) => {
|
|
627
|
+
if (!valid)
|
|
628
|
+
return "unreadable";
|
|
629
|
+
const listed = runGit(clone, ["ls-tree", "-z", "--name-only", dir === "" ? commit : `${commit}:${dir}`]);
|
|
630
|
+
if (listed.status !== 0)
|
|
631
|
+
return "unreadable";
|
|
632
|
+
return listed.stdout
|
|
633
|
+
.toString("utf8")
|
|
634
|
+
.split("\0")
|
|
635
|
+
.filter((name) => name !== "");
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function hubReaders(hub) {
|
|
640
|
+
return {
|
|
641
|
+
bundle: (digest) => readStoredApplyBundle(hub, digest),
|
|
642
|
+
setupSet: (digest) => readStoredChangeSet(hub, digest),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
function packageActsOf(set, authority) {
|
|
646
|
+
const acts = [];
|
|
647
|
+
for (const item of set.items)
|
|
648
|
+
if (isPackageItem(item))
|
|
649
|
+
acts.push({ name: item.package.name, version: item.package.version, integrity: item.package.integrity });
|
|
650
|
+
const planned = planPackagesFor(authority, set.repository.id);
|
|
651
|
+
for (const deferral of set.deferred) {
|
|
652
|
+
const act = planned.find((candidate) => candidate.planItem === deferral.planItem);
|
|
653
|
+
if (act === undefined)
|
|
654
|
+
return null;
|
|
655
|
+
acts.push({ name: act.name, version: act.version, integrity: act.integrity });
|
|
656
|
+
}
|
|
657
|
+
return acts;
|
|
658
|
+
}
|
|
659
|
+
/** One key per (name, version, integrity): a JSON array, so no value can borrow another's separator. */
|
|
660
|
+
const packageKey = (entry) => JSON.stringify([entry.name, entry.version, entry.integrity]);
|
|
661
|
+
/** Whether two lists hold the same strings, the same number of times each, in any order. */
|
|
662
|
+
function sameStrings(left, right) {
|
|
663
|
+
const a = [...left].sort();
|
|
664
|
+
const b = [...right].sort();
|
|
665
|
+
return a.length === b.length && a.every((item, index) => item === b[index]);
|
|
666
|
+
}
|
|
667
|
+
const notCurrent = (detail) => ({ state: "refused", exitCode: 1, reason: "authorization-not-current", detail });
|
|
668
|
+
const unverified = (detail) => ({ state: "refused", exitCode: 2, reason: "authorization-unverified", detail });
|
|
669
|
+
function defaultRunner(timeoutMs) {
|
|
670
|
+
return (request) => {
|
|
671
|
+
const run = spawnSync(request.bin, [request.assessmentPath, request.asOf], {
|
|
672
|
+
cwd: request.cwd,
|
|
673
|
+
env: { PATH: process.env.PATH ?? "" },
|
|
674
|
+
stdio: "ignore",
|
|
675
|
+
timeout: timeoutMs,
|
|
676
|
+
killSignal: "SIGKILL",
|
|
677
|
+
});
|
|
678
|
+
return { status: run.error === undefined ? run.status : null };
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* K11. A set with package acts is bound only while the hub's execution
|
|
683
|
+
* authorization is current: clossys/advisor/assessment-input.json, read as a
|
|
684
|
+
* git object at the commit the plan was read from (H3: HEAD must still be
|
|
685
|
+
* attached, still equal `authority.head` and still match its upstream, else
|
|
686
|
+
* `authorization-unverified` `hub-head-moved`), must carry an authorization
|
|
687
|
+
* for this plan digest that permits this repository and every package act of
|
|
688
|
+
* the set (and of what it defers), and the hub's own
|
|
689
|
+
* advisor-execution-readiness, run on those exact committed bytes at the
|
|
690
|
+
* current instant, must exit 0. The permitted packages must also equal the
|
|
691
|
+
* plan's packages exactly (H4): the distinct `(name, version, integrity)` keys of
|
|
692
|
+
* every act in the plan, no more, no fewer and none repeated, else
|
|
693
|
+
* `authorization-not-current` `packages-not-exact`; repositories stay a subset
|
|
694
|
+
* check. Exit 1 is `authorization-not-current`; exit 2 and anything else is
|
|
695
|
+
* `authorization-unverified`. A set with no package act skips this step.
|
|
696
|
+
*/
|
|
697
|
+
function checkAuthorization(input) {
|
|
698
|
+
const { hub, set, authority } = input;
|
|
699
|
+
const acts = packageActsOf(set, authority);
|
|
700
|
+
if (acts === null)
|
|
701
|
+
return unverified("deferred-unplanned");
|
|
702
|
+
if (acts.length === 0)
|
|
703
|
+
return null;
|
|
704
|
+
const hubHead = resolveHubHead(hub);
|
|
705
|
+
if (hubHead.state !== "upstream" || hubHead.commit !== authority.head)
|
|
706
|
+
return unverified("hub-head-moved");
|
|
707
|
+
const bytes = readCommittedBlob(hub, authority.head, ASSESSMENT_PATH);
|
|
708
|
+
if (bytes === null)
|
|
709
|
+
return unverified("assessment-unreadable");
|
|
710
|
+
let document;
|
|
711
|
+
try {
|
|
712
|
+
document = readContractDocument(bytes);
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
return unverified("assessment-unreadable");
|
|
716
|
+
}
|
|
717
|
+
if (!isRecord(document) || !isRecord(document.engagement))
|
|
718
|
+
return unverified("assessment-unreadable");
|
|
719
|
+
const authorization = document.engagement.executionAuthorization;
|
|
720
|
+
if (authorization === undefined)
|
|
721
|
+
return notCurrent("authorization-absent");
|
|
722
|
+
if (!isRecord(authorization))
|
|
723
|
+
return unverified("assessment-unreadable");
|
|
724
|
+
const { permittedRepositoryIds, permittedPackages } = authorization;
|
|
725
|
+
if (typeof authorization.planDigest !== "string" || !SHA256.test(authorization.planDigest))
|
|
726
|
+
return unverified("assessment-unreadable");
|
|
727
|
+
if (!Array.isArray(permittedRepositoryIds) || !permittedRepositoryIds.every((id) => typeof id === "string"))
|
|
728
|
+
return unverified("assessment-unreadable");
|
|
729
|
+
if (!Array.isArray(permittedPackages) || !permittedPackages.every((entry) => isRecord(entry) && typeof entry.name === "string" && typeof entry.version === "string" && typeof entry.integrity === "string")) {
|
|
730
|
+
return unverified("assessment-unreadable");
|
|
731
|
+
}
|
|
732
|
+
if (authorization.planDigest !== authority.planDigest)
|
|
733
|
+
return notCurrent("plan-mismatch");
|
|
734
|
+
if (!permittedRepositoryIds.includes(set.repository.id))
|
|
735
|
+
return notCurrent("repository-not-permitted");
|
|
736
|
+
const permitted = permittedPackages;
|
|
737
|
+
for (const act of acts) {
|
|
738
|
+
if (!permitted.some((entry) => entry.name === act.name && entry.version === act.version && entry.integrity === act.integrity))
|
|
739
|
+
return notCurrent("package-not-permitted");
|
|
740
|
+
}
|
|
741
|
+
// H4: the authorization is for the plan's packages, all of them and only them; a repeated entry is not the same list.
|
|
742
|
+
const planned = new Set((authority.plan.packages ?? []).map(packageKey));
|
|
743
|
+
if (!sameStrings(permitted.map(packageKey), [...planned]))
|
|
744
|
+
return notCurrent("packages-not-exact");
|
|
745
|
+
const bin = join(hub, READINESS_BIN);
|
|
746
|
+
try {
|
|
747
|
+
if (!statSync(bin).isFile())
|
|
748
|
+
return unverified("readiness-bin-missing");
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
return unverified("readiness-bin-missing");
|
|
752
|
+
}
|
|
753
|
+
const runner = input.runReadiness ?? defaultRunner(input.readinessTimeoutMs ?? 120_000);
|
|
754
|
+
const asOf = (input.now ?? (() => new Date()))().toISOString();
|
|
755
|
+
let directory;
|
|
756
|
+
try {
|
|
757
|
+
directory = mkdtempSync(join(tmpdir(), "launcher-readiness-"));
|
|
758
|
+
const assessmentPath = join(directory, "assessment.json");
|
|
759
|
+
writeFileSync(assessmentPath, bytes, { mode: 0o600 });
|
|
760
|
+
const { status } = runner({ bin, assessmentPath, asOf, cwd: hub });
|
|
761
|
+
if (status === 0)
|
|
762
|
+
return null;
|
|
763
|
+
if (status === 1)
|
|
764
|
+
return notCurrent("readiness-violated");
|
|
765
|
+
if (status === 2)
|
|
766
|
+
return unverified("readiness-indeterminate");
|
|
767
|
+
return unverified("readiness-failed");
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
return unverified("readiness-failed");
|
|
771
|
+
}
|
|
772
|
+
finally {
|
|
773
|
+
if (directory !== undefined)
|
|
774
|
+
rmSync(directory, { recursive: true, force: true });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* The binding of one change set, from the hub alone: the pure core wired to
|
|
779
|
+
* the hub's stores (never a caller-supplied list) and to git objects at the
|
|
780
|
+
* set's base commit in the clone, then the execution authorization for any set
|
|
781
|
+
* that has package acts. `authority` is what readHubAuthority returned, and
|
|
782
|
+
* `baseLedger` and `baseLedgerBytes` the base ledger the caller already
|
|
783
|
+
* trusted (trustInstalledLedger, over the committed plan's acts).
|
|
784
|
+
*/
|
|
785
|
+
export async function decideSetBinding(input) {
|
|
786
|
+
const readers = { ...hubReaders(input.hub), ...gitBaseReaders(input.clone, input.set.repository.baseCommit) };
|
|
787
|
+
const decided = decideBinding({ set: input.set, authority: input.authority, baseLedger: input.baseLedger, baseLedgerBytes: input.baseLedgerBytes, readers });
|
|
788
|
+
if (decided.state !== "bound")
|
|
789
|
+
return decided;
|
|
790
|
+
let authorization;
|
|
791
|
+
try {
|
|
792
|
+
authorization = checkAuthorization(input);
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
authorization = unverified("readiness-failed");
|
|
796
|
+
}
|
|
797
|
+
return authorization ?? decided;
|
|
798
|
+
}
|
|
799
|
+
//# sourceMappingURL=admission.js.map
|