@celilo/core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/core",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Lightweight shared core for Celilo CLI tools — command registry, NDJSON API protocol, and remote SSH client. No Ink/React/drizzle/aws-sdk transitive deps, so an MCP server can reach the transport without installing the full CLI.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -684,8 +684,21 @@ export const COMMANDS: CommandDef[] = [
684
684
  },
685
685
  {
686
686
  name: 'verify',
687
- description: 'Verify module integrity (signature + checksums)',
687
+ description: 'Verify module integrity across the installed, generated and host planes',
688
688
  args: [{ name: 'id', description: 'Module ID', completion: 'module_ids' }],
689
+ flags: [
690
+ {
691
+ name: 'deep',
692
+ description:
693
+ "Also ask each of the module's systems whether what is running is what celilo generated (one SSH per system)",
694
+ takesValue: false,
695
+ },
696
+ {
697
+ name: 'json',
698
+ description: 'Emit per-file class, expected and observed digests, and per-host results',
699
+ takesValue: false,
700
+ },
701
+ ],
689
702
  },
690
703
  {
691
704
  // Deprecation alias for `module verify`. Removed after one
@@ -693,6 +706,10 @@ export const COMMANDS: CommandDef[] = [
693
706
  name: 'audit',
694
707
  description: 'DEPRECATED — use `module verify` instead',
695
708
  args: [{ name: 'id', description: 'Module ID', completion: 'module_ids' }],
709
+ flags: [
710
+ { name: 'deep', description: 'See `module verify --deep`', takesValue: false },
711
+ { name: 'json', description: 'See `module verify --json`', takesValue: false },
712
+ ],
696
713
  },
697
714
  {
698
715
  name: 'config',
@@ -1058,6 +1075,33 @@ export const COMMANDS: CommandDef[] = [
1058
1075
  description: 'Re-verify a container service connection',
1059
1076
  args: [{ name: 'service-id', description: 'Service ID', completion: 'service_ids' }],
1060
1077
  },
1078
+ {
1079
+ name: 'set-credentials',
1080
+ description: 'Update a provider endpoint or API credential',
1081
+ args: [{ name: 'service-id', description: 'Service ID', completion: 'service_ids' }],
1082
+ flags: [
1083
+ {
1084
+ name: 'api-url',
1085
+ description: 'Proxmox API URL (or $PROXMOX_API_URL)',
1086
+ takesValue: true,
1087
+ },
1088
+ {
1089
+ name: 'api-token-id',
1090
+ description: 'Proxmox API token ID (or $PROXMOX_API_TOKEN_ID)',
1091
+ takesValue: true,
1092
+ },
1093
+ {
1094
+ name: 'api-token-secret',
1095
+ description: 'Proxmox API token secret (or $PROXMOX_API_TOKEN_SECRET)',
1096
+ takesValue: true,
1097
+ },
1098
+ {
1099
+ name: 'api-token',
1100
+ description: 'DigitalOcean API token (or $DIGITALOCEAN_API_TOKEN)',
1101
+ takesValue: true,
1102
+ },
1103
+ ],
1104
+ },
1061
1105
  {
1062
1106
  name: 'remove',
1063
1107
  description: 'Remove a container service',
@@ -1907,7 +1951,19 @@ export const COMMANDS: CommandDef[] = [
1907
1951
  completion: 'module_ids',
1908
1952
  },
1909
1953
  ],
1910
- flags: [{ name: 'limit', description: 'Number of backups to show', takesValue: true }],
1954
+ flags: [
1955
+ { name: 'limit', description: 'Number of backups to show', takesValue: true },
1956
+ {
1957
+ name: 'json',
1958
+ description: 'Emit exact timestamps as JSON, for the console and other programs',
1959
+ takesValue: false,
1960
+ },
1961
+ {
1962
+ name: 'since',
1963
+ description: 'Only attempts from the last N days',
1964
+ takesValue: true,
1965
+ },
1966
+ ],
1911
1967
  },
1912
1968
  {
1913
1969
  name: 'restore',
@@ -1997,6 +2053,34 @@ export const COMMANDS: CommandDef[] = [
1997
2053
  },
1998
2054
  ],
1999
2055
  },
2056
+ {
2057
+ name: 'console',
2058
+ description: 'Narrow read-only projections for the web console',
2059
+ subcommands: [
2060
+ // The subcommand token decides read/write classification (READ_VERBS in
2061
+ // read-only-classifier.ts), and the console's API principal is granted
2062
+ // read ops only. These are named from that vocabulary rather than for
2063
+ // prose: `console roster` would read better and classify as a WRITE.
2064
+ {
2065
+ name: 'status',
2066
+ description: 'Fleet roster with observed health, systems and backup age (dashboard poll)',
2067
+ flags: [{ name: 'json', description: 'Emit JSON', takesValue: false }],
2068
+ },
2069
+ {
2070
+ name: 'get',
2071
+ description: "A module's bounded capability closure",
2072
+ args: [{ name: 'module-id', description: 'Module to resolve the closure for' }],
2073
+ flags: [
2074
+ {
2075
+ name: 'depth',
2076
+ description: 'Maximum hops to walk (default 2; 0 walks the whole graph)',
2077
+ takesValue: true,
2078
+ },
2079
+ { name: 'json', description: 'Emit JSON', takesValue: false },
2080
+ ],
2081
+ },
2082
+ ],
2083
+ },
2000
2084
  {
2001
2085
  name: 'completion',
2002
2086
  description: 'Generate shell completion scripts',
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export * from './command-registry';
2
2
  export * from './protocol';
3
3
  export * from './remote-client';
4
+ export * from './read-only-classifier';
5
+ export * from './ssh-transport';
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The gate that keeps a read-only principal read-only.
3
+ *
4
+ * The MCP server's RO key and the web console's API principal are both granted
5
+ * exactly `readOnlyGrants(COMMANDS)`. If a write op ever appears in that set,
6
+ * both of them quietly gain the ability to change the fleet, and nothing else in
7
+ * either codebase would notice: the grant is minted server-side from this list,
8
+ * and the server would be doing precisely what it was told.
9
+ *
10
+ * So this asserts the negative directly against the live registry.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { COMMANDS, type CommandDef } from './command-registry';
14
+ import { isReadOnlyPath, opOf, readOnlyGrants } from './read-only-classifier';
15
+
16
+ /** Every op the registry can run, read and write alike. */
17
+ function allOps(commands: CommandDef[], prefix: string[] = []): string[] {
18
+ return commands.flatMap((cmd) => {
19
+ const path = [...prefix, cmd.name];
20
+ return cmd.subcommands?.length ? allOps(cmd.subcommands, path) : [opOf(path)];
21
+ });
22
+ }
23
+
24
+ /**
25
+ * Ops that are unambiguously mutating, written out by hand.
26
+ *
27
+ * Hand-maintained ON PURPOSE, and the one place in this file where that is
28
+ * right. The first version of this gate derived "the writes" from
29
+ * `isReadOnlyPath` and then asserted the grant set excluded them, which is the
30
+ * classifier agreeing with itself: mark `remove` as a read verb and both sides
31
+ * move together, so the test stays green while the console gains the ability to
32
+ * remove modules. Verified by doing exactly that (Rule 7.6). An assertion has to
33
+ * know something the code under test does not.
34
+ */
35
+ const MUST_NEVER_BE_GRANTED = [
36
+ 'module:deploy',
37
+ 'module:remove',
38
+ 'module:pause',
39
+ 'module:unpause',
40
+ 'module:import',
41
+ 'module:update',
42
+ 'module:config',
43
+ 'module:secret',
44
+ 'system:init',
45
+ 'system:update',
46
+ 'system:secret',
47
+ 'machine:add',
48
+ 'machine:remove',
49
+ 'service:remove',
50
+ 'backup:restore',
51
+ 'api:grant',
52
+ 'api:revoke',
53
+ ];
54
+
55
+ describe('readOnlyGrants', () => {
56
+ test('grants none of the ops that change the fleet', () => {
57
+ const granted = new Set(readOnlyGrants(COMMANDS));
58
+ const leaked = MUST_NEVER_BE_GRANTED.filter((op) => granted.has(op));
59
+ expect(leaked).toEqual([]);
60
+ });
61
+
62
+ test('the list above names ops the registry actually has', () => {
63
+ // Otherwise the gate decays into asserting things about commands that no
64
+ // longer exist, which passes forever and checks nothing.
65
+ const real = new Set(allOps(COMMANDS));
66
+ const stale = MUST_NEVER_BE_GRANTED.filter((op) => !real.has(op));
67
+ expect(stale).toEqual([]);
68
+ });
69
+
70
+ test('a mutating verb added to the registry is NOT granted', () => {
71
+ // Rule 7.6: watch the gate work rather than trusting that it would. This is
72
+ // the registry shape a future command would introduce.
73
+ const withWrite: CommandDef[] = [
74
+ {
75
+ name: 'module',
76
+ description: 'module operations',
77
+ subcommands: [
78
+ { name: 'list', description: 'list modules' },
79
+ { name: 'obliterate', description: 'a new and very mutating verb' },
80
+ ],
81
+ } as CommandDef,
82
+ ];
83
+
84
+ const granted = readOnlyGrants(withWrite);
85
+ expect(granted).toContain('module:list');
86
+ expect(granted).not.toContain('module:obliterate');
87
+ });
88
+
89
+ test('a read verb added to the registry IS granted, with no list to edit', () => {
90
+ const withRead: CommandDef[] = [
91
+ {
92
+ name: 'console',
93
+ description: 'console reads',
94
+ subcommands: [{ name: 'status', description: 'a newly added read verb' }],
95
+ } as CommandDef,
96
+ ];
97
+
98
+ expect(readOnlyGrants(withRead)).toContain('console:status');
99
+ });
100
+
101
+ test('a mixed-verb op is classified as a write', () => {
102
+ // `module config` covers get AND set. Its token is `config`, which is not a
103
+ // read verb, so the whole op is a write. Collapsing it would hand a
104
+ // read-only principal the ability to set configuration.
105
+ expect(isReadOnlyPath(['module', 'config'])).toBe(false);
106
+ expect(readOnlyGrants(COMMANDS)).not.toContain('module:config');
107
+ });
108
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Which command-registry leaves are reads, and the authz ops a read-only
3
+ * principal therefore needs.
4
+ *
5
+ * This lives in `@celilo/core`, beside `COMMANDS`, because it is a property of
6
+ * the command registry rather than of any one consumer. Two now derive from it:
7
+ * the MCP server routes each tool to its RO or RW principal, and the web console
8
+ * enrols a principal granted exactly the read ops.
9
+ *
10
+ * That shared derivation is the point. A hand-maintained grant list goes stale
11
+ * in the dangerous direction: a newly added read verb is merely missing, while a
12
+ * newly added WRITE verb silently lands inside a principal that was supposed to
13
+ * be read-only. Deriving from the registry means a new read verb is covered
14
+ * automatically and a new write verb is not.
15
+ */
16
+
17
+ import type { ArgDef, CommandDef, FlagDef } from './command-registry';
18
+
19
+ /** A runnable leaf command flattened from the registry tree. */
20
+ export interface Leaf {
21
+ /** Full path from a top-level command, e.g. `['module', 'list']`. */
22
+ path: string[];
23
+ description: string;
24
+ args: ArgDef[];
25
+ flags: FlagDef[];
26
+ }
27
+
28
+ /**
29
+ * Read-only verbs. The server's authz is 2-level (`command:subcommand`), so the
30
+ * read/write split is too: a leaf is read-only iff its *op token* — the
31
+ * subcommand (or, for a top-level leaf, the command) — is a read verb. This
32
+ * guarantees a read tool's op is exactly what the RO principal is granted, and
33
+ * never routes a mutating op onto the RO key. A mixed op like `module:config`
34
+ * (get + set) has a non-read token (`config`) → classified write → RW.
35
+ */
36
+ export const READ_VERBS: ReadonlySet<string> = new Set([
37
+ 'list',
38
+ 'status',
39
+ 'where',
40
+ 'get',
41
+ // Reads a deployed module's daemon journal. Read-only by construction —
42
+ // the only remote command it can emit is `journalctl` (services/module-journal.ts).
43
+ 'journal',
44
+ 'audit',
45
+ 'commands', // the MCP's own registry fetch rides the RO key
46
+ ]);
47
+
48
+ /** The 2-level authz op a leaf runs as — `command:subcommand`, or bare command. */
49
+ export function opOf(path: string[]): string {
50
+ return path.length >= 2 ? `${path[0]}:${path[1]}` : path[0];
51
+ }
52
+
53
+ /** Token that decides read vs write: the subcommand, or the bare command. */
54
+ function opToken(path: string[]): string {
55
+ return path.length >= 2 ? path[1] : path[0];
56
+ }
57
+
58
+ /** Is this leaf read-only (→ RO principal)? See `READ_VERBS`. */
59
+ export function isReadOnlyPath(path: string[]): boolean {
60
+ return READ_VERBS.has(opToken(path));
61
+ }
62
+
63
+ /** Flatten the registry tree into its runnable leaves (nodes with no subcommands). */
64
+ export function flattenLeaves(commands: CommandDef[], prefix: string[] = []): Leaf[] {
65
+ const leaves: Leaf[] = [];
66
+ for (const cmd of commands) {
67
+ const path = [...prefix, cmd.name];
68
+ if (cmd.subcommands && cmd.subcommands.length > 0) {
69
+ leaves.push(...flattenLeaves(cmd.subcommands, path));
70
+ } else {
71
+ leaves.push({
72
+ path,
73
+ description: cmd.description,
74
+ args: cmd.args ?? [],
75
+ flags: cmd.flags ?? [],
76
+ });
77
+ }
78
+ }
79
+ return leaves;
80
+ }
81
+
82
+ /**
83
+ * Every authz op a read-only principal needs, derived from a command registry.
84
+ *
85
+ * Sorted and de-duplicated so the result is stable enough to compare in a test
86
+ * and to print in a grant line.
87
+ */
88
+ export function readOnlyGrants(commands: CommandDef[]): string[] {
89
+ return [
90
+ ...new Set(
91
+ flattenLeaves(commands)
92
+ .filter((l) => isReadOnlyPath(l.path))
93
+ .map((l) => opOf(l.path)),
94
+ ),
95
+ ].sort();
96
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Reaching a celilo management server over SSH.
3
+ *
4
+ * Lives here rather than in any one client because every client needs the same
5
+ * two things and one of them is load-bearing safety. The MCP server and the web
6
+ * console server both drive `celilo-api@<server>`, both select a principal by
7
+ * key, and both are long-running processes that get restarted freely.
8
+ *
9
+ * That last fact is why this is shared rather than copied: Node does not kill
10
+ * spawned children when it exits, so every restart used to orphan whatever ssh
11
+ * clients were in flight. 656 of them accumulated on one machine and threw
12
+ * celilo-mgr into MaxStartups throttling, which is an SSH lockout of the control
13
+ * plane (celilo#921). A second copy of this code is a second chance to
14
+ * reintroduce that, and the console server has exactly the same lifecycle.
15
+ */
16
+
17
+ import { type ChildProcess, spawn } from 'node:child_process';
18
+ import { Readable } from 'node:stream';
19
+ import type { RemoteTransport } from './remote-client';
20
+
21
+ /**
22
+ * Adapt a Node child process to the core `RemoteTransport` (web-stream) shape —
23
+ * plain `node:child_process`, no Bun runtime dependency, so published packages
24
+ * run on Node. Shared by the ssh transport (production) and by tests that spawn
25
+ * a local `api-serve`, so both exercise the same adapter.
26
+ */
27
+ export function childProcessTransport(proc: ChildProcess): RemoteTransport {
28
+ if (!proc.stdout || !proc.stdin)
29
+ throw new Error('child process was not spawned with piped stdio');
30
+ return {
31
+ stdin: { write: (chunk: string) => void proc.stdin?.write(chunk) },
32
+ stdout: Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>,
33
+ kill: () => void proc.kill(),
34
+ exited: new Promise<number>((resolve) => proc.once('exit', (code) => resolve(code ?? 0))),
35
+ };
36
+ }
37
+
38
+ /**
39
+ * ssh args to select a principal by key with no connection sharing.
40
+ *
41
+ * -T: no pty (raw NDJSON pipe). -i: force the principal's key. -o
42
+ * IdentitiesOnly=yes so a loaded agent key can't shadow the one we mean.
43
+ * ControlMaster=no + ControlPath=none: the RO and RW principals share one dest
44
+ * (celilo-api@<server>) and differ only by key, so an operator ssh config that
45
+ * multiplexes (Host * ControlMaster auto — %C hashes user+host, not the key)
46
+ * would collapse both onto whichever master connected first (RO), silently
47
+ * running every write as RO -> denied (126). Refusing to use or create a shared
48
+ * master keeps the principals isolated regardless of the operator's ssh config.
49
+ * (ce-h86)
50
+ */
51
+ export function sshArgs(identityFile: string, dest: string): string[] {
52
+ return [
53
+ '-T',
54
+ '-o',
55
+ 'IdentitiesOnly=yes',
56
+ '-o',
57
+ 'ControlMaster=no',
58
+ '-o',
59
+ 'ControlPath=none',
60
+ '-i',
61
+ identityFile,
62
+ dest,
63
+ ];
64
+ }
65
+
66
+ /**
67
+ * ssh children this process has open, so exiting doesn't abandon them (celilo#921).
68
+ *
69
+ * Node does not kill spawned children when it exits, and these servers are
70
+ * restarted freely. Every restart therefore orphaned whatever ssh clients were
71
+ * in flight: they reparent to init, keep their sockets ESTABLISHED, and keep an
72
+ * `api-serve` resident on the far end. 656 of them accumulated on one machine
73
+ * and threw celilo-mgr into MaxStartups throttling, an SSH lockout of the
74
+ * control plane.
75
+ */
76
+ const liveSshChildren = new Set<ChildProcess>();
77
+ let reaperInstalled = false;
78
+
79
+ function reapOnExit(proc: ChildProcess): ChildProcess {
80
+ liveSshChildren.add(proc);
81
+ proc.once('exit', () => liveSshChildren.delete(proc));
82
+ if (!reaperInstalled) {
83
+ reaperInstalled = true;
84
+ const killAll = () => {
85
+ for (const child of liveSshChildren) child.kill();
86
+ };
87
+ process.once('exit', killAll);
88
+ // Installing a signal handler suppresses node's default termination, so
89
+ // these have to exit for us — reaping and then staying alive would be a
90
+ // worse leak than the one we are fixing.
91
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
92
+ process.once(signal, () => {
93
+ killAll();
94
+ process.exit(128 + (signal === 'SIGINT' ? 2 : 15));
95
+ });
96
+ }
97
+ }
98
+ return proc;
99
+ }
100
+
101
+ /** Build a `runRemoteClient` transport factory that selects a principal by key. */
102
+ export function sshTransportWith(identityFile: string): (dest: string) => RemoteTransport {
103
+ return (dest: string) =>
104
+ childProcessTransport(
105
+ reapOnExit(
106
+ spawn('ssh', sshArgs(identityFile, dest), {
107
+ stdio: ['pipe', 'pipe', 'inherit'],
108
+ }),
109
+ ),
110
+ );
111
+ }