@standardagents/code 0.0.0-dev.fffff

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/src/bridge.ts ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * The client-tool bridge: holds a WebSocket open to the instance, receives
3
+ * forwarded tool requests, runs them through the permission engine + safety
4
+ * guard, executes the approved ones on the host, and returns results.
5
+ */
6
+ import type { ApiClient } from "./api.ts";
7
+ import type { HostTools } from "./host-tools.ts";
8
+ import { decide, isCatastrophic, type PermissionState } from "./permissions.ts";
9
+ import { saveApprovals } from "./approvals.ts";
10
+ import type { ToolRequest } from "./types.ts";
11
+
12
+ export type ApprovalChoice = "allow" | "deny" | "always" | "always_risk";
13
+
14
+ export type ConnectionState = "connected" | "reconnecting" | "reconnected";
15
+
16
+ export interface BridgeHooks {
17
+ /** Print a permanent line (a finished/denied/blocked tool). */
18
+ onActivity(line: string): void;
19
+ /** Update the live working status (the currently-running tool, or null = idle). */
20
+ onStatus?(label: string | null): void;
21
+ /** Report connection lifecycle so the UI can show disconnect/reconnect state. */
22
+ onConnection?(state: ConnectionState, attempt: number): void;
23
+ /** Ask the user to approve a tool; resolve with their choice. */
24
+ requestApproval(req: ToolRequest, summary: string, effectiveRisk: number): Promise<ApprovalChoice>;
25
+ }
26
+
27
+ /** Tools that mutate or execute (used to deny in plan mode cleanly). */
28
+ const PATH_ARG_TOOLS = new Set(["read_file", "list_dir", "grep", "glob", "write_file", "edit_file", "delete"]);
29
+
30
+ export class Bridge {
31
+ private ws: WebSocket | null = null;
32
+ private closed = false;
33
+ private heartbeat: ReturnType<typeof setInterval> | null = null;
34
+ private reconnectAttempt = 0;
35
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
36
+ private resolveConnected: (() => void) | null = null;
37
+ // Durable forwarded calls we've started handling, so a server re-send (after a
38
+ // reconnect) doesn't prompt or run them twice.
39
+ private handledDurable = new Set<string>();
40
+
41
+ constructor(
42
+ private api: ApiClient,
43
+ private threadId: string,
44
+ private host: HostTools,
45
+ private perm: PermissionState,
46
+ private hooks: BridgeHooks
47
+ ) {}
48
+
49
+ /**
50
+ * Connect and keep the bridge connected. Resolves on the first successful
51
+ * open; thereafter any drop is reconnected automatically with exponential
52
+ * backoff (disconnections are expected — e.g. a dev-server reload — so this
53
+ * must be rock solid). A short safety timeout resolves startup even if the
54
+ * very first attempt is slow, since reconnection continues in the background.
55
+ */
56
+ connect(): Promise<void> {
57
+ return new Promise((resolve) => {
58
+ let settled = false;
59
+ this.resolveConnected = () => {
60
+ if (!settled) {
61
+ settled = true;
62
+ resolve();
63
+ }
64
+ };
65
+ // Don't hang startup forever if the first attempt is slow.
66
+ setTimeout(() => this.resolveConnected?.(), 8000);
67
+ this.openSocket();
68
+ });
69
+ }
70
+
71
+ private openSocket(): void {
72
+ if (this.closed) return;
73
+ const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/bridge?token=${encodeURIComponent(this.api.bearer)}`;
74
+ let ws: WebSocket;
75
+ try {
76
+ ws = new WebSocket(url);
77
+ } catch {
78
+ this.scheduleReconnect();
79
+ return;
80
+ }
81
+ this.ws = ws;
82
+
83
+ ws.addEventListener("open", () => {
84
+ const wasReconnecting = this.reconnectAttempt > 0;
85
+ this.reconnectAttempt = 0;
86
+ this.startHeartbeat(ws);
87
+ this.hooks.onConnection?.(wasReconnecting ? "reconnected" : "connected", 0);
88
+ this.resolveConnected?.();
89
+ });
90
+
91
+ ws.addEventListener("message", (ev) => this.onMessage(String((ev as MessageEvent).data)));
92
+
93
+ // A socket that fails to open fires 'error' but may never fire 'close', so
94
+ // both must drive reconnection. handleDrop dedupes via the current-socket check.
95
+ ws.addEventListener("error", () => this.handleDrop(ws));
96
+ ws.addEventListener("close", () => this.handleDrop(ws));
97
+ }
98
+
99
+ private handleDrop(ws: WebSocket): void {
100
+ if (this.ws !== ws) return; // stale event from a superseded socket
101
+ this.ws = null;
102
+ this.stopHeartbeat();
103
+ this.scheduleReconnect();
104
+ }
105
+
106
+ private scheduleReconnect(): void {
107
+ if (this.closed || this.reconnectTimer) return;
108
+ this.reconnectAttempt++;
109
+ this.hooks.onConnection?.("reconnecting", this.reconnectAttempt);
110
+ // Exponential backoff with jitter, capped — keep trying indefinitely.
111
+ const base = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15000);
112
+ const delay = base + Math.floor(Math.random() * 400);
113
+ this.reconnectTimer = setTimeout(() => {
114
+ this.reconnectTimer = null;
115
+ this.openSocket();
116
+ }, delay);
117
+ }
118
+
119
+ private startHeartbeat(ws: WebSocket): void {
120
+ this.stopHeartbeat();
121
+ // Heartbeat so the server can tell a live client (mid-build or awaiting an
122
+ // approval) from a dead one and never freeze a thread on us.
123
+ this.heartbeat = setInterval(() => {
124
+ try {
125
+ if (ws.readyState === WebSocket.OPEN) ws.send("ping");
126
+ } catch {
127
+ // ignore
128
+ }
129
+ }, 5000);
130
+ }
131
+
132
+ private stopHeartbeat(): void {
133
+ if (this.heartbeat) {
134
+ clearInterval(this.heartbeat);
135
+ this.heartbeat = null;
136
+ }
137
+ }
138
+
139
+ close(): void {
140
+ this.closed = true;
141
+ this.stopHeartbeat();
142
+ if (this.reconnectTimer) {
143
+ clearTimeout(this.reconnectTimer);
144
+ this.reconnectTimer = null;
145
+ }
146
+ this.ws?.close();
147
+ }
148
+
149
+ private send(payload: object): void {
150
+ try {
151
+ this.ws?.send(JSON.stringify(payload));
152
+ } catch {
153
+ // socket gone; the server will time the call out
154
+ }
155
+ }
156
+
157
+ private async onMessage(raw: string): Promise<void> {
158
+ let msg: any;
159
+ try {
160
+ msg = JSON.parse(raw);
161
+ } catch {
162
+ return;
163
+ }
164
+ if (msg.type !== "tool_request") return;
165
+ const req = msg as ToolRequest;
166
+ await this.handleToolRequest(req);
167
+ }
168
+
169
+ /**
170
+ * Reply to a tool request. Durable calls (the agent parked them) deliver the
171
+ * result over HTTP so it lands even if this socket later drops; legacy calls
172
+ * reply over the WebSocket.
173
+ */
174
+ private respond(req: ToolRequest, ok: boolean, result?: string, error?: string): void {
175
+ if (req.durable && req.toolCallId) {
176
+ void this.api.postToolResult(this.threadId, req.toolCallId, ok, result, error);
177
+ } else {
178
+ this.send({ type: "tool_response", id: req.id, ok, result, error });
179
+ }
180
+ }
181
+
182
+ private async handleToolRequest(req: ToolRequest): Promise<void> {
183
+ // Durable calls may be re-sent by the server after a reconnect — only handle
184
+ // each once (prompt + run), so the user isn't asked twice.
185
+ if (req.durable && req.toolCallId) {
186
+ if (this.handledDurable.has(req.toolCallId)) return;
187
+ this.handledDurable.add(req.toolCallId);
188
+ }
189
+
190
+ const summary = describe(req);
191
+
192
+ // Compute an effective risk, escalating anything that touches paths outside
193
+ // the project directory (the agent is told to stay inside it).
194
+ let effectiveRisk = typeof req.risk === "number" ? req.risk : req.requestPermission ? 3 : 1;
195
+ if (PATH_ARG_TOOLS.has(req.tool) && this.host.isOutsideProject(req.args.path as string | undefined)) {
196
+ effectiveRisk = Math.max(effectiveRisk, 4);
197
+ }
198
+
199
+ // Hard safety: never run catastrophic shell commands, even in bypass mode.
200
+ if (req.tool === "bash" && isCatastrophic(String(req.args.command || ""))) {
201
+ this.hooks.onActivity(`⛔ blocked dangerous command: ${summary}`);
202
+ this.respond(req, false, undefined, "Blocked: this command is considered catastrophic and was refused by the client safety guard.");
203
+ return;
204
+ }
205
+
206
+ // Permission key: normally the tool name, but MCP calls are gated at the
207
+ // finer `mcp:<server>/<tool>` granularity so "always allow this tool" approves
208
+ // one server's tool rather than every MCP call.
209
+ const permKey = permissionKey(req);
210
+ const decision = decide(this.perm, permKey, effectiveRisk, !!req.requestPermission);
211
+
212
+ if (decision === "deny") {
213
+ this.hooks.onActivity(`⛔ ${summary} — blocked (risk ${effectiveRisk})`);
214
+ this.respond(req, false, undefined, `Denied by policy (risk ${effectiveRisk}).`);
215
+ return;
216
+ }
217
+
218
+ if (decision === "ask") {
219
+ const choice = await this.hooks.requestApproval(req, summary, effectiveRisk);
220
+ if (choice === "deny") {
221
+ this.hooks.onActivity(`⛔ ${summary} — you declined`);
222
+ this.respond(req, false, undefined, "The user declined to run this operation.");
223
+ return;
224
+ }
225
+ if (choice === "always") this.perm.alwaysAllow.add(permKey);
226
+ if (choice === "always_risk") this.perm.allowRisk.add(effectiveRisk);
227
+ if (choice === "always" || choice === "always_risk") {
228
+ saveApprovals(this.api, this.threadId, this.perm);
229
+ }
230
+ }
231
+
232
+ // Show the running tool in the live status, then leave a permanent line.
233
+ this.hooks.onStatus?.(summary);
234
+ const result = await this.host.execute(req.tool, req.args);
235
+ this.hooks.onStatus?.(null);
236
+ if (result.ok) {
237
+ this.hooks.onActivity(`✓ ${summary}${detailSuffix(req.tool, result.result)}`);
238
+ this.respond(req, true, result.result ?? "");
239
+ } else {
240
+ this.hooks.onActivity(`✗ ${summary} — ${result.error}`);
241
+ this.respond(req, false, undefined, result.error);
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Stable permission key for a request. For MCP, gate on the specific server +
248
+ * tool/resource so approvals are fine-grained; for everything else, the tool name.
249
+ */
250
+ export function permissionKey(req: ToolRequest): string {
251
+ if (req.tool !== "mcp") return req.tool;
252
+ const a = req.args;
253
+ const server = String(a.server || "?");
254
+ const action = String(a.action || "call");
255
+ if (action === "read_resource") return `mcp:${server}/resource`;
256
+ if (action === "list_tools") return `mcp:${server}/list`;
257
+ return `mcp:${server}/${String(a.tool || "?")}`;
258
+ }
259
+
260
+ /** One-line human summary of a tool request for the activity feed. */
261
+ export function describe(req: ToolRequest): string {
262
+ const a = req.args;
263
+ switch (req.tool) {
264
+ case "mcp": {
265
+ const server = String(a.server || "?");
266
+ const action = String(a.action || "call");
267
+ if (action === "list_tools") return `mcp ${server}: list tools`;
268
+ if (action === "read_resource") return `mcp ${server}: read ${a.uri}`;
269
+ return `mcp ${server}: ${a.tool}`;
270
+ }
271
+ case "read_file":
272
+ return `read ${a.path}`;
273
+ case "list_dir":
274
+ return `list ${a.path || "."}`;
275
+ case "grep":
276
+ return `grep "${a.pattern}"${a.glob ? ` in ${a.glob}` : ""}`;
277
+ case "glob":
278
+ return `find ${a.pattern}`;
279
+ case "write_file":
280
+ return `write ${a.path}`;
281
+ case "edit_file":
282
+ return `edit ${a.path}`;
283
+ case "delete":
284
+ return `delete ${a.path}`;
285
+ case "bash":
286
+ return `bash: ${String(a.command).slice(0, 80)}`;
287
+ default:
288
+ return `${req.tool} ${JSON.stringify(a).slice(0, 80)}`;
289
+ }
290
+ }
291
+
292
+ /** A short parenthetical hint appended to a finished read-style tool line. */
293
+ function detailSuffix(tool: string, result?: string): string {
294
+ if (!result) return "";
295
+ // Mutations already read clearly from their summary; don't pile on.
296
+ if (tool === "write_file" || tool === "edit_file" || tool === "delete") return "";
297
+ if (tool === "bash") {
298
+ const m = result.match(/\[exit code (\d+)\]\s*$/);
299
+ return m ? ` (exit ${m[1]})` : "";
300
+ }
301
+ const lines = result.split("\n").length;
302
+ return ` (${lines} line${lines === 1 ? "" : "s"})`;
303
+ }
@@ -0,0 +1,49 @@
1
+ /** Read/write ~/.standardagents/credentials. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import type { CredentialsFile, InstanceCredential } from "./types.ts";
6
+
7
+ const DIR = path.join(os.homedir(), ".standardagents");
8
+ const FILE = path.join(DIR, "credentials");
9
+
10
+ export function normalizeEndpoint(endpoint: string): string {
11
+ let e = endpoint.trim();
12
+ if (!/^https?:\/\//i.test(e)) e = "http://" + e;
13
+ return e.replace(/\/+$/, "");
14
+ }
15
+
16
+ export function loadCredentials(): CredentialsFile {
17
+ try {
18
+ const raw = fs.readFileSync(FILE, "utf8");
19
+ const parsed = JSON.parse(raw) as CredentialsFile;
20
+ if (!parsed.instances) parsed.instances = {};
21
+ return parsed;
22
+ } catch {
23
+ return { instances: {} };
24
+ }
25
+ }
26
+
27
+ export function getCredential(endpoint: string): InstanceCredential | null {
28
+ const creds = loadCredentials();
29
+ return creds.instances[normalizeEndpoint(endpoint)] ?? null;
30
+ }
31
+
32
+ export function saveCredential(cred: InstanceCredential): void {
33
+ const creds = loadCredentials();
34
+ const endpoint = normalizeEndpoint(cred.endpoint);
35
+ creds.instances[endpoint] = { ...cred, endpoint };
36
+ creds.default_endpoint = endpoint;
37
+ fs.mkdirSync(DIR, { recursive: true });
38
+ // Credentials contain a bearer token — keep them private.
39
+ fs.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
40
+ try {
41
+ fs.chmodSync(FILE, 0o600);
42
+ } catch {
43
+ // best effort on platforms without chmod
44
+ }
45
+ }
46
+
47
+ export function defaultEndpoint(): string | null {
48
+ return loadCredentials().default_endpoint ?? null;
49
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Subscribes to the instance-wide system events channel (`GET /api/events`) to
3
+ * learn when threads are created, updated, or terminated. We use it to track
4
+ * *subagents*: a subagent runs in its own child thread, so the only live signal
5
+ * of "a subagent is working" is its child thread appearing here with our thread
6
+ * as its `parent` (and disappearing/terminating when it finishes).
7
+ */
8
+ import type { ApiClient } from "./api.ts";
9
+
10
+ /** The subset of a thread registry entry we care about. */
11
+ export interface ThreadEntry {
12
+ id: string;
13
+ agent_name?: string | null;
14
+ tags?: string[] | null;
15
+ parent?: string | null;
16
+ terminated?: number | null;
17
+ created_at?: number;
18
+ }
19
+
20
+ export interface SystemEventHooks {
21
+ onThreadCreated(thread: ThreadEntry): void;
22
+ onThreadUpdated(thread: ThreadEntry): void;
23
+ onThreadDeleted(threadId: string): void;
24
+ }
25
+
26
+ export class SystemEvents {
27
+ private ws: WebSocket | null = null;
28
+ private closed = false;
29
+ private reconnectAttempt = 0;
30
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
31
+
32
+ constructor(
33
+ private api: ApiClient,
34
+ private hooks: SystemEventHooks
35
+ ) {}
36
+
37
+ connect(): void {
38
+ this.openSocket();
39
+ }
40
+
41
+ private openSocket(): void {
42
+ if (this.closed) return;
43
+ const url = `${this.api.wsEndpoint}/api/events?token=${encodeURIComponent(this.api.bearer)}`;
44
+ let ws: WebSocket;
45
+ try {
46
+ ws = new WebSocket(url);
47
+ } catch {
48
+ this.scheduleReconnect();
49
+ return;
50
+ }
51
+ this.ws = ws;
52
+ ws.addEventListener("open", () => {
53
+ this.reconnectAttempt = 0;
54
+ });
55
+ ws.addEventListener("message", (ev) => this.onMessage(String((ev as MessageEvent).data)));
56
+ ws.addEventListener("error", () => this.handleDrop(ws));
57
+ ws.addEventListener("close", () => this.handleDrop(ws));
58
+ }
59
+
60
+ private onMessage(raw: string): void {
61
+ let msg: any;
62
+ try {
63
+ msg = JSON.parse(raw);
64
+ } catch {
65
+ return;
66
+ }
67
+ if (msg?.type === "thread_created" && msg.thread) this.hooks.onThreadCreated(msg.thread);
68
+ else if (msg?.type === "thread_updated" && msg.thread) this.hooks.onThreadUpdated(msg.thread);
69
+ else if (msg?.type === "thread_deleted" && typeof msg.threadId === "string") {
70
+ this.hooks.onThreadDeleted(msg.threadId);
71
+ }
72
+ }
73
+
74
+ private handleDrop(ws: WebSocket): void {
75
+ if (this.ws !== ws) return;
76
+ this.ws = null;
77
+ this.scheduleReconnect();
78
+ }
79
+
80
+ private scheduleReconnect(): void {
81
+ if (this.closed || this.reconnectTimer) return;
82
+ this.reconnectAttempt++;
83
+ const base = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15000);
84
+ const delay = base + Math.floor(Math.random() * 400);
85
+ this.reconnectTimer = setTimeout(() => {
86
+ this.reconnectTimer = null;
87
+ this.openSocket();
88
+ }, delay);
89
+ }
90
+
91
+ close(): void {
92
+ this.closed = true;
93
+ if (this.reconnectTimer) {
94
+ clearTimeout(this.reconnectTimer);
95
+ this.reconnectTimer = null;
96
+ }
97
+ this.ws?.close();
98
+ }
99
+ }