@x-tech-laboratories/x-libs 0.2.0 → 0.4.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 (63) hide show
  1. package/README.md +23 -13
  2. package/dist/agent/client.d.ts +9 -6
  3. package/dist/agent/client.js +3 -3
  4. package/dist/agent/keys.js +31 -119
  5. package/dist/agent/long_runs.d.ts +5 -5
  6. package/dist/agent/long_runs.js +9 -10
  7. package/dist/agent/protocol.d.ts +20 -14
  8. package/dist/agent/protocol.js +34 -17
  9. package/dist/agent/session.d.ts +11 -8
  10. package/dist/agent/session.js +13 -9
  11. package/dist/agent/tasks.d.ts +17 -0
  12. package/dist/agent/tasks.js +15 -0
  13. package/dist/durations.js +18 -20
  14. package/dist/server/agents/agent.d.ts +4 -4
  15. package/dist/server/agents/connection.d.ts +8 -7
  16. package/dist/server/agents/connection.js +11 -9
  17. package/dist/server/agents/hub.d.ts +12 -12
  18. package/dist/server/agents/hub.js +14 -14
  19. package/dist/server/agents/local.d.ts +10 -7
  20. package/dist/server/agents/local.js +12 -9
  21. package/dist/server/agents/route.d.ts +1 -1
  22. package/dist/server/hash_password.js +16 -34
  23. package/dist/web/format.js +25 -29
  24. package/dist/web/menus.d.ts +0 -7
  25. package/dist/web/menus.js +2 -87
  26. package/dist/web/preact/badges.d.ts +26 -0
  27. package/dist/web/preact/badges.js +28 -0
  28. package/dist/web/preact/controls.d.ts +14 -1
  29. package/dist/web/preact/controls.js +14 -7
  30. package/dist/web/preact/dialog.d.ts +25 -1
  31. package/dist/web/preact/dialog.js +56 -3
  32. package/dist/web/preact/layout.d.ts +37 -37
  33. package/dist/web/preact/layout.js +27 -25
  34. package/dist/web/preact/menus.d.ts +4 -3
  35. package/dist/web/preact/menus.js +56 -15
  36. package/dist/web/preact/shell.d.ts +27 -0
  37. package/dist/web/preact/shell.js +63 -0
  38. package/dist/web/preact/states.d.ts +27 -0
  39. package/dist/web/preact/states.js +48 -0
  40. package/dist/web/preact/table.d.ts +6 -1
  41. package/dist/web/preact/table.js +8 -1
  42. package/dist/web/preact/time.d.ts +4 -0
  43. package/dist/web/preact/time.js +9 -0
  44. package/dist/web/{toasts.d.ts → preact/toasts.d.ts} +2 -1
  45. package/dist/web/preact/toasts.js +34 -0
  46. package/dist/web/states.d.ts +1 -8
  47. package/dist/web/states.js +6 -43
  48. package/dist/web/tables.d.ts +4 -14
  49. package/dist/web/tables.js +1 -27
  50. package/dist/web/theme.d.ts +1 -1
  51. package/dist/web/theme.js +4 -1
  52. package/package.json +9 -2
  53. package/dist/web/badges.d.ts +0 -10
  54. package/dist/web/badges.js +0 -30
  55. package/dist/web/controls.d.ts +0 -6
  56. package/dist/web/controls.js +0 -55
  57. package/dist/web/dialogs.d.ts +0 -18
  58. package/dist/web/dialogs.js +0 -49
  59. package/dist/web/layout.d.ts +0 -28
  60. package/dist/web/layout.js +0 -53
  61. package/dist/web/shell.d.ts +0 -7
  62. package/dist/web/shell.js +0 -51
  63. package/dist/web/toasts.js +0 -22
package/README.md CHANGED
@@ -12,16 +12,18 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
12
12
  | `agent/keys` | Reads and makes OpenSSH ed25519 agent keys; signs and checks the login challenge. |
13
13
  | `agent/protocol` | The messages between the server and an agent. |
14
14
  | `agent/session`, `agent/client` | The agent's side: `start_agent` dials the server and answers it. |
15
+ | `agent/tasks` | `TaskRunner`, and `shell_runner` for apps whose tasks are command lines. |
15
16
  | `server/agents/hub` | `AgentHub`: the agents that have dialled in, and the server's built-in one. |
16
17
  | `server/agents/connection`, `agent`, `local`, `route` | One connected agent, the `Agent` interface, `LocalAgent`, and the Fastify route. |
17
18
  | `server/passwords` | scrypt hashes and the `name:hash` accounts file. |
18
19
  | `server/hash_password` | The `hash-password` command. |
19
20
  | `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder, formatters, hash router and theme choice. |
20
- | `web/layout`, `web/badges`, `web/states` | Panels, facts, tiles, cards, tabs, crumbs, toolbars, steppers; badges and chips; loading, empty and failed states. |
21
- | `web/controls`, `web/tables` | Search boxes, selects, segmented choices, switches, button groups; sortable tables that turn into cards on phones, and a pager. |
22
- | `web/toasts`, `web/dialogs`, `web/menus` | Toasts with a link or Undo, modal and confirm dialogs, right-click menus and popovers kept on screen. |
23
- | `web/split`, `web/shell` | Pane sizing and resize handles; the sidebar menu, the section it lights up, and the theme button. |
24
- | `web/preact/*` | The same widgets as Preact components: menus, split, controls, layout, dialog, table. |
21
+ | `web/states`, `web/tables`, `web/menus`, `web/split` | `LoginNeeded`; table sorting; menu items and on-screen placement; pane sizing. |
22
+ | `web/preact/shell` | `Shell`: top bar, sidebar, theme button, toasts and dialogs; `use_route`. |
23
+ | `web/preact/layout`, `badges`, `states` | Panels, facts, tiles, cards, tabs, crumbs, steppers, settings rows; badges and chips; `use_resource` and its loading states. |
24
+ | `web/preact/time` | `Age`: a time as how long ago, with the full stamp on hover. |
25
+ | `web/preact/controls`, `table` | Segmented choices, button groups, switches, search boxes, selects; sortable tables that turn into cards on phones, and a pager. |
26
+ | `web/preact/toasts`, `dialog`, `menus`, `split` | Toasts with a link or Undo; modal, confirm and text dialogs openable from anywhere; context menus and popovers; resize handles and splits. |
25
27
 
26
28
  ```ts
27
29
  import { AgentHub } from "@x-tech-laboratories/x-libs/server/agents/hub";
@@ -36,17 +38,25 @@ which an app imports once:
36
38
  ```ts
37
39
  import "@x-tech-laboratories/halon/halon-tokens.css";
38
40
  import "@x-tech-laboratories/halon/halon.css";
39
- import { data_table } from "@x-tech-laboratories/x-libs/web/tables";
40
- import { ContextMenu } from "@x-tech-laboratories/x-libs/web/preact/menus";
41
+ import { DataTable } from "@x-tech-laboratories/x-libs/web/preact/table";
42
+ import { toast } from "@x-tech-laboratories/x-libs/web/preact/toasts";
41
43
  ```
42
44
 
43
- A request whose session has lapsed throws `LoginNeeded` from `web/states`; `load` and `attempt` pass it on so the page can ask for a login.
45
+ The widgets are Preact components, so a page renders with Preact; `Shell` holds the toasts and dialogs that `toast` and
46
+ `confirm_dialog` open from anywhere. A request whose session has lapsed throws `LoginNeeded` from `web/states`, and
47
+ `use_resource` passes it on as an unhandled rejection, which the page's login listens for.
44
48
 
45
- ## The app's name
49
+ ## The app and its tasks
46
50
 
47
- The agent modules take the app's name, such as `x-nix`. It names the key login's header (`x-nix-agent-host`), is bound
48
- into the challenge the agent signs (`x-nix-agent-v1|<host>|<nonce>`), and names a long run's process (`x-nix-run`), so
49
- agents already deployed for an app keep logging in.
51
+ The server and every agent are given the same `AgentApp`: the app's name, its protocol version, and its tasks.
52
+
53
+ - The **name**, such as `x-nix`, names the key login's header (`x-nix-agent-host`) and is bound into the challenge the
54
+ agent signs (`x-nix-agent-v1|<host>|<nonce>`), so agents already deployed for an app keep logging in.
55
+ - A **task** is what a run, a long run or a stream carries: a shell command line for one app, a typed operation for
56
+ another. The app names the wire field it travels in and checks it. `shell_app("x-backups")` sends a string in
57
+ `command`.
58
+ - The agent and the server's `LocalAgent` carry tasks out through a `TaskRunner`: `run`, an optional `stream`, and
59
+ `launch`, the program a long run's process starts as. `shell_runner("x-backups-run")` runs each under `sh`.
50
60
 
51
61
  ## The app's own messages
52
62
 
@@ -67,7 +77,7 @@ npm test
67
77
  npm run lint
68
78
  ```
69
79
 
70
- `npm publish` builds first.
80
+ `npm pack` and `npm publish` build first.
71
81
 
72
82
  ## License
73
83
 
@@ -1,8 +1,11 @@
1
1
  import type { KeyObject } from "node:crypto";
2
2
  import { LongRuns, type LongRunOptions } from "./long_runs.ts";
3
+ import { type AgentApp } from "./protocol.ts";
3
4
  import { AgentSession, type SessionHooks } from "./session.ts";
4
- export interface AgentConfig extends SessionHooks {
5
- app: string;
5
+ import type { TaskRunner } from "./tasks.ts";
6
+ export interface AgentConfig<Task> extends SessionHooks<Task> {
7
+ app: AgentApp<Task>;
8
+ runner: TaskRunner<Task>;
6
9
  url: string;
7
10
  key_file?: string;
8
11
  token_file?: string;
@@ -20,10 +23,10 @@ export declare class AgentConfigError extends Error {
20
23
  }
21
24
  export declare function check_url(text: string): URL;
22
25
  export declare function read_key_file(file: string): KeyObject;
23
- export interface RunningAgent {
24
- readonly runs: LongRuns;
26
+ export interface RunningAgent<Task> {
27
+ readonly runs: LongRuns<Task>;
25
28
  connected(): boolean;
26
- session(): AgentSession | null;
29
+ session(): AgentSession<Task> | null;
27
30
  stop(): Promise<void>;
28
31
  }
29
- export declare function start_agent(config: AgentConfig): RunningAgent;
32
+ export declare function start_agent<Task>(config: AgentConfig<Task>): RunningAgent<Task>;
@@ -63,7 +63,7 @@ export function start_agent(config) {
63
63
  throw error;
64
64
  }
65
65
  }
66
- const runs = new LongRuns(config.state_directory, { process_name: `${config.app}-run`, ...config.long_runs });
66
+ const runs = new LongRuns(config.state_directory, config.runner, config.long_runs);
67
67
  const log = config.log ?? ((line) => console.log(line));
68
68
  const heartbeat = config.heartbeat_in_milliseconds ?? 15_000;
69
69
  const initial = config.backoff_initial_in_milliseconds ?? 1000;
@@ -90,7 +90,7 @@ export function start_agent(config) {
90
90
  try {
91
91
  if (login.kind === "key") {
92
92
  key = read_key_file(login.file);
93
- headers = { [key_host_header(config.app)]: config.host };
93
+ headers = { [key_host_header(config.app.name)]: config.host };
94
94
  }
95
95
  else {
96
96
  headers = { authorization: `Bearer ${readFileSync(login.file, "utf8").trim()}` };
@@ -109,7 +109,7 @@ export function start_agent(config) {
109
109
  failures = 0;
110
110
  log(`Connected to ${url.origin}`);
111
111
  session = new AgentSession(current, runs, {
112
- app: config.app, host: config.host, agent_version: config.agent_version, heartbeat_in_milliseconds: heartbeat, key,
112
+ app: config.app, runner: config.runner, host: config.host, agent_version: config.agent_version, heartbeat_in_milliseconds: heartbeat, key,
113
113
  requests: config.requests, on_message: config.on_message, on_hello: config.on_hello,
114
114
  });
115
115
  });
@@ -1,10 +1,9 @@
1
1
  // A host's agent key: an ed25519 key made by `ssh-keygen -t ed25519 -N ""`, whose private half the agent holds and
2
2
  // whose public line the server's config declares. The agent proves it holds the key by signing the server's nonce.
3
- //
4
- // Node's crypto reads neither OpenSSH format, so both are read here: the public line `ssh-ed25519 <base64> [comment]`,
5
- // and the unencrypted `openssh-key-v1` private key, whose 32-byte seed and public key become a JWK.
3
+ // sshpk reads and writes the OpenSSH formats, which Node's crypto does not, and Node's crypto signs.
6
4
  // ── Imports ──────────────────────────────────────────────────────────────────
7
- import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, verify } from "node:crypto";
5
+ import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
6
+ import sshpk from "sshpk";
8
7
  // ── Settings ─────────────────────────────────────────────────────────────────
9
8
  export class AgentKeyError extends Error {
10
9
  constructor(message) {
@@ -12,13 +11,6 @@ export class AgentKeyError extends Error {
12
11
  this.name = "AgentKeyError";
13
12
  }
14
13
  }
15
- const ed25519_name = "ssh-ed25519";
16
- const format_magic = "openssh-key-v1\0";
17
- // Spelled in parts so secret scanners do not take this file for a key.
18
- const armor_label = ["OPENSSH", "PRIVATE", "KEY"].join(" ");
19
- const armor = new RegExp(`-{5}BEGIN ${armor_label}-{5}([\\s\\S]*?)-{5}END ${armor_label}-{5}`);
20
- const ed25519_public_length = 32;
21
- const ed25519_private_length = 64;
22
14
  // ── Challenge ────────────────────────────────────────────────────────────────
23
15
  // What the agent signs: the app, the protocol, the host it logs in as and the server's nonce, so a signature fits one
24
16
  // login to one app only; `app` is the app's name, such as "x-nix".
@@ -42,128 +34,48 @@ export function parse_public_key(line) {
42
34
  const parts = line.trim().split(/\s+/);
43
35
  if (parts.length < 2 || parts[0] === "")
44
36
  throw new AgentKeyError('must be an OpenSSH public key line such as "ssh-ed25519 AAAA... comment".');
45
- if (parts[0] !== ed25519_name)
37
+ if (parts[0] !== "ssh-ed25519")
46
38
  throw new AgentKeyError(`must be an ssh-ed25519 key, not ${parts[0]}; make one with ssh-keygen -t ed25519.`);
47
39
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(parts[1]))
48
40
  throw new AgentKeyError("holds a key that is not base64.");
49
- const reader = new WireReader(Buffer.from(parts[1], "base64"), "public key");
50
- const blob_type = reader.text();
51
- if (blob_type !== ed25519_name)
52
- throw new AgentKeyError(`says ssh-ed25519 but holds a ${blob_type} key.`);
53
- const public_bytes = reader.bytes();
54
- if (public_bytes.length !== ed25519_public_length || !reader.done())
55
- throw new AgentKeyError("holds an ed25519 key of the wrong length.");
56
- return createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: public_bytes.toString("base64url") }, format: "jwk" });
41
+ let key;
42
+ try {
43
+ key = sshpk.parseKey(line.trim(), "ssh");
44
+ }
45
+ catch {
46
+ throw new AgentKeyError("is cut short or damaged.");
47
+ }
48
+ if (key.type !== "ed25519")
49
+ throw new AgentKeyError(`says ssh-ed25519 but holds a ${key.type} key.`);
50
+ return createPublicKey(key.toString("pkcs8"));
57
51
  }
58
52
  // The key's SHA256 fingerprint as ssh-keygen -l prints it.
59
53
  export function key_fingerprint(line) {
60
- const blob = Buffer.from(line.trim().split(/\s+/)[1] ?? "", "base64");
61
- return `SHA256:${createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
54
+ return sshpk.parseKey(line.trim(), "ssh").fingerprint("sha256").toString();
62
55
  }
63
56
  // ── Private key ──────────────────────────────────────────────────────────────
64
57
  // Reads an unencrypted OpenSSH ed25519 private key; the messages never quote the key itself.
65
58
  export function parse_private_key(text) {
66
- const armored = armor.exec(text);
67
- if (armored === null)
59
+ if (!/-{5}BEGIN OPENSSH PRIVATE KEY-{5}/.test(text))
68
60
  throw new AgentKeyError('is not an OpenSSH private key; make one with ssh-keygen -t ed25519 -N "".');
69
- const data = Buffer.from(armored[1].replace(/\s+/g, ""), "base64");
70
- if (data.subarray(0, format_magic.length).toString("latin1") !== format_magic) {
71
- throw new AgentKeyError("is not in the openssh-key-v1 format.");
72
- }
73
- const reader = new WireReader(data.subarray(format_magic.length), "private key");
74
- const cipher = reader.text();
75
- reader.bytes();
76
- reader.bytes();
77
- if (cipher !== "none") {
78
- throw new AgentKeyError(`is encrypted with a passphrase (${cipher}), which the agent cannot type; make it with -N "" and keep it in a secret store instead.`);
61
+ let key;
62
+ try {
63
+ key = sshpk.parsePrivateKey(text, "ssh");
79
64
  }
80
- if (reader.number() !== 1)
81
- throw new AgentKeyError("must hold exactly one key.");
82
- reader.bytes();
83
- const section = new WireReader(reader.bytes(), "private key");
84
- if (section.number() !== section.number())
85
- throw new AgentKeyError("is damaged: its check numbers differ.");
86
- const type = section.text();
87
- if (type !== ed25519_name)
88
- throw new AgentKeyError(`must be an ssh-ed25519 key, not ${type}; make one with ssh-keygen -t ed25519.`);
89
- const public_bytes = section.bytes();
90
- const private_bytes = section.bytes();
91
- if (public_bytes.length !== ed25519_public_length || private_bytes.length !== ed25519_private_length) {
92
- throw new AgentKeyError("holds an ed25519 key of the wrong length.");
65
+ catch (error) {
66
+ if (error.name === "KeyEncryptedError") {
67
+ throw new AgentKeyError('is encrypted with a passphrase, which the agent cannot type; make it with -N "" and keep it in a secret store instead.');
68
+ }
69
+ throw new AgentKeyError("is cut short or damaged.");
93
70
  }
94
- // The private half is the seed followed by the public key again.
95
- if (!private_bytes.subarray(ed25519_public_length).equals(public_bytes))
96
- throw new AgentKeyError("is damaged: its two public keys differ.");
97
- const x = public_bytes.toString("base64url");
98
- const seed = private_bytes.subarray(0, ed25519_public_length).toString("base64url");
99
- const private_key = createPrivateKey({ key: { kty: "OKP", crv: "Ed25519", d: seed, x }, format: "jwk" });
100
- // A seed that does not give the stored public key would sign with a key the server does not know.
101
- if (createPublicKey(private_key).export({ format: "jwk" }).x !== x)
102
- throw new AgentKeyError("is damaged: its seed does not match its public key.");
103
- return private_key;
71
+ if (key.type !== "ed25519")
72
+ throw new AgentKeyError(`must be an ssh-ed25519 key, not ${key.type}; make one with ssh-keygen -t ed25519.`);
73
+ return createPrivateKey(key.toString("pkcs8"));
104
74
  }
105
75
  // ── New keys ─────────────────────────────────────────────────────────────────
106
- // A new ed25519 key in the files ssh-keygen would write: the unencrypted openssh-key-v1 private key and its public line.
76
+ // A new ed25519 key in the files ssh-keygen would write: the unencrypted OpenSSH private key and its public line.
107
77
  export function generate_agent_key(comment) {
108
- const { privateKey } = generateKeyPairSync("ed25519");
109
- const jwk = privateKey.export({ format: "jwk" });
110
- const public_bytes = Buffer.from(jwk.x, "base64url");
111
- const seed = Buffer.from(jwk.d, "base64url");
112
- const public_blob = Buffer.concat([wire_text(ed25519_name), wire_bytes(public_bytes)]);
113
- // Both check numbers are the same random number, which tells a reader the section decrypted correctly.
114
- const check = randomBytes(4);
115
- const section_parts = [check, check, wire_text(ed25519_name), wire_bytes(public_bytes), wire_bytes(Buffer.concat([seed, public_bytes])), wire_text(comment)];
116
- let section = Buffer.concat(section_parts);
117
- // The section is padded with the bytes 1, 2, 3 and on to a multiple of eight.
118
- const padding = (8 - (section.length % 8)) % 8;
119
- section = Buffer.concat([section, Buffer.from(Array.from({ length: padding }, (_, index) => index + 1))]);
120
- const data = Buffer.concat([
121
- Buffer.from(format_magic, "latin1"), wire_text("none"), wire_text("none"), wire_bytes(Buffer.alloc(0)), wire_number(1), wire_bytes(public_blob), wire_bytes(section),
122
- ]);
123
- const body = data.toString("base64").match(/.{1,70}/g).join("\n");
124
- const private_text = `-----BEGIN ${armor_label}-----\n${body}\n-----END ${armor_label}-----\n`;
125
- return { private_text, public_line: `${ed25519_name} ${public_blob.toString("base64")} ${comment}` };
126
- }
127
- function wire_number(value) {
128
- const buffer = Buffer.alloc(4);
129
- buffer.writeUInt32BE(value);
130
- return buffer;
131
- }
132
- function wire_bytes(value) {
133
- return Buffer.concat([wire_number(value.length), value]);
134
- }
135
- function wire_text(value) {
136
- return wire_bytes(Buffer.from(value, "latin1"));
137
- }
138
- // ── Reader ───────────────────────────────────────────────────────────────────
139
- // Reads the SSH wire format: big-endian 32-bit numbers, and strings as a length then that many bytes.
140
- class WireReader {
141
- data;
142
- what;
143
- offset = 0;
144
- constructor(data, what) {
145
- this.data = data;
146
- this.what = what;
147
- }
148
- number() {
149
- if (this.offset + 4 > this.data.length)
150
- throw new AgentKeyError(`is cut short: the ${this.what} ends early.`);
151
- const value = this.data.readUInt32BE(this.offset);
152
- this.offset += 4;
153
- return value;
154
- }
155
- bytes() {
156
- const length = this.number();
157
- if (this.offset + length > this.data.length)
158
- throw new AgentKeyError(`is cut short: the ${this.what} ends early.`);
159
- const value = this.data.subarray(this.offset, this.offset + length);
160
- this.offset += length;
161
- return value;
162
- }
163
- text() {
164
- return this.bytes().toString("latin1");
165
- }
166
- done() {
167
- return this.offset === this.data.length;
168
- }
78
+ const key = sshpk.generatePrivateKey("ed25519");
79
+ key.comment = comment;
80
+ return { private_text: key.toString("ssh"), public_line: key.toPublic().toString("ssh") };
169
81
  }
@@ -1,22 +1,22 @@
1
1
  import { type CommandStream } from "./commands.ts";
2
2
  import { type LongRun, type LongRunPoll } from "./protocol.ts";
3
+ import type { TaskRunner } from "./tasks.ts";
3
4
  export declare const poll_limit_in_bytes: number;
4
5
  export interface LongRunOptions {
5
- process_name?: string;
6
6
  kill_grace_in_milliseconds?: number;
7
7
  tail_interval_in_milliseconds?: number;
8
8
  }
9
9
  export declare class LongRunError extends Error {
10
10
  constructor(message: string);
11
11
  }
12
- export declare class LongRuns {
12
+ export declare class LongRuns<Task> {
13
13
  private readonly directory;
14
14
  private readonly grace;
15
15
  private readonly tail_interval;
16
- private readonly process_name;
16
+ private readonly runner;
17
17
  private readonly children;
18
- constructor(state_directory: string, options?: LongRunOptions);
19
- start(run_id: string, command: string, stdin: string | null): LongRun;
18
+ constructor(state_directory: string, runner: TaskRunner<Task>, options?: LongRunOptions);
19
+ start(run_id: string, task: Task, stdin: string | null): LongRun;
20
20
  list(): LongRun[];
21
21
  get(run_id: string): LongRun;
22
22
  poll(run_id: string, offset: number, limit?: number): Promise<LongRunPoll>;
@@ -1,4 +1,4 @@
1
- // Long runs: commands that keep going on their own, in their own process group, so they outlive the connection that
1
+ // Long runs: tasks that keep going on their own, in their own process group, so they outlive the connection that
2
2
  // started them and the agent itself. Each writes its output and exit code into its own folder under the state
3
3
  // directory, which is all a later agent needs to report on it, follow it, or stop it.
4
4
  //
@@ -19,8 +19,6 @@ import { is_run_id } from "./protocol.js";
19
19
  export const poll_limit_in_bytes = 1024 * 1024;
20
20
  // A followed run is read in pieces of this size.
21
21
  const tail_chunk_in_bytes = 64 * 1024;
22
- // The outer shell runs the command with its output in the run's folder, then writes the exit code in one rename.
23
- const wrapper = 'sh -c "$2" > "$1/out" 2>&1; echo $? > "$1/exit.part"; mv -f "$1/exit.part" "$1/exit"';
24
22
  export class LongRunError extends Error {
25
23
  constructor(message) {
26
24
  super(message);
@@ -32,36 +30,37 @@ export class LongRuns {
32
30
  directory;
33
31
  grace;
34
32
  tail_interval;
35
- process_name;
33
+ runner;
36
34
  // The runs this agent started, whose exit it hears of directly.
37
35
  children = new Map();
38
- constructor(state_directory, options = {}) {
36
+ constructor(state_directory, runner, options = {}) {
39
37
  this.directory = join(state_directory, "runs");
40
38
  this.grace = options.kill_grace_in_milliseconds ?? kill_grace_in_milliseconds;
41
39
  this.tail_interval = options.tail_interval_in_milliseconds ?? 250;
42
- this.process_name = options.process_name ?? "x-libs-run";
40
+ this.runner = runner;
43
41
  mkdirSync(this.directory, { recursive: true, mode: 0o700 });
44
42
  }
45
43
  // ── Starting ─────────────────────────────────────────────────────────────
46
- start(run_id, command, stdin) {
44
+ start(run_id, task, stdin) {
47
45
  const folder = this.folder(run_id);
48
46
  if (existsSync(folder))
49
47
  throw new LongRunError(`A run named '${run_id}' already exists.`);
50
48
  mkdirSync(folder, { mode: 0o700 });
51
49
  writeFileSync(join(folder, "out"), "", { mode: 0o600 });
52
- const child = spawn("sh", ["-c", wrapper, this.process_name, folder, command], {
50
+ const launch = this.runner.launch(task, folder, stdin !== null);
51
+ const child = spawn(launch.program, launch.args, {
53
52
  detached: true,
54
53
  stdio: [stdin === null ? "ignore" : "pipe", "ignore", "ignore"],
55
54
  });
56
55
  if (child.pid === undefined) {
57
56
  rmSync(folder, { recursive: true, force: true });
58
- throw new LongRunError("Could not start sh.");
57
+ throw new LongRunError(`Could not start ${launch.program}.`);
59
58
  }
60
59
  if (stdin !== null) {
61
60
  child.stdin.on("error", () => { });
62
61
  child.stdin.end(stdin);
63
62
  }
64
- // A wrapper killed before it could write its exit code is given the shell's code for that signal.
63
+ // A process killed before it could write its exit code is given the shell's code for that signal.
65
64
  child.on("exit", (code, signal) => {
66
65
  this.children.delete(run_id);
67
66
  const exit_file = join(folder, "exit");
@@ -1,4 +1,10 @@
1
- export declare const protocol_version = 1;
1
+ export interface AgentApp<Task> {
2
+ name: string;
3
+ protocol_version: number;
4
+ task_field: string;
5
+ is_task(value: unknown): value is Task;
6
+ }
7
+ export declare function shell_app(name: string, protocol_version?: number): AgentApp<string>;
2
8
  export interface RunResult {
3
9
  exit_code: number;
4
10
  stdout: string;
@@ -24,10 +30,10 @@ export interface AppMessage {
24
30
  type: string;
25
31
  [field: string]: unknown;
26
32
  }
27
- export interface Requests {
33
+ export interface Requests<Task> {
28
34
  run: {
29
35
  body: {
30
- command: string;
36
+ task: Task;
31
37
  stdin: string | null;
32
38
  timeout_in_seconds: number;
33
39
  };
@@ -36,7 +42,7 @@ export interface Requests {
36
42
  start: {
37
43
  body: {
38
44
  run_id: string;
39
- command: string;
45
+ task: Task;
40
46
  stdin: string | null;
41
47
  };
42
48
  reply: LongRun;
@@ -65,17 +71,17 @@ export interface Requests {
65
71
  reply: LongRun[];
66
72
  };
67
73
  }
68
- export type RequestType = keyof Requests;
69
- export type RequestMessage = {
74
+ export type RequestType = keyof Requests<unknown>;
75
+ export type RequestMessage<Task> = {
70
76
  [K in RequestType]: {
71
77
  type: K;
72
78
  id: string;
73
- } & Requests[K]["body"];
79
+ } & Requests<Task>[K]["body"];
74
80
  }[RequestType];
75
- export type ServerMessage = RequestMessage | {
81
+ export type ServerMessage<Task> = RequestMessage<Task> | {
76
82
  type: "stream";
77
83
  id: string;
78
- command: string;
84
+ task: Task;
79
85
  stdin: string | null;
80
86
  window: number;
81
87
  } | {
@@ -128,15 +134,15 @@ export type AgentMessage = {
128
134
  type: "pong";
129
135
  epoch_ms: number;
130
136
  };
131
- export declare function parse_server_message(text: string): ServerMessage | AppMessage | null;
132
- export declare function parse_agent_message(text: string): AgentMessage | AppMessage | null;
133
- export declare function is_server_message(message: ServerMessage | AppMessage): message is ServerMessage;
137
+ export declare function parse_server_message<Task>(text: string, app: AgentApp<Task>): ServerMessage<Task> | AppMessage | null;
138
+ export declare function parse_agent_message(text: string, app: AgentApp<unknown>): AgentMessage | AppMessage | null;
139
+ export declare function is_server_message<Task>(message: ServerMessage<Task> | AppMessage): message is ServerMessage<Task>;
134
140
  export declare function is_agent_message(message: AgentMessage | AppMessage): message is AgentMessage;
135
- export declare function encode_message(message: ServerMessage | AgentMessage | AppMessage): string;
141
+ export declare function encode_message<Task>(message: ServerMessage<Task> | AgentMessage | AppMessage, app: AgentApp<Task>): string;
136
142
  export declare function encode_chunk(id: string, data: Uint8Array): Buffer<ArrayBuffer>;
137
143
  export declare function decode_chunk(frame: Uint8Array): {
138
144
  id: string;
139
145
  data: Buffer;
140
146
  } | null;
141
- export declare function key_host_header(app: string): string;
147
+ export declare function key_host_header(app_name: string): string;
142
148
  export declare function is_run_id(value: string): boolean;
@@ -5,23 +5,28 @@
5
5
  // frames tagged with its id, only as far as the credit the server has granted, then one `stream_end`. Either side
6
6
  // sends `ping` while idle and drops a connection that has been silent too long.
7
7
  //
8
+ // A run, a long run and a stream each carry the app's task: a shell command for one app, a typed operation for another.
9
+ // The app names the field its task travels in and checks it, so each app keeps its own wire format and version.
10
+ //
8
11
  // An agent logging in with its host's key, rather than a token, names the host in the upgrade; the server's first
9
12
  // message is then a `challenge`, and the agent's hello carries its signature of that nonce (see keys.ts).
10
13
  //
11
14
  // An app adds its own messages beside these: any other `type` is handed to the app, and one carrying a string `id` is
12
15
  // a request the app's handler answers like any other.
13
- // ── Version ──────────────────────────────────────────────────────────────────
14
- export const protocol_version = 1;
16
+ // An app whose tasks are shell command lines, sent in a `command` field.
17
+ export function shell_app(name, protocol_version = 1) {
18
+ return { name, protocol_version, task_field: "command", is_task: (value) => typeof value === "string" };
19
+ }
15
20
  const id_fields = { id: "string" };
16
21
  const run_fields = { ...id_fields, run_id: "string" };
17
22
  const server_fields = {
18
- run: { ...id_fields, command: "string", stdin: "string_or_null", timeout_in_seconds: "number" },
19
- start: { ...run_fields, command: "string", stdin: "string_or_null" },
23
+ run: { ...id_fields, task: "task", stdin: "string_or_null", timeout_in_seconds: "number" },
24
+ start: { ...run_fields, task: "task", stdin: "string_or_null" },
20
25
  poll: { ...run_fields, offset: "number" },
21
26
  stop: run_fields,
22
27
  forget: run_fields,
23
28
  runs: id_fields,
24
- stream: { ...id_fields, command: "string", stdin: "string_or_null", window: "number" },
29
+ stream: { ...id_fields, task: "task", stdin: "string_or_null", window: "number" },
25
30
  attach: { ...run_fields, offset: "number", window: "number" },
26
31
  credit: { ...id_fields, bytes: "number" },
27
32
  cancel: id_fields,
@@ -37,9 +42,11 @@ const agent_fields = {
37
42
  ping: { epoch_ms: "number" },
38
43
  pong: { epoch_ms: "number" },
39
44
  };
40
- function has_kind(value, kind) {
45
+ function has_kind(value, kind, app) {
41
46
  if (kind === "any")
42
47
  return value !== undefined;
48
+ if (kind === "task")
49
+ return app.is_task(value);
43
50
  if (kind === "array")
44
51
  return Array.isArray(value);
45
52
  if (kind === "string_or_null")
@@ -49,7 +56,7 @@ function has_kind(value, kind) {
49
56
  return typeof value === kind;
50
57
  }
51
58
  // A known message with every field it needs, an app's message for any other type, or null for one that is neither.
52
- function parse_with(text, fields) {
59
+ function parse_with(text, fields, app) {
53
60
  let message;
54
61
  try {
55
62
  message = JSON.parse(text);
@@ -64,19 +71,25 @@ function parse_with(text, fields) {
64
71
  return null;
65
72
  if (!Object.hasOwn(fields, record.type))
66
73
  return record;
67
- for (const [name, kind] of Object.entries(fields[record.type])) {
68
- if (!has_kind(record[name], kind))
74
+ // The task arrives in the app's own field and is read as `task`.
75
+ const known_fields = fields[record.type];
76
+ if (Object.hasOwn(known_fields, "task") && app.task_field !== "task") {
77
+ record.task = record[app.task_field];
78
+ delete record[app.task_field];
79
+ }
80
+ for (const [name, kind] of Object.entries(known_fields)) {
81
+ if (!has_kind(record[name], kind, app))
69
82
  return null;
70
83
  }
71
84
  return message;
72
85
  }
73
86
  // A message from the server, or null for one that is not a message at all.
74
- export function parse_server_message(text) {
75
- return parse_with(text, server_fields);
87
+ export function parse_server_message(text, app) {
88
+ return parse_with(text, server_fields, app);
76
89
  }
77
90
  // A message from an agent, or null for one that is not a message at all.
78
- export function parse_agent_message(text) {
79
- return parse_with(text, agent_fields);
91
+ export function parse_agent_message(text, app) {
92
+ return parse_with(text, agent_fields, app);
80
93
  }
81
94
  // Whether a parsed message is one of this protocol's own rather than the app's.
82
95
  export function is_server_message(message) {
@@ -85,8 +98,12 @@ export function is_server_message(message) {
85
98
  export function is_agent_message(message) {
86
99
  return Object.hasOwn(agent_fields, message.type);
87
100
  }
88
- export function encode_message(message) {
89
- return JSON.stringify(message);
101
+ // The message as one text frame, with any task in the app's own field.
102
+ export function encode_message(message, app) {
103
+ if (!Object.hasOwn(server_fields, message.type) || !("task" in message))
104
+ return JSON.stringify(message);
105
+ const { task, ...rest } = message;
106
+ return JSON.stringify({ ...rest, [app.task_field]: task });
90
107
  }
91
108
  // ── Output frames ────────────────────────────────────────────────────────────
92
109
  // A binary frame is one byte giving the id's length, the id, then the output bytes.
@@ -104,8 +121,8 @@ export function decode_chunk(frame) {
104
121
  }
105
122
  // ── Key login ────────────────────────────────────────────────────────────────
106
123
  // The upgrade header an agent logging in with its key names its host in, such as "x-nix-agent-host".
107
- export function key_host_header(app) {
108
- return `${app}-agent-host`;
124
+ export function key_host_header(app_name) {
125
+ return `${app_name}-agent-host`;
109
126
  }
110
127
  // ── Long run ids ─────────────────────────────────────────────────────────────
111
128
  // A long run's id names its folder on the agent, so it is kept to safe characters.
@@ -1,14 +1,16 @@
1
1
  import type { KeyObject } from "node:crypto";
2
2
  import type { LongRuns } from "./long_runs.ts";
3
- import { type AgentMessage, type AppMessage } from "./protocol.ts";
3
+ import { type AgentApp, type AgentMessage, type AppMessage } from "./protocol.ts";
4
+ import type { TaskRunner } from "./tasks.ts";
4
5
  export type AppRequests = Record<string, (message: AppMessage) => unknown>;
5
- export interface SessionHooks {
6
+ export interface SessionHooks<Task> {
6
7
  requests?: AppRequests;
7
- on_message?: (message: AppMessage, session: AgentSession) => void;
8
- on_hello?: (session: AgentSession) => void;
8
+ on_message?: (message: AppMessage, session: AgentSession<Task>) => void;
9
+ on_hello?: (session: AgentSession<Task>) => void;
9
10
  }
10
- export interface SessionOptions extends SessionHooks {
11
- app: string;
11
+ export interface SessionOptions<Task> extends SessionHooks<Task> {
12
+ app: AgentApp<Task>;
13
+ runner: TaskRunner<Task>;
12
14
  host: string;
13
15
  agent_version: string;
14
16
  heartbeat_in_milliseconds: number;
@@ -19,7 +21,7 @@ export interface MessageSocket {
19
21
  send(data: string | Uint8Array<ArrayBuffer>): void;
20
22
  close(code?: number, reason?: string): void;
21
23
  }
22
- export declare class AgentSession {
24
+ export declare class AgentSession<Task> {
23
25
  private readonly socket;
24
26
  private readonly runs;
25
27
  private readonly options;
@@ -28,7 +30,7 @@ export declare class AgentSession {
28
30
  private readonly heartbeat;
29
31
  private closed;
30
32
  private greeted;
31
- constructor(socket: MessageSocket, runs: LongRuns, options: SessionOptions);
33
+ constructor(socket: MessageSocket, runs: LongRuns<Task>, options: SessionOptions<Task>);
32
34
  private say_hello;
33
35
  receive(text: string): void;
34
36
  close(): void;
@@ -42,6 +44,7 @@ export declare class AgentSession {
42
44
  private handle_app;
43
45
  private start_pump;
44
46
  private answer;
47
+ private open_stream;
45
48
  private value_for;
46
49
  private beat;
47
50
  }