@namewta/speculo 1.0.20 → 1.0.22

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.
Files changed (56) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -1
  3. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
  4. package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
  5. package/template/workflows/ops/H-host-manage/references/host-profiles.md +4 -0
  6. package/template/workflows/ops/I-initialize/I-initialize.md +1 -1
  7. package/template/workflows/ops/INDEX.md +2 -0
  8. package/template/workflows/ops/README.md +7 -3
  9. package/template/workflows/ops/S-server-connect/S-server-connect.md +5 -5
  10. package/template/workflows/ops/V-inventory-view/V-inventory-view.md +2 -0
  11. package/template/workflows/ops/common/CAPABILITIES.md +2 -0
  12. package/template/workflows/ops/common/USAGE.md +5 -5
  13. package/template/workflows/ops/common/WORKSPACE-USAGE.md +29 -2
  14. package/template/workflows/ops/common/examples/README.md +1 -1
  15. package/template/workflows/ops/common/examples/register.example.json +7 -3
  16. package/template/workflows/ops/common/examples/server-connection.example.json +11 -0
  17. package/template/workflows/ops/common/rules/deployment-layout.md +1 -1
  18. package/template/workflows/ops/common/rules/host-root-and-onboarding.md +47 -0
  19. package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
  20. package/template/workflows/ops/common/rules/research-and-decisions.md +8 -0
  21. package/template/workflows/ops/common/rules/server-onboarding.md +3 -3
  22. package/template/workflows/ops/common/rules/workspace-and-authorization.md +2 -2
  23. package/template/workflows/ops/common/schemas/fleet-view.schema.json +64 -0
  24. package/template/workflows/ops/common/schemas/host.schema.json +19 -0
  25. package/template/workflows/ops/common/schemas/plan.schema.json +57 -0
  26. package/template/workflows/ops/common/schemas/root-confirmation.schema.json +118 -0
  27. package/template/workflows/ops/common/schemas/server-connection.schema.json +67 -0
  28. package/template/workflows/ops/common/schemas/server-discovery.schema.json +171 -0
  29. package/template/workflows/ops/common/schemas/spec.schema.json +19 -0
  30. package/template/workflows/ops/common/schemas/status.schema.json +19 -0
  31. package/template/workflows/ops/common/templates/FLEET.html +1 -1
  32. package/template/workflows/ops/common/templates/HOST-README.md +3 -1
  33. package/template/workflows/ops/common/tests/test_ops.mjs +3 -3
  34. package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +30 -2
  35. package/template/workflows/ops/common/tests/test_ops_onboarding.mjs +225 -0
  36. package/template/workflows/ops/common/tools/demo-local.mjs +4 -0
  37. package/template/workflows/ops/common/tools/opslib/agent.mjs +26 -0
  38. package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +50 -24
  39. package/template/workflows/ops/common/tools/opslib/cli.mjs +1 -0
  40. package/template/workflows/ops/common/tools/opslib/core.mjs +1 -1
  41. package/template/workflows/ops/common/tools/opslib/docs.mjs +1 -0
  42. package/template/workflows/ops/common/tools/opslib/execution.mjs +4 -0
  43. package/template/workflows/ops/common/tools/opslib/fleet.mjs +26 -4
  44. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +34 -0
  45. package/template/workflows/ops/common/tools/opslib/model.mjs +6 -0
  46. package/template/workflows/ops/common/tools/opslib/onboarding.mjs +215 -0
  47. package/template/workflows/ops/common/tools/opslib/planner.mjs +3 -0
  48. package/template/workflows/ops/common/tools/opslib/transport.mjs +7 -3
  49. package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +20 -1
  50. package/template/workflows/ops/common/tools/server-discover.sh +65 -0
  51. package/template/workflows/ops/common/tools/validate-ops.mjs +1 -1
  52. package/template/workflows/ops/manifest.json +12 -5
  53. package/template/skills/optimize-codex-config/SKILL.md +0 -81
  54. package/template/skills/optimize-codex-config/references/configuration-contract.md +0 -103
  55. package/template/skills/optimize-codex-config/references/troubleshooting.md +0 -79
  56. package/template/skills/optimize-codex-config/scripts/audit-codex-config.mjs +0 -747
@@ -0,0 +1,215 @@
1
+ /** S-owned account discovery and explicit root confirmation. No target writes. */
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join, posix, win32 } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { digest, identifier, newId, now, noSymlinks, OpsError, rootPath, withLock, writeJson } from "./core.mjs";
6
+ import { load, validate } from "./model.mjs";
7
+ import { call, posixCall, validateSshEndpoint } from "./transport.mjs";
8
+ import { readBoundedJson, statePath } from "./workspace.mjs";
9
+
10
+ const SCRIPT = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../server-discover.sh"), "utf8").replace(/\r\n?/g, "\n");
11
+ export const onboardingHooks = { observe: observeServer };
12
+ const pathApi = (platform) => platform === "windows" ? win32 : posix;
13
+ const clean = (s) => typeof s === "string" && s.trim().length > 0 && s.length <= 4096 && !/[\x00-\x1f\x7f]/.test(s);
14
+
15
+ export function endpointDigest(host) {
16
+ const c = { ...(host.connection || {}) };
17
+ // Runtime selection may change after a pinned Node bootstrap, not SSH identity/trust.
18
+ delete c.node;
19
+ if (host.transport === "ssh") {
20
+ validateSshEndpoint(c);
21
+ noSymlinks(c.known_hosts, { allowMissing: false });
22
+ }
23
+ return digest({ platform: host.platform, transport: host.transport, connection: c,
24
+ known_hosts_digest: host.transport === "ssh" ? digest(readFileSync(c.known_hosts)) : null });
25
+ }
26
+
27
+ export function parseLinuxDiscovery(stdout, withRoot = false) {
28
+ const values = {};
29
+ const keys = ["machine", "system", "username", "uid", "home", "node", "node_version",
30
+ ...(withRoot ? ["root_exists", "root_writable", "root_nonempty", "root_owner"] : [])];
31
+ for (const line of String(stdout).trimEnd().split(/\r?\n/)) {
32
+ const match = /^([a-z_]+)=((?:[a-f0-9]{2})*)$/.exec(line);
33
+ if (!match || !keys.includes(match[1]) || Object.hasOwn(values, match[1])) throw new OpsError("invalid Linux discovery response");
34
+ values[match[1]] = Buffer.from(match[2], "hex").toString("utf8");
35
+ }
36
+ if (Object.keys(values).length !== keys.length || values.system !== "Linux" || !clean(values.machine)) throw new OpsError("incomplete Linux identity discovery");
37
+ const facts = { identity: digest({ machine: values.machine.trim(), platform: "Linux" }), platform: "linux",
38
+ username: values.username, uid: values.uid, home: values.home, node: values.node || null, node_version: values.node_version || null };
39
+ if (withRoot) {
40
+ for (const key of ["root_exists", "root_writable", "root_nonempty"]) if (!["true", "false"].includes(values[key])) throw new OpsError("invalid root observation");
41
+ let owner = null;
42
+ if (values.root_owner) { try { owner = JSON.parse(values.root_owner); } catch { throw new OpsError("invalid root owner marker"); } }
43
+ facts.root_check = { exists: values.root_exists === "true", writable: values.root_writable === "true", nonempty: values.root_nonempty === "true", owner };
44
+ }
45
+ return facts;
46
+ }
47
+
48
+ export function observeServer(host, root = null) {
49
+ if (host.transport === "ssh" && host.platform === "linux") {
50
+ const result = posixCall(host.connection, SCRIPT, { sudo: false, args: root ? [root] : [], timeout: 30 });
51
+ return parseLinuxDiscovery(result.stdout, Boolean(root));
52
+ }
53
+ if (host.transport === "ssh" && !host.connection?.node) throw new OpsError("non-Linux SSH discovery requires an existing Node executable");
54
+ return call({ ...host, connection: host.transport === "ssh" ? { ...host.connection, sudo: false } : {} },
55
+ { action: "onboarding", identity: null, root }, { timeout: 30 });
56
+ }
57
+
58
+ function validateFacts(host, facts) {
59
+ if (!facts || facts.platform !== host.platform || !/^[a-f0-9]{64}$/.test(facts.identity || "")
60
+ || !clean(facts.username) || !clean(facts.home) || !pathApi(host.platform).isAbsolute(facts.home)) throw new OpsError("reliable login account/home/identity unavailable");
61
+ if (host.platform !== "windows" && !/^\d+$/.test(facts.uid)) throw new OpsError("reliable login uid unavailable");
62
+ if (host.transport === "ssh") {
63
+ const accountMatches = host.platform === "windows" ? facts.username.toLowerCase() === host.connection.username.toLowerCase() : facts.username === host.connection.username;
64
+ if (!accountMatches) throw new OpsError("observed login account differs from SSH username");
65
+ }
66
+ return facts;
67
+ }
68
+
69
+ export function assertUniqueMachine(registry, host) {
70
+ const peers = Object.values(registry.hosts).filter((h) => h.identity === host.identity);
71
+ if (peers.length > 1) throw new OpsError("ambiguous legacy machine roots; preserve evidence and resolve the affected Host explicitly");
72
+ if (peers.length && peers[0].host_id !== host.host_id) throw new OpsError("machine already registered; reuse Host " + peers[0].host_id + " and its root " + peers[0].root);
73
+ return peers[0] || null;
74
+ }
75
+
76
+ function folder(state, id) { identifier(id, "server"); return join(state, "records", "servers", id, "onboarding"); }
77
+ function artifactPath(state, id, rid) { identifier(rid, "receipt"); return join(folder(state, id), rid + ".json"); }
78
+ function persist(state, id, rid, value, schema) {
79
+ validate(value, schema);
80
+ const path = artifactPath(state, id, rid);
81
+ writeJson(path, value, { exclusive: true });
82
+ if (digest(readBoundedJson(path)) !== digest(value)) throw new OpsError("onboarding receipt readback mismatch");
83
+ return { receipt_id: rid, digest: digest(value) };
84
+ }
85
+
86
+ function assertSelectedPath(host, facts, root) {
87
+ const normalized = rootPath(root, host.platform);
88
+ if (!clean(root) || normalized !== root || (host.platform !== "windows" && root.includes("\\"))) throw new OpsError("selected root must be a normalized absolute path without control characters");
89
+ const p = pathApi(host.platform);
90
+ const home = p.normalize(facts.home);
91
+ if (host.platform === "windows" ? home.toLowerCase() === root.toLowerCase() : home === root) throw new OpsError("select a dedicated child, not the login home itself");
92
+ const system = host.platform === "windows" ? ["C:\\Windows", "C:\\Program Files", "C:\\Program Files (x86)"] : ["/etc", "/usr", "/bin", "/sbin", "/dev", "/proc", "/sys", "/boot"];
93
+ for (const base of system) {
94
+ const rel = p.relative(base, root);
95
+ if (rel === "" || (!rel.startsWith("..") && !p.isAbsolute(rel))) throw new OpsError("system directory cannot contain a managed root");
96
+ }
97
+ }
98
+
99
+ function assertRootObservation(host, facts, { legacy = false } = {}) {
100
+ const c = facts.root_check;
101
+ if (!c || typeof c.exists !== "boolean" || typeof c.nonempty !== "boolean" || c.writable !== true) throw new OpsError("selected root or nearest parent is not writable/searchable by the login account; provision permissions explicitly");
102
+ if (c.owner && (c.owner.host_id !== host.host_id || c.owner.identity !== facts.identity)) throw new OpsError("root ownership conflict");
103
+ if (!legacy && c.nonempty && !c.owner) throw new OpsError("nonempty unowned root; choose a dedicated empty directory or use a reviewed adoption plan");
104
+ }
105
+
106
+ export function discoverServer(state, serverId, endpoint) {
107
+ statePath(state); identifier(serverId);
108
+ const host = { host_id: serverId, platform: endpoint.platform, transport: endpoint.transport, connection: endpoint.connection || {} };
109
+ validate({ platform: host.platform, transport: host.transport, connection: host.connection }, "server-connection");
110
+ const registry = load(state);
111
+ if (!registry.controller) throw new OpsError("initialize the controller before connecting a Host");
112
+ if (!["linux", "windows", "darwin"].includes(host.platform) || !["local", "ssh"].includes(host.transport)) throw new OpsError("explicit platform and transport required");
113
+ if (host.transport === "local" && Object.keys(host.connection).length) throw new OpsError("local connection must be empty");
114
+ const before = endpointDigest(host);
115
+ const facts = validateFacts(host, onboardingHooks.observe(host));
116
+ const previous = assertUniqueMachine(registry, { ...host, identity: facts.identity });
117
+ if (registry.hosts[serverId] && !previous) throw new OpsError("registered machine identity drift");
118
+ if (before !== endpointDigest(host)) throw new OpsError("SSH trust changed during discovery");
119
+ const id = newId("discovery");
120
+ const record = { schema_version: 1, artifact: "ops-server-discovery", discovery_id: id, host_id: serverId,
121
+ discovered_at: now(), endpoint: host, endpoint_digest: before, facts,
122
+ suggested_root: previous?.root ?? pathApi(host.platform).join(facts.home, "ops"), registered: Boolean(previous) };
123
+ const ref = persist(state, serverId, id, record, "server-discovery");
124
+ return { status: previous ? "discovered-existing-host" : "discovered-not-confirmed", discovery: ref, ...record,
125
+ next: previous ? "Reuse the registered root; connection changes require S verification and an exact approved plan." : "Ask the user to specify or accept the suggested root, then server-root-confirm." };
126
+ }
127
+
128
+ export function confirmServerRoot(state, serverId, discoveryId, root, by, statement) {
129
+ statePath(state);
130
+ if (!clean(by) || !clean(statement)) throw new OpsError("actual user confirmation by/statement required");
131
+ const discovery = readBoundedJson(artifactPath(state, serverId, discoveryId));
132
+ validate(discovery, "server-discovery");
133
+ if (discovery.host_id !== serverId || discovery.discovery_id !== discoveryId) throw new OpsError("discovery identity mismatch");
134
+ const host = discovery.endpoint;
135
+ assertSelectedPath(host, discovery.facts, root);
136
+ if (endpointDigest(host) !== discovery.endpoint_digest) throw new OpsError("SSH endpoint/trust changed; rediscover");
137
+ const facts = validateFacts(host, onboardingHooks.observe(host, root));
138
+ for (const k of ["identity", "username", "uid", "home"]) if (facts[k] !== discovery.facts[k]) throw new OpsError("login account/home/identity drift; rediscover");
139
+ return withLock(join(state, ".locks", "catalog"), { operation: "server-root-confirm" }, () => {
140
+ const registry = load(state);
141
+ const previous = assertUniqueMachine(registry, { ...host, identity: facts.identity });
142
+ if (registry.hosts[serverId] && !previous) throw new OpsError("registered machine identity drift");
143
+ if (previous && previous.root !== root) throw new OpsError("registered root is immutable; explicit migration required");
144
+ assertRootObservation(host, facts, { legacy: Boolean(previous && !previous.root_confirmation) });
145
+ if (endpointDigest(host) !== discovery.endpoint_digest) throw new OpsError("SSH trust changed during root confirmation");
146
+ const id = newId("root");
147
+ const record = { schema_version: 1, artifact: "ops-root-confirmation", receipt_id: id, host_id: serverId,
148
+ platform: host.platform, transport: host.transport, identity: facts.identity, root,
149
+ account: { username: facts.username, uid: facts.uid, home: facts.home }, endpoint_digest: discovery.endpoint_digest,
150
+ discovery: { receipt_id: discoveryId, digest: digest(discovery) }, confirmed_at: now(), by, statement };
151
+ const ref = persist(state, serverId, id, record, "root-confirmation");
152
+ return { status: "root-confirmed", host_id: serverId, root, identity: facts.identity, root_confirmation: ref,
153
+ note: "Local confirmation evidence only; no directory created or software installed." };
154
+ });
155
+ }
156
+
157
+ export function validateRootConfirmation(state, host, { fresh = false, freshLegacy = false, require = false, checkEndpoint = false } = {}) {
158
+ const registry = load(state);
159
+ const previous = registry.hosts[host.host_id];
160
+ const ref = host.root_confirmation;
161
+ if (!ref) {
162
+ if (require || !previous || previous.identity !== host.identity || previous.root !== host.root || previous.root_confirmation) throw new OpsError("new Host requires server-root-confirm evidence before registration or target writes");
163
+ if (freshLegacy) {
164
+ const facts = validateFacts(host, onboardingHooks.observe(host, host.root));
165
+ if (facts.identity !== host.identity) throw new OpsError("legacy target machine identity drift");
166
+ assertRootObservation(host, facts, { legacy: true });
167
+ }
168
+ return null; // Existing v3 root remains authoritative; never fabricate retrospective consent.
169
+ }
170
+ assertUniqueMachine(registry, host);
171
+ const record = readBoundedJson(artifactPath(state, host.host_id, ref.receipt_id));
172
+ validate(record, "root-confirmation");
173
+ if (digest(record) !== ref.digest || record.receipt_id !== ref.receipt_id
174
+ || ["host_id", "identity", "platform", "transport", "root"].some((k) => host[k] !== record[k])) throw new OpsError("root confirmation digest/target mismatch");
175
+ const discovery = readBoundedJson(artifactPath(state, host.host_id, record.discovery.receipt_id));
176
+ validate(discovery, "server-discovery");
177
+ if (digest(discovery) !== record.discovery.digest) throw new OpsError("discovery evidence digest mismatch");
178
+ if (checkEndpoint && record.endpoint_digest !== endpointDigest(host)) throw new OpsError("confirmed SSH endpoint/trust drift; rediscover");
179
+ if (fresh) {
180
+ const facts = validateFacts(host, onboardingHooks.observe(host, host.root));
181
+ if (facts.identity !== host.identity) throw new OpsError("target machine identity drift");
182
+ // A reviewed connection-account update keeps the original root and confirmation.
183
+ if (endpointDigest(host) === record.endpoint_digest && ["username", "uid", "home"].some((k) => facts[k] !== record.account[k])) throw new OpsError("login account/home drift; rediscover and review the connection");
184
+ assertSelectedPath(host, facts, host.root);
185
+ assertRootObservation(host, facts);
186
+ }
187
+ return record;
188
+ }
189
+
190
+ export function confirmedBootstrapHost(state, raw, serverId, root) {
191
+ if (!state || !serverId) throw new OpsError("bootstrap apply requires --state and --host-id with root confirmation evidence");
192
+ statePath(state);
193
+ const previous = load(state).hosts[serverId];
194
+ const ref = raw.root_confirmation ?? previous?.root_confirmation;
195
+ if (!ref) {
196
+ if (!previous || previous.root !== root || previous.platform !== "linux" || previous.transport !== "ssh") throw new OpsError("bootstrap requires server-root-confirm evidence");
197
+ const host = { ...previous, connection: raw.connection || raw };
198
+ if (endpointDigest(previous) !== endpointDigest(host)) throw new OpsError("legacy bootstrap connection changes require S verification first");
199
+ assertUniqueMachine(load(state), host);
200
+ validateRootConfirmation(state, host, { freshLegacy: true });
201
+ return host;
202
+ }
203
+ const receipt = readBoundedJson(artifactPath(state, serverId, ref.receipt_id));
204
+ const host = { host_id: serverId, platform: "linux", transport: "ssh", root,
205
+ identity: receipt.identity, connection: raw.connection || raw, root_confirmation: ref };
206
+ if (raw.identity && raw.identity !== "discover" && raw.identity !== receipt.identity) throw new OpsError("bootstrap machine identity mismatch");
207
+ validateRootConfirmation(state, host, { require: true, fresh: true, checkEndpoint: true });
208
+ return host;
209
+ }
210
+
211
+ export function rootSummary(state, host) {
212
+ if (!host.root_confirmation) return { status: "legacy-registered", login_home: null, confirmed_at: null };
213
+ const r = validateRootConfirmation(state, host);
214
+ return { status: "confirmed", login_home: r.account.home, confirmed_at: r.confirmed_at };
215
+ }
@@ -12,6 +12,7 @@ import { allocationOperation } from "./services.mjs";
12
12
  import { credentialRefs, planReport, remotePaths } from "./docs.mjs";
13
13
  import { taskFiles } from "./native_windows.mjs";
14
14
  import { engineDigest } from "./execution.mjs";
15
+ import { validateRootConfirmation } from "./onboarding.mjs";
15
16
  import { assertSafeControlPath, defaultFileMode, reservedHostDocumentPaths } from "./control_files.mjs";
16
17
 
17
18
  export const plannerHooks = { call: transportCall };
@@ -246,6 +247,7 @@ export function compilePlan(state, specPath) {
246
247
  if (previous == null || fixed.some((k) => previous[k] !== item[k])) {
247
248
  throw new OpsError("resource update cannot change identity/root/kind; create explicit migration resources");
248
249
  }
250
+ if (group === "hosts" && digest(previous.root_confirmation ?? null) !== digest(item.root_confirmation ?? null)) throw new OpsError("root confirmation cannot be removed or replaced through resource updates");
249
251
  after[group][item[idkey]] = structuredClone(item);
250
252
  }
251
253
  }
@@ -668,6 +670,7 @@ export function compilePlan(state, specPath) {
668
670
  const snapshots = {};
669
671
  const docPreconditions = {};
670
672
  for (const [hid, host] of Object.entries(selected)) {
673
+ validateRootConfirmation(state, host, { fresh: true });
671
674
  const paths = new Set(ops.filter((o) => o.host_id === hid && ["write", "quarantine", "purge-quarantine"].includes(o.kind)).map((o) => o.path));
672
675
  for (const did of uniqueDocs) {
673
676
  if (after.deployments[did].host_id === hid) for (const p of remotePaths(after, after.deployments[did])) paths.add(p);
@@ -75,12 +75,12 @@ function buffers(p) {
75
75
  return { stdout, stderr };
76
76
  }
77
77
 
78
- export function posixCall(endpoint, script, { timeout = 180, args = [], sudo = false } = {}) {
78
+ export function posixCall(endpoint, script, { timeout = 180, args = [], sudo } = {}) {
79
79
  validateSshEndpoint(endpoint);
80
80
  if ((endpoint.shell ?? "posix") === "powershell") {
81
81
  throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
82
82
  }
83
- const useSudo = sudo || endpoint.sudo;
83
+ const useSudo = sudo ?? endpoint.sudo;
84
84
  let remote = ["/bin/sh", "-s", "--", ...args.map(String)];
85
85
  if (useSudo) remote = ["sudo", "-n", "--", ...remote];
86
86
  const argv = sshArgv(endpoint);
@@ -155,7 +155,11 @@ export function call(host, request, { timeout = 1800 } = {}) {
155
155
  const c = host.connection;
156
156
  const argv = sshArgv(c);
157
157
  let remote = [c.node, "--input-type=module"];
158
- if (c.sudo) remote = ["sudo", "-n", "--", ...remote];
158
+ // New confirmed roots and their owner/lock files belong to the login account.
159
+ // Running first lock as root would make later unprivileged discovery unreadable.
160
+ // Legacy Hosts retain their existing privilege behavior.
161
+ const useSudo = c.sudo && !(host.root_confirmation && ["lock", "unlock"].includes(request.action));
162
+ if (useSudo) remote = ["sudo", "-n", "--", ...remote];
159
163
  let remoteCommand;
160
164
  if ((c.shell ?? "posix") === "powershell") {
161
165
  if (c.sudo) throw new OpsError("sudo not valid for PowerShell transport");
@@ -5,9 +5,16 @@ import { readBoundedJson, readRegistry, route, statePath } from "./workspace.mjs
5
5
  import { checkServer } from "./server_checks.mjs";
6
6
  import { generateFleet } from "./fleet.mjs";
7
7
  import { authorizeTask, prepareTask, runTask } from "./tasks.mjs";
8
+ import { discoverServer, confirmServerRoot } from "./onboarding.mjs";
9
+ import { initializeHostSpec, connectionSpec } from "./host_recipes.mjs";
10
+ import { writeJson } from "./core.mjs";
8
11
 
9
- export const COMMANDS = Object.freeze(["workspace-help", "route", "server-check", "fleet", "task-plan", "task-authorize", "task-run"]);
12
+ export const COMMANDS = Object.freeze(["workspace-help", "route", "server-discover", "server-root-confirm", "server-initialize-spec", "server-connection-spec", "server-check", "fleet", "task-plan", "task-authorize", "task-run"]);
10
13
  const options = {
14
+ "server-discover": ["server", "connection-file"],
15
+ "server-root-confirm": ["server", "discovery", "root", "by", "statement"],
16
+ "server-initialize-spec": ["server", "output"],
17
+ "server-connection-spec": ["server", "connection-file", "output"],
11
18
  route: ["scope", "server", "project"],
12
19
  "server-check": ["server", "profile", "min-free-mib", "min-memory-mib"],
13
20
  fleet: ["stale-hours"], "task-plan": ["file"],
@@ -59,6 +66,18 @@ export async function main(argv = process.argv.slice(2)) {
59
66
  load(state);
60
67
  let result;
61
68
  switch (args.command) {
69
+ case "server-discover": result = discoverServer(state, required(args, "server"), readBoundedJson(required(args, "connection-file"))); break;
70
+ case "server-root-confirm": result = confirmServerRoot(state, required(args, "server"), required(args, "discovery"), required(args, "root"), required(args, "by"), required(args, "statement")); break;
71
+ case "server-initialize-spec":
72
+ case "server-connection-spec": {
73
+ const output = required(args, "output");
74
+ if (!isAbsolute(output)) throw new OpsError("--output must be absolute");
75
+ const spec = args.command === "server-initialize-spec" ? initializeHostSpec(state, required(args, "server"))
76
+ : connectionSpec(state, required(args, "server"), readBoundedJson(required(args, "connection-file")));
77
+ writeJson(output, spec, { exclusive: true });
78
+ result = { status: "generated-not-executed", spec_path: output, next: "plan -> exact approve -> apply; root selection is not installation authorization" };
79
+ break;
80
+ }
62
81
  case "route": result = route(readRegistry(state), { scope: required(args, "scope"), server_ids: list(args.server), project_ids: list(args.project) }); break;
63
82
  case "server-check": result = await checkServer(state, required(args, "server"), { profile: args.profile ?? "base",
64
83
  minFreeMiB: integer(args["min-free-mib"], 1024), minMemoryMiB: integer(args["min-memory-mib"], 256) }); break;
@@ -0,0 +1,65 @@
1
+ #!/bin/sh
2
+ # Read-only Linux account and root inspection. Never sudo, mkdir or install here.
3
+ set -eu
4
+ [ "$(uname -s)" = Linux ] || { echo 'Linux discovery adapter required' >&2; exit 2; }
5
+ emit() { printf '%s=' "$1"; printf '%s' "$2" | od -An -v -tx1 | tr -d ' \n'; printf '\n'; }
6
+ account=$(id -un)
7
+ uid=$(id -u)
8
+ if command -v getent >/dev/null 2>&1; then
9
+ record=$(getent passwd "$uid")
10
+ else
11
+ record=$(awk -F: -v uid="$uid" '$3 == uid { print }' /etc/passwd)
12
+ fi
13
+ [ "$(printf '%s\n' "$record" | wc -l | tr -d ' ')" = 1 ] || { echo 'Ambiguous account database entry' >&2; exit 2; }
14
+ [ "$(printf '%s' "$record" | cut -d: -f1)" = "$account" ] || { echo 'Account database mismatch' >&2; exit 2; }
15
+ home=$(printf '%s' "$record" | cut -d: -f6)
16
+ case "$home" in /*) ;; *) echo 'Account home is not absolute' >&2; exit 2;; esac
17
+ machine=''
18
+ for p in /etc/machine-id /var/lib/dbus/machine-id; do
19
+ if [ -r "$p" ]; then machine=$(cat "$p"); [ -z "$machine" ] || break; fi
20
+ done
21
+ [ -n "$machine" ] || { echo 'Stable machine identity unavailable' >&2; exit 2; }
22
+ node=$(command -v node 2>/dev/null || true)
23
+ version=''
24
+ if [ -n "$node" ]; then version=$("$node" --version 2>/dev/null || true); fi
25
+ emit machine "$machine"
26
+ emit system Linux
27
+ emit username "$account"
28
+ emit uid "$uid"
29
+ emit home "$home"
30
+ emit node "$node"
31
+ emit node_version "$version"
32
+ root=${1-}
33
+ if [ -n "$root" ]; then
34
+ cursor=$root
35
+ while :; do
36
+ [ ! -L "$cursor" ] || { echo 'Symlink root traversal rejected' >&2; exit 2; }
37
+ if [ -e "$cursor" ] && [ ! -d "$cursor" ]; then echo 'Root ancestor is not a directory' >&2; exit 2; fi
38
+ [ "$cursor" != / ] || break
39
+ cursor=$(dirname -- "$cursor")
40
+ done
41
+ cursor=$root
42
+ while [ ! -e "$cursor" ]; do cursor=$(dirname -- "$cursor"); done
43
+ writable=false
44
+ if [ -w "$cursor" ] && [ -x "$cursor" ]; then writable=true; fi
45
+ present=false
46
+ nonempty=false
47
+ owner=''
48
+ if [ -d "$root" ]; then
49
+ present=true
50
+ [ -r "$root" ] || { echo 'Selected root cannot be inspected by the login account' >&2; exit 2; }
51
+ entries=$(find "$root" -mindepth 1 -maxdepth 1 -print -quit) || { echo 'Selected root inspection failed' >&2; exit 2; }
52
+ if [ -n "$entries" ]; then nonempty=true; fi
53
+ for rel in .ops-host.json _host _runtime docs knowledge README.md DEPLOYMENTS.md; do
54
+ [ ! -L "$root/$rel" ] || { echo 'Managed path is a symlink' >&2; exit 2; }
55
+ done
56
+ if [ -e "$root/.ops-host.json" ]; then
57
+ [ -f "$root/.ops-host.json" ] && [ "$(wc -c < "$root/.ops-host.json")" -le 16384 ] || { echo 'Invalid root owner marker' >&2; exit 2; }
58
+ owner=$(cat "$root/.ops-host.json")
59
+ fi
60
+ fi
61
+ emit root_exists "$present"
62
+ emit root_writable "$writable"
63
+ emit root_nonempty "$nonempty"
64
+ emit root_owner "$owner"
65
+ fi
@@ -26,7 +26,7 @@ try {
26
26
  const text = readFileSync(join(root, id, id + ".md"), "utf8");
27
27
  if (!text.includes("type: workflow-entry") || !text.includes("读取范围") || !text.includes("id: ops/" + id.slice(2))) throw new Error("invalid work " + id);
28
28
  }
29
- for (const file of ["INDEX.md", "README.md", "manifest.json", "runtime-contract.json", "common/USAGE.md", "common/WORKSPACE-USAGE.md", "common/CAPABILITIES.md", "common/templates/FLEET.html", "common/schemas/fleet-view.schema.json", "common/schemas/task-request.schema.json", "common/schemas/server-check.schema.json", "common/tools/opslib/execution.mjs", "common/tools/opslib/tasks.mjs", "common/tools/opslib/fleet.mjs"]) {
29
+ for (const file of ["INDEX.md", "README.md", "manifest.json", "runtime-contract.json", "common/USAGE.md", "common/WORKSPACE-USAGE.md", "common/CAPABILITIES.md", "common/templates/FLEET.html", "common/schemas/fleet-view.schema.json", "common/schemas/task-request.schema.json", "common/schemas/server-check.schema.json", "common/tools/opslib/execution.mjs", "common/tools/opslib/tasks.mjs", "common/tools/opslib/fleet.mjs", "common/tools/opslib/onboarding.mjs", "common/tools/server-discover.sh", "common/schemas/server-discovery.schema.json", "common/schemas/root-confirmation.schema.json", "common/rules/host-root-and-onboarding.md"]) {
30
30
  if (!existsSync(join(root, file))) throw new Error("missing " + file);
31
31
  }
32
32
  for (const name of readdirSync(join(root, "common/schemas")).filter((x) => x.endsWith(".json"))) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "id": "ops",
4
- "version": "2.2.0",
4
+ "version": "2.3.0",
5
5
  "persistent_knowledge": [
6
6
  "<Path>{roots.state}/ops/knowledge/</Path>",
7
7
  "<Path>{roots.state}/ops/hosts/{host_id}/knowledge/</Path>",
@@ -30,19 +30,24 @@
30
30
  "inputs": [
31
31
  "server",
32
32
  "verified-host-key",
33
- "key-or-agent"
33
+ "key-or-agent",
34
+ "user-selected-persistence-root"
34
35
  ],
35
36
  "outputs": [
36
37
  "server-registration",
37
38
  "inventory",
38
- "server-checks"
39
+ "server-checks",
40
+ "login-account-discovery",
41
+ "root-confirmation"
39
42
  ],
40
43
  "risk": "production-critical",
41
44
  "context_budget": 20000
42
45
  },
43
46
  {
44
47
  "id": "host-manage",
45
- "after": [],
48
+ "after": [
49
+ "server-connect"
50
+ ],
46
51
  "inputs": [
47
52
  "server",
48
53
  "spec",
@@ -59,7 +64,9 @@
59
64
  },
60
65
  {
61
66
  "id": "project-deploy",
62
- "after": [],
67
+ "after": [
68
+ "host-manage"
69
+ ],
63
70
  "inputs": [
64
71
  "project",
65
72
  "explicit-servers",
@@ -1,81 +0,0 @@
1
- ---
2
- name: optimize-codex-config
3
- description: Audit config.toml/auth.json and diagnose Codex 413 or related failures; modify configuration only after explicit confirmation.
4
- ---
5
-
6
- # Optimize Codex Config
7
-
8
- 以**体检**为主导词:先建立脱敏事实,再提出配置变更。默认只读;修改本机配置前必须向用户展示完整目标和脱敏 diff,并取得本次修改的明确确认。
9
-
10
- ## 1. 锁定范围与权限
11
-
12
- 1. 解析实际 `CODEX_HOME`;未显式设置时使用当前用户的 `~/.codex`。将它转成绝对路径并确认目标是目录且不是符号链接。
13
- 2. 将请求归类为只读体检、故障诊断或配置修改。只读体检和诊断不取得写权限。
14
- 3. 将本 skill 的写入边界限制为用户明确指定的本机 Codex 文件。CC Switch 数据库、远端 API、反向代理和 Nginx 只输出归因与交接建议。
15
- 4. 在任何可能写入前记录 `config.toml` 的哈希、大小和修改时间,并检查目标文件是否存在已证明的可写句柄。普通 Codex CLI、ChatGPT/Codex 应用进程及其 helper 的存在不构成 writer 证据。
16
-
17
- **完成标准:** 实际 `CODEX_HOME`、任务类型、允许写入的文件和外部边界均已明确;符号链接、已证明的活跃 writer、不明确目标,或修改任务无法取得 writer 观测时已成为 blocker。只读任务可以把不可用探针记录为 unknown 后继续。
18
-
19
- ## 2. 建立只读基线
20
-
21
- 从本 `SKILL.md` 所在目录运行:
22
-
23
- ```bash
24
- node scripts/audit-codex-config.mjs --codex-home <absolute-directory> --json
25
- ```
26
-
27
- 需要离线或可复现 fixture 时加入 `--no-command-probes`;需要缩小会话扫描范围时使用 `--since-days <N>`。先运行 `--help` 核对当前接口;CLI 不在导出的 `PATH` 中时,用 `command -v codex` 取得绝对路径并传给 `--codex-bin`。
28
-
29
- 1. 保留审计脚本的结构化结果;不得把 `auth.json` 内容、提示词、工具输出、完整接口 URL 或 bearer token复制进报告。
30
- 2. 直接查看配置时,先遮蔽 `experimental_bearer_token`、静态认证 header、环境变量值和 URL 主机。只检查 `auth.json` 的存在、文件类型、权限和 Codex 报告的认证模式,不读取或打印文件内容。
31
- 3. 对配置、供应商、认证、权限、Agent、MCP、Hook 或历史设置提出判断前,读取 [configuration contract](references/configuration-contract.md),并用已安装 CLI 与当前官方配置参考验证每个拟使用的键。
32
- 4. 把用户提供的既有设置视为需要保留或评估的事实,不把个人模型、认证方式或权限策略提升为通用默认值。
33
-
34
- **完成标准:** 当前版本、配置指纹、认证存储模式、供应商契约、权限、Agent、MCP、Hook、会话故障和 writer 状态均有脱敏证据;无法取得的事实被标为 unknown。
35
-
36
- ## 3. 归因故障
37
-
38
- 当请求包含 HTTP 状态码、SSE、超时或 compaction 失败时,读取 [troubleshooting](references/troubleshooting.md),按其中证据梯度完成归因。
39
-
40
- 1. 关联错误发生前最近一次 `token_count`,但只保留 token 数和模型上下文窗口。
41
- 2. 区分本机配置、认证、供应商 wire API、远端模型服务和前置代理。HTML 代理错误页属于代理证据,不归因给模型。
42
- 3. 对外部问题给出可复现证据、影响、临时本机缓解和服务端交接项。本 skill 不探测或修改用户未授权的远端系统。
43
-
44
- **完成标准:** 每个错误只有一个主要归属域,证据与推断分开,所有本机缓解都标明质量、成本或频率代价。
45
-
46
- ## 4. 设计目标状态
47
-
48
- 只询问审计无法发现且会改变方案的偏好:模型与推理等级、认证存储、审批与沙箱、网络访问、Agent 并发、供应商认证方式、历史保留,以及 MCP/Hook 的保留意图。
49
-
50
- 输出确认包:
51
-
52
- 1. 当前状态和问题证据;
53
- 2. 目标状态及每项理由;
54
- 3. 逐文件脱敏 diff;
55
- 4. 明确保留的未知项、MCP、Hook、profile 和兼容设置;
56
- 5. 备份名、原子写入方法、验证命令和回滚条件;
57
- 6. 不在本机范围内的外部 blocker。
58
-
59
- 只采用当前官方参考与已安装 CLI 均能验证的键。项目级 `.codex/config.toml` 不承载 provider、auth 或其他被 Codex 忽略的机器级设置。
60
-
61
- **完成标准:** 用户无需猜测任何目标值;diff 不含 secret;未关联的现有设置不会被清理;外部问题不会伪装成本机可修复项。
62
-
63
- ## 5. 确认后原子写入
64
-
65
- 只有用户在看到确认包后明确同意本次变更,才执行以下动作:
66
-
67
- 1. 重读指纹;若配置已变化、存在目标文件的可写句柄,或 writer 探针仍为 unknown,停止并重新体检。
68
- 2. 为每个待改文件创建不覆盖的 `*.pre-optimize-<YYYYMMDD-HHMMSS>.bak`,并将包含凭据的文件权限设为 `0600`。
69
- 3. 在同一目录写临时文件、解析或加载验证成功后 rename 到目标,保留与任务无关的表和注释。
70
- 4. 仅在用户明确要求且已安装 Codex 能验证格式时处理 `auth.json`。文件存储是有效选择,不强制迁移钥匙串;不得自行发明认证 JSON schema。
71
-
72
- **完成标准:** 写入前后的指纹、备份和确认可对应;目标文件是原子替换结果;没有越出已确认文件集合。
73
-
74
- ## 6. 验证与交付
75
-
76
- 1. 运行 `codex doctor --json`,再核对 `codex features list` 和 `codex debug models --bundled` 中与目标相关的能力。
77
- 2. 验证配置加载、认证模式、权限与沙箱、MCP、Hook、Agent 和旧会话恢复。只有用户授权可能计费的网络请求后,才执行最小第三方 API 请求。
78
- 3. 任一必须验证项失败时恢复备份,重跑相同检查并报告原始失败与回滚结果。
79
- 4. 报告已改变、已保留、已验证、未验证和外部 blocker;不回显任何 secret 或完整 URL。
80
-
81
- **完成标准:** 所有已确认变更通过本机验证,或已完整回滚;报告包含命令、退出码和关键脱敏证据。
@@ -1,103 +0,0 @@
1
- # Codex Configuration Contract
2
-
3
- 在设计或修改 `config.toml`、`auth.json`、模型供应商、权限、Agent、MCP、Hook 或历史配置时完整应用本契约。配置键会随 Codex 版本变化;以已安装 CLI 和当前 [Configuration Reference](https://learn.chatgpt.com/docs/config-file/config-reference) 为最终事实源。
4
-
5
- ## 1. 配置归属与事实优先级
6
-
7
- 1. 用户级配置位于 `$CODEX_HOME/config.toml`;profile 文件位于同一目录并通过 `--profile` 选择。
8
- 2. 受信任项目可以使用 `.codex/config.toml` 覆盖项目设置,但 provider、auth、host metadata、通知、profile 选择和 telemetry 等机器级键会被忽略,必须留在用户级配置。
9
- 3. 先读取现有配置、`codex doctor --json`、`codex features list` 和 `codex debug models --bundled`,再核对当前官方参考。API 模型网页上的最大上下文不是本机 Codex 目录的替代品。
10
- 4. 只更改与目标直接相关的键。MCP、Hook、profile、插件、历史兼容项和未知集成默认原样保留,直到有证据证明无效且用户确认删除。
11
-
12
- ## 2. 模型与推理
13
-
14
- - 从已安装模型目录选择 model,并确认所选 reasoning effort 受支持。常规工作可以从 `medium` 或现有值开始;`high`、`xhigh` 和更高等级只在质量收益能够覆盖延迟与成本时采用。
15
- - `plan_mode_reasoning_effort` 独立于普通 `model_reasoning_effort`;未设置时使用 Codex 内置 Plan preset。
16
- - 仅在自定义模型目录确实需要时设置 `model_context_window`。不得为了绕过 compaction 或代理限制而夸大它。
17
- - `model_auto_compact_token_limit` 是触发阈值,不是服务端请求体限制。降低它会更早、更频繁地压缩,并可能改变成本和上下文质量。
18
- - `model_auto_compact_token_limit_scope` 默认是 `total`;只有理解 carried prefix 行为后才选择 `body_after_prefix`。
19
-
20
- ## 3. 第三方 Responses 供应商
21
-
22
- Codex 当前自定义供应商只支持 `responses` wire API。供应商必须兼容 Codex 实际使用的 Responses 请求、SSE 输出、工具项和 compaction 路径;仅兼容 Chat Completions 不够。
23
-
24
- 使用存储在 `auth.json` 的 OpenAI-style API key 时,可以采用:
25
-
26
- ```toml
27
- model = "gpt-5.6-sol"
28
- model_provider = "third-party"
29
- model_reasoning_effort = "high"
30
- plan_mode_reasoning_effort = "xhigh"
31
- cli_auth_credentials_store = "file"
32
-
33
- [model_providers.third-party]
34
- name = "Third Party Responses"
35
- base_url = "https://relay.example/api-root"
36
- wire_api = "responses"
37
- requires_openai_auth = true
38
- ```
39
-
40
- `base_url` 必须是供应商声明的 API root。先确认 Codex 最终请求路径,不能机械添加或删除 `/v1`。生产连接优先 HTTPS;HTTP 会在主机到中转之间明文传输凭据和内容。
41
-
42
- 供应商使用独立环境变量时改用:
43
-
44
- ```toml
45
- [model_providers.third-party]
46
- name = "Third Party Responses"
47
- base_url = "https://relay.example/api-root"
48
- wire_api = "responses"
49
- env_key = "THIRD_PARTY_API_KEY"
50
- ```
51
-
52
- 动态 token 可以使用 `[model_providers.<id>.auth]` 下的 command、args、cwd、timeout 和 refresh interval。`auth`、`env_key`、`experimental_bearer_token` 与 `requires_openai_auth` 是互斥认证路线;每个供应商只保留一条。
53
-
54
- 仅在证据要求时调整 `request_max_retries`、`stream_max_retries` 或 `stream_idle_timeout_ms`。重试不能修复稳定的 401、403、404 或 413。
55
-
56
- ## 4. 认证存储
57
-
58
- - `cli_auth_credentials_store = "file"` 正式选择 `$CODEX_HOME/auth.json`;`keyring` 选择系统钥匙串;`auto` 由 Codex 决定。三者是偏好,不是安全等级排序。
59
- - 文件模式下,验证 `auth.json` 是普通文件、权限为 `0600` 且 `codex doctor --json` 报告认证可用。不要读取、打印、diff 或记录其中的 key。
60
- - 保留已安装 Codex 创建并验证过的 JSON schema。需要重新登录时优先通过 stdin 使用 `codex login --with-api-key`;手工修改只有在当前 schema 已被可靠确认时进行。
61
- - 自定义 provider 的 command-backed token 和 `env_key` 不应同时把同一 secret 写入 `auth.json` 或 `config.toml`。
62
-
63
- ## 5. 权限、沙箱与网络
64
-
65
- 一个保守、可交互的本机基线是:
66
-
67
- ```toml
68
- approval_policy = "on-request"
69
- approvals_reviewer = "auto_review"
70
- sandbox_mode = "workspace-write"
71
-
72
- [sandbox_workspace_write]
73
- network_access = false
74
- ```
75
-
76
- 这只是安全起点,不是固定默认。`approval_policy = "never"` 和 `sandbox_mode = "danger-full-access"` 适合用户明确接受风险的受控环境;不要通过 profile 名称暗示它们更安全。
77
-
78
- `default_permissions` 与 `sandbox_mode` / `[sandbox_workspace_write]` 是两套选择,不能混合。网络访问只按实际工具需求开放;第三方模型请求由 Codex host 发出,不等同于给沙箱内命令开放任意网络。
79
-
80
- ## 6. Agent、MCP、Hook 与历史
81
-
82
- Agent 配置先确认当前 CLI 支持的键:
83
-
84
- ```toml
85
- [agents]
86
- enabled = true
87
- max_concurrent_threads_per_session = 4
88
- default_subagent_model = "gpt-5.6-terra"
89
- default_subagent_reasoning_effort = "medium"
90
- ```
91
-
92
- 并发数由 CPU、内存、供应商速率限制和成本共同决定。不要假定四个线程适合所有机器或接口。
93
-
94
- - 对每个 MCP 逐项验证 command/path、启动、tool discovery 和审批策略;不因某个 MCP 失败而清理其他 MCP。
95
- - Hook 按事件和集成所有者保留。两个 Hook 调用不同集成时不是重复;只有调用链、输入和副作用等价时才合并。
96
- - `history.persistence` 和 `history.max_bytes` 由隐私、恢复需求和磁盘预算决定。修改前验证旧会话与数据库兼容。
97
- - 审计期间 `config.toml` 指纹变化或目标文件存在已证明的可写句柄时,先关闭写入来源并重新审计;进程名本身不是 writer 证据。修改任务无法取得精确 writer 观测时保持 blocked,且不得覆盖竞争写入。
98
-
99
- ## 7. 确认包与写入契约
100
-
101
- 确认包必须列出当前值、目标值、理由、脱敏 diff、保留项、备份路径、验证和回滚。对每个 secret 只写 `<redacted>` 或“存在/不存在”。
102
-
103
- 确认后在目标目录创建权限正确的临时文件,完成 TOML/JSON 和 Codex 加载验证后原子 rename。写入前再次比较哈希、大小和 mtime;任一变化都使原确认失效。验证失败时恢复备份并重复验证,不留下部分应用状态。