@celilo/core 0.12.0 → 0.14.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.12.0",
3
+ "version": "0.14.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",
@@ -98,7 +98,7 @@ export const COMMANDS: CommandDef[] = [
98
98
  subcommands: [
99
99
  {
100
100
  name: 'grant',
101
- description: 'Grant or replace API access for a principal',
101
+ description: 'Grant, amend, or replace API access for a principal',
102
102
  args: [{ name: 'principal', description: 'Principal name (kebab-case)' }],
103
103
  flags: [
104
104
  {
@@ -109,9 +109,24 @@ export const COMMANDS: CommandDef[] = [
109
109
  },
110
110
  {
111
111
  name: 'can',
112
- description: 'Comma-separated grants (e.g. module:deploy,service:*)',
112
+ description: 'Comma-separated grants, REPLACING the set (e.g. module:deploy,service:*)',
113
113
  takesValue: true,
114
114
  },
115
+ {
116
+ name: 'add',
117
+ description: 'Comma-separated grants to add to the existing set',
118
+ takesValue: true,
119
+ },
120
+ {
121
+ name: 'remove',
122
+ description: 'Comma-separated grants to remove from the existing set',
123
+ takesValue: true,
124
+ },
125
+ {
126
+ name: 'force',
127
+ description: 'Allow a --can replacement that drops existing grants',
128
+ takesValue: false,
129
+ },
115
130
  ],
116
131
  },
117
132
  { name: 'list', description: 'List API principals and their grants' },
@@ -1315,6 +1330,25 @@ export const COMMANDS: CommandDef[] = [
1315
1330
  ],
1316
1331
  flags: [{ name: 'clear', description: 'Clear the earmark', takesValue: false }],
1317
1332
  },
1333
+ {
1334
+ name: 'reclassify',
1335
+ description:
1336
+ "Re-classify a machine's stored interface zone labels against the current zone model",
1337
+ args: [
1338
+ {
1339
+ name: 'hostname',
1340
+ description: 'Machine hostname or IP',
1341
+ completion: 'machine_hostnames',
1342
+ },
1343
+ ],
1344
+ flags: [
1345
+ {
1346
+ name: 'apply',
1347
+ description: 'Write the new labels (default: dry run)',
1348
+ takesValue: false,
1349
+ },
1350
+ ],
1351
+ },
1318
1352
  ],
1319
1353
  },
1320
1354
  {
@@ -1469,6 +1503,25 @@ export const COMMANDS: CommandDef[] = [
1469
1503
  },
1470
1504
  ],
1471
1505
  },
1506
+ {
1507
+ name: 'reboot',
1508
+ description:
1509
+ 'Reboot the management server. Refuses while a module operation is in flight, names it, and records the boot id so the return can be verified',
1510
+ flags: [
1511
+ {
1512
+ name: 'allow-reboot',
1513
+ description:
1514
+ 'Approve the reboot without an interview question (the downtime takes the dispatcher, MCP server and remote API with it)',
1515
+ takesValue: false,
1516
+ },
1517
+ {
1518
+ name: 'verify',
1519
+ description:
1520
+ 'Confirm a requested reboot came back: the boot id changed, the dispatcher is live and the CLI answers. Reports which is missing when it has not',
1521
+ takesValue: false,
1522
+ },
1523
+ ],
1524
+ },
1472
1525
  {
1473
1526
  name: 'migrate',
1474
1527
  description: 'Apply pending database migrations (idempotent; safe to re-run)',
@@ -1751,6 +1804,45 @@ export const COMMANDS: CommandDef[] = [
1751
1804
  description:
1752
1805
  'Per-subscriber delivery history (success rate, last delivery, recent failures)',
1753
1806
  },
1807
+ {
1808
+ name: 'install-daemon',
1809
+ description: 'Write a supervisor unit (systemd/launchd) for the build-bus webhook receiver',
1810
+ flags: [
1811
+ {
1812
+ name: 'system',
1813
+ description: 'System-scope unit (/etc/systemd/system or LaunchDaemons)',
1814
+ takesValue: false,
1815
+ },
1816
+ {
1817
+ name: 'secret',
1818
+ description:
1819
+ 'Shared HMAC secret incoming webhooks sign with (or CELILO_BUS_SECRET env)',
1820
+ takesValue: true,
1821
+ },
1822
+ { name: 'port', description: 'TCP port to listen on (default 8123)', takesValue: true },
1823
+ {
1824
+ name: 'dispatch',
1825
+ description:
1826
+ 'Render a combined unit (receiver + hook dispatcher) for boxes with no events daemon',
1827
+ takesValue: false,
1828
+ },
1829
+ {
1830
+ name: 'print',
1831
+ description: 'Render the unit to stdout without writing (Ansible seam)',
1832
+ takesValue: false,
1833
+ },
1834
+ {
1835
+ name: 'celilo-path',
1836
+ description: 'Explicit celilo binary path for ExecStart',
1837
+ takesValue: true,
1838
+ },
1839
+ ],
1840
+ },
1841
+ {
1842
+ name: 'uninstall-daemon',
1843
+ description: 'Remove the build-bus receiver supervisor unit',
1844
+ flags: [{ name: 'system', description: 'System-scope unit', takesValue: false }],
1845
+ },
1754
1846
  ],
1755
1847
  },
1756
1848
  {
package/src/protocol.ts CHANGED
@@ -100,6 +100,14 @@ export type ProgressMessage = z.infer<typeof ProgressMessageSchema>;
100
100
  export const LogMessageSchema = z.object({
101
101
  type: z.literal('log'),
102
102
  message: z.string(),
103
+ /**
104
+ * Which child stream the line came from. The server merges nothing —
105
+ * each stream is pumped separately — but the message schema is the only
106
+ * place the distinction can survive the trip, so it is recorded here
107
+ * (celilo#1362). Optional so an older client parses a newer server
108
+ * unchanged; unmarked lines are stdout.
109
+ */
110
+ stream: z.enum(['stdout', 'stderr']).optional(),
103
111
  });
104
112
  export type LogMessage = z.infer<typeof LogMessageSchema>;
105
113
 
@@ -99,10 +99,35 @@ describe('readOnlyGrants', () => {
99
99
  });
100
100
 
101
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.
102
+ // `module config` covers get AND set. One mutating leaf disqualifies the
103
+ // whole op, because a grant cannot be finer than the op. Collapsing it would
104
+ // hand a read-only principal the ability to set configuration.
105
105
  expect(isReadOnlyPath(['module', 'config'])).toBe(false);
106
106
  expect(readOnlyGrants(COMMANDS)).not.toContain('module:config');
107
107
  });
108
108
  });
109
+
110
+ describe('op-level classification (celilo#1404)', () => {
111
+ test('a sole-leaf read op nested 3 deep is a read, not a write', () => {
112
+ // `proxmox node list` is `proxmox:node`, whose only leaf is a read. Keying
113
+ // on the middle segment (`node`) called it a write, so the read-only
114
+ // principal was not granted it and the read tool that routes RO could not
115
+ // run — a silent exit 126.
116
+ expect(isReadOnlyPath(['proxmox', 'node', 'list'])).toBe(true);
117
+ expect(readOnlyGrants(COMMANDS)).toContain('proxmox:node');
118
+ });
119
+
120
+ test('one mutating leaf disqualifies its whole op', () => {
121
+ // `proxmox vm` covers list AND resize. The grant cannot be finer than the
122
+ // op, so the read `vm list` rides the RW key rather than widening RO.
123
+ expect(isReadOnlyPath(['proxmox', 'vm', 'list'])).toBe(false);
124
+ expect(readOnlyGrants(COMMANDS)).not.toContain('proxmox:vm');
125
+ });
126
+
127
+ test('module journal and module where are reachable', () => {
128
+ // The two the live server was missing.
129
+ const granted = new Set(readOnlyGrants(COMMANDS));
130
+ expect(granted).toContain('module:journal');
131
+ expect(granted).toContain('module:where');
132
+ });
133
+ });
@@ -14,7 +14,7 @@
14
14
  * automatically and a new write verb is not.
15
15
  */
16
16
 
17
- import type { ArgDef, CommandDef, FlagDef } from './command-registry';
17
+ import { type ArgDef, COMMANDS, type CommandDef, type FlagDef } from './command-registry';
18
18
 
19
19
  /** A runnable leaf command flattened from the registry tree. */
20
20
  export interface Leaf {
@@ -26,18 +26,44 @@ export interface Leaf {
26
26
  }
27
27
 
28
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.
29
+ * Read-only LEAF verbs — the last path segment of a runnable command.
30
+ *
31
+ * Classification is two steps, because the server's authz is 2-level
32
+ * (`command:subcommand`) while the registry is up to 3 deep. A LEAF is a read
33
+ * iff its own verb is here; an OP is a read iff EVERY leaf under it is. That
34
+ * second step is what keeps a mixed op like `module config` (get + set) a write,
35
+ * and it is what stopped `proxmox node list` from being one: keying on the
36
+ * middle segment (`node`) classified a sole-leaf read op as a write, so the
37
+ * read-only principal could not run it (celilo#1404).
38
+ *
39
+ * DELIBERATELY NOT read verbs, though they look like ones:
40
+ * health, verify — run hooks ON the target host; reads in intent, side effects
41
+ * in mechanism.
42
+ * doctor, poll — poll writes alert state; doctor can repair.
43
+ * search — reaches the registry over the network.
44
+ * authorized-keys — renders every principal's full public key and forced-command
45
+ * line. `api list` (granted) shows key type and comment only.
35
46
  */
36
47
  export const READ_VERBS: ReadonlySet<string> = new Set([
37
48
  'list',
38
49
  'status',
39
50
  'where',
40
51
  'get',
52
+ 'show',
53
+ 'show-config',
54
+ 'show-zone',
55
+ 'show-daemon',
56
+ 'info',
57
+ 'logs',
58
+ 'tail',
59
+ 'registrations',
60
+ 'list-allocations',
61
+ 'list-reservations',
62
+ 'list-exclusions',
63
+ 'list-subscribers',
64
+ 'list-pending',
65
+ 'list-failed',
66
+ 'list-unanswered',
41
67
  // Reads a deployed module's daemon journal. Read-only by construction —
42
68
  // the only remote command it can emit is `journalctl` (services/module-journal.ts).
43
69
  'journal',
@@ -50,14 +76,14 @@ export function opOf(path: string[]): string {
50
76
  return path.length >= 2 ? `${path[0]}:${path[1]}` : path[0];
51
77
  }
52
78
 
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];
79
+ /** The leaf's own verb — the token that decides read vs write. */
80
+ function leafVerb(path: string[]): string {
81
+ return path[path.length - 1];
56
82
  }
57
83
 
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));
84
+ /** Is this single leaf a read? (Not the same question as its OP — see below.) */
85
+ export function isReadOnlyLeaf(path: string[]): boolean {
86
+ return READ_VERBS.has(leafVerb(path));
61
87
  }
62
88
 
63
89
  /** Flatten the registry tree into its runnable leaves (nodes with no subcommands). */
@@ -82,15 +108,44 @@ export function flattenLeaves(commands: CommandDef[], prefix: string[] = []): Le
82
108
  /**
83
109
  * Every authz op a read-only principal needs, derived from a command registry.
84
110
  *
111
+ * An op is granted iff EVERY leaf it can run is a read. One mutating leaf under
112
+ * an op disqualifies the whole op, because the grant cannot be finer than the op.
113
+ *
85
114
  * Sorted and de-duplicated so the result is stable enough to compare in a test
86
115
  * and to print in a grant line.
87
116
  */
88
117
  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();
118
+ return [...readOnlyOps(commands)].sort();
119
+ }
120
+
121
+ function readOnlyOps(commands: CommandDef[]): Set<string> {
122
+ const leavesByOp = new Map<string, string[][]>();
123
+ for (const leaf of flattenLeaves(commands)) {
124
+ const op = opOf(leaf.path);
125
+ const group = leavesByOp.get(op);
126
+ if (group) group.push(leaf.path);
127
+ else leavesByOp.set(op, [leaf.path]);
128
+ }
129
+ const ops = new Set<string>();
130
+ for (const [op, paths] of leavesByOp) {
131
+ if (paths.every(isReadOnlyLeaf)) ops.add(op);
132
+ }
133
+ return ops;
134
+ }
135
+
136
+ /** Every authz op the registry can run, read and write alike. Sorted, de-duped. */
137
+ export function allOps(commands: CommandDef[]): string[] {
138
+ return [...new Set(flattenLeaves(commands).map((l) => opOf(l.path)))].sort();
139
+ }
140
+
141
+ let liveReadOnlyOps: Set<string> | null = null;
142
+
143
+ /**
144
+ * Is this path routed to the read-only principal? Answered at OP level against
145
+ * the live registry, so a leaf's routing and the RO principal's grants can never
146
+ * disagree — a read tool routes RO iff RO is granted its op.
147
+ */
148
+ export function isReadOnlyPath(path: string[]): boolean {
149
+ liveReadOnlyOps ??= readOnlyOps(COMMANDS);
150
+ return liveReadOnlyOps.has(opOf(path));
96
151
  }
@@ -177,3 +177,40 @@ describe('stdin carrying JSON-RPC survives a raised interview', () => {
177
177
  expect(out).toContain(`SURVIVED:${rpc}`);
178
178
  }, 30_000);
179
179
  });
180
+
181
+ describe("a denial's text survives to a capturing caller (celilo#1404)", () => {
182
+ test('the outcome carries the server error, which never reaches `out`', async () => {
183
+ // The server DOES say why: `permission denied: "<principal>" is not granted
184
+ // "<op>"`. But it says it as a protocol `error` message, not as command
185
+ // output, so a caller that captures `out` (the MCP server) saw exit 126 with
186
+ // an empty body and read it as the command failing on the target host.
187
+ const denial = 'permission denied: "celilo-mcp-ro" is not granted "module:journal"';
188
+ const transport = fakeTransport([
189
+ JSON.stringify({ type: 'error', error: denial }),
190
+ JSON.stringify({ type: 'result', success: false, exitCode: 126 }),
191
+ ]);
192
+ const captured: string[] = [];
193
+
194
+ const outcome = await runRemoteClient('host', ['module', 'journal', 'caddy'], {
195
+ openTransport: () => transport,
196
+ out: { write: (s: string) => void captured.push(s), isTTY: false },
197
+ onBlocked: 'return',
198
+ });
199
+
200
+ expect(outcome).toEqual({ status: 'result', exitCode: 126, error: denial });
201
+ // Still not command output — a caller must not mistake it for one.
202
+ expect(captured.join('')).toBe('');
203
+ });
204
+
205
+ test('a clean run carries no error', async () => {
206
+ const transport = fakeTransport([
207
+ JSON.stringify({ type: 'result', success: true, exitCode: 0 }),
208
+ ]);
209
+ const outcome = await runRemoteClient('host', ['module', 'list'], {
210
+ openTransport: () => transport,
211
+ out: { write() {}, isTTY: false },
212
+ onBlocked: 'return',
213
+ });
214
+ expect(outcome).toEqual({ status: 'result', exitCode: 0 });
215
+ });
216
+ });
@@ -190,16 +190,19 @@ function applyMessage(
190
190
  // Nest under the active step so the footer isn't corrupted; otherwise print.
191
191
  if (display.hasPending) {
192
192
  display.subEvent(msg.message);
193
+ } else if (msg.stream === 'stderr') {
194
+ // The server names each log line's origin stream; keep stderr on
195
+ // stderr locally so diagnostics never masquerade as command output
196
+ // (celilo#1362). Unmarked lines are stdout from an older server.
197
+ process.stderr.write(`${msg.message}\n`);
193
198
  } else {
194
199
  out.write(`${msg.message}\n`);
195
200
  }
196
201
  return null;
197
202
  case 'error':
198
- process.stderr.write(`${msg.error}\n`);
199
- return null;
200
203
  case 'interview':
201
204
  case 'blocked':
202
- // Handled by the caller (needs the transport to reply) — never reached here.
205
+ // Handled by consumeStream (needs to retain the text / reply) — never here.
203
206
  return null;
204
207
  case 'result':
205
208
  return msg.exitCode;
@@ -212,7 +215,14 @@ function applyMessage(
212
215
  * is still alive under `sessionId`.
213
216
  */
214
217
  export type RemoteOutcome =
215
- | { status: 'result'; exitCode: number }
218
+ /**
219
+ * `error` is the server's in-band `error` text, retained so a CAPTURING
220
+ * caller can report it. It is written to stderr as well, for a terminal one.
221
+ * Without it an authz denial reached the MCP as exit 126 with empty output —
222
+ * a silent denial the caller reads as the command failing on the host
223
+ * (celilo#1404).
224
+ */
225
+ | { status: 'result'; exitCode: number; error?: string }
216
226
  | { status: 'blocked'; sessionId: string; eventId: string; question: string; key?: string };
217
227
 
218
228
  /** Exit code a caller reports when a command parked rather than finishing. */
@@ -237,6 +247,7 @@ async function consumeStream(
237
247
  ): Promise<RemoteOutcome | null> {
238
248
  const { out, display, renderInterview } = opts;
239
249
  const decoder = new TextDecoder();
250
+ const errors: string[] = [];
240
251
  let buffer = '';
241
252
 
242
253
  for await (const chunk of transport.stdout) {
@@ -276,6 +287,12 @@ async function consumeStream(
276
287
  continue;
277
288
  }
278
289
 
290
+ if (msg.type === 'error') {
291
+ errors.push(msg.error);
292
+ process.stderr.write(`${msg.error}\n`);
293
+ continue;
294
+ }
295
+
279
296
  if (msg.type === 'blocked') {
280
297
  if (opts.onBlocked === 'wait') {
281
298
  out.write(
@@ -293,7 +310,9 @@ async function consumeStream(
293
310
  }
294
311
 
295
312
  const exit = applyMessage(msg, display, out);
296
- if (exit !== null) return { status: 'result', exitCode: exit };
313
+ if (exit !== null) {
314
+ return { status: 'result', exitCode: exit, error: errors.join('\n') || undefined };
315
+ }
297
316
  }
298
317
  }
299
318
  return null;