@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.
Files changed (55) hide show
  1. package/README.md +1 -0
  2. package/dist/console.d.ts +15 -2
  3. package/dist/console.d.ts.map +1 -1
  4. package/dist/console.js +49 -15
  5. package/dist/console.js.map +1 -1
  6. package/dist/console.types.json +483 -0
  7. package/dist/credentials.d.ts +77 -0
  8. package/dist/credentials.d.ts.map +1 -0
  9. package/dist/credentials.js +118 -0
  10. package/dist/credentials.js.map +1 -0
  11. package/dist/enrolment.d.ts +91 -0
  12. package/dist/enrolment.d.ts.map +1 -0
  13. package/dist/enrolment.js +94 -0
  14. package/dist/enrolment.js.map +1 -0
  15. package/dist/extract.d.ts.map +1 -1
  16. package/dist/extract.js +97 -3
  17. package/dist/extract.js.map +1 -1
  18. package/dist/grants.d.ts +25 -0
  19. package/dist/grants.d.ts.map +1 -0
  20. package/dist/grants.js +62 -0
  21. package/dist/grants.js.map +1 -0
  22. package/dist/index.js +254 -41
  23. package/dist/index.js.map +1 -1
  24. package/dist/mcp.d.ts +5 -0
  25. package/dist/mcp.d.ts.map +1 -1
  26. package/dist/mcp.js +329 -2
  27. package/dist/mcp.js.map +1 -1
  28. package/dist/network.d.ts +47 -1
  29. package/dist/network.d.ts.map +1 -1
  30. package/dist/network.js +17 -1
  31. package/dist/network.js.map +1 -1
  32. package/dist/node.d.ts +15 -0
  33. package/dist/node.d.ts.map +1 -1
  34. package/dist/node.js +14 -1
  35. package/dist/node.js.map +1 -1
  36. package/dist/pairing.d.ts +52 -0
  37. package/dist/pairing.d.ts.map +1 -0
  38. package/dist/pairing.js +57 -0
  39. package/dist/pairing.js.map +1 -0
  40. package/dist/scripting.d.ts +10 -0
  41. package/dist/scripting.d.ts.map +1 -1
  42. package/dist/scripting.js +2 -2
  43. package/dist/scripting.js.map +1 -1
  44. package/dist/scripts.d.ts +27 -3
  45. package/dist/scripts.d.ts.map +1 -1
  46. package/dist/scripts.js +31 -8
  47. package/dist/scripts.js.map +1 -1
  48. package/dist/tasks.d.ts +189 -0
  49. package/dist/tasks.d.ts.map +1 -0
  50. package/dist/tasks.js +485 -0
  51. package/dist/tasks.js.map +1 -0
  52. package/dist/web/app.css +1 -1
  53. package/dist/web/app.js +12 -12
  54. package/dist/web/app.js.map +1 -1
  55. 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, statSync, writeFileSync } from 'node:fs';
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 { createHmacSigner, createHmacVerifier, createTokenAuthenticator, defaultSecureWebPort, defaultSecureWebSocketPort, defaultWebPort, defaultWebSocketPort, namespaceProblems, readableNameFor } from '@source-repo/rpc';
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
- keys = JSON.parse(readFileSync(path, 'utf8'));
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}: cannot read keys from ${path}: ${e.message}\n`);
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
- // Worth saying out loud: this file is the console's identity on the network.
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
- // Not worth failing over if the mode cannot be read.
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
- const sign = createHmacSigner(keys.secret);
272
- const verify = keys.peers ? createHmacVerifier((peer) => keys.peers?.[peer]) : undefined;
273
- return { keys, sign, verify };
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 { cert: readFileSync(cert), key: readFileSync(key) };
404
+ return loadTls(cert, key);
295
405
  }
296
406
  catch (e) {
297
- process.stderr.write(`source-rpc ${command}: cannot read the certificate or key: ${e.message}\n`);
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 (statSync(path).mode & 0o077)
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
- // Not worth failing over if the mode cannot be read.
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
- authenticate = auth.tokens ? createTokenAuthenticator(auth.tokens) : undefined;
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 running = await startNode({ ...network, scripts: resolve(scriptsDir), scriptableBy }).catch((e) => {
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
- const files = positionals(argv);
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);