@source-repo/rpc-cli 4.5.0 → 5.0.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/README.md +1 -0
- package/dist/console.d.ts +15 -2
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +49 -15
- package/dist/console.js.map +1 -1
- package/dist/console.types.json +483 -0
- package/dist/credentials.d.ts +77 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +118 -0
- package/dist/credentials.js.map +1 -0
- package/dist/enrolment.d.ts +91 -0
- package/dist/enrolment.d.ts.map +1 -0
- package/dist/enrolment.js +94 -0
- package/dist/enrolment.js.map +1 -0
- package/dist/extract.d.ts.map +1 -1
- package/dist/extract.js +97 -3
- package/dist/extract.js.map +1 -1
- package/dist/grants.d.ts +25 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +62 -0
- package/dist/grants.js.map +1 -0
- package/dist/index.js +254 -41
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +5 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +329 -2
- package/dist/mcp.js.map +1 -1
- package/dist/network.d.ts +47 -1
- package/dist/network.d.ts.map +1 -1
- package/dist/network.js +17 -1
- package/dist/network.js.map +1 -1
- package/dist/node.d.ts +15 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +14 -1
- package/dist/node.js.map +1 -1
- package/dist/pairing.d.ts +52 -0
- package/dist/pairing.d.ts.map +1 -0
- package/dist/pairing.js +57 -0
- package/dist/pairing.js.map +1 -0
- package/dist/scripting.d.ts +10 -0
- package/dist/scripting.d.ts.map +1 -1
- package/dist/scripting.js +2 -2
- package/dist/scripting.js.map +1 -1
- package/dist/scripts.d.ts +27 -3
- package/dist/scripts.d.ts.map +1 -1
- package/dist/scripts.js +31 -8
- package/dist/scripts.js.map +1 -1
- package/dist/tasks.d.ts +189 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +485 -0
- package/dist/tasks.js.map +1 -0
- package/dist/web/app.css +1 -1
- package/dist/web/app.js +12 -12
- package/dist/web/app.js.map +1 -1
- package/package.json +7 -5
package/dist/grants.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { openAiGrants, validateAiGrants } from '@source-repo/rpc';
|
|
3
|
+
/**
|
|
4
|
+
* The AI grants document, read from a file rather than assembled from flags.
|
|
5
|
+
*
|
|
6
|
+
* A path and not a set of options, for the reason the document exists at all: it is declarative
|
|
7
|
+
* data with a revision, so that a console can render it and a reviewer can diff it. Something built
|
|
8
|
+
* out of `--grant ai.tool.write --to bench --until …` would be neither - there would be nothing to
|
|
9
|
+
* diff, and the revision would have nowhere to live.
|
|
10
|
+
*
|
|
11
|
+
* Not a secret, either, which is why it is only ever a path and never written inline in a task
|
|
12
|
+
* file the way `sign` and `auth` may be. The revision field is there so policy can be replaced on
|
|
13
|
+
* its own cadence; burying the document inside another file that changes for unrelated reasons
|
|
14
|
+
* takes that away.
|
|
15
|
+
*/
|
|
16
|
+
export const loadAiGrants = (path) => {
|
|
17
|
+
let document;
|
|
18
|
+
try {
|
|
19
|
+
document = JSON.parse(readFileSync(path, 'utf8'));
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
throw new Error(`cannot read grants from ${path}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
// validateAiGrants throws with the reason. A node that starts holding an unreadable security
|
|
26
|
+
// policy is the failure the document exists to prevent, so this is never softened into a
|
|
27
|
+
// warning: the operator meant to grant something, and carrying on with nothing granted would
|
|
28
|
+
// be a quiet answer to a loud problem.
|
|
29
|
+
return validateAiGrants(document);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
throw new Error(`${path}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const describeEntry = (entry) => {
|
|
36
|
+
const bounds = [
|
|
37
|
+
entry.to?.length ? `to ${entry.to.join(', ')}` : undefined,
|
|
38
|
+
entry.roles?.length ? `roles ${entry.roles.join(', ')}` : undefined,
|
|
39
|
+
// Said out loud because it is the one an operator is most likely to have meant to set and
|
|
40
|
+
// not set: an unbounded grant is a real choice and should not read like an oversight.
|
|
41
|
+
entry.expiresAt === undefined ? 'no expiry' : `until ${new Date(entry.expiresAt).toISOString()}`,
|
|
42
|
+
entry.maxGeneration === undefined ? undefined : `generation ${entry.maxGeneration}`
|
|
43
|
+
].filter(Boolean);
|
|
44
|
+
return bounds.length ? bounds.join(', ') : 'every AI principal of that provenance';
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* What this node has open, in the order a person would ask it.
|
|
48
|
+
*
|
|
49
|
+
* Printed at startup because closed-by-default means "it is running" and "it can do something" are
|
|
50
|
+
* separately true, and an operator who has just written a grants file is entitled to see whether
|
|
51
|
+
* the thing they wrote is the thing that took effect. An expired grant is simply absent here, which
|
|
52
|
+
* is the honest answer and not the same as it having been removed from the file.
|
|
53
|
+
*/
|
|
54
|
+
export const grantLines = (grants, now = Date.now()) => {
|
|
55
|
+
if (!grants)
|
|
56
|
+
return ['no grants document, so AI principals may observe and nothing else'];
|
|
57
|
+
const open = openAiGrants(grants, now);
|
|
58
|
+
if (!open.length)
|
|
59
|
+
return [`grants revision ${grants.revision}: nothing open, so AI principals may observe and nothing else`];
|
|
60
|
+
return [`grants revision ${grants.revision}:`, ...open.map(({ grant, ...entry }) => ` ${grant} — ${describeEntry(entry)}`)];
|
|
61
|
+
};
|
|
62
|
+
//# sourceMappingURL=grants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"grants.js","sourceRoot":"","sources":["../src/grants.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdirSync, readFileSync,
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { basename, resolve } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { createDerivedAuthenticator, createTokenAuthenticator, firstAuthenticator, defaultSecureWebPort, defaultSecureWebSocketPort, defaultWebPort, defaultWebSocketPort, namespaceProblems, readableNameFor } from '@source-repo/rpc';
|
|
6
6
|
import { extractSchema } from './extract.js';
|
|
7
7
|
import { startConsole } from './console.js';
|
|
8
8
|
import { startBroker } from './broker.js';
|
|
@@ -15,6 +15,9 @@ import { startFake } from './fake.js';
|
|
|
15
15
|
import { replaySession, startRecording } from './record.js';
|
|
16
16
|
import { checkPeer, diffPeers } from './conform.js';
|
|
17
17
|
import { bench, benchArguments } from './bench.js';
|
|
18
|
+
import { loadAuthFile, loadSigningKeys, loadTls, scriptCredentials } from './credentials.js';
|
|
19
|
+
import { grantLines, loadAiGrants } from './grants.js';
|
|
20
|
+
import { defaultTaskFile, startTaskFile, taskFileSkeleton, taskFileSkeletonNotes } from './tasks.js';
|
|
18
21
|
/**
|
|
19
22
|
* msgrpc extract - read the contract out of TypeScript source and write it to a file
|
|
20
23
|
* msgrpc check - compare the source against a written contract and report breaking changes
|
|
@@ -32,6 +35,8 @@ const usage = `source-rpc <command> [options] --version prints the CLI
|
|
|
32
35
|
console browse a live network in a browser: peers, what they expose, calls and events
|
|
33
36
|
broker run a WebSocket bus: relays between the peers that connect to it, until Ctrl-C
|
|
34
37
|
node make this machine scriptable from another one, and nothing else, until Ctrl-C
|
|
38
|
+
run start console, node and serve roles together from one JSON task file, until Ctrl-C
|
|
39
|
+
with no file named it runs ./source-rpc.tasks.json; --init writes one to start from
|
|
35
40
|
mcp serve the network to an MCP client over stdio: list peers, describe them, call them
|
|
36
41
|
stdio carries the protocol, so it is not for interactive use; --port opens a second
|
|
37
42
|
door over streamable HTTP, so two clients can share one node
|
|
@@ -70,6 +75,8 @@ const usage = `source-rpc <command> [options] --version prints the CLI
|
|
|
70
75
|
|
|
71
76
|
peers / describe / call / watch
|
|
72
77
|
--broker <url> an MQTT network, e.g. mqtt://localhost:1883
|
|
78
|
+
SOURCE_RPC_MQTT_USERNAME and SOURCE_RPC_MQTT_PASSWORD are used
|
|
79
|
+
as broker credentials when set
|
|
73
80
|
--hub <url> a socket.io network, e.g. http://hub:7843
|
|
74
81
|
one of --broker and --hub is required; both watches both
|
|
75
82
|
--prefix <topic> topic namespace, default the transport's own
|
|
@@ -86,6 +93,8 @@ const usage = `source-rpc <command> [options] --version prints the CLI
|
|
|
86
93
|
|
|
87
94
|
console
|
|
88
95
|
--broker <url> an MQTT network, e.g. mqtt://localhost:1883
|
|
96
|
+
SOURCE_RPC_MQTT_USERNAME and SOURCE_RPC_MQTT_PASSWORD are used
|
|
97
|
+
as broker credentials when set
|
|
89
98
|
--hub <url> a socket.io network, e.g. http://hub:7843
|
|
90
99
|
one of --broker and --hub is required; both watches both
|
|
91
100
|
--prefix <topic> topic namespace, default the transport's own
|
|
@@ -102,6 +111,8 @@ const usage = `source-rpc <command> [options] --version prints the CLI
|
|
|
102
111
|
|
|
103
112
|
mcp
|
|
104
113
|
--broker <url> an MQTT network
|
|
114
|
+
SOURCE_RPC_MQTT_USERNAME and SOURCE_RPC_MQTT_PASSWORD are used
|
|
115
|
+
as broker credentials when set
|
|
105
116
|
--hub <url> a socket.io network
|
|
106
117
|
one of --broker and --hub is required; both watches both
|
|
107
118
|
--prefix <topic> topic namespace, default the transport's own
|
|
@@ -122,6 +133,7 @@ const usage = `source-rpc <command> [options] --version prints the CLI
|
|
|
122
133
|
nothing can script it. The peer must authenticate as that name,
|
|
123
134
|
so the key it presents reaches it out of band - deliberately not
|
|
124
135
|
something this bus can hand over
|
|
136
|
+
--grants <file> what an AI principal may do here; see node --grants above
|
|
125
137
|
--port <n> serve streamable HTTP here as a second door beside stdio, so a
|
|
126
138
|
second client shares this node's scripts, fakes and watches
|
|
127
139
|
rather than forking them. No default: absent means stdio only
|
|
@@ -162,6 +174,19 @@ const usage = `source-rpc <command> [options] --version prints the CLI
|
|
|
162
174
|
on a broker, --sign at both ends is what makes the grant work:
|
|
163
175
|
without it nothing can prove who a caller is and every call is
|
|
164
176
|
refused
|
|
177
|
+
--grants <file> what an AI principal may do here, as a grants document. Without one
|
|
178
|
+
a badged principal may observe and nothing else, which is the
|
|
179
|
+
default everywhere. SIGHUP re-reads it, so a grant can be closed
|
|
180
|
+
without stopping the node
|
|
181
|
+
|
|
182
|
+
run [<file.json>] defaults to ./source-rpc.tasks.json, in this directory only
|
|
183
|
+
shared network settings and console, node or serve tasks; relative paths are resolved from the
|
|
184
|
+
task file. Each task's credentials are its own: a key file under 'sign', an auth file under
|
|
185
|
+
'auth', or the same secrets written inline in either. Full format:
|
|
186
|
+
https://source-repo.github.io/rpc/tools/cli#task-files
|
|
187
|
+
--init write a task file to start from, with three roles and fresh signing
|
|
188
|
+
secrets, and refuse to write over one that exists. --broker, --hub
|
|
189
|
+
and --scriptable-by fill in what they name
|
|
165
190
|
|
|
166
191
|
strip <file…>
|
|
167
192
|
--out <dir> where each decorator-free twin lands, under the same file name.
|
|
@@ -191,7 +216,12 @@ const usage = `source-rpc <command> [options] --version prints the CLI
|
|
|
191
216
|
{ "token": "…", presented when this command dials a hub that authenticates
|
|
192
217
|
"tokens": { accepted when this command is the bus: token -> the peer it admits
|
|
193
218
|
"…": "plantServer",
|
|
194
|
-
"…": { "name": "hmi", "roles": ["operator"] } }
|
|
219
|
+
"…": { "name": "hmi", "roles": ["operator"] } },
|
|
220
|
+
"derive": "…", on a node: the secret it mints credentials with for the scripts it
|
|
221
|
+
starts, so each one connects as itself and the node's own token
|
|
222
|
+
never reaches a script's environment
|
|
223
|
+
"issuers": { on a bus: which nodes it lets vouch for the programs they start
|
|
224
|
+
"node-a": "…" } } issuer peer name -> the same secret that node derives with
|
|
195
225
|
SOURCE_RPC_TOKEN the same "token", for a container
|
|
196
226
|
SOURCE_RPC_TOKENS the same "tokens" as JSON, for a container
|
|
197
227
|
never a flag: ps is readable by everyone on the box
|
|
@@ -247,30 +277,110 @@ const withHistory = (next, previous) => {
|
|
|
247
277
|
}
|
|
248
278
|
return next;
|
|
249
279
|
};
|
|
280
|
+
/**
|
|
281
|
+
* HMAC keys for the console, read from a file rather than a flag: a secret on the command line is
|
|
282
|
+
* visible to anyone who can run ps.
|
|
283
|
+
*
|
|
284
|
+
* { "name": "console-1", "secret": "…", "peers": { "plantServer": "…" } }
|
|
285
|
+
*
|
|
286
|
+
* `peers` is optional. Supplying it makes the console check signatures on what it receives too,
|
|
287
|
+
* which means an unsigned peer's frames are then dropped.
|
|
288
|
+
*/
|
|
250
289
|
const readSigningKeys = (path, command) => {
|
|
251
|
-
let keys;
|
|
252
290
|
try {
|
|
253
|
-
|
|
291
|
+
const signing = loadSigningKeys(path);
|
|
292
|
+
if (signing.readableByOthers)
|
|
293
|
+
process.stderr.write(`source-rpc ${command}: ${path} is readable by other users\n`);
|
|
294
|
+
return signing;
|
|
254
295
|
}
|
|
255
296
|
catch (e) {
|
|
256
|
-
process.stderr.write(`source-rpc ${command}:
|
|
257
|
-
process.exit(1);
|
|
258
|
-
}
|
|
259
|
-
if (typeof keys.secret !== 'string' || !keys.secret) {
|
|
260
|
-
process.stderr.write(`source-rpc ${command}: ${path} has no "secret"\n`);
|
|
297
|
+
process.stderr.write(`source-rpc ${command}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
261
298
|
process.exit(1);
|
|
262
299
|
}
|
|
300
|
+
};
|
|
301
|
+
/**
|
|
302
|
+
* Bearer tokens, read from a file or the environment. Never a flag, for the same reason the signing
|
|
303
|
+
* secret is not one: `ps` is readable by everyone on the box.
|
|
304
|
+
*
|
|
305
|
+
* { "token": "…", "tokens": { "…": "plantServer", "…": { "name": "hmi", "roles": ["operator"] } } }
|
|
306
|
+
*
|
|
307
|
+
* `token` is what this command presents when it dials a bus that authenticates. `tokens` is what
|
|
308
|
+
* `broker` accepts, each mapping to the one peer name it admits. A file may carry either or both -
|
|
309
|
+
* a broker joining an upstream needs both, since it is a bus to one side and a peer to the other.
|
|
310
|
+
*
|
|
311
|
+
* `SOURCE_RPC_TOKEN` and `SOURCE_RPC_TOKENS` say the same two things, for a container where a file
|
|
312
|
+
* is a mount and an environment variable is a line in the compose file. `--auth` names a path
|
|
313
|
+
* rather than a secret, so it is explicit and wins over both.
|
|
314
|
+
*/
|
|
315
|
+
/**
|
|
316
|
+
* Builds the per-script credential minter for a node that has a signing secret, or undefined.
|
|
317
|
+
*
|
|
318
|
+
* Undefined is a real answer rather than a failure: a bench with no authentication needs no
|
|
319
|
+
* credentials, and the thing that must never happen - a script inheriting the node's own token -
|
|
320
|
+
* is now impossible either way. Lifetimes are short and renewal does not exist, so stopping the
|
|
321
|
+
* node means its scripts' credentials expire on their own; immediate revocation is the grants
|
|
322
|
+
* work, not this.
|
|
323
|
+
*
|
|
324
|
+
* The minting itself lives in credentials.ts, because `run` mints the same credentials from a task
|
|
325
|
+
* file and two implementations of a credential are two things to get subtly different.
|
|
326
|
+
*/
|
|
327
|
+
const scriptCredentialsFor = (auth, issuer, command) => scriptCredentials(auth, issuer, (message) => process.stderr.write(`source-rpc ${command}: ${message}\n`));
|
|
328
|
+
const readAiGrants = (path, command) => {
|
|
263
329
|
try {
|
|
264
|
-
|
|
265
|
-
if (statSync(path).mode & 0o077)
|
|
266
|
-
process.stderr.write(`source-rpc ${command}: ${path} is readable by other users\n`);
|
|
330
|
+
return loadAiGrants(path);
|
|
267
331
|
}
|
|
268
|
-
catch {
|
|
269
|
-
|
|
332
|
+
catch (e) {
|
|
333
|
+
process.stderr.write(`source-rpc ${command}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
334
|
+
process.exit(1);
|
|
270
335
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
336
|
+
};
|
|
337
|
+
/**
|
|
338
|
+
* Refusals reach the operator; permitted calls do not.
|
|
339
|
+
*
|
|
340
|
+
* Both are audit, and both belong in the fleet-side sink rather than here - but of the two, a
|
|
341
|
+
* refusal is the one somebody is standing at a terminal wondering about, and printing every allowed
|
|
342
|
+
* call would bury it. The sentence the library supplies is the whole line: it already says which
|
|
343
|
+
* grant was wanted and why the answer was no.
|
|
344
|
+
*/
|
|
345
|
+
const aiDecisionReporter = (command) => (record) => {
|
|
346
|
+
if (!record.allowed)
|
|
347
|
+
process.stderr.write(`source-rpc ${command}: refused ${record.source} calling ${record.method}: ${record.reason}\n`);
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Re-read the grants document on SIGHUP, so a grant can be closed without restarting the node.
|
|
351
|
+
*
|
|
352
|
+
* A signal rather than a file watcher, deliberately. A watcher fires on a half-written file and
|
|
353
|
+
* has to be taught what an atomic replace looks like on three platforms; a signal is an operator
|
|
354
|
+
* saying *now*, which is the same instinct as everything else here - a change in what is permitted
|
|
355
|
+
* is something somebody states rather than something that happens when a file is touched.
|
|
356
|
+
*
|
|
357
|
+
* A failed reload keeps the document that was already in force. The alternative - falling back to
|
|
358
|
+
* no document - reads as "closed, therefore safe" and is in fact the node quietly disagreeing with
|
|
359
|
+
* the policy its operator believes is loaded.
|
|
360
|
+
*/
|
|
361
|
+
const reloadGrantsOnHangUp = (path, initial, apply, command) => {
|
|
362
|
+
// Not every platform has SIGHUP, and Windows has none of this. Nothing else is affected.
|
|
363
|
+
if (process.platform === 'win32')
|
|
364
|
+
return;
|
|
365
|
+
let current = initial;
|
|
366
|
+
process.on('SIGHUP', () => {
|
|
367
|
+
let next;
|
|
368
|
+
try {
|
|
369
|
+
next = loadAiGrants(path);
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
process.stderr.write(`source-rpc ${command}: grants unchanged, ${e instanceof Error ? e.message : String(e)}\n`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// The revision exists so a rollback is visible. Applied anyway - an operator may be
|
|
376
|
+
// deliberately reverting - but never silently, since the other cause is a stale file.
|
|
377
|
+
if (current && next.revision < current.revision)
|
|
378
|
+
process.stderr.write(`source-rpc ${command}: grants revision went backwards, ${current.revision} to ${next.revision}\n`);
|
|
379
|
+
current = next;
|
|
380
|
+
apply(next);
|
|
381
|
+
for (const line of grantLines(next))
|
|
382
|
+
process.stderr.write(`source-rpc ${command}: ${line}\n`);
|
|
383
|
+
});
|
|
274
384
|
};
|
|
275
385
|
/**
|
|
276
386
|
* Certificate and key for a server this command opens, or undefined for plain HTTP.
|
|
@@ -291,10 +401,10 @@ const readTls = (argv, command) => {
|
|
|
291
401
|
process.exit(1);
|
|
292
402
|
}
|
|
293
403
|
try {
|
|
294
|
-
return
|
|
404
|
+
return loadTls(cert, key);
|
|
295
405
|
}
|
|
296
406
|
catch (e) {
|
|
297
|
-
process.stderr.write(`source-rpc ${command}:
|
|
407
|
+
process.stderr.write(`source-rpc ${command}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
298
408
|
process.exit(1);
|
|
299
409
|
}
|
|
300
410
|
};
|
|
@@ -325,29 +435,17 @@ const readAuth = (argv, command) => {
|
|
|
325
435
|
...(tokens ? { tokens } : {})
|
|
326
436
|
};
|
|
327
437
|
}
|
|
328
|
-
let auth;
|
|
329
|
-
try {
|
|
330
|
-
auth = JSON.parse(readFileSync(path, 'utf8'));
|
|
331
|
-
}
|
|
332
|
-
catch (e) {
|
|
333
|
-
process.stderr.write(`source-rpc ${command}: cannot read tokens from ${path}: ${e.message}\n`);
|
|
334
|
-
process.exit(1);
|
|
335
|
-
}
|
|
336
|
-
if (!auth.token && !auth.tokens) {
|
|
337
|
-
// An empty file is the failure that looks like success: the command starts, and the bus it
|
|
338
|
-
// meant to gate is open. Better to refuse than to run unauthenticated on request.
|
|
339
|
-
process.stderr.write(`source-rpc ${command}: ${path} has neither "token" nor "tokens"\n`);
|
|
340
|
-
process.exit(1);
|
|
341
|
-
}
|
|
342
438
|
try {
|
|
439
|
+
const loaded = loadAuthFile(path);
|
|
343
440
|
// Worth saying out loud: whoever can read this file can be these peers.
|
|
344
|
-
if (
|
|
441
|
+
if (loaded.readableByOthers)
|
|
345
442
|
process.stderr.write(`source-rpc ${command}: ${path} is readable by other users\n`);
|
|
443
|
+
return loaded.auth;
|
|
346
444
|
}
|
|
347
|
-
catch {
|
|
348
|
-
|
|
445
|
+
catch (e) {
|
|
446
|
+
process.stderr.write(`source-rpc ${command}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
447
|
+
process.exit(1);
|
|
349
448
|
}
|
|
350
|
-
return auth;
|
|
351
449
|
};
|
|
352
450
|
/**
|
|
353
451
|
* The flags every command that joins a network takes, read once.
|
|
@@ -774,7 +872,11 @@ const runBroker = async (argv) => {
|
|
|
774
872
|
const auth = readAuth(argv, 'broker');
|
|
775
873
|
let authenticate;
|
|
776
874
|
try {
|
|
777
|
-
|
|
875
|
+
const byToken = auth.tokens ? createTokenAuthenticator(auth.tokens) : undefined;
|
|
876
|
+
const byDerivation = auth.issuers ? createDerivedAuthenticator({ issuers: auth.issuers }) : undefined;
|
|
877
|
+
// Operators hold tokens; nodes vouch for the programs they start. A bus configured with
|
|
878
|
+
// both admits both, and one configured with neither admits nobody, as before.
|
|
879
|
+
authenticate = byToken && byDerivation ? firstAuthenticator(byToken, byDerivation) : (byToken ?? byDerivation);
|
|
778
880
|
}
|
|
779
881
|
catch (e) {
|
|
780
882
|
// Every way of getting this wrong - a blank token, a grant with no name, an empty map -
|
|
@@ -853,15 +955,24 @@ const runMcp = async (argv) => {
|
|
|
853
955
|
process.exit(1);
|
|
854
956
|
}
|
|
855
957
|
}
|
|
958
|
+
const credentialFor = scriptsDir ? scriptCredentialsFor(readAuth(argv, 'mcp'), network.name, 'mcp') : undefined;
|
|
959
|
+
const grantsPath = argument(argv, '--grants', '');
|
|
960
|
+
const aiGrants = grantsPath ? readAiGrants(grantsPath, 'mcp') : undefined;
|
|
856
961
|
const running = await startMcp({ ...network, ...(contracts ? { contracts: resolve(contracts) } : {}), ...(argv.includes('--allow-exec') ? { allowExec: true } : {}),
|
|
857
962
|
...(scriptsDir ? { scripts: resolve(scriptsDir) } : {}),
|
|
963
|
+
...(credentialFor ? { credentialFor } : {}),
|
|
858
964
|
...(scriptableBy.length ? { scriptableBy } : {}),
|
|
965
|
+
...(aiGrants ? { aiGrants } : {}),
|
|
966
|
+
onAiDecision: aiDecisionReporter('mcp'),
|
|
859
967
|
...(doorPort ? { port: doorPort, host: doorHost, ...(doorToken ? { doorToken } : {}) } : {}) }).catch((e) => {
|
|
860
968
|
// The refusal a wide bind without a token earns arrives here, with its sentence intact.
|
|
861
969
|
process.stderr.write(`source-rpc ${e.message}\n`);
|
|
862
970
|
process.exit(1);
|
|
863
971
|
});
|
|
864
972
|
// Nothing is written to stdout here: it carries the protocol. See mcp.ts.
|
|
973
|
+
if (scriptsDir || aiGrants)
|
|
974
|
+
for (const line of grantLines(aiGrants))
|
|
975
|
+
process.stderr.write(`source-rpc mcp: ${line}\n`);
|
|
865
976
|
const stop = () => void running
|
|
866
977
|
.close()
|
|
867
978
|
.then(() => process.exit(0))
|
|
@@ -881,7 +992,17 @@ const runNode = async (argv) => {
|
|
|
881
992
|
process.stderr.write('source-rpc node: needs --scripts <dir> and at least one --scriptable-by <peer>, or it offers nothing to anybody\n');
|
|
882
993
|
process.exit(1);
|
|
883
994
|
}
|
|
884
|
-
const
|
|
995
|
+
const credentialFor = scriptCredentialsFor(readAuth(argv, 'node'), network.name, 'node');
|
|
996
|
+
const grantsPath = argument(argv, '--grants', '');
|
|
997
|
+
const aiGrants = grantsPath ? readAiGrants(grantsPath, 'node') : undefined;
|
|
998
|
+
const running = await startNode({
|
|
999
|
+
...network,
|
|
1000
|
+
scripts: resolve(scriptsDir),
|
|
1001
|
+
scriptableBy,
|
|
1002
|
+
...(credentialFor ? { credentialFor } : {}),
|
|
1003
|
+
...(aiGrants ? { aiGrants } : {}),
|
|
1004
|
+
onAiDecision: aiDecisionReporter('node')
|
|
1005
|
+
}).catch((e) => {
|
|
885
1006
|
process.stderr.write(`source-rpc node: cannot start: ${e.message}\n`);
|
|
886
1007
|
process.exit(1);
|
|
887
1008
|
});
|
|
@@ -898,6 +1019,12 @@ const runNode = async (argv) => {
|
|
|
898
1019
|
// node is unreachable for the thing it exists to do. Said now rather than discovered as a
|
|
899
1020
|
// Forbidden on the other machine.
|
|
900
1021
|
process.stderr.write('source-rpc node: on a broker without --sign nothing can prove who a caller is, so every scripting call will be refused. Give both ends keys.\n');
|
|
1022
|
+
// Said whether or not a document was given, because closed-by-default means "it is running" and
|
|
1023
|
+
// "it can do something" are separately true, and this node's scripts carry `ai-program`.
|
|
1024
|
+
for (const line of grantLines(aiGrants))
|
|
1025
|
+
process.stderr.write(`source-rpc node: ${line}\n`);
|
|
1026
|
+
if (grantsPath)
|
|
1027
|
+
reloadGrantsOnHangUp(grantsPath, aiGrants, running.setAiGrants, 'node');
|
|
901
1028
|
const stop = () => void running
|
|
902
1029
|
.close()
|
|
903
1030
|
.then(() => process.exit(0))
|
|
@@ -937,6 +1064,79 @@ const runConsole = async (argv) => {
|
|
|
937
1064
|
process.on('SIGINT', stop);
|
|
938
1065
|
process.on('SIGTERM', stop);
|
|
939
1066
|
};
|
|
1067
|
+
const taskStartedLine = (task) => {
|
|
1068
|
+
if (task.type === 'console')
|
|
1069
|
+
return `${task.id}: console ${task.name} on ${task.url}`;
|
|
1070
|
+
if (task.type === 'node')
|
|
1071
|
+
return `${task.id}: node ${task.name}`;
|
|
1072
|
+
return `${task.id}: serve ${task.name} answering ${task.namespaces?.join(', ')}`;
|
|
1073
|
+
};
|
|
1074
|
+
/**
|
|
1075
|
+
* Writes a task file to start from, and refuses to write over one that is already there.
|
|
1076
|
+
*
|
|
1077
|
+
* Refusing matters more here than it usually does: the file it would replace holds signing secrets,
|
|
1078
|
+
* and overwriting it does not lose a configuration that can be typed again - it loses the identity
|
|
1079
|
+
* every other machine on the network was told to expect, and does it silently.
|
|
1080
|
+
*/
|
|
1081
|
+
const initTaskFile = (argv, file) => {
|
|
1082
|
+
const skeleton = taskFileSkeleton({
|
|
1083
|
+
...(argument(argv, '--broker', '') ? { broker: argument(argv, '--broker', '') } : {}),
|
|
1084
|
+
...(argument(argv, '--hub', '') ? { hub: argument(argv, '--hub', '') } : {}),
|
|
1085
|
+
...(argument(argv, '--scriptable-by', '') ? { controller: argument(argv, '--scriptable-by', '') } : {})
|
|
1086
|
+
});
|
|
1087
|
+
try {
|
|
1088
|
+
// wx rather than a check and a write: between the two there is a window, and the thing in it
|
|
1089
|
+
// is a key file.
|
|
1090
|
+
writeFileSync(file, `${JSON.stringify(skeleton, undefined, 4)}\n`, { flag: 'wx', mode: 0o600 });
|
|
1091
|
+
}
|
|
1092
|
+
catch (e) {
|
|
1093
|
+
const already = e.code === 'EEXIST';
|
|
1094
|
+
process.stderr.write(`source-rpc run: ${already ? `${file} already exists, and it may hold this host's signing secrets - name a new file or move that one aside` : `cannot write ${file}: ${e.message}`}\n`);
|
|
1095
|
+
process.exit(1);
|
|
1096
|
+
}
|
|
1097
|
+
for (const note of taskFileSkeletonNotes(file, argument(argv, '--scriptable-by', 'controller')))
|
|
1098
|
+
process.stdout.write(`source-rpc run: ${note}\n`);
|
|
1099
|
+
};
|
|
1100
|
+
const runTasks = async (argv) => {
|
|
1101
|
+
const [, named, ...extra] = positionals(argv);
|
|
1102
|
+
if (extra.length) {
|
|
1103
|
+
process.stderr.write(`source-rpc run: give it one task file, or none to use ./${defaultTaskFile}\n`);
|
|
1104
|
+
process.exit(1);
|
|
1105
|
+
}
|
|
1106
|
+
const file = named ?? defaultTaskFile;
|
|
1107
|
+
if (argv.includes('--init'))
|
|
1108
|
+
return initTaskFile(argv, file);
|
|
1109
|
+
// Checked before startTaskFile so that the answer to "run" with nothing set up is the thing to
|
|
1110
|
+
// do next, rather than an ENOENT for a file the operator never mentioned.
|
|
1111
|
+
if (!named && !existsSync(file)) {
|
|
1112
|
+
process.stderr.write(`source-rpc run: no ${defaultTaskFile} here, and no task file named. Write one with 'source-rpc run --init', or name one.\n`);
|
|
1113
|
+
process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
const running = await startTaskFile(file, {
|
|
1116
|
+
started: (task) => process.stdout.write(`source-rpc run: started ${taskStartedLine(task)}\n`),
|
|
1117
|
+
warning: (message) => process.stderr.write(`source-rpc run: ${message}\n`)
|
|
1118
|
+
});
|
|
1119
|
+
process.stdout.write(`source-rpc run: ${running.tasks.length} tasks running from ${running.file}\n`);
|
|
1120
|
+
let stopping = false;
|
|
1121
|
+
const stop = () => {
|
|
1122
|
+
if (stopping)
|
|
1123
|
+
return;
|
|
1124
|
+
stopping = true;
|
|
1125
|
+
void running
|
|
1126
|
+
.close()
|
|
1127
|
+
.then(() => process.exit(0))
|
|
1128
|
+
.catch((e) => {
|
|
1129
|
+
process.stderr.write(`source-rpc run: shutdown failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1130
|
+
process.exit(1);
|
|
1131
|
+
});
|
|
1132
|
+
};
|
|
1133
|
+
process.on('SIGINT', stop);
|
|
1134
|
+
process.on('SIGTERM', stop);
|
|
1135
|
+
// The same hang-up that re-reads a node's grants, for every node this file started.
|
|
1136
|
+
if (process.platform !== 'win32')
|
|
1137
|
+
process.on('SIGHUP', () => running.reloadGrants());
|
|
1138
|
+
await new Promise(() => { });
|
|
1139
|
+
};
|
|
940
1140
|
const main = () => {
|
|
941
1141
|
// `source-rpc describe plantServer | head -4` closes stdout while there is still output to
|
|
942
1142
|
// write, and Node turns that into an unhandled 'error' event: a stack trace where a command
|
|
@@ -968,7 +1168,10 @@ const main = () => {
|
|
|
968
1168
|
process.exit(1);
|
|
969
1169
|
};
|
|
970
1170
|
if (command === 'strip') {
|
|
971
|
-
|
|
1171
|
+
// Past the command word, as every other verb here does. Without the slice the first file
|
|
1172
|
+
// to be stripped is one called `strip`, which fails as a missing file rather than saying
|
|
1173
|
+
// no file was named - and it means the command has never worked from the command line.
|
|
1174
|
+
const files = positionals(argv).slice(1);
|
|
972
1175
|
const out = argument(argv, '--out', '');
|
|
973
1176
|
if (!files.length || !out) {
|
|
974
1177
|
process.stderr.write('source-rpc strip: give it one or more .ts files and --out <dir>, e.g. strip scripts/hello.ts --out scripts/stripped\n');
|
|
@@ -1004,6 +1207,10 @@ const main = () => {
|
|
|
1004
1207
|
void runNode(argv).catch(fail);
|
|
1005
1208
|
return;
|
|
1006
1209
|
}
|
|
1210
|
+
if (command === 'run') {
|
|
1211
|
+
void runTasks(argv).catch(fail);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1007
1214
|
if (command === 'console') {
|
|
1008
1215
|
void runConsole(argv).catch(fail);
|
|
1009
1216
|
return;
|
|
@@ -1054,6 +1261,12 @@ const main = () => {
|
|
|
1054
1261
|
}
|
|
1055
1262
|
if (command !== 'extract' && command !== 'check') {
|
|
1056
1263
|
process.stderr.write(usage);
|
|
1264
|
+
// Pointed at rather than started. Typing the bare command is what someone does to see what
|
|
1265
|
+
// this is, and answering that by joining a bus under whatever identities happen to be in
|
|
1266
|
+
// this directory - and opening a console, and possibly making the machine scriptable -
|
|
1267
|
+
// would be a great deal to have happen while reading the help.
|
|
1268
|
+
if (!command && existsSync(defaultTaskFile))
|
|
1269
|
+
process.stderr.write(`\nthere is a ${defaultTaskFile} here: 'source-rpc run' starts it\n`);
|
|
1057
1270
|
process.exit(command ? 1 : 0);
|
|
1058
1271
|
}
|
|
1059
1272
|
const { schema, diagnostics } = extractSchema(project);
|