@namewta/speculo 1.0.19 → 1.0.21
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 +8 -7
- package/THIRD-PARTY-NOTICES.md +33 -0
- package/dist/src/agent-files.js +2 -2
- package/dist/src/agent-files.js.map +1 -1
- package/dist/src/agent-skills.d.ts +81 -0
- package/dist/src/agent-skills.js +530 -0
- package/dist/src/agent-skills.js.map +1 -0
- package/dist/src/cli.js +11 -3
- package/dist/src/cli.js.map +1 -1
- package/dist/src/config.js +5 -3
- package/dist/src/config.js.map +1 -1
- package/dist/src/doctor.js +7 -2
- package/dist/src/doctor.js.map +1 -1
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +23 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/manifest.d.ts +9 -0
- package/dist/src/manifest.js +45 -7
- package/dist/src/manifest.js.map +1 -1
- package/dist/src/refresh.js +40 -0
- package/dist/src/refresh.js.map +1 -1
- package/dist/src/structured.js +15 -4
- package/dist/src/structured.js.map +1 -1
- package/dist/src/transaction.d.ts +24 -3
- package/dist/src/transaction.js +191 -12
- package/dist/src/transaction.js.map +1 -1
- package/package.json +7 -4
- package/scripts/validate-skills.mjs +56 -0
- package/template/AGENTS.md +1 -1
- package/template/CLAUDE.md +1 -1
- package/template/canonical/canonical-specdev-goal-plan.md +156 -28
- package/template/canonical/canonical-specdev-grill-with-docs.md +153 -31
- package/template/canonical/canonical-specdev-spec.md +153 -27
- package/template/canonical/canonical-specdev-tickets.md +153 -27
- package/template/canonical/canonical-specdev-wayfinder.md +142 -27
- package/template/commands/retro.md +11 -9
- package/template/skills/archive-and-consolidate/SKILL.md +163 -7
- package/template/skills/archive-and-consolidate/references/cleanup-rules.md +1 -1
- package/template/skills/docs-sync/references/agents/claude-redirect.md +8 -8
- package/template/skills/docs-sync/references/agents/content-contract.md +1 -1
- package/template/skills/docs-sync/references/agents/manifest-discovery.md +7 -7
- package/template/skills/docs-sync/references/agents/role-classification.md +4 -4
- package/template/skills/docs-sync/references/agents-contract.md +1 -1
- package/template/skills/engineering-standards-builder/SKILL.md +149 -9
- package/template/skills/engineering-standards-builder/agents/openai.yaml +2 -0
- package/template/skills/engineering-standards-builder/manifest.txt +2 -1
- package/template/skills/engineering-standards-builder/references/rules/09-mechanical-guardrails.md +9 -0
- package/template/skills/engineering-standards-builder/scripts/validate-builder.mjs +4 -7
- package/template/skills/git-history-squash/SKILL.md +87 -11
- package/template/skills/git-history-squash/agents/openai.yaml +2 -0
- package/template/skills/optimize-codex-config/SKILL.md +71 -11
- package/template/skills/{speculo-retro/references/entry-procedure.md → retrospective/SKILL.md} +12 -8
- package/template/skills/{speculo-retro → retrospective}/references/friction-taxonomy.md +22 -13
- package/template/skills/{speculo-retro → retrospective}/references/issue-drafting-sop.md +10 -20
- package/template/skills/source-code-zip/SKILL.md +44 -8
- package/template/skills/source-code-zip/references/{entry-procedure.md → cli-reference.md} +0 -43
- package/template/skills/upstream-fork-sync/SKILL.md +71 -8
- package/template/skills/{writing-great-skills → writing-for-agents}/GLOSSARY.md +1 -1
- package/template/skills/writing-for-agents/SKILL.md +22 -0
- package/template/skills/writing-for-agents/agents/openai.yaml +2 -0
- package/template/skills/{writing-great-skills/references/entry-procedure.md → writing-for-agents/references/authoring-principles.md} +2 -9
- package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
- package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
- package/template/workflows/ops/H-host-manage/references/host-profiles.md +4 -0
- package/template/workflows/ops/I-initialize/I-initialize.md +1 -1
- package/template/workflows/ops/INDEX.md +2 -0
- package/template/workflows/ops/README.md +7 -3
- package/template/workflows/ops/S-server-connect/S-server-connect.md +5 -5
- package/template/workflows/ops/V-inventory-view/V-inventory-view.md +2 -0
- package/template/workflows/ops/common/CAPABILITIES.md +2 -0
- package/template/workflows/ops/common/USAGE.md +5 -5
- package/template/workflows/ops/common/WORKSPACE-USAGE.md +29 -2
- package/template/workflows/ops/common/examples/README.md +1 -1
- package/template/workflows/ops/common/examples/register.example.json +7 -3
- package/template/workflows/ops/common/examples/server-connection.example.json +11 -0
- package/template/workflows/ops/common/rules/deployment-layout.md +1 -1
- package/template/workflows/ops/common/rules/host-root-and-onboarding.md +47 -0
- package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
- package/template/workflows/ops/common/rules/research-and-decisions.md +8 -0
- package/template/workflows/ops/common/rules/server-onboarding.md +3 -3
- package/template/workflows/ops/common/rules/workspace-and-authorization.md +2 -2
- package/template/workflows/ops/common/schemas/fleet-view.schema.json +64 -0
- package/template/workflows/ops/common/schemas/host.schema.json +19 -0
- package/template/workflows/ops/common/schemas/plan.schema.json +57 -0
- package/template/workflows/ops/common/schemas/root-confirmation.schema.json +118 -0
- package/template/workflows/ops/common/schemas/server-connection.schema.json +67 -0
- package/template/workflows/ops/common/schemas/server-discovery.schema.json +171 -0
- package/template/workflows/ops/common/schemas/spec.schema.json +19 -0
- package/template/workflows/ops/common/schemas/status.schema.json +19 -0
- package/template/workflows/ops/common/templates/FLEET.html +1 -1
- package/template/workflows/ops/common/templates/HOST-README.md +3 -1
- package/template/workflows/ops/common/tests/test_ops.mjs +8 -11
- package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +101 -16
- package/template/workflows/ops/common/tests/test_ops_onboarding.mjs +225 -0
- package/template/workflows/ops/common/tests/test_ops_workspace.mjs +17 -8
- package/template/workflows/ops/common/tools/demo-local.mjs +4 -0
- package/template/workflows/ops/common/tools/opslib/agent.mjs +26 -0
- package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +52 -26
- package/template/workflows/ops/common/tools/opslib/cli.mjs +1 -0
- package/template/workflows/ops/common/tools/opslib/core.mjs +1 -1
- package/template/workflows/ops/common/tools/opslib/docs.mjs +1 -0
- package/template/workflows/ops/common/tools/opslib/execution.mjs +4 -0
- package/template/workflows/ops/common/tools/opslib/fleet.mjs +26 -4
- package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +34 -0
- package/template/workflows/ops/common/tools/opslib/model.mjs +6 -0
- package/template/workflows/ops/common/tools/opslib/onboarding.mjs +215 -0
- package/template/workflows/ops/common/tools/opslib/planner.mjs +3 -0
- package/template/workflows/ops/common/tools/opslib/transport.mjs +10 -6
- package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +20 -1
- package/template/workflows/ops/common/tools/server-discover.sh +65 -0
- package/template/workflows/ops/common/tools/validate-ops.mjs +1 -1
- package/template/workflows/ops/manifest.json +12 -5
- package/template/workflows/specdev/A-archive-and-consolidate/consolidation-interview.md +3 -3
- package/template/workflows/specdev/C-code-review/C-code-review.md +1 -1
- package/template/workflows/specdev/D-diagnose-bugs/D-diagnose-bugs.md +6 -0
- package/template/workflows/specdev/G-grill-with-docs/references/interview-procedure.md +6 -6
- package/template/workflows/specdev/I-implement/references/implementation-procedure.md +1 -1
- package/template/workflows/specdev/I-implement/tdd-rules.md +4 -0
- package/template/workflows/specdev/I-init-setup/config-template.json +5 -1
- package/template/workflows/specdev/INDEX.md +2 -2
- package/template/workflows/specdev/P-goal-plan/planning-modes.md +1 -1
- package/template/workflows/specdev/P-prototype/P-prototype.md +7 -3
- package/template/workflows/specdev/P-prototype/logic-protocol.md +11 -0
- package/template/workflows/specdev/P-prototype/logic-template.md +27 -0
- package/template/workflows/specdev/P-prototype/project-preview-protocol.md +9 -0
- package/template/workflows/specdev/R-retro/R-retro.md +26 -0
- package/template/workflows/specdev/R-retro/retro-template.md +27 -0
- package/template/workflows/specdev/R-review-architecture/references/selected-design.md +1 -1
- package/template/workflows/specdev/README.md +13 -2
- package/template/workflows/specdev/S-spec/S-spec.md +4 -0
- package/template/workflows/specdev/T-tickets/T-tickets.md +4 -0
- package/template/workflows/specdev/T-triage/T-triage.md +31 -25
- package/template/workflows/specdev/T-triage/capture-protocol.md +1 -1
- package/template/workflows/specdev/T-triage/ci-security-protocol.md +11 -0
- package/template/workflows/specdev/T-triage/intake-protocol.md +10 -4
- package/template/workflows/specdev/T-triage/pr-delivery-protocol.md +13 -0
- package/template/workflows/specdev/T-triage/pr-record-template.md +39 -0
- package/template/workflows/specdev/T-triage/publish-protocol.md +1 -1
- package/template/workflows/specdev/T-triage/queue-protocol.md +7 -0
- package/template/workflows/specdev/T-triage/reconcile-protocol.md +1 -1
- package/template/workflows/specdev/T-triage/references/issue-pr-policy.md +23 -0
- package/template/workflows/specdev/T-triage/references/release-authentication.md +9 -0
- package/template/workflows/specdev/T-triage/references/release-notes-injection.md +21 -0
- package/template/workflows/specdev/T-triage/references/workflow-yaml-reference.md +37 -0
- package/template/workflows/specdev/T-triage/release-preflight.md +13 -0
- package/template/workflows/specdev/T-triage/release-protocol.md +12 -0
- package/template/workflows/specdev/T-triage/release-recovery.md +18 -0
- package/template/workflows/specdev/T-triage/remote-operations.md +25 -0
- package/template/workflows/specdev/T-triage/scripts/github-transport.mjs +208 -0
- package/template/workflows/specdev/T-triage/source-template.md +7 -0
- package/template/workflows/specdev/T-triage/triage-run-template.md +34 -0
- package/template/workflows/specdev/T-triage/triage-template.md +4 -1
- package/template/workflows/specdev/common/rules/artifact-contract.md +7 -0
- package/template/workflows/specdev/common/rules/change-completion.md +6 -0
- package/template/workflows/specdev/common/rules/workflow-routing.md +6 -0
- package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +1 -1
- package/template/workflows/specdev/common/schemas/config.schema.json +137 -26
- package/template/workflows/specdev/common/schemas/logic-prototype.schema.json +62 -0
- package/template/workflows/specdev/common/schemas/pull-request.schema.json +90 -0
- package/template/workflows/specdev/common/schemas/retrospective.schema.json +58 -0
- package/template/workflows/specdev/common/schemas/triage-run.schema.json +149 -0
- package/template/workflows/specdev/common/schemas/triage.schema.json +117 -15
- package/template/workflows/specdev/common/tools/delivery-artifacts.mjs +95 -0
- package/template/workflows/specdev/common/tools/validate-specdev.mjs +60 -19
- package/template/workflows/specdev/runtime-contract.json +4 -2
- package/template/skills/archive-and-consolidate/references/entry-procedure.md +0 -178
- package/template/skills/docs-sync/references/entry-procedure.md +0 -3
- package/template/skills/engineering-standards-builder/references/entry-procedure.md +0 -154
- package/template/skills/git-history-squash/references/entry-procedure.md +0 -94
- package/template/skills/github-npm-ops/SKILL.md +0 -19
- package/template/skills/github-npm-ops/references/ci-and-security-ops.md +0 -178
- package/template/skills/github-npm-ops/references/entry-procedure.md +0 -25
- package/template/skills/github-npm-ops/references/failure-recovery.md +0 -123
- package/template/skills/github-npm-ops/references/issue-pr-triage.md +0 -227
- package/template/skills/github-npm-ops/references/issue-transport.md +0 -26
- package/template/skills/github-npm-ops/references/package-json-checklist.md +0 -171
- package/template/skills/github-npm-ops/references/preflight-checklist.md +0 -39
- package/template/skills/github-npm-ops/references/publish-detection.md +0 -68
- package/template/skills/github-npm-ops/references/release-notes-injection.md +0 -238
- package/template/skills/github-npm-ops/references/release-pipeline.md +0 -108
- package/template/skills/github-npm-ops/references/setup-npm-token.md +0 -63
- package/template/skills/github-npm-ops/references/troubleshooting-playbook.md +0 -293
- package/template/skills/github-npm-ops/references/version-bump-flow.md +0 -217
- package/template/skills/github-npm-ops/references/workflow-yaml-reference.md +0 -268
- package/template/skills/github-npm-ops/scripts/issue-transport.mjs +0 -227
- package/template/skills/optimize-codex-config/references/entry-procedure.md +0 -78
- package/template/skills/speculo-retro/SKILL.md +0 -19
- package/template/skills/upstream-fork-sync/references/entry-procedure.md +0 -78
- package/template/skills/writing-great-skills/SKILL.md +0 -23
- /package/template/skills/{writing-great-skills → writing-for-agents}/references/document-contract.md +0 -0
- /package/template/workflows/specdev/{G-grill-with-docs → common/rules}/adr-format.md +0 -0
- /package/template/workflows/specdev/{G-grill-with-docs → common/rules}/context-format.md +0 -0
- /package/template/workflows/specdev/{I-implement → common/rules}/design-it-twice.md +0 -0
- /package/template/workflows/specdev/{G-grill-with-docs/domain-modeling-rules.md → common/rules/domain-modeling.md} +0 -0
|
@@ -67,7 +67,7 @@ function setupFixture() {
|
|
|
67
67
|
project_id: "app-a", display_name: "APP A", kind: "app", service_type: null,
|
|
68
68
|
source: { type: "local", location: join(tmp, "source"), revision: "v1" },
|
|
69
69
|
};
|
|
70
|
-
model.
|
|
70
|
+
const legacy = model.load(state); legacy.hosts[host.host_id] = host; legacy.projects[project.project_id] = project; model.save(state, legacy); // pre-upgrade v3 fixture
|
|
71
71
|
model.putCredential(state, { credential_id: "app-auth", version: 1, purpose: "Synthetic test only", values: { username: "app-user", password: PASSWORD } });
|
|
72
72
|
const spec = (version = "v1", operation = "deploy", program = null) => {
|
|
73
73
|
if (program == null) {
|
|
@@ -175,16 +175,13 @@ test("lock is not autobroken", () => {
|
|
|
175
175
|
});
|
|
176
176
|
rmSync(d, { recursive: true, force: true });
|
|
177
177
|
});
|
|
178
|
-
test("symlink rejected", () => {
|
|
178
|
+
test("symlink rejected", (t) => {
|
|
179
179
|
const d = mkdtempSync(join(tmpdir(), "ops-link-"));
|
|
180
|
+
t.after(() => rmSync(d, { recursive: true, force: true }));
|
|
180
181
|
mkdirSync(join(d, "real"));
|
|
181
|
-
|
|
182
|
-
catch (e) {
|
|
183
|
-
if (process.platform === "win32") { rmSync(d, { recursive: true, force: true }); return; }
|
|
184
|
-
throw e;
|
|
185
|
-
}
|
|
182
|
+
symlinkSync(join(d, "real"), join(d, "link"), process.platform === "win32" ? "junction" : "dir");
|
|
186
183
|
assert.throws(() => atomicWrite(join(d, "link", "a.txt"), "x"), OpsError);
|
|
187
|
-
|
|
184
|
+
assert.equal(existsSync(join(d, "real", "a.txt")), false);
|
|
188
185
|
});
|
|
189
186
|
test("file permissions", () => {
|
|
190
187
|
const d = mkdtempSync(join(tmpdir(), "ops-perm-"));
|
|
@@ -443,7 +440,7 @@ test("planner contracts", async (t) => {
|
|
|
443
440
|
});
|
|
444
441
|
await each("unrelated host registration does not block approval", (fx) => {
|
|
445
442
|
const [info] = fx.plan();
|
|
446
|
-
model.
|
|
443
|
+
const legacy = model.load(fx.state); legacy.hosts["node-b"] = { ...fx.host, host_id: "node-b", display_name: "Node B", root: join(fx.tmp, "server-b") }; model.save(fx.state, legacy); // preserve legacy multi-root executor coverage
|
|
447
444
|
fx.approve(info);
|
|
448
445
|
});
|
|
449
446
|
await each("touched host catalog drift blocks approval", (fx) => {
|
|
@@ -891,7 +888,7 @@ test("control file helpers", () => {
|
|
|
891
888
|
});
|
|
892
889
|
|
|
893
890
|
test("disjoint host plans apply sequentially", () => runFx((fx) => {
|
|
894
|
-
model.
|
|
891
|
+
const legacy = model.load(fx.state); legacy.hosts["node-b"] = { ...fx.host, host_id: "node-b", display_name: "Node B", root: join(fx.tmp, "server-b") }; model.save(fx.state, legacy); // preserve legacy multi-root executor coverage
|
|
895
892
|
const specA = {
|
|
896
893
|
schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-a"], reason: "Host A cache fixture",
|
|
897
894
|
rollback_note: "Keep isolated cache until a separate purge is approved.",
|
|
@@ -994,7 +991,7 @@ test("new controllers keep secrets out of default documentation", () => runFx((f
|
|
|
994
991
|
}
|
|
995
992
|
assert.match(readFileSync(join(local,"OPERATIONS.md"),"utf8"), /secret_ref:.*app-auth@1/);
|
|
996
993
|
assert.ok(readFileSync(join(remote,"env","app.env"),"utf8").includes(PASSWORD));
|
|
997
|
-
assert.equal(plan.operations.find((o) => o.path
|
|
994
|
+
assert.equal(plan.operations.find((o) => o.path === join(remote, "env", "app.env")).mode, 0o600);
|
|
998
995
|
if (process.platform !== "win32") assert.equal(statSync(join(remote,"env","app.env")).mode & 0o777, 0o600);
|
|
999
996
|
assert.equal(readJson(join(local,"docs-receipt.json")).status, "both-sides-verified");
|
|
1000
997
|
}));
|
|
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
6
|
+
import { dirname, join, posix } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import test from "node:test";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
@@ -14,10 +14,12 @@ import * as bootstrap from "../tools/opslib/bootstrap.mjs";
|
|
|
14
14
|
import { initialize, main, cliHooks } from "../tools/opslib/cli.mjs";
|
|
15
15
|
import * as agent from "../tools/opslib/agent.mjs";
|
|
16
16
|
import * as model from "../tools/opslib/model.mjs";
|
|
17
|
+
import { onboardingHooks, discoverServer, confirmServerRoot } from "../tools/opslib/onboarding.mjs";
|
|
17
18
|
|
|
18
19
|
const toolsDir = join(dirname(fileURLToPath(import.meta.url)), "../tools");
|
|
19
20
|
const scriptPath = join(toolsDir, "bootstrap-volta.sh");
|
|
20
21
|
const ACK = "I-APPROVE-THIS-BOOTSTRAP";
|
|
22
|
+
const linuxExecution = { skip: process.platform !== "linux" && "Requires Linux /bin/sh and a Linux Node executable; SSH protocol contracts run on every platform" };
|
|
21
23
|
|
|
22
24
|
function sha256(p) {
|
|
23
25
|
return createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
@@ -58,13 +60,14 @@ function pinFor(archives, arch = "linux-x64") {
|
|
|
58
60
|
return { ...base, archives: { ...base.archives, [arch]: slot } };
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
function mockTransport({ probeJson = null } = {}) {
|
|
63
|
+
function mockTransport({ probeJson = null, agentJson = null, applyJson = null } = {}) {
|
|
62
64
|
const prev = transport.transportHooks.spawnSync;
|
|
63
65
|
const log = [];
|
|
64
66
|
transport.transportHooks.spawnSync = (cmd, args, kw) => {
|
|
65
67
|
log.push({ cmd, args: [...args], encoding: kw.encoding, inputIsBuffer: Buffer.isBuffer(kw.input) });
|
|
66
68
|
const last = args[args.length - 1];
|
|
67
69
|
if (cmd === "scp") {
|
|
70
|
+
if (applyJson) return { status: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
|
|
68
71
|
const local = args[args.length - 2];
|
|
69
72
|
const spec = args[args.length - 1];
|
|
70
73
|
const remotePath = spec.slice(spec.indexOf(":") + 1);
|
|
@@ -80,11 +83,13 @@ function mockTransport({ probeJson = null } = {}) {
|
|
|
80
83
|
return { status: 0, stdout: Buffer.from(JSON.stringify(probeJson) + "\n"), stderr: Buffer.alloc(0) };
|
|
81
84
|
}
|
|
82
85
|
if (String(last).includes("--input-type=module")) {
|
|
86
|
+
if (agentJson) return { status: 0, stdout: Buffer.from(JSON.stringify({ ok: true, result: agentJson }) + "\n"), stderr: Buffer.alloc(0) };
|
|
83
87
|
const parts = splitRemote(last);
|
|
84
88
|
return spawnSync(parts[0], parts.slice(1), {
|
|
85
89
|
input: kw.input, encoding: "buffer", timeout: kw.timeout, maxBuffer: kw.maxBuffer,
|
|
86
90
|
});
|
|
87
91
|
}
|
|
92
|
+
if (applyJson) return { status: 0, stdout: Buffer.from(String(last).includes("--apply") ? JSON.stringify(applyJson) + "\n" : ""), stderr: Buffer.alloc(0) };
|
|
88
93
|
const parts = splitRemote(last);
|
|
89
94
|
return spawnSync(parts[0], parts.slice(1), {
|
|
90
95
|
input: kw.input, encoding: "buffer", timeout: kw.timeout, maxBuffer: kw.maxBuffer,
|
|
@@ -182,7 +187,20 @@ test("scp uses the same host-key contract", () => {
|
|
|
182
187
|
} finally { transport.transportHooks.spawnSync = prev; rmSync(d, { recursive: true, force: true }); }
|
|
183
188
|
});
|
|
184
189
|
|
|
185
|
-
test("
|
|
190
|
+
test("scp rejects controller paths and unsafe Linux paths before spawning", (t) => {
|
|
191
|
+
const d = mkdtempSync(join(tmpdir(), "ops-scp-path-"));
|
|
192
|
+
t.after(() => rmSync(d, { recursive: true, force: true }));
|
|
193
|
+
const kh = join(d, "known_hosts"), local = join(d, "archive.tar.gz");
|
|
194
|
+
writeFileSync(kh, "fixture pinned key\n"); writeFileSync(local, "reviewed archive");
|
|
195
|
+
const prev = transport.transportHooks.spawnSync;
|
|
196
|
+
t.after(() => { transport.transportHooks.spawnSync = prev; });
|
|
197
|
+
transport.transportHooks.spawnSync = () => { assert.fail("invalid paths must not reach scp"); };
|
|
198
|
+
for (const path of ["C:\\server\\archive.tar.gz", "C:/server/archive.tar.gz", "\\\\server\\share\\archive.tar.gz", "/srv/ops\\archive.tar.gz", "relative.tar.gz", "/srv/../archive.tar.gz", "/srv/space name.tar.gz", "/srv/nul\0.tar.gz"]) {
|
|
199
|
+
assert.throws(() => transport.posixSend({ hostname: "host.invalid", username: "ops", known_hosts: kh }, local, path), /remote scp path/);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("bootstrap-volta apply installs wrapper and does not touch profile", linuxExecution, () => {
|
|
186
204
|
const d = mkdtempSync(join(tmpdir(), "ops-vol-"));
|
|
187
205
|
const home = join(d, "home");
|
|
188
206
|
mkdirSync(home);
|
|
@@ -205,28 +223,33 @@ test("bootstrap-volta apply installs wrapper and does not touch profile", () =>
|
|
|
205
223
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
206
224
|
});
|
|
207
225
|
|
|
208
|
-
test("bootstrap-volta apply without ack blocked", () => {
|
|
226
|
+
test("bootstrap-volta apply without ack blocked", linuxExecution, () => {
|
|
209
227
|
const d = mkdtempSync(join(tmpdir(), "ops-ack-"));
|
|
210
228
|
const archives = makeArchives(d);
|
|
211
229
|
const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, archives.voltaSha, archives.nodeTar, archives.nodeSha, join(d, "volta"), "24.21.0", "nope"], { encoding: "utf8" });
|
|
212
|
-
assert.
|
|
230
|
+
assert.equal(p.error, undefined);
|
|
231
|
+
assert.equal(p.status, 2);
|
|
232
|
+
assert.match(p.stderr, /acknowledgement/);
|
|
213
233
|
rmSync(d, { recursive: true, force: true });
|
|
214
234
|
});
|
|
215
235
|
|
|
216
|
-
test("bootstrap-volta sha mismatch blocked", () => {
|
|
236
|
+
test("bootstrap-volta sha mismatch blocked", linuxExecution, () => {
|
|
217
237
|
const d = mkdtempSync(join(tmpdir(), "ops-sha-"));
|
|
218
238
|
const archives = makeArchives(d);
|
|
219
239
|
const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, "0".repeat(64), archives.nodeTar, archives.nodeSha, join(d, "volta"), "24.21.0", ACK], { encoding: "utf8" });
|
|
220
|
-
assert.
|
|
240
|
+
assert.equal(p.error, undefined);
|
|
241
|
+
assert.equal(p.status, 2);
|
|
242
|
+
assert.match(p.stderr, /installer changed after review/);
|
|
221
243
|
assert.ok(!existsSync(join(d, "volta", "bin", "ops-node")));
|
|
222
244
|
rmSync(d, { recursive: true, force: true });
|
|
223
245
|
});
|
|
224
246
|
|
|
225
|
-
function cliFixture() {
|
|
247
|
+
function cliFixture({ executeLinux = false } = {}) {
|
|
226
248
|
const tmp = mkdtempSync(join(tmpdir(), "ops-bootcli-"));
|
|
227
249
|
const state = join(tmp, "controller");
|
|
228
|
-
|
|
229
|
-
|
|
250
|
+
// A simulated Linux SSH target is not a path on the controller filesystem.
|
|
251
|
+
const target = executeLinux ? join(tmp, "server") : "/srv/speculo-bootstrap-test";
|
|
252
|
+
if (executeLinux) mkdirSync(target, { recursive: true });
|
|
230
253
|
const kh = join(tmp, "known_hosts");
|
|
231
254
|
writeFileSync(kh, "fixture pinned key\n");
|
|
232
255
|
const prevProbe = cliHooks.probeLocal;
|
|
@@ -236,9 +259,13 @@ function cliFixture() {
|
|
|
236
259
|
const archives = makeArchives(tmp);
|
|
237
260
|
const prevPin = bootstrap.bootstrapHooks.loadPin;
|
|
238
261
|
bootstrap.bootstrapHooks.loadPin = () => pinFor(archives);
|
|
262
|
+
const previousObserve = onboardingHooks.observe;
|
|
263
|
+
onboardingHooks.observe = (h, root) => ({ identity: executeLinux ? agent.fingerprint() : "a".repeat(64), platform: "linux", username: h.connection.username, uid: "1000", home: "/home/ops", node: null, node_version: null, ...(root ? {root_check: {exists: false, writable: true, nonempty: false, owner: null}} : {}) });
|
|
239
264
|
return {
|
|
240
265
|
tmp, state, target, kh, archives,
|
|
266
|
+
confirm(host) { const d = discoverServer(state, host.host_id, host); const r = confirmServerRoot(state, host.host_id, d.discovery.receipt_id, host.root, "fixture-user", "Confirm only this isolated simulated target root"); host.root_confirmation = r.root_confirmation; },
|
|
241
267
|
cleanup() {
|
|
268
|
+
onboardingHooks.observe = previousObserve;
|
|
242
269
|
bootstrap.bootstrapHooks.loadPin = prevPin;
|
|
243
270
|
rmSync(tmp, { recursive: true, force: true });
|
|
244
271
|
},
|
|
@@ -251,6 +278,49 @@ const missingProbe = {
|
|
|
251
278
|
node_path: null, node_version: null, node_usable: false, profile_has_volta: false,
|
|
252
279
|
};
|
|
253
280
|
|
|
281
|
+
test("Linux SSH bootstrap and enrollment preserve remote paths on every controller platform", () => {
|
|
282
|
+
const fx = cliFixture();
|
|
283
|
+
const connectionNode = posix.join(fx.target, "_host/toolchains/ops/volta/bin/ops-node");
|
|
284
|
+
const mock = mockTransport({ probeJson: missingProbe, applyJson: { status: "installed", connection_node: connectionNode, profile_unchanged: true } });
|
|
285
|
+
try {
|
|
286
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
|
|
287
|
+
fx.confirm(host);
|
|
288
|
+
const conn = writeConn(fx.tmp, host);
|
|
289
|
+
const r = captureMain(["bootstrap-node", "--state", fx.state, "--host-id", host.host_id, "--connection-file", conn, "--apply", "--account", "ops", "--host-root", fx.target, "--ack", ACK,
|
|
290
|
+
"--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha, "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha]);
|
|
291
|
+
assert.equal(r.code, 0, r.stderr);
|
|
292
|
+
const boot = JSON.parse(r.stdout), pins = pinFor(fx.archives).archives["linux-x64"];
|
|
293
|
+
const paths = [pins.volta.filename, pins.node.filename].map(name => posix.join(fx.target, "_host/installers", name));
|
|
294
|
+
assert.deepEqual(mock.log.filter(e => e.cmd === "scp").map(e => e.args.at(-1)), paths.map(p => "ops@host.invalid:" + p));
|
|
295
|
+
const applyArgs = splitRemote(mock.log.find(e => e.cmd === "ssh" && e.args.at(-1).includes("--apply")).args.at(-1));
|
|
296
|
+
assert.deepEqual(applyArgs.slice(applyArgs.indexOf("--apply") + 1), [paths[0], fx.archives.voltaSha, paths[1], fx.archives.nodeSha, posix.dirname(posix.dirname(connectionNode)), fx.archives.nodeVersion, ACK]);
|
|
297
|
+
assert.equal(JSON.parse(readFileSync(boot.receipt, "utf8")).connection_node, connectionNode);
|
|
298
|
+
assert.deepEqual(Object.keys(model.load(fx.state).hosts), []);
|
|
299
|
+
// A new confirmation cannot reset an interrupted bootstrap's machine/root guard.
|
|
300
|
+
fx.confirm(host); writeConn(fx.tmp, host);
|
|
301
|
+
const retryArgs = ["bootstrap-node", "--state", fx.state, "--host-id", host.host_id, "--connection-file", conn, "--apply", "--account", "ops", "--host-root", fx.target, "--ack", ACK,
|
|
302
|
+
"--volta-archive", fx.archives.voltaTar, "--node-archive", fx.archives.nodeTar];
|
|
303
|
+
const blockedRetry = captureMain(retryArgs); assert.equal(blockedRetry.code, 2); assert.match(blockedRetry.stderr, /already started/);
|
|
304
|
+
assert.equal(mock.log.filter(e => e.cmd === "scp").length, 2);
|
|
305
|
+
const recovered = captureMain([...retryArgs, "--recover-ack", "I-VERIFIED-BOOTSTRAP-IS-STOPPED", "--recovery-statement", "Fixture only: the simulated old process is stopped; retry exactly the same pinned inputs"]);
|
|
306
|
+
assert.equal(recovered.code, 0, recovered.stderr);
|
|
307
|
+
assert.ok(readdirSync(join(fx.state, "hosts", host.host_id, "bootstrap")).some(n => n.startsWith("recovery-")));
|
|
308
|
+
mock.restore();
|
|
309
|
+
host.connection.node = connectionNode;
|
|
310
|
+
const enrollFile = join(fx.tmp, "register.json"); writeFileSync(enrollFile, JSON.stringify({ hosts: [host], projects: [] }));
|
|
311
|
+
const inventory = { identity: "a".repeat(64), platform: "linux", tools: { node: { path: connectionNode, version: "v24.21.0" } } };
|
|
312
|
+
const enrolledMock = mockTransport({ probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: connectionNode }, node_path: connectionNode, node_usable: true }, agentJson: inventory });
|
|
313
|
+
try {
|
|
314
|
+
const enrolled = captureMain(["--state", fx.state, "enroll", "--file", enrollFile]);
|
|
315
|
+
assert.equal(enrolled.code, 0, enrolled.stderr);
|
|
316
|
+
assert.equal(model.load(fx.state).hosts[host.host_id].connection.node, connectionNode);
|
|
317
|
+
const snapshot = JSON.parse(enrolled.stdout).hosts[0].snapshot;
|
|
318
|
+
assert.deepEqual(JSON.parse(readFileSync(snapshot, "utf8")), inventory);
|
|
319
|
+
assert.equal(enrolledMock.log.filter(e => e.args.at(-1).includes("--input-type=module")).length, 2);
|
|
320
|
+
} finally { enrolledMock.restore(); }
|
|
321
|
+
} finally { mock.restore(); fx.cleanup(); }
|
|
322
|
+
});
|
|
323
|
+
|
|
254
324
|
test("discover probe without node returns node-missing", () => {
|
|
255
325
|
const fx = cliFixture();
|
|
256
326
|
const mock = mockTransport({ probeJson: missingProbe });
|
|
@@ -300,6 +370,18 @@ test("bootstrap-node apply without ack blocked", () => {
|
|
|
300
370
|
} finally { mock.restore(); fx.cleanup(); }
|
|
301
371
|
});
|
|
302
372
|
|
|
373
|
+
test("bootstrap with valid archives but no root confirmation cannot write or upload", () => {
|
|
374
|
+
const fx = cliFixture(), mock = mockTransport({ probeJson: missingProbe });
|
|
375
|
+
try {
|
|
376
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" }), conn = writeConn(fx.tmp, host);
|
|
377
|
+
const r = captureMain(["bootstrap-node", "--state", fx.state, "--host-id", host.host_id, "--connection-file", conn, "--apply", "--account", "ops", "--host-root", fx.target, "--ack", ACK,
|
|
378
|
+
"--volta-archive", fx.archives.voltaTar, "--node-archive", fx.archives.nodeTar]);
|
|
379
|
+
assert.equal(r.code, 2); assert.match(r.stderr, /root-confirm/);
|
|
380
|
+
assert.equal(mock.log.filter(e => e.cmd === "scp").length, 0);
|
|
381
|
+
assert.ok(mock.log.every(e => e.args.at(-1).includes("--probe")));
|
|
382
|
+
} finally { mock.restore(); fx.cleanup(); }
|
|
383
|
+
});
|
|
384
|
+
|
|
303
385
|
test("bootstrap-node sha mismatch blocked", () => {
|
|
304
386
|
const fx = cliFixture();
|
|
305
387
|
const mock = mockTransport({ probeJson: missingProbe });
|
|
@@ -313,6 +395,7 @@ test("bootstrap-node sha mismatch blocked", () => {
|
|
|
313
395
|
"--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
|
|
314
396
|
]);
|
|
315
397
|
assert.equal(r.code, 2);
|
|
398
|
+
assert.match(r.stderr, /sha256 does not match the reviewed pin/i);
|
|
316
399
|
assert.ok(!mock.log.some((e) => String(e.args.at(-1) || "").includes("--apply")));
|
|
317
400
|
} finally { mock.restore(); fx.cleanup(); }
|
|
318
401
|
});
|
|
@@ -362,8 +445,8 @@ test("bootstrap-node skips when node exists", () => {
|
|
|
362
445
|
} finally { mock.restore(); fx.cleanup(); }
|
|
363
446
|
});
|
|
364
447
|
|
|
365
|
-
test("bootstrap-node apply then enroll writes inventory and status", () => {
|
|
366
|
-
const fx = cliFixture();
|
|
448
|
+
test("bootstrap-node apply then enroll writes inventory and status", linuxExecution, () => {
|
|
449
|
+
const fx = cliFixture({ executeLinux: true });
|
|
367
450
|
const home = join(fx.tmp, "home");
|
|
368
451
|
mkdirSync(home);
|
|
369
452
|
writeFileSync(join(home, ".bashrc"), "keep-me\n");
|
|
@@ -372,6 +455,7 @@ test("bootstrap-node apply then enroll writes inventory and status", () => {
|
|
|
372
455
|
const mock = mockTransport({ probeJson: missingProbe });
|
|
373
456
|
try {
|
|
374
457
|
const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
|
|
458
|
+
fx.confirm(host);
|
|
375
459
|
const conn = writeConn(fx.tmp, host);
|
|
376
460
|
const applied = captureMain([
|
|
377
461
|
"bootstrap-node", "--state", fx.state, "--host-id", "wsl-a",
|
|
@@ -435,10 +519,11 @@ test("enroll without node blocked", () => {
|
|
|
435
519
|
test("probe --connection-file still does not imply register", () => {
|
|
436
520
|
const fx = cliFixture();
|
|
437
521
|
const mock = mockTransport({
|
|
438
|
-
probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node:
|
|
522
|
+
probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: "/usr/bin/node" }, node_path: "/usr/bin/node", node_usable: true },
|
|
523
|
+
agentJson: { identity: "a".repeat(64), platform: "linux" },
|
|
439
524
|
});
|
|
440
525
|
try {
|
|
441
|
-
const host = sshHost({ root: fx.target, kh: fx.kh,
|
|
526
|
+
const host = sshHost({ root: fx.target, kh: fx.kh, identity: "discover" });
|
|
442
527
|
const conn = writeConn(fx.tmp, host);
|
|
443
528
|
const r = captureMain(["probe", "--connection-file", conn]);
|
|
444
529
|
assert.equal(r.code, 0, r.stderr);
|
|
@@ -461,7 +546,7 @@ test("environmentSpec uses managed volta path", () => {
|
|
|
461
546
|
host_id: "node-a", display_name: "Node A", platform: process.platform === "win32" ? "windows" : "linux",
|
|
462
547
|
transport: "local", root: target, identity: agent.fingerprint(), connection: {},
|
|
463
548
|
};
|
|
464
|
-
model.
|
|
549
|
+
const legacy = model.load(state); legacy.hosts[host.host_id] = host; model.save(state, legacy); // existing runtime, no fabricated confirmation
|
|
465
550
|
const prev = recipes.hostRecipesHooks.call;
|
|
466
551
|
const voltaBin = core.targetJoin(host, "_host/toolchains/ops/volta/bin/volta");
|
|
467
552
|
recipes.hostRecipesHooks.call = (_h, req) => {
|
|
@@ -497,7 +582,7 @@ test("environmentSpec still throws when volta truly absent", () => {
|
|
|
497
582
|
host_id: "node-a", display_name: "Node A", platform: process.platform === "win32" ? "windows" : "linux",
|
|
498
583
|
transport: "local", root: target, identity: agent.fingerprint(), connection: {},
|
|
499
584
|
};
|
|
500
|
-
model.
|
|
585
|
+
const legacy = model.load(state); legacy.hosts[host.host_id] = host; model.save(state, legacy); // existing runtime, no fabricated confirmation
|
|
501
586
|
const prev = recipes.hostRecipesHooks.call;
|
|
502
587
|
recipes.hostRecipesHooks.call = (_h, req) => {
|
|
503
588
|
if (req.action === "snapshot") return { paths: { [req.paths[0]]: { kind: "absent" } } };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/** Root-consent contracts, simulated SSH and real disposable local initialization. */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { emptyStatus, digest, writeJson } from "../tools/opslib/core.mjs";
|
|
10
|
+
import { load, register, save, validate } from "../tools/opslib/model.mjs";
|
|
11
|
+
import { discoverServer, confirmServerRoot, validateRootConfirmation, onboardingHooks, observeServer, parseLinuxDiscovery } from "../tools/opslib/onboarding.mjs";
|
|
12
|
+
import { call, transportHooks } from "../tools/opslib/transport.mjs";
|
|
13
|
+
import { initializeHostSpec, connectionSpec, hostRecipesHooks } from "../tools/opslib/host_recipes.mjs";
|
|
14
|
+
import { compilePlan, plannerHooks } from "../tools/opslib/planner.mjs";
|
|
15
|
+
import { approval, apply } from "../tools/opslib/execution.mjs";
|
|
16
|
+
import { fileState } from "../tools/opslib/agent.mjs";
|
|
17
|
+
import { generateFleet } from "../tools/opslib/fleet.mjs";
|
|
18
|
+
|
|
19
|
+
function fixture(t, local = false) {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), "ops-onboard-")), state = join(dir, "state");
|
|
21
|
+
mkdirSync(state); mkdirSync(join(state, "private"));
|
|
22
|
+
const registry = emptyStatus();
|
|
23
|
+
registry.controller = { controller_id: "fixture", state_root: state, created_at: "2026-01-01T00:00:00Z" };
|
|
24
|
+
writeFileSync(join(state, "status.json"), JSON.stringify(registry));
|
|
25
|
+
writeFileSync(join(state, "private/credentials.json"), '{"schema_version":1,"entries":{}}', { mode: 0o600 });
|
|
26
|
+
const knownHosts = join(dir, "known_hosts"); writeFileSync(knownHosts, "synthetic pinned key\n");
|
|
27
|
+
const host = { host_id: "node-a", display_name: "Node A", platform: local ? process.platform === "win32" ? "windows" : process.platform : "linux",
|
|
28
|
+
transport: local ? "local" : "ssh", root: local ? join(dir, "target") : "/home/wta/ops", identity: "a".repeat(64),
|
|
29
|
+
connection: local ? {} : { hostname: "server.invalid", username: "wta", known_hosts: knownHosts, node: "/usr/bin/node", sudo: true } };
|
|
30
|
+
const prev = onboardingHooks.observe;
|
|
31
|
+
let facts = { identity: host.identity, platform: "linux", username: "wta", uid: "1000", home: "/home/wta", node: null, node_version: null };
|
|
32
|
+
let check = { exists: false, writable: true, nonempty: false, owner: null };
|
|
33
|
+
if (!local) onboardingHooks.observe = (_h, root) => ({ ...facts, ...(root ? { root_check: { ...check } } : {}) });
|
|
34
|
+
t.after(() => { onboardingHooks.observe = prev; rmSync(dir, { recursive: true, force: true }); });
|
|
35
|
+
return { dir, state, host, knownHosts, facts: (v) => { facts = { ...facts, ...v }; }, check: (v) => { check = { ...check, ...v }; },
|
|
36
|
+
discover: () => discoverServer(state, host.host_id, host),
|
|
37
|
+
confirm(root = host.root) {
|
|
38
|
+
const d = discoverServer(state, host.host_id, host);
|
|
39
|
+
const r = confirmServerRoot(state, host.host_id, d.discovery.receipt_id, root, "fixture-user", "Use this exact isolated root for this fixture");
|
|
40
|
+
Object.assign(host, { root: r.root, identity: r.identity, root_confirmation: r.root_confirmation });
|
|
41
|
+
return r;
|
|
42
|
+
} };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function wire(values) { return Object.entries(values).map(([k, v]) => k + "=" + Buffer.from(v).toString("hex")).join("\n") + "\n"; }
|
|
46
|
+
const accountWire = { machine: "fixture-machine", system: "Linux", username: "wta", uid: "1000", home: "/accounts/wta", node: "", node_version: "" };
|
|
47
|
+
|
|
48
|
+
test("Linux wire discovery reads account database home, rejects malformed/noisy responses", () => {
|
|
49
|
+
const r = parseLinuxDiscovery(wire(accountWire));
|
|
50
|
+
assert.equal(r.home, "/accounts/wta"); assert.equal(r.node, null);
|
|
51
|
+
assert.equal(r.identity, digest({ machine: "fixture-machine", platform: "Linux" }));
|
|
52
|
+
for (const input of ["banner\n" + wire(accountWire), wire(accountWire) + "home=00\n", "home=xyz\n"]) assert.throws(() => parseLinuxDiscovery(input));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("sudo-enabled Linux SSH discovery is unprivileged and does not need Node", (t) => {
|
|
56
|
+
const f = fixture(t), prev = transportHooks.spawnSync;
|
|
57
|
+
t.after(() => { transportHooks.spawnSync = prev; });
|
|
58
|
+
let called = false;
|
|
59
|
+
transportHooks.spawnSync = (cmd, args, options) => {
|
|
60
|
+
called = true; assert.equal(cmd, "ssh"); assert.ok(args.includes("StrictHostKeyChecking=yes"));
|
|
61
|
+
assert.ok(!args.at(-1).includes("sudo")); assert.ok(!args.at(-1).includes("node"));
|
|
62
|
+
assert.ok(options.input.toString().includes('getent passwd "$uid"'));
|
|
63
|
+
return { status: 0, stdout: wire(accountWire), stderr: "" };
|
|
64
|
+
};
|
|
65
|
+
assert.equal(observeServer(f.host).username, "wta"); assert.ok(called);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("root, ordinary and custom account homes generate suggestions, never confirmation", (t) => {
|
|
69
|
+
const f = fixture(t);
|
|
70
|
+
for (const [username, uid, home] of [["root", "0", "/root"], ["wta", "1000", "/home/wta"], ["service_user", "1001", "/accounts/service user"]]) {
|
|
71
|
+
f.host.connection.username = username; f.facts({ username, uid, home });
|
|
72
|
+
const d = f.discover(); assert.equal(d.suggested_root, home + "/ops");
|
|
73
|
+
assert.deepEqual(load(f.state).hosts, {});
|
|
74
|
+
assert.throws(() => register(f.state, { hosts: [f.host] }), /requires server-root-confirm/);
|
|
75
|
+
}
|
|
76
|
+
assert.equal(existsSync(join(f.dir, "target")), false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("confirmed root locks retain login ownership while approved steps can use sudo", (t) => {
|
|
80
|
+
const f = fixture(t), prev = transportHooks.spawnSync;
|
|
81
|
+
t.after(() => { transportHooks.spawnSync = prev; });
|
|
82
|
+
const commands = [];
|
|
83
|
+
transportHooks.spawnSync = (_cmd, args) => {
|
|
84
|
+
commands.push(args.at(-1));
|
|
85
|
+
return { status: 0, stdout: JSON.stringify({ ok: true, result: {} }), stderr: "" };
|
|
86
|
+
};
|
|
87
|
+
f.host.root_confirmation = { receipt_id: "root-test", digest: "b".repeat(64) };
|
|
88
|
+
for (const action of ["lock", "step", "unlock"]) call(f.host, { action });
|
|
89
|
+
assert.equal(commands[0].startsWith("sudo "), false);
|
|
90
|
+
assert.equal(commands[1].startsWith("sudo -n -- "), true);
|
|
91
|
+
assert.equal(commands[2].startsWith("sudo "), false);
|
|
92
|
+
delete f.host.root_confirmation;
|
|
93
|
+
call(f.host, { action: "lock" });
|
|
94
|
+
assert.equal(commands[3].startsWith("sudo -n -- "), true, "legacy privilege behavior is unchanged");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("invalid endpoint and missing controller initialization fail before probing", (t) => {
|
|
98
|
+
const f = fixture(t); onboardingHooks.observe = () => assert.fail("invalid inputs must not reach a target");
|
|
99
|
+
assert.throws(() => discoverServer(f.state, "node-a", { ...f.host, connection: { ...f.host.connection, password: "synthetic-only" } }), /unknown/);
|
|
100
|
+
const s = load(f.state); s.controller = null; save(f.state, s);
|
|
101
|
+
assert.throws(() => f.discover(), /initialize the controller/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("Windows account discovery accepts SSH account casing and uses its actual home", (t) => {
|
|
105
|
+
const f = fixture(t);
|
|
106
|
+
f.host.platform = "windows"; f.host.connection.username = "WTA"; f.host.connection.shell = "powershell";
|
|
107
|
+
f.facts({ platform: "windows", username: "wta", uid: "-1", home: "D:\\Profiles\\wta" });
|
|
108
|
+
assert.equal(f.discover().suggested_root, "D:\\Profiles\\wta\\ops");
|
|
109
|
+
f.facts({ username: "different-user" }); assert.throws(() => f.discover(), /login account differs/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("explicit default/custom roots register with immutable evidence and repeat without new consent", (t) => {
|
|
113
|
+
const f = fixture(t); f.confirm("/data/ops");
|
|
114
|
+
const ref = structuredClone(f.host.root_confirmation);
|
|
115
|
+
register(f.state, { hosts: [f.host] }); register(f.state, { hosts: [f.host] });
|
|
116
|
+
assert.equal(load(f.state).hosts["node-a"].root, "/data/ops");
|
|
117
|
+
assert.deepEqual(load(f.state).hosts["node-a"].root_confirmation, ref);
|
|
118
|
+
assert.equal(f.discover().suggested_root, "/data/ops");
|
|
119
|
+
assert.throws(() => f.confirm("/new-root"), /immutable/);
|
|
120
|
+
const other = { ...f.host, host_id: "node-b", root: "/other/ops" };
|
|
121
|
+
assert.throws(() => register(f.state, { hosts: [other] }), /reuse Host/);
|
|
122
|
+
assert.throws(() => discoverServer(f.state, "node-b", other), /reuse Host/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("missing user statement, unsafe paths, nonempty roots, permissions and ownership block confirmation", (t) => {
|
|
126
|
+
const f = fixture(t), d = f.discover();
|
|
127
|
+
const confirm = (root = f.host.root, statement = "Use this isolated root") => confirmServerRoot(f.state, "node-a", d.discovery.receipt_id, root, "user", statement);
|
|
128
|
+
for (const statement of ["", " "]) assert.throws(() => confirm(f.host.root, statement), /confirmation/);
|
|
129
|
+
for (const root of ["relative", "/", "/root", "/home/wta", "/etc/nested", "/data/../root", "/data/ops\n", "/data\\ops"]) assert.throws(() => confirm(root));
|
|
130
|
+
f.check({ writable: false }); assert.throws(() => confirm(), /writable/);
|
|
131
|
+
f.check({ writable: true, exists: true, nonempty: true }); assert.throws(() => confirm(), /unowned/);
|
|
132
|
+
f.check({ owner: { host_id: "other", identity: f.host.identity } }); assert.throws(() => confirm(), /ownership/);
|
|
133
|
+
assert.deepEqual(load(f.state).hosts, {});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("identity, home, trust and receipt drift never authorize the changed target", (t) => {
|
|
137
|
+
const f = fixture(t), d = f.discover();
|
|
138
|
+
const confirm = () => confirmServerRoot(f.state, "node-a", d.discovery.receipt_id, f.host.root, "user", "Use this exact root");
|
|
139
|
+
f.facts({ home: "/changed/home" }); assert.throws(confirm, /drift/);
|
|
140
|
+
f.facts({ home: "/home/wta", identity: "b".repeat(64) }); assert.throws(confirm, /drift/);
|
|
141
|
+
f.facts({ identity: "a".repeat(64) });
|
|
142
|
+
writeFileSync(f.knownHosts, "changed host key"); assert.throws(confirm, /trust changed/);
|
|
143
|
+
f.confirm();
|
|
144
|
+
assert.throws(() => register(f.state, { hosts: [{ ...f.host, root: "/elsewhere/ops" }] }), /mismatch/);
|
|
145
|
+
const receipt = join(f.state, "records/servers/node-a/onboarding", f.host.root_confirmation.receipt_id + ".json");
|
|
146
|
+
const data = JSON.parse(readFileSync(receipt)); data.statement = "tampered"; writeFileSync(receipt, JSON.stringify(data));
|
|
147
|
+
assert.throws(() => register(f.state, { hosts: [f.host] }), /mismatch/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("same-machine connection update preserves the selected root and verifies new account access", (t) => {
|
|
151
|
+
const f = fixture(t); f.confirm(); register(f.state, { hosts: [f.host] });
|
|
152
|
+
f.facts({ username: "root", uid: "0", home: "/root" });
|
|
153
|
+
const endpoint = { ...f.host, connection: { ...f.host.connection, username: "root" } };
|
|
154
|
+
const spec = connectionSpec(f.state, "node-a", endpoint);
|
|
155
|
+
assert.equal(spec.resource_updates.hosts[0].root, "/home/wta/ops");
|
|
156
|
+
assert.deepEqual(spec.resource_updates.hosts[0].root_confirmation, f.host.root_confirmation);
|
|
157
|
+
assert.equal(load(f.state).hosts["node-a"].connection.username, "wta");
|
|
158
|
+
f.check({ writable: false }); assert.throws(() => connectionSpec(f.state, "node-a", endpoint), /writable/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("legacy v3 roots and evidence stay unchanged; ambiguous reconnection is blocked", (t) => {
|
|
162
|
+
const f = fixture(t), s = load(f.state); s.hosts["node-a"] = { ...f.host, root: "/srv/ops" }; save(f.state, s);
|
|
163
|
+
const before = readFileSync(join(f.state, "status.json"));
|
|
164
|
+
assert.equal(f.discover().suggested_root, "/srv/ops");
|
|
165
|
+
assert.equal(validateRootConfirmation(f.state, s.hosts["node-a"]), null);
|
|
166
|
+
assert.deepEqual(readFileSync(join(f.state, "status.json")), before);
|
|
167
|
+
s.hosts["old-second"] = { ...s.hosts["node-a"], host_id: "old-second", root: "/srv/second" }; save(f.state, s);
|
|
168
|
+
assert.throws(() => f.discover(), /ambiguous legacy/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("real local root rejects symlink ancestors and existing unrelated files", (t) => {
|
|
172
|
+
const f = fixture(t, true), real = join(f.dir, "real"), link = join(f.dir, "link"); mkdirSync(real);
|
|
173
|
+
symlinkSync(real, link, process.platform === "win32" ? "junction" : "dir");
|
|
174
|
+
assert.throws(() => f.confirm(join(link, "ops")), /symlink|reparse/i);
|
|
175
|
+
mkdirSync(f.host.root); writeFileSync(join(f.host.root, "README.md"), "User file, preserve");
|
|
176
|
+
assert.throws(() => f.confirm(), /unowned/);
|
|
177
|
+
assert.equal(readFileSync(join(f.host.root, "README.md"), "utf8"), "User file, preserve");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("real local onboarding and approved minimal initialization deliver both sides, then reuse directories", (t) => {
|
|
181
|
+
const f = fixture(t, true); f.confirm(); register(f.state, { hosts: [f.host] });
|
|
182
|
+
assert.equal(existsSync(f.host.root), false, "registration must not create target directories");
|
|
183
|
+
const pp = plannerHooks.call, hp = hostRecipesHooks.call;
|
|
184
|
+
const inventory = (h, req) => ({ identity: h.identity, platform: h.platform, tools: {}, defaults: {}, diagnostics: {},
|
|
185
|
+
snapshots: Object.fromEntries((req.paths || []).map(p => [p, fileState(p)])) });
|
|
186
|
+
plannerHooks.call = inventory; hostRecipesHooks.call = inventory;
|
|
187
|
+
t.after(() => { plannerHooks.call = pp; hostRecipesHooks.call = hp; });
|
|
188
|
+
const spec = initializeHostSpec(f.state, "node-a");
|
|
189
|
+
assert.ok(spec.host_actions.length > 0); assert.ok(spec.host_actions.every(a => a.kind === "mkdir"));
|
|
190
|
+
const specPath = join(f.dir, "init.json"); writeJson(specPath, spec);
|
|
191
|
+
const plan = compilePlan(f.state, specPath);
|
|
192
|
+
assert.equal(existsSync(f.host.root), false, "planning must not prepare the target");
|
|
193
|
+
approval(f.state, plan.run_id, plan.plan_digest, "fixture-user", "Initialize only this disposable local Host");
|
|
194
|
+
const redirected = join(f.dir, "redirected"); mkdirSync(redirected);
|
|
195
|
+
symlinkSync(redirected, f.host.root, process.platform === "win32" ? "junction" : "dir");
|
|
196
|
+
assert.throws(() => apply(f.state, plan.run_id), /symlink|reparse/i);
|
|
197
|
+
assert.equal(existsSync(join(redirected, ".ops-host.json")), false);
|
|
198
|
+
unlinkSync(f.host.root);
|
|
199
|
+
const result = apply(f.state, plan.run_id); assert.equal(result.status, "completed", JSON.stringify(result));
|
|
200
|
+
for (const p of ["README.md", "DEPLOYMENTS.md", "docs/standards/DEPLOYMENT-STANDARD.md", ".ops-host.json"]) assert.ok(existsSync(join(f.host.root, p)), p);
|
|
201
|
+
assert.equal(initializeHostSpec(f.state, "node-a").host_actions.length, 0);
|
|
202
|
+
assert.equal(existsSync(join(f.host.root, "_runtime/docker")), false, "minimal initialization never installs Docker");
|
|
203
|
+
const previous = onboardingHooks.observe; onboardingHooks.observe = () => assert.fail("V must not connect to targets");
|
|
204
|
+
try {
|
|
205
|
+
const view = generateFleet(f.state); const data = JSON.parse(readFileSync(view.json)); validate(data, "fleet-view");
|
|
206
|
+
assert.equal(data.servers[0].root_confirmation.status, "confirmed");
|
|
207
|
+
assert.equal(data.servers[0].initialization.documents, "both-sides-verified");
|
|
208
|
+
assert.equal(data.servers[0].initialization.runtime, "unverified");
|
|
209
|
+
assert.ok(!readFileSync(view.html, "utf8").includes("Use this exact isolated root"));
|
|
210
|
+
const s = load(f.state), last = s.releases[plan.run_id];
|
|
211
|
+
s.releases["pending-fixture"] = { ...last, run_id: "pending-fixture", status: "docs_pending", updated_at: new Date(Date.now() + 2000).toISOString() };
|
|
212
|
+
save(f.state, s);
|
|
213
|
+
const pending = JSON.parse(readFileSync(generateFleet(f.state).json));
|
|
214
|
+
assert.equal(pending.servers[0].initialization.documents, "unknown", "an older receipt cannot conceal a newer pending delivery");
|
|
215
|
+
} finally { onboardingHooks.observe = previous; }
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("Linux shell adapter matches real account identity without creating the selected root", { skip: process.platform !== "linux" && "Linux shell execution requires a Linux runner" }, (t) => {
|
|
219
|
+
const f = fixture(t, true), script = fileURLToPath(new URL("../tools/server-discover.sh", import.meta.url));
|
|
220
|
+
const run = spawnSync("/bin/sh", [script, f.host.root], { encoding: "utf8", timeout: 30000 });
|
|
221
|
+
assert.equal(run.status, 0, run.stderr);
|
|
222
|
+
const shell = parseLinuxDiscovery(run.stdout, true), native = observeServer(f.host, f.host.root);
|
|
223
|
+
for (const key of ["identity", "username", "uid", "home"]) assert.equal(shell[key], native[key]);
|
|
224
|
+
assert.equal(existsSync(f.host.root), false);
|
|
225
|
+
});
|