@anusornneal/chat-relay 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,6 +84,13 @@ The published package locates its bundled local agent relative to the package it
84
84
  - Recovery after retirement does not require copying tokens: run `chat-relay login --force`, complete browser authorization, then run `chat-relay remote`.
85
85
  - A different account cannot reclaim another owner's active or retired agent id; a colliding login receives a separate device identity instead.
86
86
 
87
+
88
+ ## Agent protocol and capabilities
89
+
90
+ Local agents send a lightweight hello handshake when their WebSocket connects. The handshake reports the protocol version, package version, platform/architecture, and the capabilities that are actually enabled on that machine. Feature routing should use the advertised capability list rather than inferring support from the package version alone.
91
+
92
+ Protocol v1 keeps legacy agents backward compatible: a connected agent that does not send hello metadata can still use the pre-handshake behavior. An agent that explicitly advertises an unsupported protocol is disconnected with a clear incompatibility reason instead of failing later on an unrelated tool call or entering a restart loop.
93
+
87
94
  ## Dashboard administrator sessions
88
95
 
89
96
  Browser dashboard access uses the same Chat Relay username/password accounts but requires an explicit global administrator entitlement. Successful dashboard login creates a short-lived opaque server-side session; the browser receives an HttpOnly, Secure, SameSite=Strict cookie plus a CSRF token for state-changing requests. `ADMIN_TOKEN` remains an operator/CLI recovery credential and must never be embedded in dashboard JavaScript, browser storage, or URLs.
@@ -318,3 +325,26 @@ The first registry publish still requires npm authorization for the @anusornneal
318
325
  npx @anusornneal/chat-relay@latest status
319
326
  npx @anusornneal/chat-relay@latest remote
320
327
  ```
328
+
329
+
330
+ ### Safe local-agent restart
331
+
332
+ Agent updates remain explicit and user-controlled. Check the current agent version, protocol compatibility, and lifecycle with `chat-relay status`.
333
+
334
+ Before restarting an active remote, run `chat-relay drain`. Drain mode rejects new long-running work while existing terminal/session control remains available so bounded work can finish or be stopped safely. When `status` reports the lifecycle is ready to restart, run `chat-relay restart`. Use `chat-relay resume` to cancel a drain before restart.
335
+
336
+ To install a newer package, stop/restart the wrapper with the desired npm version (for example `npx @anusornneal/chat-relay@latest remote --desktop`). The agent does not self-modify or automatically cross an incompatible protocol version.
337
+
338
+
339
+ ### Trusted-team security baseline
340
+
341
+ Chat Relay is currently designed for a small trusted internal team, not as an enterprise zero-trust control plane. The intentionally small baseline is:
342
+
343
+ - Filesystem access is constrained to configured `allowedRoots`; canonical real paths are checked so `..` traversal and symlink/junction escapes are rejected.
344
+ - Agent grants continue to enforce the existing read/write/process/terminal/desktop scopes. User and agent credentials can be revoked or rotated without retaining raw tokens server-side.
345
+ - Persisted usage/audit/dashboard telemetry is metadata-only. Commands, tool arguments, payloads, stdout/stderr, file contents, clipboard contents, screenshots, credentials, and arbitrary remote messages are excluded.
346
+ - Accidental runaway use is bounded by request/response size limits, rate/quota controls, per-capability queues, queue timeouts, terminal batch limits, and bounded history/query windows.
347
+
348
+ Regression coverage is provided by the filesystem boundary checks in `test/multiuser-smoke.mjs`, credential/scope tests in the device/admin/multi-user smoke suites, telemetry privacy checks in `test/ops-smoke.mjs`, and queue/size tests in the terminal and filesystem smoke suites.
349
+
350
+ Deferred by design: enterprise role hierarchies, approval workflows, device-attestation/trust frameworks, and a general policy engine. Add those only if the deployment threat model changes.
@@ -0,0 +1,121 @@
1
+ const DEFAULT_QUEUE_TIMEOUT_MS = 15_000;
2
+ const DEFAULT_MAX_QUEUED = 64;
3
+
4
+ function boundedInteger(value, fallback, min, max) {
5
+ const number = Number(value);
6
+ if (!Number.isFinite(number)) return fallback;
7
+ return Math.min(Math.max(Math.trunc(number), min), max);
8
+ }
9
+
10
+ function queueError(code) {
11
+ const error = new Error(code);
12
+ error.code = code;
13
+ return error;
14
+ }
15
+
16
+ export class BoundedLane {
17
+ constructor(options = {}) {
18
+ this.name = String(options.name || "lane");
19
+ this.concurrency = boundedInteger(options.concurrency, 1, 1, 64);
20
+ this.maxQueued = boundedInteger(options.maxQueued, DEFAULT_MAX_QUEUED, 1, 512);
21
+ this.queueTimeoutMs = boundedInteger(options.queueTimeoutMs, DEFAULT_QUEUE_TIMEOUT_MS, 100, 120_000);
22
+ this.active = 0;
23
+ this.queue = [];
24
+ }
25
+
26
+ run(work) {
27
+ if (typeof work !== "function") return Promise.reject(queueError("invalid_work"));
28
+ return new Promise((resolve, reject) => {
29
+ const job = { work, resolve, reject, timer: null, enqueuedAt: Date.now() };
30
+ if (this.active < this.concurrency) {
31
+ this.#start(job);
32
+ return;
33
+ }
34
+ if (this.queue.length >= this.maxQueued) {
35
+ reject(queueError("queue_full"));
36
+ return;
37
+ }
38
+ job.timer = setTimeout(() => {
39
+ const index = this.queue.indexOf(job);
40
+ if (index < 0) return;
41
+ this.queue.splice(index, 1);
42
+ reject(queueError("queue_timeout"));
43
+ }, this.queueTimeoutMs);
44
+ this.queue.push(job);
45
+ });
46
+ }
47
+
48
+ snapshot() {
49
+ return {
50
+ concurrency: this.concurrency,
51
+ active: this.active,
52
+ queued: this.queue.length,
53
+ maxQueued: this.maxQueued,
54
+ queueTimeoutMs: this.queueTimeoutMs,
55
+ };
56
+ }
57
+
58
+ #start(job) {
59
+ if (job.timer) clearTimeout(job.timer);
60
+ this.active += 1;
61
+ const queueWaitMs = Math.max(0, Date.now() - job.enqueuedAt);
62
+ const metadata = {
63
+ lane: this.name,
64
+ queueWaitMs,
65
+ queueDepthAtStart: this.queue.length,
66
+ activeAtStart: this.active,
67
+ };
68
+ Promise.resolve()
69
+ .then(() => job.work(metadata))
70
+ .then(job.resolve, job.reject)
71
+ .finally(() => {
72
+ this.active -= 1;
73
+ this.#drain();
74
+ });
75
+ }
76
+
77
+ #drain() {
78
+ while (this.active < this.concurrency && this.queue.length > 0) {
79
+ this.#start(this.queue.shift());
80
+ }
81
+ }
82
+ }
83
+
84
+ export class CapabilityScheduler {
85
+ constructor(options = {}) {
86
+ const maxQueued = boundedInteger(options.maxQueued, DEFAULT_MAX_QUEUED, 1, 512);
87
+ const queueTimeoutMs = boundedInteger(options.queueTimeoutMs, DEFAULT_QUEUE_TIMEOUT_MS, 100, 120_000);
88
+ const lane = (name, concurrency) => new BoundedLane({ name, concurrency, maxQueued, queueTimeoutMs });
89
+
90
+ this.lanes = new Map([
91
+ ["agent", lane("agent", options.agentConcurrency ?? 4)],
92
+ ["filesystem", lane("filesystem", options.fileConcurrency ?? 8)],
93
+ ["process", lane("process", options.processConcurrency ?? 4)],
94
+ ["terminalExec", lane("terminalExec", options.terminalExecConcurrency ?? 4)],
95
+ ["terminalControl", lane("terminalControl", options.terminalControlConcurrency ?? 8)],
96
+ ["desktopRead", lane("desktopRead", options.desktopReadConcurrency ?? 2)],
97
+ ]);
98
+ }
99
+
100
+ run(action, work) {
101
+ const lane = this.#laneFor(action);
102
+ return lane ? lane.run(work) : Promise.resolve().then(work);
103
+ }
104
+
105
+ snapshot() {
106
+ return Object.fromEntries([...this.lanes.entries()].map(([name, lane]) => [name, lane.snapshot()]));
107
+ }
108
+
109
+ #laneFor(action) {
110
+ const value = String(action || "unknown");
111
+ if (value.startsWith("fs.")) return this.lanes.get("filesystem");
112
+ if (value.startsWith("process.")) return this.lanes.get("process");
113
+ if (value === "terminal.exec") return this.lanes.get("terminalExec");
114
+ if (value.startsWith("terminal.")) return this.lanes.get("terminalControl");
115
+ if (value === "desktop.screenshot") return this.lanes.get("desktopRead");
116
+ if (value.startsWith("desktop.")) return null;
117
+ return this.lanes.get("agent");
118
+ }
119
+ }
120
+
121
+ export { boundedInteger };
@@ -0,0 +1,126 @@
1
+ function boundedInteger(value, fallback, min, max) {
2
+ const number = Number(value);
3
+ if (!Number.isFinite(number)) return fallback;
4
+ return Math.min(Math.max(Math.trunc(number), min), max);
5
+ }
6
+
7
+ function isoTime(value) {
8
+ const number = Number(value);
9
+ return new Date(Number.isFinite(number) ? number : Date.now()).toISOString();
10
+ }
11
+
12
+ function cleanReason(value) {
13
+ const reason = String(value || "").trim();
14
+ return reason ? reason.slice(0, 160) : null;
15
+ }
16
+
17
+ export function computeReconnectDelay(attempt, options = {}) {
18
+ const baseMs = boundedInteger(options.baseMs, 2000, 100, 60_000);
19
+ const maxMs = boundedInteger(options.maxMs, 30_000, baseMs, 120_000);
20
+ const jitterRatio = Math.min(Math.max(Number(options.jitterRatio ?? 0.2), 0), 0.5);
21
+ const random = typeof options.random === "function" ? options.random : Math.random;
22
+ const exponent = Math.min(Math.max(Math.trunc(Number(attempt) || 1) - 1, 0), 10);
23
+ const raw = Math.min(maxMs, baseMs * (2 ** exponent));
24
+ const spread = raw * jitterRatio;
25
+ const sample = Math.min(Math.max(Number(random()) || 0, 0), 1);
26
+ return Math.max(100, Math.round(raw - spread + (spread * 2 * sample)));
27
+ }
28
+
29
+ export class AgentConnectionState {
30
+ constructor(options = {}) {
31
+ this.baseReconnectMs = boundedInteger(options.baseReconnectMs, 2000, 100, 60_000);
32
+ this.maxReconnectMs = boundedInteger(options.maxReconnectMs, 30_000, this.baseReconnectMs, 120_000);
33
+ this.heartbeatMs = boundedInteger(options.heartbeatMs, 15_000, 5_000, 60_000);
34
+ this.processStartedAt = new Date().toISOString();
35
+ this.state = "starting";
36
+ this.connectedAt = null;
37
+ this.disconnectedAt = null;
38
+ this.lastHeartbeatAt = null;
39
+ this.lastCloseCode = null;
40
+ this.lastDisconnectReason = null;
41
+ this.reconnectAttempt = 0;
42
+ this.reconnectCount = 0;
43
+ this.nextReconnectAt = null;
44
+ this.nextReconnectDelayMs = null;
45
+ }
46
+
47
+ markConnecting(now = Date.now()) {
48
+ this.state = "connecting";
49
+ this.nextReconnectAt = null;
50
+ this.nextReconnectDelayMs = null;
51
+ this.connectingAt = isoTime(now);
52
+ }
53
+
54
+ markConnected(now = Date.now()) {
55
+ this.state = "connected";
56
+ this.connectedAt = isoTime(now);
57
+ this.disconnectedAt = null;
58
+ this.reconnectAttempt = 0;
59
+ this.reconnectCount = 0;
60
+ this.nextReconnectAt = null;
61
+ this.nextReconnectDelayMs = null;
62
+ }
63
+
64
+ markHeartbeat(now = Date.now()) {
65
+ this.lastHeartbeatAt = isoTime(now);
66
+ }
67
+
68
+ markDisconnected(code, reason, now = Date.now()) {
69
+ this.state = "waiting";
70
+ this.disconnectedAt = isoTime(now);
71
+ this.lastCloseCode = Number.isFinite(Number(code)) ? Number(code) : null;
72
+ this.lastDisconnectReason = cleanReason(reason);
73
+ this.reconnectAttempt += 1;
74
+ this.reconnectCount += 1;
75
+ }
76
+
77
+ scheduleReconnect(delayMs, now = Date.now()) {
78
+ const delay = boundedInteger(delayMs, this.baseReconnectMs, 100, this.maxReconnectMs);
79
+ this.nextReconnectDelayMs = delay;
80
+ this.nextReconnectAt = isoTime(Number(now) + delay);
81
+ }
82
+
83
+ nextDelay(random = Math.random) {
84
+ return computeReconnectDelay(this.reconnectAttempt, {
85
+ baseMs: this.baseReconnectMs,
86
+ maxMs: this.maxReconnectMs,
87
+ random,
88
+ });
89
+ }
90
+
91
+ markReauthorization(reason = "credential_revoked", now = Date.now()) {
92
+ this.state = "reauthorization-required";
93
+ this.disconnectedAt = isoTime(now);
94
+ this.lastDisconnectReason = cleanReason(reason);
95
+ this.nextReconnectAt = null;
96
+ this.nextReconnectDelayMs = null;
97
+ }
98
+
99
+ markStopping(reason = "shutdown", now = Date.now()) {
100
+ this.state = "stopping";
101
+ this.disconnectedAt = isoTime(now);
102
+ this.lastDisconnectReason = cleanReason(reason);
103
+ this.nextReconnectAt = null;
104
+ this.nextReconnectDelayMs = null;
105
+ }
106
+
107
+ snapshot() {
108
+ return {
109
+ state: this.state,
110
+ processStartedAt: this.processStartedAt,
111
+ connectingAt: this.connectingAt ?? null,
112
+ connectedAt: this.connectedAt,
113
+ disconnectedAt: this.disconnectedAt,
114
+ lastHeartbeatAt: this.lastHeartbeatAt,
115
+ lastCloseCode: this.lastCloseCode,
116
+ lastDisconnectReason: this.lastDisconnectReason,
117
+ reconnectAttempt: this.reconnectAttempt,
118
+ reconnectCount: this.reconnectCount,
119
+ nextReconnectAt: this.nextReconnectAt,
120
+ nextReconnectDelayMs: this.nextReconnectDelayMs,
121
+ baseReconnectMs: this.baseReconnectMs,
122
+ maxReconnectMs: this.maxReconnectMs,
123
+ heartbeatMs: this.heartbeatMs,
124
+ };
125
+ }
126
+ }