@celilo/core 0.8.0 → 0.9.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 +4 -1
- package/src/command-registry.ts +19 -0
- package/src/remote-client.test.ts +179 -0
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts"
|
|
9
9
|
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "bun test"
|
|
12
|
+
},
|
|
10
13
|
"files": [
|
|
11
14
|
"src/",
|
|
12
15
|
"README.md"
|
package/src/command-registry.ts
CHANGED
|
@@ -1229,6 +1229,11 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1229
1229
|
},
|
|
1230
1230
|
],
|
|
1231
1231
|
},
|
|
1232
|
+
{
|
|
1233
|
+
name: 'discover-network',
|
|
1234
|
+
description:
|
|
1235
|
+
"Record the network this box sits on, read from its own routing table. celilo's own write: `apply-config` refuses network.* keys",
|
|
1236
|
+
},
|
|
1232
1237
|
{
|
|
1233
1238
|
name: 'config',
|
|
1234
1239
|
description: 'Get or set system configuration',
|
|
@@ -1434,6 +1439,20 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1434
1439
|
},
|
|
1435
1440
|
],
|
|
1436
1441
|
},
|
|
1442
|
+
{
|
|
1443
|
+
name: 'edit',
|
|
1444
|
+
description: "Change an existing exclusion's reason in place",
|
|
1445
|
+
args: [{ name: 'ip', description: 'IP address of the exclusion to edit' }],
|
|
1446
|
+
flags: [
|
|
1447
|
+
{ name: 'reason', description: 'New exclusion reason', takesValue: true },
|
|
1448
|
+
{
|
|
1449
|
+
name: 'zone',
|
|
1450
|
+
description: 'Network zone (auto-detected from IP)',
|
|
1451
|
+
takesValue: true,
|
|
1452
|
+
valueHint: 'internal dmz app secure',
|
|
1453
|
+
},
|
|
1454
|
+
],
|
|
1455
|
+
},
|
|
1437
1456
|
{ name: 'list-exclusions', description: 'List all IP exclusions' },
|
|
1438
1457
|
],
|
|
1439
1458
|
},
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* celilo#606 — the interview path must never read the process stdin that
|
|
3
|
+
* carries JSON-RPC.
|
|
4
|
+
*
|
|
5
|
+
* An MCP server's stdin IS its JSON-RPC transport. `defaultRenderInterview`
|
|
6
|
+
* used to prompt with clack, and clack reads keypresses off stdin whether or
|
|
7
|
+
* not stdin is a TTY — so raising an interview consumed bytes off the wire.
|
|
8
|
+
* That desynchronises the framing, which is why the reported symptom was not a
|
|
9
|
+
* failed call but a session that never answered again: every later call hung,
|
|
10
|
+
* a fresh session was clean, and cancelling the stuck call could not help,
|
|
11
|
+
* because you cannot re-synchronise a corrupted stream by cancelling.
|
|
12
|
+
*
|
|
13
|
+
* Two independent fixes now close it — the non-TTY guard in
|
|
14
|
+
* `defaultRenderInterview`, and the MCP always supplying `answersRenderer`,
|
|
15
|
+
* which touches no stdin at all. This asserts the first, and asserts the
|
|
16
|
+
* PROPERTY rather than either implementation: a raised interview must leave
|
|
17
|
+
* stdin byte-for-byte intact.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ These are the first tests in `@celilo/core`. The package had no `test`
|
|
20
|
+
* script, so `bun run --filter '@celilo/*' test` silently skipped it — the only
|
|
21
|
+
* `@celilo/*` package that was skipped, while holding the SSH/NDJSON transport,
|
|
22
|
+
* the wire protocol, and the command registry the MCP builds its whole tool
|
|
23
|
+
* surface from. That absence is why this mechanism could ship unnoticed.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, expect, test } from 'bun:test';
|
|
27
|
+
import type { RemoteTransport } from './remote-client';
|
|
28
|
+
import { InterviewUnanswerableError, interviewKey, runRemoteClient } from './remote-client';
|
|
29
|
+
|
|
30
|
+
/** A transport that replays canned server lines and records what we wrote. */
|
|
31
|
+
function fakeTransport(serverLines: string[]): RemoteTransport & { written: string[] } {
|
|
32
|
+
const written: string[] = [];
|
|
33
|
+
return {
|
|
34
|
+
written,
|
|
35
|
+
stdin: {
|
|
36
|
+
write: (chunk: string) => {
|
|
37
|
+
written.push(chunk);
|
|
38
|
+
},
|
|
39
|
+
end: () => {},
|
|
40
|
+
},
|
|
41
|
+
stdout: new ReadableStream<Uint8Array>({
|
|
42
|
+
start(controller) {
|
|
43
|
+
for (const line of serverLines) {
|
|
44
|
+
controller.enqueue(new TextEncoder().encode(`${line}\n`));
|
|
45
|
+
}
|
|
46
|
+
controller.close();
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
kill: () => {},
|
|
50
|
+
exited: Promise.resolve(0),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const INTERVIEW = JSON.stringify({
|
|
55
|
+
type: 'interview',
|
|
56
|
+
id: 'iv-1',
|
|
57
|
+
scope: 'module.deploy',
|
|
58
|
+
key: 'confirm_breaking',
|
|
59
|
+
kind: 'confirm',
|
|
60
|
+
message: 'This is a breaking change. Continue?',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('interviewKey', () => {
|
|
64
|
+
test('joins scope and key into the pre-staging identity', () => {
|
|
65
|
+
expect(
|
|
66
|
+
interviewKey({
|
|
67
|
+
type: 'interview',
|
|
68
|
+
id: 'x',
|
|
69
|
+
scope: 'module.deploy',
|
|
70
|
+
key: 'confirm_breaking',
|
|
71
|
+
kind: 'confirm',
|
|
72
|
+
message: 'go?',
|
|
73
|
+
}),
|
|
74
|
+
).toBe('module.deploy.confirm_breaking');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('is null when the server sent no scope/key (older server)', () => {
|
|
78
|
+
expect(
|
|
79
|
+
interviewKey({ type: 'interview', id: 'x', kind: 'confirm', message: 'go?' }),
|
|
80
|
+
).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('an interview on a non-TTY stdin (celilo#606)', () => {
|
|
85
|
+
test('a supplied renderer is used and its answer goes back to the server', async () => {
|
|
86
|
+
const transport = fakeTransport([INTERVIEW, JSON.stringify({ type: 'result', exitCode: 0 })]);
|
|
87
|
+
|
|
88
|
+
await runRemoteClient('host', ['module', 'deploy', 'caddy'], {
|
|
89
|
+
openTransport: () => transport,
|
|
90
|
+
renderInterview: async () => true,
|
|
91
|
+
out: { write: () => {} },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const answer = transport.written.find((w) => w.includes('"answer"'));
|
|
95
|
+
expect(answer).toBeTruthy();
|
|
96
|
+
expect(answer).toContain('iv-1');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The default renderer must REFUSE rather than prompt. Refusing is what keeps
|
|
101
|
+
* the prompt away from stdin; a renderer that prompts on a non-TTY is the
|
|
102
|
+
* defect, whatever it does with the answer afterwards.
|
|
103
|
+
*/
|
|
104
|
+
test('the DEFAULT renderer refuses instead of prompting', async () => {
|
|
105
|
+
const transport = fakeTransport([INTERVIEW, JSON.stringify({ type: 'result', exitCode: 0 })]);
|
|
106
|
+
|
|
107
|
+
// No `renderInterview` — exercises defaultRenderInterview, and this suite
|
|
108
|
+
// runs with stdin not a TTY, exactly like an MCP server.
|
|
109
|
+
expect(process.stdin.isTTY).toBeFalsy();
|
|
110
|
+
|
|
111
|
+
let threw: unknown;
|
|
112
|
+
try {
|
|
113
|
+
await runRemoteClient('host', ['module', 'deploy', 'caddy'], {
|
|
114
|
+
openTransport: () => transport,
|
|
115
|
+
out: { write: () => {} },
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
threw = error;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Either it throws unanswerable, or it reports the command as unable to
|
|
122
|
+
// proceed — what it must NOT do is silently succeed by reading a keypress.
|
|
123
|
+
const answered = transport.written.some((w) => w.includes('"answer"'));
|
|
124
|
+
expect(answered).toBe(false);
|
|
125
|
+
if (threw) expect(threw).toBeInstanceOf(InterviewUnanswerableError);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The property, proven the only way it can be: in a child process whose stdin
|
|
131
|
+
* is a pipe carrying JSON-RPC, assert the bytes are still there afterwards.
|
|
132
|
+
*
|
|
133
|
+
* An in-process assertion cannot show this — the harness's own stdin is shared,
|
|
134
|
+
* and "nothing read it" is not observable from inside. The issue's original
|
|
135
|
+
* reproduction was `echo '{...}' | bun run clacktest.ts`; this is that,
|
|
136
|
+
* inverted into an assertion.
|
|
137
|
+
*/
|
|
138
|
+
describe('stdin carrying JSON-RPC survives a raised interview', () => {
|
|
139
|
+
test('the JSON-RPC line is still readable after the interview is refused', async () => {
|
|
140
|
+
const script = `
|
|
141
|
+
const { runRemoteClient } = await import('${import.meta.dir}/remote-client.ts');
|
|
142
|
+
const transport = {
|
|
143
|
+
stdin: { write() {}, end() {} },
|
|
144
|
+
stdout: new ReadableStream({
|
|
145
|
+
start(c) {
|
|
146
|
+
c.enqueue(new TextEncoder().encode(${JSON.stringify(INTERVIEW)} + '\\n'));
|
|
147
|
+
c.enqueue(new TextEncoder().encode('{"type":"result","exitCode":0}\\n'));
|
|
148
|
+
c.close();
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
kill() {}, exited: Promise.resolve(0),
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
await runRemoteClient('host', ['module','deploy','caddy'], {
|
|
155
|
+
openTransport: () => transport, out: { write() {} },
|
|
156
|
+
});
|
|
157
|
+
} catch {}
|
|
158
|
+
// Whatever happened above, OUR stdin must be untouched.
|
|
159
|
+
const survived = await new Response(Bun.stdin.stream()).text();
|
|
160
|
+
process.stdout.write('SURVIVED:' + survived.trim());
|
|
161
|
+
`;
|
|
162
|
+
|
|
163
|
+
const proc = Bun.spawn(['bun', '-e', script], {
|
|
164
|
+
stdin: 'pipe',
|
|
165
|
+
stdout: 'pipe',
|
|
166
|
+
stderr: 'pipe',
|
|
167
|
+
});
|
|
168
|
+
const rpc = '{"jsonrpc":"2.0","id":7,"method":"tools/call"}';
|
|
169
|
+
proc.stdin.write(`${rpc}\n`);
|
|
170
|
+
await proc.stdin.end();
|
|
171
|
+
await proc.exited;
|
|
172
|
+
|
|
173
|
+
const out = await new Response(proc.stdout).text();
|
|
174
|
+
// The exact bytes we piped in, still unread by anything in the interview
|
|
175
|
+
// path. Before celilo#607 a clack prompt consumed this line and answered
|
|
176
|
+
// itself with it.
|
|
177
|
+
expect(out).toContain(`SURVIVED:${rpc}`);
|
|
178
|
+
}, 30_000);
|
|
179
|
+
});
|