@standardagents/code 0.0.0-dev.fffff → 0.0.2-dev.517db40
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 +11 -3
- package/dist/index.js +3463 -0
- package/dist/index.js.map +1 -0
- package/package.json +5 -4
- package/bin/standardcode.mjs +0 -25
- package/src/api.ts +0 -169
- package/src/approvals.ts +0 -42
- package/src/bridge.ts +0 -303
- package/src/credentials.ts +0 -49
- package/src/events-stream.ts +0 -99
- package/src/host-tools.ts +0 -570
- package/src/index.ts +0 -1152
- package/src/markdown.ts +0 -226
- package/src/mcp-config.ts +0 -137
- package/src/mcp.ts +0 -563
- package/src/permissions.ts +0 -53
- package/src/process-registry.ts +0 -122
- package/src/stream.ts +0 -134
- package/src/tui.ts +0 -911
- package/src/types.ts +0 -78
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@standardagents/code",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2-dev.517db40",
|
|
4
4
|
"description": "Standard Code — a terminal coding agent whose LLM loop runs on a Standard Agents instance while its tools execute on your machine via this CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
"registry": "https://registry.npmjs.org/"
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
-
"standardcode": "
|
|
17
|
+
"standardcode": "dist/index.js"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
|
-
"
|
|
21
|
-
"src"
|
|
20
|
+
"dist"
|
|
22
21
|
],
|
|
23
22
|
"scripts": {
|
|
24
23
|
"start": "tsx src/index.ts",
|
|
25
24
|
"code": "tsx src/index.ts",
|
|
25
|
+
"build": "tsup",
|
|
26
26
|
"release": "node scripts/release.mjs",
|
|
27
27
|
"release:next": "node scripts/release.mjs --tag=next",
|
|
28
28
|
"release:dev": "node scripts/release.mjs --tag=dev",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@clack/prompts": "^1.6.0",
|
|
34
34
|
"@types/node": "^22.10.0",
|
|
35
|
+
"tsup": "^8.5.1",
|
|
35
36
|
"tsx": "^4.20.6",
|
|
36
37
|
"typescript": "^5.9.3"
|
|
37
38
|
}
|
package/bin/standardcode.mjs
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Launcher for the Standard Code CLI. Runs the TypeScript entry through tsx so
|
|
3
|
-
// it works without a separate build step during local development.
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { createRequire } from "node:module";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
|
|
9
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
const entry = path.resolve(here, "../src/index.ts");
|
|
11
|
-
const require = createRequire(import.meta.url);
|
|
12
|
-
|
|
13
|
-
let tsxBin;
|
|
14
|
-
try {
|
|
15
|
-
// Resolve tsx's CLI entry from wherever it is installed in the workspace.
|
|
16
|
-
tsxBin = require.resolve("tsx/cli");
|
|
17
|
-
} catch {
|
|
18
|
-
console.error("Could not find 'tsx'. Run `pnpm install` in the workspace.");
|
|
19
|
-
process.exit(1);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const child = spawn(process.execPath, [tsxBin, entry, ...process.argv.slice(2)], {
|
|
23
|
-
stdio: "inherit",
|
|
24
|
-
});
|
|
25
|
-
child.on("exit", (code) => process.exit(code ?? 0));
|
package/src/api.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/** Minimal REST client for the Standard Agents instance. */
|
|
2
|
-
import type { ThreadSummary } from "./types.ts";
|
|
3
|
-
|
|
4
|
-
export class ApiClient {
|
|
5
|
-
constructor(
|
|
6
|
-
private endpoint: string,
|
|
7
|
-
private token: string
|
|
8
|
-
) {}
|
|
9
|
-
|
|
10
|
-
get wsEndpoint(): string {
|
|
11
|
-
return this.endpoint.replace(/^http/, "ws");
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** The instance origin (used to build admin-UI links). */
|
|
15
|
-
get origin(): string {
|
|
16
|
-
return this.endpoint;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
get bearer(): string {
|
|
20
|
-
return this.token;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
private async json<T>(pathname: string, init?: RequestInit): Promise<T> {
|
|
24
|
-
const res = await fetch(`${this.endpoint}${pathname}`, {
|
|
25
|
-
...init,
|
|
26
|
-
headers: {
|
|
27
|
-
"Content-Type": "application/json",
|
|
28
|
-
Authorization: `Bearer ${this.token}`,
|
|
29
|
-
...(init?.headers || {}),
|
|
30
|
-
},
|
|
31
|
-
});
|
|
32
|
-
const text = await res.text();
|
|
33
|
-
if (!res.ok) {
|
|
34
|
-
throw new Error(`${init?.method || "GET"} ${pathname} -> ${res.status}: ${text.slice(0, 300)}`);
|
|
35
|
-
}
|
|
36
|
-
try {
|
|
37
|
-
return JSON.parse(text) as T;
|
|
38
|
-
} catch {
|
|
39
|
-
return text as unknown as T;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async verify(): Promise<boolean> {
|
|
44
|
-
try {
|
|
45
|
-
await this.json("/api/auth/me");
|
|
46
|
-
return true;
|
|
47
|
-
} catch {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Agent name → display title map (best effort; for labeling subagents). */
|
|
53
|
-
async listAgents(): Promise<{ name: string; title: string }[]> {
|
|
54
|
-
const res = await this.json<{ agents?: any[] } | any[]>("/api/agents");
|
|
55
|
-
const arr = Array.isArray(res) ? res : res.agents || [];
|
|
56
|
-
return arr
|
|
57
|
-
.filter((a) => a && typeof a.name === "string")
|
|
58
|
-
.map((a) => ({ name: a.name, title: typeof a.title === "string" ? a.title : a.name }));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async createThread(agentId: string, tags: string[]): Promise<string> {
|
|
62
|
-
const res = await this.json<{ threadId?: string; id?: string }>("/api/threads", {
|
|
63
|
-
method: "POST",
|
|
64
|
-
body: JSON.stringify({ agent_id: agentId, tags }),
|
|
65
|
-
});
|
|
66
|
-
const id = res.threadId || res.id;
|
|
67
|
-
if (!id) throw new Error("Thread create returned no id");
|
|
68
|
-
return id;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** List threads for an agent, optionally filtering to those carrying all given tags. */
|
|
72
|
-
async listThreads(agentId: string, requireTags: string[]): Promise<ThreadSummary[]> {
|
|
73
|
-
const res = await this.json<{ threads?: any[] } | any[]>(
|
|
74
|
-
`/api/threads?agent_id=${encodeURIComponent(agentId)}&limit=100`
|
|
75
|
-
);
|
|
76
|
-
const arr = Array.isArray(res) ? res : res.threads || [];
|
|
77
|
-
return arr
|
|
78
|
-
.map((t) => ({
|
|
79
|
-
id: t.id,
|
|
80
|
-
tags: Array.isArray(t.tags) ? t.tags : [],
|
|
81
|
-
created_at: t.created_at,
|
|
82
|
-
title: t.title,
|
|
83
|
-
preview: t.preview || t.last_message,
|
|
84
|
-
}))
|
|
85
|
-
.filter((t: ThreadSummary) => requireTags.every((tag) => t.tags.includes(tag)));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async sendMessage(threadId: string, content: string): Promise<void> {
|
|
89
|
-
await this.json(`/api/threads/${threadId}/messages`, {
|
|
90
|
-
method: "POST",
|
|
91
|
-
body: JSON.stringify({ role: "user", content }),
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async getMessages(threadId: string, limit = 50): Promise<any[]> {
|
|
96
|
-
const res = await this.json<{ messages?: any[] } | any[]>(
|
|
97
|
-
`/api/threads/${threadId}/messages?limit=${limit}`
|
|
98
|
-
);
|
|
99
|
-
return Array.isArray(res) ? res : res.messages || [];
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async getLogs(threadId: string, limit = 100): Promise<any[]> {
|
|
103
|
-
const res = await this.json<{ logs?: any[] } | any[]>(
|
|
104
|
-
`/api/threads/${threadId}/logs?limit=${limit}&order=desc`
|
|
105
|
-
);
|
|
106
|
-
return Array.isArray(res) ? res : res.logs || [];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Deliver a durable forwarded tool result to the thread, resuming the turn.
|
|
111
|
-
* Retries with backoff — this is the durable delivery path, so it must land
|
|
112
|
-
* even if the connection is briefly flaky after a permission wait.
|
|
113
|
-
*/
|
|
114
|
-
async postToolResult(
|
|
115
|
-
threadId: string,
|
|
116
|
-
toolCallId: string,
|
|
117
|
-
ok: boolean,
|
|
118
|
-
result?: string,
|
|
119
|
-
error?: string
|
|
120
|
-
): Promise<boolean> {
|
|
121
|
-
const body = JSON.stringify({ tool_call_id: toolCallId, ok, result, error });
|
|
122
|
-
for (let attempt = 0; attempt < 6; attempt++) {
|
|
123
|
-
try {
|
|
124
|
-
await this.json(`/api/threads/${threadId}/tool-result`, {
|
|
125
|
-
method: "POST",
|
|
126
|
-
headers: { "Content-Type": "application/json" },
|
|
127
|
-
body,
|
|
128
|
-
});
|
|
129
|
-
return true;
|
|
130
|
-
} catch {
|
|
131
|
-
await new Promise((r) => setTimeout(r, Math.min(500 * 2 ** attempt, 8000)));
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Read a value from the thread's durable KV store (null if absent). */
|
|
138
|
-
async kvGet(threadId: string, key: string): Promise<unknown> {
|
|
139
|
-
try {
|
|
140
|
-
const res = await this.json<{ value?: unknown }>(
|
|
141
|
-
`/api/threads/${threadId}/kv?key=${encodeURIComponent(key)}`
|
|
142
|
-
);
|
|
143
|
-
return res?.value ?? null;
|
|
144
|
-
} catch {
|
|
145
|
-
return null; // 404 = key not yet set
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Write a value to the thread's durable KV store. */
|
|
150
|
-
async kvSet(threadId: string, key: string, value: unknown): Promise<void> {
|
|
151
|
-
try {
|
|
152
|
-
await this.json(`/api/threads/${threadId}/kv`, {
|
|
153
|
-
method: "POST",
|
|
154
|
-
headers: { "Content-Type": "application/json" },
|
|
155
|
-
body: JSON.stringify({ key, value }),
|
|
156
|
-
});
|
|
157
|
-
} catch {
|
|
158
|
-
// best effort
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async stop(threadId: string): Promise<void> {
|
|
163
|
-
try {
|
|
164
|
-
await this.json(`/api/threads/${threadId}/stop`, { method: "POST" });
|
|
165
|
-
} catch {
|
|
166
|
-
// best effort
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
package/src/approvals.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session approvals — the permission mode plus the tools and risk levels the
|
|
3
|
-
* user has pre-approved — persisted in the THREAD's KV store (server-side), so
|
|
4
|
-
* they resume with the session and a server hook can show the agent what's
|
|
5
|
-
* already approved. Nothing is stored on the client.
|
|
6
|
-
*/
|
|
7
|
-
import type { ApiClient } from "./api.ts";
|
|
8
|
-
import type { PermissionState } from "./permissions.ts";
|
|
9
|
-
import type { Level } from "./types.ts";
|
|
10
|
-
|
|
11
|
-
const KEY = "approvals";
|
|
12
|
-
|
|
13
|
-
export interface ApprovalsKV {
|
|
14
|
-
level?: Level;
|
|
15
|
-
allowTools: string[];
|
|
16
|
-
allowRisk: number[];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function loadApprovals(api: ApiClient, threadId: string): Promise<ApprovalsKV> {
|
|
20
|
-
const v = await api.kvGet(threadId, KEY);
|
|
21
|
-
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
22
|
-
const o = v as Record<string, unknown>;
|
|
23
|
-
const n = Number(o.level);
|
|
24
|
-
const level = n >= 1 && n <= 5 ? (n as Level) : undefined;
|
|
25
|
-
return {
|
|
26
|
-
level,
|
|
27
|
-
allowTools: Array.isArray(o.allowTools) ? (o.allowTools as string[]) : [],
|
|
28
|
-
allowRisk: Array.isArray(o.allowRisk) ? (o.allowRisk as number[]) : [],
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
return { allowTools: [], allowRisk: [] };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Persist the current approvals to the thread KV (fire-and-forget). */
|
|
35
|
-
export function saveApprovals(api: ApiClient, threadId: string, perm: PermissionState): void {
|
|
36
|
-
const payload: ApprovalsKV = {
|
|
37
|
-
level: perm.level,
|
|
38
|
-
allowTools: Array.from(perm.alwaysAllow).sort(),
|
|
39
|
-
allowRisk: Array.from(perm.allowRisk).sort((a, b) => a - b),
|
|
40
|
-
};
|
|
41
|
-
void api.kvSet(threadId, KEY, payload);
|
|
42
|
-
}
|
package/src/bridge.ts
DELETED
|
@@ -1,303 +0,0 @@
|
|
|
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
|
-
}
|
package/src/credentials.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
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
|
-
}
|