@celilo/core 0.9.1 → 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.1",
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",
@@ -1951,7 +1951,19 @@ export const COMMANDS: CommandDef[] = [
1951
1951
  completion: 'module_ids',
1952
1952
  },
1953
1953
  ],
1954
- 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
+ ],
1955
1967
  },
1956
1968
  {
1957
1969
  name: 'restore',
@@ -2041,6 +2053,34 @@ export const COMMANDS: CommandDef[] = [
2041
2053
  },
2042
2054
  ],
2043
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
+ },
2044
2084
  {
2045
2085
  name: 'completion',
2046
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
+ }