edge-ai-client-ts 1.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 (57) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +174 -0
  4. package/bin/ec-ts.js +18 -0
  5. package/dist/buffer/disk-queue.d.ts +140 -0
  6. package/dist/buffer/disk-queue.js +370 -0
  7. package/dist/cli/devices.d.ts +1 -0
  8. package/dist/cli/devices.js +61 -0
  9. package/dist/cli/enroll.d.ts +2 -0
  10. package/dist/cli/enroll.js +89 -0
  11. package/dist/cli/index.d.ts +10 -0
  12. package/dist/cli/index.js +116 -0
  13. package/dist/cli/messages.d.ts +1 -0
  14. package/dist/cli/messages.js +59 -0
  15. package/dist/cli/run.d.ts +5 -0
  16. package/dist/cli/run.js +112 -0
  17. package/dist/cli/status.d.ts +1 -0
  18. package/dist/cli/status.js +56 -0
  19. package/dist/cli/whoami.d.ts +2 -0
  20. package/dist/cli/whoami.js +41 -0
  21. package/dist/config/cmd-gate.d.ts +65 -0
  22. package/dist/config/cmd-gate.js +128 -0
  23. package/dist/config/settings.d.ts +209 -0
  24. package/dist/config/settings.js +627 -0
  25. package/dist/crypto/aes-gcm.d.ts +38 -0
  26. package/dist/crypto/aes-gcm.js +90 -0
  27. package/dist/crypto/hmac.d.ts +31 -0
  28. package/dist/crypto/hmac.js +52 -0
  29. package/dist/crypto/tls-guard.d.ts +36 -0
  30. package/dist/crypto/tls-guard.js +54 -0
  31. package/dist/daemon/manager.d.ts +82 -0
  32. package/dist/daemon/manager.js +461 -0
  33. package/dist/index.d.ts +39 -0
  34. package/dist/index.js +63 -0
  35. package/dist/logging/file-logger.d.ts +21 -0
  36. package/dist/logging/file-logger.js +71 -0
  37. package/dist/network/ws-client.d.ts +221 -0
  38. package/dist/network/ws-client.js +1134 -0
  39. package/dist/session/fail-fast.d.ts +70 -0
  40. package/dist/session/fail-fast.js +122 -0
  41. package/dist/session/manager.d.ts +136 -0
  42. package/dist/session/manager.js +291 -0
  43. package/dist/session/persistence.d.ts +103 -0
  44. package/dist/session/persistence.js +194 -0
  45. package/dist/session/pi-rpc.d.ts +164 -0
  46. package/dist/session/pi-rpc.js +412 -0
  47. package/dist/session/sftp.d.ts +64 -0
  48. package/dist/session/sftp.js +335 -0
  49. package/dist/session/shell-frame.d.ts +77 -0
  50. package/dist/session/shell-frame.js +199 -0
  51. package/dist/session/shell.d.ts +124 -0
  52. package/dist/session/shell.js +300 -0
  53. package/docs/CONFIGURATION.md +169 -0
  54. package/docs/INSTALLATION.md +164 -0
  55. package/docs/PROTOCOL.md +248 -0
  56. package/docs/TROUBLESHOOTING.md +177 -0
  57. package/package.json +79 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * PTY session manager — mirrors `edge-client/src/session/shell.rs`.
3
+ *
4
+ * Owns PTY sessions keyed by `session_id` (UUID v4 string). Spawns the
5
+ * shell via `node-pty`, pumps PTY stdout → framed data frames → an outbound
6
+ * channel, writes browser keystrokes (data frames) into the PTY, handles
7
+ * resize/close, and emits `shellExited` lifecycle events.
8
+ *
9
+ * Security: the PTY shell runs as the edge-client's Standard User (per
10
+ * SPEC.md) — no privilege escalation. Shell is only active when
11
+ * `shell.enabled = true` in config (default false); enforced by the
12
+ * constructor gate.
13
+ *
14
+ * Frame codec: the 17-byte binary frame format (`[16-byte session_id |
15
+ * 1-byte frame_type | payload]`) is provided by `shell-frame.ts`. We use
16
+ * it directly via a thin `FrameCodec` adapter that converts UUID strings
17
+ * to/from raw bytes at the codec boundary — keeping the wire bytes
18
+ * EXACTLY byte-compatible with the Rust `portable-pty` + `portable-pty`
19
+ * implementation.
20
+ */
21
+ import type { IPty } from 'node-pty';
22
+ import { FrameType, type FrameTypeValue } from './shell-frame.js';
23
+ export type { IPty } from 'node-pty';
24
+ export { FrameType };
25
+ export type { FrameTypeValue };
26
+ /**
27
+ * Frame codec adapter — internally delegates to `shell-frame.ts`'s
28
+ * byte-exact codec, but exposes a string-keyed interface so this module
29
+ * can pass UUID strings around as the rest of `session/` does. Wire
30
+ * format is IDENTICAL to `shell-frame.ts` (no behavior drift possible).
31
+ */
32
+ export interface FrameCodec {
33
+ encode(sessionId: string, frameType: FrameTypeValue, payload: Buffer): Buffer;
34
+ decode(data: Buffer): {
35
+ sessionId: string;
36
+ frameType: FrameTypeValue;
37
+ payload: Buffer;
38
+ } | undefined;
39
+ }
40
+ /** Default codec backed by `shell-frame.ts`'s byte-exact primitives. */
41
+ export declare const inlineFrameCodec: FrameCodec;
42
+ /** Options passed to the PTY spawn function. */
43
+ export interface PtySpawnOptions {
44
+ readonly name: string;
45
+ readonly cols: number;
46
+ readonly rows: number;
47
+ readonly cwd: string;
48
+ readonly env: Record<string, string>;
49
+ }
50
+ /**
51
+ * Function that spawns a PTY. Production uses `node-pty`, tests inject mocks.
52
+ * Mirrors `pty.spawn(shell, args, opts)`. Returns IPty synchronously.
53
+ */
54
+ export type PtyFactory = (program: string, args: string[], options: PtySpawnOptions) => IPty;
55
+ export interface ShellManagerOptions {
56
+ /** Shell gate — if false, all open() calls throw. Mirrors Rust shell.enabled. */
57
+ readonly enabled: boolean;
58
+ }
59
+ export type DataHandler = (sessionId: string, frameType: FrameTypeValue, payload: Buffer) => void;
60
+ export type ExitHandler = (sessionId: string, code: number) => void;
61
+ export declare class ShellManager {
62
+ private readonly enabled;
63
+ private readonly sessions;
64
+ private readonly emitter;
65
+ private codec;
66
+ /** Injectable PTY factory — tests inject a mock, production uses defaultPtyFactory. */
67
+ private readonly ptyFactory;
68
+ constructor(opts: ShellManagerOptions, codec?: FrameCodec, ptyFactory?: PtyFactory);
69
+ /**
70
+ * Open a new PTY shell session. The caller provides a UUID session_id
71
+ * (assigned by the session manager).
72
+ *
73
+ * Mirrors Rust `ShellManager::open`.
74
+ */
75
+ open(sessionId: string, cols: number, rows: number, shell?: string): Promise<void>;
76
+ /**
77
+ * Write data to a session's PTY stdin (browser keystrokes).
78
+ * Mirrors Rust `FrameType::Data` handling in `handle_frame`.
79
+ */
80
+ write(sessionId: string, data: Buffer): void;
81
+ /**
82
+ * Resize a session's PTY.
83
+ * Mirrors Rust `FrameType::Resize` handling in `handle_frame`.
84
+ */
85
+ resize(sessionId: string, cols: number, rows: number): void;
86
+ /**
87
+ * Close a session's PTY (browser-initiated).
88
+ * Mirrors Rust `close_session`.
89
+ */
90
+ close(sessionId: string): void;
91
+ /**
92
+ * Handle an inbound binary frame from the browser (relayed by relay).
93
+ * Mirrors Rust `ShellManager::handle_frame`.
94
+ */
95
+ handleFrame(frameType: FrameTypeValue, sessionId: string, payload: Buffer): void;
96
+ /**
97
+ * Register a handler for PTY data frames (sessionId, frameType, payload).
98
+ * The handler receives the structured payload, not raw wire bytes.
99
+ */
100
+ onData(handler: DataHandler): void;
101
+ /**
102
+ * Register a handler for session exit events (sessionId, exitCode).
103
+ */
104
+ onExit(handler: ExitHandler): void;
105
+ /**
106
+ * Register a handler for raw wire frames (Buffer).
107
+ * The binary WS transport layer listens on this to send outbound frames.
108
+ */
109
+ onFrame(handler: (frame: Buffer) => void): void;
110
+ /** Number of active PTY sessions. For heartbeat metadata. */
111
+ activeCount(): number;
112
+ /**
113
+ * Close all sessions (edge shutdown). Mirrors Rust `close_all`.
114
+ */
115
+ closeAll(): void;
116
+ }
117
+ /** Build a data frame (FrameType::Data). */
118
+ export declare function buildDataFrame(sessionId: string, payload: Buffer): Buffer;
119
+ /**
120
+ * Build a resize frame (FrameType::Resize). payload = cols (u16 BE) + rows (u16 BE).
121
+ */
122
+ export declare function buildResizeFrame(sessionId: string, cols: number, rows: number): Buffer;
123
+ /** Build a close frame (FrameType::Close). No payload. */
124
+ export declare function buildCloseFrame(sessionId: string): Buffer;
@@ -0,0 +1,300 @@
1
+ /**
2
+ * PTY session manager — mirrors `edge-client/src/session/shell.rs`.
3
+ *
4
+ * Owns PTY sessions keyed by `session_id` (UUID v4 string). Spawns the
5
+ * shell via `node-pty`, pumps PTY stdout → framed data frames → an outbound
6
+ * channel, writes browser keystrokes (data frames) into the PTY, handles
7
+ * resize/close, and emits `shellExited` lifecycle events.
8
+ *
9
+ * Security: the PTY shell runs as the edge-client's Standard User (per
10
+ * SPEC.md) — no privilege escalation. Shell is only active when
11
+ * `shell.enabled = true` in config (default false); enforced by the
12
+ * constructor gate.
13
+ *
14
+ * Frame codec: the 17-byte binary frame format (`[16-byte session_id |
15
+ * 1-byte frame_type | payload]`) is provided by `shell-frame.ts`. We use
16
+ * it directly via a thin `FrameCodec` adapter that converts UUID strings
17
+ * to/from raw bytes at the codec boundary — keeping the wire bytes
18
+ * EXACTLY byte-compatible with the Rust `portable-pty` + `portable-pty`
19
+ * implementation.
20
+ */
21
+ import * as os from 'node:os';
22
+ import { EventEmitter } from 'node:events';
23
+ import { FrameType, encodeFrame, decodeFrame, sessionIdFromString, sessionIdToString, } from './shell-frame.js';
24
+ // Re-export the frame-type enum so consumers importing from `./shell.js`
25
+ // (which they have historically done) keep working.
26
+ export { FrameType };
27
+ /** Default codec backed by `shell-frame.ts`'s byte-exact primitives. */
28
+ export const inlineFrameCodec = {
29
+ encode(sessionId, frameType, payload) {
30
+ const sidBytes = sessionIdFromString(sessionId);
31
+ if (sidBytes === null) {
32
+ throw new RangeError(`shell.ts: invalid sessionId (not a valid UUID/hex): ${sessionId}`);
33
+ }
34
+ return encodeFrame({ sessionId: sidBytes, frameType, payload });
35
+ },
36
+ decode(data) {
37
+ const f = decodeFrame(data);
38
+ if (f === null)
39
+ return undefined;
40
+ const sidStr = sessionIdToString(f.sessionId);
41
+ if (sidStr === null)
42
+ return undefined;
43
+ return { sessionId: sidStr, frameType: f.frameType, payload: Buffer.from(f.payload) };
44
+ },
45
+ };
46
+ /* (Old hex-string codec + helpers removed; see `shell-frame.ts` for the
47
+ * authoritative byte-exact implementation. Keeping these here would have
48
+ * meant producing DIFFERENT wire bytes than the Rust side, since the
49
+ * Rust `ShellFrame` writes the 16 raw UUID bytes directly — not a hex
50
+ * encoding of them.) */
51
+ // ---------------------------------------------------------------------------
52
+ // Default shell selection (mirrors Rust `default_shell()`)
53
+ // ---------------------------------------------------------------------------
54
+ function defaultShell() {
55
+ if (process.platform === 'win32') {
56
+ return process.env.COMSPEC || 'cmd.exe';
57
+ }
58
+ // Unix: os.userInfo().shell, env SHELL, or /bin/sh
59
+ try {
60
+ const info = os.userInfo();
61
+ if (info.shell && info.shell.length > 0)
62
+ return info.shell;
63
+ }
64
+ catch {
65
+ // userInfo() can throw in certain container environments
66
+ }
67
+ if (process.env.SHELL && process.env.SHELL.length > 0) {
68
+ return process.env.SHELL;
69
+ }
70
+ return '/bin/sh';
71
+ }
72
+ /** Default PTY factory using node-pty (synchronous spawn). */
73
+ function defaultPtyFactory(program, args, options) {
74
+ // node-pty is imported statically at the top level for production use.
75
+ // Tests inject a mock factory via the constructor.
76
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
77
+ const pty = require('node-pty');
78
+ return pty.spawn(program, args, options);
79
+ }
80
+ export class ShellManager {
81
+ enabled;
82
+ sessions = new Map();
83
+ emitter = new EventEmitter();
84
+ codec;
85
+ /** Injectable PTY factory — tests inject a mock, production uses defaultPtyFactory. */
86
+ ptyFactory;
87
+ constructor(opts, codec, ptyFactory) {
88
+ this.enabled = opts.enabled;
89
+ this.codec = codec ?? inlineFrameCodec;
90
+ this.ptyFactory = ptyFactory ?? defaultPtyFactory;
91
+ }
92
+ /**
93
+ * Open a new PTY shell session. The caller provides a UUID session_id
94
+ * (assigned by the session manager).
95
+ *
96
+ * Mirrors Rust `ShellManager::open`.
97
+ */
98
+ async open(sessionId, cols, rows, shell) {
99
+ if (!this.enabled) {
100
+ throw new Error('Shell is disabled (shell.enabled=false in config)');
101
+ }
102
+ if (this.sessions.has(sessionId)) {
103
+ // Guard against duplicate opens (mirrors Rust: "open for already-open session (ignored)")
104
+ return;
105
+ }
106
+ const shellCmd = shell ?? defaultShell();
107
+ const shellName = process.platform === 'win32' ? shellCmd : shellCmd;
108
+ const args = [];
109
+ const ptyInstance = this.ptyFactory(shellName, args, {
110
+ name: 'xterm-256color',
111
+ cols,
112
+ rows,
113
+ cwd: process.cwd(),
114
+ env: {
115
+ ...process.env,
116
+ TERM: 'xterm-256color',
117
+ },
118
+ });
119
+ const handle = {
120
+ pty: ptyInstance,
121
+ kill() {
122
+ try {
123
+ ptyInstance.kill();
124
+ }
125
+ catch {
126
+ // process may already be dead
127
+ }
128
+ },
129
+ resize(c, r) {
130
+ try {
131
+ ptyInstance.resize(c, r);
132
+ }
133
+ catch {
134
+ // session may have been closed
135
+ }
136
+ },
137
+ write(data) {
138
+ try {
139
+ ptyInstance.write(data.toString('utf8'));
140
+ }
141
+ catch {
142
+ // session may have been closed
143
+ }
144
+ },
145
+ };
146
+ this.sessions.set(sessionId, handle);
147
+ // Wire PTY stdout → frame emission.
148
+ // PTY data is a string (terminal output); we encode as UTF-8 Buffer.
149
+ ptyInstance.onData((data) => {
150
+ if (!this.sessions.has(sessionId))
151
+ return;
152
+ const payload = Buffer.from(data, 'utf8');
153
+ this.emitter.emit('data', sessionId, FrameType.Data, payload);
154
+ // Also emit raw wire bytes for the binary WS transport
155
+ const frame = this.codec.encode(sessionId, FrameType.Data, payload);
156
+ this.emitter.emit('frame', frame);
157
+ });
158
+ // Wire PTY exit → lifecycle event.
159
+ ptyInstance.onExit(({ exitCode }) => {
160
+ // Send a Close frame for this session (mirrors Rust reader thread EOF behavior).
161
+ const closePayload = Buffer.alloc(0);
162
+ this.emitter.emit('frame', this.codec.encode(sessionId, FrameType.Close, closePayload));
163
+ this.emitter.emit('exit', sessionId, exitCode);
164
+ // Remove the session (the PTY is dead).
165
+ this.sessions.delete(sessionId);
166
+ });
167
+ }
168
+ /**
169
+ * Write data to a session's PTY stdin (browser keystrokes).
170
+ * Mirrors Rust `FrameType::Data` handling in `handle_frame`.
171
+ */
172
+ write(sessionId, data) {
173
+ const handle = this.sessions.get(sessionId);
174
+ if (handle === undefined)
175
+ return;
176
+ handle.write(data);
177
+ }
178
+ /**
179
+ * Resize a session's PTY.
180
+ * Mirrors Rust `FrameType::Resize` handling in `handle_frame`.
181
+ */
182
+ resize(sessionId, cols, rows) {
183
+ const handle = this.sessions.get(sessionId);
184
+ if (handle === undefined)
185
+ return;
186
+ handle.resize(cols, rows);
187
+ }
188
+ /**
189
+ * Close a session's PTY (browser-initiated).
190
+ * Mirrors Rust `close_session`.
191
+ */
192
+ close(sessionId) {
193
+ const handle = this.sessions.get(sessionId);
194
+ if (handle === undefined)
195
+ return;
196
+ handle.kill();
197
+ this.sessions.delete(sessionId);
198
+ }
199
+ /**
200
+ * Handle an inbound binary frame from the browser (relayed by relay).
201
+ * Mirrors Rust `ShellManager::handle_frame`.
202
+ */
203
+ handleFrame(frameType, sessionId, payload) {
204
+ switch (frameType) {
205
+ case FrameType.Data:
206
+ this.write(sessionId, payload);
207
+ break;
208
+ case FrameType.Resize: {
209
+ // payload = cols (u16 BE) + rows (u16 BE) — 4 bytes
210
+ if (payload.length >= 4) {
211
+ const cols = payload.readUInt16BE(0);
212
+ const rows = payload.readUInt16BE(2);
213
+ this.resize(sessionId, cols, rows);
214
+ }
215
+ break;
216
+ }
217
+ case FrameType.Close:
218
+ this.close(sessionId);
219
+ break;
220
+ case FrameType.OpenControl: {
221
+ // payload = JSON { cols, rows } — default 80x24
222
+ let cols = 80;
223
+ let rows = 24;
224
+ if (payload.length > 0) {
225
+ try {
226
+ const parsed = JSON.parse(payload.toString('utf8'));
227
+ if (typeof parsed.cols === 'number' && parsed.cols > 0)
228
+ cols = parsed.cols;
229
+ if (typeof parsed.rows === 'number' && parsed.rows > 0)
230
+ rows = parsed.rows;
231
+ }
232
+ catch {
233
+ // use defaults
234
+ }
235
+ }
236
+ void this.open(sessionId, cols, rows);
237
+ break;
238
+ }
239
+ case FrameType.CloseControl:
240
+ this.close(sessionId);
241
+ break;
242
+ // Sftp and SftpRoot are handled by the sftp forwarder, not here
243
+ default:
244
+ break;
245
+ }
246
+ }
247
+ /**
248
+ * Register a handler for PTY data frames (sessionId, frameType, payload).
249
+ * The handler receives the structured payload, not raw wire bytes.
250
+ */
251
+ onData(handler) {
252
+ this.emitter.on('data', handler);
253
+ }
254
+ /**
255
+ * Register a handler for session exit events (sessionId, exitCode).
256
+ */
257
+ onExit(handler) {
258
+ this.emitter.on('exit', handler);
259
+ }
260
+ /**
261
+ * Register a handler for raw wire frames (Buffer).
262
+ * The binary WS transport layer listens on this to send outbound frames.
263
+ */
264
+ onFrame(handler) {
265
+ this.emitter.on('frame', handler);
266
+ }
267
+ /** Number of active PTY sessions. For heartbeat metadata. */
268
+ activeCount() {
269
+ return this.sessions.size;
270
+ }
271
+ /**
272
+ * Close all sessions (edge shutdown). Mirrors Rust `close_all`.
273
+ */
274
+ closeAll() {
275
+ const ids = [...this.sessions.keys()];
276
+ for (const id of ids) {
277
+ this.close(id);
278
+ }
279
+ }
280
+ }
281
+ // ---------------------------------------------------------------------------
282
+ // ShellFrame helpers (mirrors Rust factory methods)
283
+ // ---------------------------------------------------------------------------
284
+ /** Build a data frame (FrameType::Data). */
285
+ export function buildDataFrame(sessionId, payload) {
286
+ return inlineFrameCodec.encode(sessionId, FrameType.Data, payload);
287
+ }
288
+ /**
289
+ * Build a resize frame (FrameType::Resize). payload = cols (u16 BE) + rows (u16 BE).
290
+ */
291
+ export function buildResizeFrame(sessionId, cols, rows) {
292
+ const payload = Buffer.alloc(4);
293
+ payload.writeUInt16BE(cols, 0);
294
+ payload.writeUInt16BE(rows, 2);
295
+ return inlineFrameCodec.encode(sessionId, FrameType.Resize, payload);
296
+ }
297
+ /** Build a close frame (FrameType::Close). No payload. */
298
+ export function buildCloseFrame(sessionId) {
299
+ return inlineFrameCodec.encode(sessionId, FrameType.Close, Buffer.alloc(0));
300
+ }
@@ -0,0 +1,169 @@
1
+ # Configuration
2
+
3
+ `edge-ai-client-ts` reads configuration from **two** sources,
4
+ merged with this precedence (highest → lowest):
5
+
6
+ 1. **Environment variables** (preferred for secrets — never commit them).
7
+ 2. **`config.toml`** (or `config.json`) in the platform-specific config
8
+ directory.
9
+ 3. Built-in defaults.
10
+
11
+ > **Precedence example:** if `EDGE_AUTH_TOKEN` is set in the environment
12
+ > AND `relay.auth_token` is set in `config.toml`, the **environment
13
+ > variable wins**.
14
+
15
+ ---
16
+
17
+ ## Config file location
18
+
19
+ | OS | Path |
20
+ |----|------|
21
+ | Linux | `~/.config/edge-ai-agent/config.toml` |
22
+ | macOS | `~/Library/Application Support/edge-ai-agent/config.toml` |
23
+ | Windows | `%APPDATA%\edge-ai-agent\config.toml` |
24
+
25
+ A JSON `config.json` is also accepted as a fallback (TOML takes
26
+ precedence if both exist).
27
+
28
+ ---
29
+
30
+ ## Full schema
31
+
32
+ This schema is **identical** to the Rust `edge-client`'s `config.toml`.
33
+ Both clients read the same fields; mismatches are a bug.
34
+
35
+ ```toml
36
+ # A stable identifier for this machine. Auto-generated on first run if
37
+ # omitted; do NOT change after enrolling (the relay binds your operator
38
+ # to a specific machine_id).
39
+ machine_id = "uuid-v4-or-fleet-name"
40
+
41
+ # Optional: pre-bind this client to an operator (otherwise set via
42
+ # `ec-ts enroll`).
43
+ owner_operator_id = "op_<your-operator-id>"
44
+
45
+ [server]
46
+ # WebSocket URL of the relay's /edge endpoint.
47
+ ws_url = "wss://relay.example.com/edge"
48
+
49
+ # Optional mode-specific URLs (Batch 5 — live network-mode switching).
50
+ # When the operator toggles network_mode='lan'/'wan' from the dashboard,
51
+ # the daemon transparently reconnects to the matching URL.
52
+ # ws_url_lan = "ws://192.168.1.10:3001/edge"
53
+ # ws_url_wan = "wss://wan-relay.example.com/edge"
54
+
55
+ [agent]
56
+ # Default agent id used when the dashboard does not pin one.
57
+ default_agent_id = "worker_default"
58
+
59
+ [buffer]
60
+ # Maximum in-memory queue size in MB before spill-to-disk is triggered.
61
+ max_memory_mb = 50
62
+
63
+ # How often (ms) the daemon flushes in-memory entries to the WS sender.
64
+ flush_interval_ms = 100
65
+
66
+ # How long (ms) an in-flight entry may wait for an ACK before being
67
+ # re-sent.
68
+ ack_timeout_ms = 5000
69
+
70
+ # Number of re-send attempts before an entry is dumped to encrypted disk
71
+ # (anti-data-loss — see docs/PROTOCOL.md).
72
+ max_retries = 3
73
+
74
+ # Buffer dump encryption key. Hex, 64 chars = 32 bytes.
75
+ # PREFER the EDGE_BUFFER_KEY env var (do not commit to source).
76
+ # key = "<64 hex chars>"
77
+
78
+ [logging]
79
+ # Log level: trace | debug | info | warn | error
80
+ level = "info"
81
+
82
+ # Local log directory (relative to config dir or absolute).
83
+ local_log_dir = "logs"
84
+
85
+ [shell]
86
+ # Enable the /shell-edge binary channel. Default OFF — opt in per node.
87
+ enabled = false
88
+
89
+ # Default TTY size when the dashboard opens a fresh shell.
90
+ default_cols = 80
91
+ default_rows = 24
92
+
93
+ [relay]
94
+ # Fleet shared secret for WS auth. PREFER the EDGE_AUTH_TOKEN env var.
95
+ # auth_token = "<your-fleet-shared-secret>"
96
+
97
+ # Optional: TLS SPKI fingerprint pin (SHA-256 hex of the
98
+ # SubjectPublicKeyInfo). Strongly recommended for production.
99
+ fingerprint = "<sha256-hex>"
100
+
101
+ [resources]
102
+ # Soft memory cap (MB). The daemon dumps the buffer to disk above this.
103
+ memory_max_mb = 2048
104
+
105
+ # Soft CPU quota (%). The daemon sheds load above this percentage.
106
+ cpu_quota_percent = 50
107
+
108
+ # Maximum concurrent tasks (sessions + RPCs).
109
+ tasks_max = 256
110
+
111
+ [cmd_gate]
112
+ # AGT-02 command gate mode. off | warn | block. Default: block.
113
+ mode = "block"
114
+
115
+ # Per-command timeout (seconds). Default: 60.
116
+ timeout_s = 60
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Environment variables
122
+
123
+ > **Treat all `EDGE_*` env vars as secrets.** Never commit them. Use your
124
+ > platform's secret manager (e.g. systemd `EnvironmentFile=` with mode
125
+ > `0600`, macOS launchd `EnvironmentVariables`, Windows Credential
126
+ > Manager, or a `.env` file with `chmod 600`).
127
+
128
+ | Variable | Equivalent config | Notes |
129
+ |----------|-------------------|-------|
130
+ | `EDGE_AUTH_TOKEN` | `relay.auth_token` | Fleet shared secret. **Required** in production. |
131
+ | `EDGE_BUFFER_KEY` | `buffer.key` | Hex, 64 chars. Required for offline buffer encryption. |
132
+ | `EDGE_BUFFER_KEY_PREVIOUS` | — | Hex, 64 chars. Used during key rotation. |
133
+ | `EDGE_RELAY_FINGERPRINT` | `relay.fingerprint` | Overrides config; recommended for prod. |
134
+ | `EDGE_OWNER_OPERATOR_ID` | `owner_operator_id` | Overrides enrolled operator. |
135
+ | `EDGEAI_REQUIRE_TLS` | — | `1` forces `wss://` outside production. |
136
+ | `NODE_ENV=production` | — | Implicitly forces `wss://`. |
137
+ | `EDGE_CMD_GATE_MODE` | `cmd_gate.mode` | `off` / `warn` / `block`. |
138
+ | `EDGE_FAIL_FAST` | — | Fail-fast policy toggle (`0`/`1`). |
139
+
140
+ ---
141
+
142
+ ## Secret handling
143
+
144
+ - **Never** paste tokens, API keys, or fingerprints into:
145
+ - `config.toml` in a git repository
146
+ - shell history (use a password manager or `read -s`)
147
+ - Slack / chat / issue trackers
148
+ - **Always prefer env vars** for any value labeled "secret" or "token"
149
+ in the schema above.
150
+ - **Rotate** the fleet shared secret via the dashboard; the daemon
151
+ reconnects within the next heartbeat interval (~30 s).
152
+ - **Revoke** lost tokens in the dashboard immediately. The relay enforces
153
+ revocation on the next WS upgrade (no waiting for the daemon to
154
+ notice).
155
+
156
+ ---
157
+
158
+ ## Verifying your config
159
+
160
+ ```bash
161
+ # Validate without running the daemon
162
+ ec-ts status --once
163
+
164
+ # Show effective config (with secrets redacted)
165
+ ec-ts whoami --verbose
166
+ ```
167
+
168
+ See [`docs/TROUBLESHOOTING.md`](TROUBLESHOOTING.md) for common
169
+ configuration mistakes.