@vibedgc/sdk 0.6.4
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/LICENSE +201 -0
- package/README.md +53 -0
- package/dist/audit.d.ts +16 -0
- package/dist/audit.js +179 -0
- package/dist/changes.d.ts +26 -0
- package/dist/changes.js +377 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.js +472 -0
- package/dist/errors.d.ts +53 -0
- package/dist/errors.js +70 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/mcp-bridge.mjs +36 -0
- package/dist/policy.d.ts +128 -0
- package/dist/policy.js +795 -0
- package/dist/runtime.d.ts +31 -0
- package/dist/runtime.js +148 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +124 -0
- package/dist/session.d.ts +264 -0
- package/dist/session.js +1739 -0
- package/dist/state.d.ts +37 -0
- package/dist/state.js +218 -0
- package/dist/tools.d.ts +56 -0
- package/dist/tools.js +339 -0
- package/dist/transport.d.ts +72 -0
- package/dist/transport.js +495 -0
- package/dist/types.d.ts +362 -0
- package/dist/types.js +3 -0
- package/dist/usage.d.ts +50 -0
- package/dist/usage.js +149 -0
- package/package.json +40 -0
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Create `path` (and missing parents) and leave it owner-only (0700). */
|
|
2
|
+
export declare function privateDir(path: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* A private state directory, created when needed.
|
|
5
|
+
*
|
|
6
|
+
* `undefined` makes a fresh `mkdtemp` directory (0700). An existing directory must be owned by
|
|
7
|
+
* this user and must not be group- or world-writable; a readable one that we own is tightened
|
|
8
|
+
* to 0700. Anything else is refused, because another local user could have planted files the
|
|
9
|
+
* DGC child would read.
|
|
10
|
+
*/
|
|
11
|
+
export declare function prepareStateDir(stateDir?: string): string;
|
|
12
|
+
export declare function configPath(stateDir: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Serialize isolated-config writes and child startups for one stateDir within this process.
|
|
15
|
+
* (The Python SDK also takes a cross-process flock; Node has none, so use one stateDir per
|
|
16
|
+
* process. A config another process rewrote while a session started is still detected and the
|
|
17
|
+
* start is retried.)
|
|
18
|
+
*/
|
|
19
|
+
export declare class StateLock {
|
|
20
|
+
private tail;
|
|
21
|
+
hold<T>(work: () => Promise<T>): Promise<T>;
|
|
22
|
+
}
|
|
23
|
+
export declare function stateLock(stateDir: string): StateLock;
|
|
24
|
+
/**
|
|
25
|
+
* Atomically replace the isolated config.json with exactly `values` (undefined/null dropped).
|
|
26
|
+
* Hold {@link stateLock} while calling. Directories are 0700 and the file is 0600. Returns the
|
|
27
|
+
* values as they read back from JSON, for {@link configDrift}.
|
|
28
|
+
*/
|
|
29
|
+
export declare function writeSessionConfig(stateDir: string, values: Record<string, unknown>): Record<string, unknown>;
|
|
30
|
+
/**
|
|
31
|
+
* Keys of `expected` whose value in the isolated config.json now differs. A DGC child saves its
|
|
32
|
+
* whole configuration when it persists anything; if another session's child did that between
|
|
33
|
+
* our write and our child's startup, our child may have loaded that session's options.
|
|
34
|
+
*/
|
|
35
|
+
export declare function configDrift(stateDir: string, expected: Record<string, unknown>): string[];
|
|
36
|
+
/** Append `line` to an owner-only (0600) file, tightening an existing file's mode. */
|
|
37
|
+
export declare function appendPrivate(path: string, line: string): void;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private SDK state: the `stateDir` tree and the isolated config each session starts from.
|
|
3
|
+
* Mirrors sdk/python/dgc_sdk/_state.py.
|
|
4
|
+
*
|
|
5
|
+
* The isolated `home/.dgc/config.json` is owned by the SDK. Every session rewrites it from
|
|
6
|
+
* scratch, under a lock, from the options the embedder passed; nothing a previous session, run,
|
|
7
|
+
* or another local user left in that file is merged back in. That keeps per-session options
|
|
8
|
+
* (model, verifier, run budgets, trusted_dirs) out of later sessions, and stops a planted file
|
|
9
|
+
* from running commands (`verify_command`, `autonomous_gate`, `hooks`...). Extra CLI settings
|
|
10
|
+
* are allowed only when passed explicitly (`new DGC({ extraConfig })`).
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, closeSync, existsSync, fchmodSync, fstatSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeSync, constants as fsConstants, } from "node:fs";
|
|
13
|
+
import { tmpdir, userInfo } from "node:os";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { DGCConfigError } from "./errors.js";
|
|
17
|
+
function posixOwnerChecks() {
|
|
18
|
+
return process.platform !== "win32" && typeof process.geteuid === "function";
|
|
19
|
+
}
|
|
20
|
+
/** True for this user's own primary group with no other listed members (umask 002 hosts). */
|
|
21
|
+
function privateGroup(gid) {
|
|
22
|
+
try {
|
|
23
|
+
if (typeof process.getegid !== "function" || gid !== process.getegid())
|
|
24
|
+
return false;
|
|
25
|
+
const name = userInfo().username;
|
|
26
|
+
for (const line of readFileSync("/etc/group", "utf8").split("\n")) {
|
|
27
|
+
const parts = line.split(":");
|
|
28
|
+
if (parts.length >= 4 && Number(parts[2]) === gid) {
|
|
29
|
+
return parts[3].split(",").filter(Boolean).every((member) => member === name);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return true; // the group is not listed: nobody else is a member by name
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Create `path` (and missing parents) and leave it owner-only (0700). */
|
|
39
|
+
export function privateDir(path) {
|
|
40
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
41
|
+
if (process.platform !== "win32") {
|
|
42
|
+
try {
|
|
43
|
+
if ((statSync(path).mode & 0o777) !== 0o700)
|
|
44
|
+
chmodSync(path, 0o700);
|
|
45
|
+
}
|
|
46
|
+
catch { /* best effort */ }
|
|
47
|
+
}
|
|
48
|
+
return path;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A private state directory, created when needed.
|
|
52
|
+
*
|
|
53
|
+
* `undefined` makes a fresh `mkdtemp` directory (0700). An existing directory must be owned by
|
|
54
|
+
* this user and must not be group- or world-writable; a readable one that we own is tightened
|
|
55
|
+
* to 0700. Anything else is refused, because another local user could have planted files the
|
|
56
|
+
* DGC child would read.
|
|
57
|
+
*/
|
|
58
|
+
export function prepareStateDir(stateDir) {
|
|
59
|
+
if (stateDir === undefined || stateDir === null) {
|
|
60
|
+
return realpathSync(mkdtempSync(join(tmpdir(), "dgc-sdk-")));
|
|
61
|
+
}
|
|
62
|
+
if (typeof stateDir !== "string" || !stateDir.trim()) {
|
|
63
|
+
throw new DGCConfigError("stateDir must be a non-empty path");
|
|
64
|
+
}
|
|
65
|
+
let path = resolve(stateDir.startsWith("~/") ? join(userInfo().homedir, stateDir.slice(2)) : stateDir);
|
|
66
|
+
const existed = existsSync(path);
|
|
67
|
+
if (existed && !statSync(path).isDirectory()) {
|
|
68
|
+
throw new DGCConfigError(`stateDir ${JSON.stringify(path)} exists and is not a directory`);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw new DGCConfigError(`could not create stateDir ${JSON.stringify(path)}: ${String(error)}`);
|
|
75
|
+
}
|
|
76
|
+
path = realpathSync(path);
|
|
77
|
+
if (posixOwnerChecks()) {
|
|
78
|
+
const info = statSync(path);
|
|
79
|
+
if (info.uid !== process.geteuid()) {
|
|
80
|
+
throw new DGCConfigError(`stateDir ${JSON.stringify(path)} is owned by another user; use a directory you own `
|
|
81
|
+
+ "(or leave stateDir unset for a private temporary one)");
|
|
82
|
+
}
|
|
83
|
+
const mode = info.mode & 0o777;
|
|
84
|
+
if (existed && ((mode & 0o002) || ((mode & 0o020) && !privateGroup(info.gid)))) {
|
|
85
|
+
throw new DGCConfigError(`stateDir ${JSON.stringify(path)} is group- or world-writable (mode ${mode.toString(8)}); `
|
|
86
|
+
+ "files in it cannot be trusted. Use a private directory (mode 0700)");
|
|
87
|
+
}
|
|
88
|
+
if (mode !== 0o700) {
|
|
89
|
+
try {
|
|
90
|
+
chmodSync(path, 0o700);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
throw new DGCConfigError(`could not make stateDir ${JSON.stringify(path)} private: ${String(error)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return path;
|
|
98
|
+
}
|
|
99
|
+
export function configPath(stateDir) {
|
|
100
|
+
return join(stateDir, "home", ".dgc", "config.json");
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Serialize isolated-config writes and child startups for one stateDir within this process.
|
|
104
|
+
* (The Python SDK also takes a cross-process flock; Node has none, so use one stateDir per
|
|
105
|
+
* process. A config another process rewrote while a session started is still detected and the
|
|
106
|
+
* start is retried.)
|
|
107
|
+
*/
|
|
108
|
+
export class StateLock {
|
|
109
|
+
tail = Promise.resolve();
|
|
110
|
+
async hold(work) {
|
|
111
|
+
let release;
|
|
112
|
+
const gate = new Promise((done) => { release = done; });
|
|
113
|
+
const previous = this.tail;
|
|
114
|
+
this.tail = previous.then(() => gate);
|
|
115
|
+
await previous;
|
|
116
|
+
try {
|
|
117
|
+
return await work();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
release();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const LOCKS = new Map();
|
|
125
|
+
export function stateLock(stateDir) {
|
|
126
|
+
const key = resolve(stateDir);
|
|
127
|
+
let lock = LOCKS.get(key);
|
|
128
|
+
if (!lock) {
|
|
129
|
+
lock = new StateLock();
|
|
130
|
+
LOCKS.set(key, lock);
|
|
131
|
+
}
|
|
132
|
+
return lock;
|
|
133
|
+
}
|
|
134
|
+
function sortKeys(value) {
|
|
135
|
+
if (Array.isArray(value))
|
|
136
|
+
return value.map(sortKeys);
|
|
137
|
+
if (value && typeof value === "object") {
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const key of Object.keys(value).sort()) {
|
|
140
|
+
out[key] = sortKeys(value[key]);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Atomically replace the isolated config.json with exactly `values` (undefined/null dropped).
|
|
148
|
+
* Hold {@link stateLock} while calling. Directories are 0700 and the file is 0600. Returns the
|
|
149
|
+
* values as they read back from JSON, for {@link configDrift}.
|
|
150
|
+
*/
|
|
151
|
+
export function writeSessionConfig(stateDir, values) {
|
|
152
|
+
const payload = {};
|
|
153
|
+
for (const [key, value] of Object.entries(values)) {
|
|
154
|
+
if (value !== undefined && value !== null)
|
|
155
|
+
payload[key] = value;
|
|
156
|
+
}
|
|
157
|
+
const text = JSON.stringify(sortKeys(payload), null, 2) + "\n";
|
|
158
|
+
const home = privateDir(join(stateDir, "home"));
|
|
159
|
+
const folder = privateDir(join(home, ".dgc"));
|
|
160
|
+
const path = join(folder, "config.json");
|
|
161
|
+
const tmp = join(folder, `.config.json.${randomBytes(6).toString("hex")}.tmp`);
|
|
162
|
+
const fd = openSync(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
|
|
163
|
+
try {
|
|
164
|
+
writeSync(fd, text);
|
|
165
|
+
fsyncSync(fd);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
closeSync(fd);
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
renameSync(tmp, path);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
try {
|
|
175
|
+
unlinkSync(tmp);
|
|
176
|
+
}
|
|
177
|
+
catch { /* renamed */ }
|
|
178
|
+
}
|
|
179
|
+
return JSON.parse(text);
|
|
180
|
+
}
|
|
181
|
+
function same(left, right) {
|
|
182
|
+
return JSON.stringify(sortKeys(left)) === JSON.stringify(sortKeys(right));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Keys of `expected` whose value in the isolated config.json now differs. A DGC child saves its
|
|
186
|
+
* whole configuration when it persists anything; if another session's child did that between
|
|
187
|
+
* our write and our child's startup, our child may have loaded that session's options.
|
|
188
|
+
*/
|
|
189
|
+
export function configDrift(stateDir, expected) {
|
|
190
|
+
let current;
|
|
191
|
+
try {
|
|
192
|
+
current = JSON.parse(readFileSync(configPath(stateDir), "utf8"));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return ["config.json"];
|
|
196
|
+
}
|
|
197
|
+
if (!current || typeof current !== "object" || Array.isArray(current))
|
|
198
|
+
return ["config.json"];
|
|
199
|
+
const record = current;
|
|
200
|
+
return Object.keys(expected).filter((key) => !same(record[key], expected[key])).sort();
|
|
201
|
+
}
|
|
202
|
+
/** Append `line` to an owner-only (0600) file, tightening an existing file's mode. */
|
|
203
|
+
export function appendPrivate(path, line) {
|
|
204
|
+
const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT, 0o600);
|
|
205
|
+
try {
|
|
206
|
+
if (process.platform !== "win32") {
|
|
207
|
+
try {
|
|
208
|
+
if (fstatSync(fd).mode & 0o077)
|
|
209
|
+
fchmodSync(fd, 0o600);
|
|
210
|
+
}
|
|
211
|
+
catch { /* best effort */ }
|
|
212
|
+
}
|
|
213
|
+
writeSync(fd, line);
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
closeSync(fd);
|
|
217
|
+
}
|
|
218
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Frame, Transport } from "./transport.ts";
|
|
2
|
+
export type ToolHandler = (args: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
3
|
+
export type ToolSpec = {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
inputSchema: Record<string, unknown>;
|
|
7
|
+
handler: ToolHandler;
|
|
8
|
+
/** How long the handler may run (default 30 000 ms; null for no limit). */
|
|
9
|
+
timeoutMs?: number | null;
|
|
10
|
+
};
|
|
11
|
+
export declare const TOKEN_ENV = "DGC_SDK_TOOL_TOKEN";
|
|
12
|
+
export declare const SERVER_NAME = "app";
|
|
13
|
+
export declare const BRIDGE: string;
|
|
14
|
+
/**
|
|
15
|
+
* Describe a tool the agent can call (as `mcp__app__<name>`) and this process runs. When the
|
|
16
|
+
* handler runs past `timeoutMs` the agent is told the call failed, but the handler is not
|
|
17
|
+
* stopped: make handlers idempotent or bound their own work. Refuse or allow the tool with
|
|
18
|
+
* `RuntimePolicy.denyTools: ["mcp__app__<name>"]` or `allowTools`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function defineTool(name: string, description: string, inputSchema: Record<string, unknown>, handler: ToolHandler, options?: {
|
|
21
|
+
timeoutMs?: number | null;
|
|
22
|
+
}): ToolSpec;
|
|
23
|
+
export declare class ToolHub {
|
|
24
|
+
readonly token: string;
|
|
25
|
+
socketPath: string;
|
|
26
|
+
private directory;
|
|
27
|
+
private server;
|
|
28
|
+
private readonly tools;
|
|
29
|
+
private live;
|
|
30
|
+
/** True once the relay proved it holds the secret. */
|
|
31
|
+
authenticated: boolean;
|
|
32
|
+
/** Connections refused for a missing or wrong secret. */
|
|
33
|
+
rejected: number;
|
|
34
|
+
constructor(tools: readonly ToolSpec[], socketPath?: string);
|
|
35
|
+
get toolCount(): number;
|
|
36
|
+
start(): Promise<void>;
|
|
37
|
+
close(): void;
|
|
38
|
+
private removeFiles;
|
|
39
|
+
/** (runtime, persisted) MCP server specs; the secret rides only in the runtime spec's env. */
|
|
40
|
+
serverSpec(): {
|
|
41
|
+
runtime: Frame;
|
|
42
|
+
persisted: Frame;
|
|
43
|
+
};
|
|
44
|
+
private accept;
|
|
45
|
+
private callHandler;
|
|
46
|
+
private handle;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Start the host tool server, register it with DGC as MCP server `app`, and confirm it
|
|
50
|
+
* connected, authenticated and offered every tool. Throws DGCRuntimeError otherwise, rather than
|
|
51
|
+
* leaving a session whose model silently lacks the tools.
|
|
52
|
+
*/
|
|
53
|
+
export declare function installTools(transport: Transport, tools: readonly ToolSpec[], options: {
|
|
54
|
+
requestId: string;
|
|
55
|
+
timeoutMs: number;
|
|
56
|
+
}): Promise<ToolHub>;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application tools the agent calls as MCP server `app`, served from this process. Mirrors
|
|
3
|
+
* sdk/python/dgc_sdk/_mcp_bridge.py.
|
|
4
|
+
*
|
|
5
|
+
* DGC runs `node mcp-bridge.mjs SOCKET` with this session's secret in DGC_SDK_TOOL_TOKEN. The
|
|
6
|
+
* socket lives in a fresh 0700 directory under a random name (never under stateDir, never a
|
|
7
|
+
* predictable path), and a connection is served only after its first line carries the secret,
|
|
8
|
+
* so other users, and processes that do not hold the secret, cannot call the tools. One relay
|
|
9
|
+
* connection is served at a time.
|
|
10
|
+
*/
|
|
11
|
+
import { chmodSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { existsSync, statSync } from "node:fs";
|
|
16
|
+
import net from "node:net";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { DGCConfigError, DGCRuntimeError, DGCUnsupportedError } from "./errors.js";
|
|
19
|
+
import { VERSION } from "./types.js";
|
|
20
|
+
export const TOKEN_ENV = "DGC_SDK_TOOL_TOKEN";
|
|
21
|
+
export const SERVER_NAME = "app";
|
|
22
|
+
const HELLO_KEY = "dgc_sdk_bridge";
|
|
23
|
+
const HELLO_TIMEOUT_MS = 10_000;
|
|
24
|
+
const HELLO_MAX = 4096;
|
|
25
|
+
// sun_path holds 104 bytes on macOS and 108 on Linux, terminator included.
|
|
26
|
+
const SOCKET_PATH_MAX = 100;
|
|
27
|
+
export const BRIDGE = fileURLToPath(new URL("./mcp-bridge.mjs", import.meta.url));
|
|
28
|
+
/**
|
|
29
|
+
* Describe a tool the agent can call (as `mcp__app__<name>`) and this process runs. When the
|
|
30
|
+
* handler runs past `timeoutMs` the agent is told the call failed, but the handler is not
|
|
31
|
+
* stopped: make handlers idempotent or bound their own work. Refuse or allow the tool with
|
|
32
|
+
* `RuntimePolicy.denyTools: ["mcp__app__<name>"]` or `allowTools`.
|
|
33
|
+
*/
|
|
34
|
+
export function defineTool(name, description, inputSchema, handler, options = {}) {
|
|
35
|
+
if (!name || typeof name !== "string" || !/^[\w-]+$/.test(name) || !/[A-Za-z0-9]/.test(name.replace(/[_-]/g, ""))) {
|
|
36
|
+
throw new DGCConfigError("tool name must be a non-empty identifier");
|
|
37
|
+
}
|
|
38
|
+
if (!description || typeof description !== "string")
|
|
39
|
+
throw new DGCConfigError("tool description is required");
|
|
40
|
+
if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
|
|
41
|
+
throw new DGCConfigError("tool inputSchema must be an object");
|
|
42
|
+
}
|
|
43
|
+
if (typeof handler !== "function")
|
|
44
|
+
throw new DGCConfigError("tool handler must be callable");
|
|
45
|
+
const timeoutMs = options.timeoutMs === undefined ? 30_000 : options.timeoutMs;
|
|
46
|
+
if (timeoutMs !== null && (typeof timeoutMs !== "number" || !(timeoutMs > 0) || !Number.isFinite(timeoutMs))) {
|
|
47
|
+
throw new DGCConfigError("tool timeoutMs must be a positive number of milliseconds, or null");
|
|
48
|
+
}
|
|
49
|
+
return { name, description, inputSchema, handler, timeoutMs };
|
|
50
|
+
}
|
|
51
|
+
/** A fresh 0700 directory with a random socket name, short enough for sun_path. */
|
|
52
|
+
function privateSocketPath() {
|
|
53
|
+
const bases = [];
|
|
54
|
+
for (const base of [process.env.XDG_RUNTIME_DIR || "", tmpdir(), "/tmp"]) {
|
|
55
|
+
try {
|
|
56
|
+
if (base && !bases.includes(base) && statSync(base).isDirectory())
|
|
57
|
+
bases.push(base);
|
|
58
|
+
}
|
|
59
|
+
catch { /* not there */ }
|
|
60
|
+
}
|
|
61
|
+
for (const base of bases) {
|
|
62
|
+
let directory;
|
|
63
|
+
try {
|
|
64
|
+
directory = mkdtempSync(join(base, "dgc-"));
|
|
65
|
+
chmodSync(directory, 0o700);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const path = join(directory, randomBytes(4).toString("hex") + ".sock");
|
|
71
|
+
if (Buffer.byteLength(path) <= SOCKET_PATH_MAX)
|
|
72
|
+
return { directory, path };
|
|
73
|
+
rmSync(directory, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
throw new DGCConfigError("custom tools need a Unix socket path under 100 bytes; none of "
|
|
76
|
+
+ `${bases.join(", ") || "the temporary directories"} is short enough (set TMPDIR to a short directory)`);
|
|
77
|
+
}
|
|
78
|
+
function same(left, right) {
|
|
79
|
+
const a = Buffer.from(left, "utf8");
|
|
80
|
+
const b = Buffer.from(right, "utf8");
|
|
81
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
82
|
+
}
|
|
83
|
+
export class ToolHub {
|
|
84
|
+
token = randomBytes(32).toString("base64url");
|
|
85
|
+
socketPath = "";
|
|
86
|
+
directory = "";
|
|
87
|
+
server = null;
|
|
88
|
+
tools;
|
|
89
|
+
live = null;
|
|
90
|
+
/** True once the relay proved it holds the secret. */
|
|
91
|
+
authenticated = false;
|
|
92
|
+
/** Connections refused for a missing or wrong secret. */
|
|
93
|
+
rejected = 0;
|
|
94
|
+
constructor(tools, socketPath) {
|
|
95
|
+
this.tools = new Map(tools.map((item) => [item.name, item]));
|
|
96
|
+
if (socketPath)
|
|
97
|
+
this.socketPath = socketPath;
|
|
98
|
+
}
|
|
99
|
+
get toolCount() {
|
|
100
|
+
return this.tools.size;
|
|
101
|
+
}
|
|
102
|
+
async start() {
|
|
103
|
+
if (process.platform === "win32") {
|
|
104
|
+
throw new DGCUnsupportedError("custom tools need Unix domain sockets, which this SDK does not use on Windows");
|
|
105
|
+
}
|
|
106
|
+
if (!this.socketPath)
|
|
107
|
+
({ directory: this.directory, path: this.socketPath } = privateSocketPath());
|
|
108
|
+
const server = net.createServer((socket) => this.accept(socket));
|
|
109
|
+
try {
|
|
110
|
+
await new Promise((resolve, reject) => {
|
|
111
|
+
server.once("error", reject);
|
|
112
|
+
server.listen(this.socketPath, () => {
|
|
113
|
+
server.off("error", reject);
|
|
114
|
+
resolve();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
chmodSync(this.socketPath, 0o600);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
server.close();
|
|
121
|
+
this.removeFiles();
|
|
122
|
+
throw new DGCConfigError(`custom tools could not open their socket: ${String(error)}`);
|
|
123
|
+
}
|
|
124
|
+
server.on("error", () => { });
|
|
125
|
+
this.server = server;
|
|
126
|
+
}
|
|
127
|
+
close() {
|
|
128
|
+
try {
|
|
129
|
+
this.server?.close();
|
|
130
|
+
}
|
|
131
|
+
catch { /* */ }
|
|
132
|
+
this.server = null;
|
|
133
|
+
try {
|
|
134
|
+
this.live?.destroy();
|
|
135
|
+
}
|
|
136
|
+
catch { /* */ }
|
|
137
|
+
this.live = null;
|
|
138
|
+
this.removeFiles();
|
|
139
|
+
}
|
|
140
|
+
removeFiles() {
|
|
141
|
+
try {
|
|
142
|
+
if (this.socketPath && existsSync(this.socketPath))
|
|
143
|
+
rmSync(this.socketPath, { force: true });
|
|
144
|
+
}
|
|
145
|
+
catch { /* */ }
|
|
146
|
+
if (this.directory) {
|
|
147
|
+
rmSync(this.directory, { recursive: true, force: true });
|
|
148
|
+
this.directory = "";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** (runtime, persisted) MCP server specs; the secret rides only in the runtime spec's env. */
|
|
152
|
+
serverSpec() {
|
|
153
|
+
const persisted = {
|
|
154
|
+
transport: "stdio", command: process.execPath, args: [BRIDGE, this.socketPath],
|
|
155
|
+
env_names: [TOKEN_ENV], log_level: "warning",
|
|
156
|
+
};
|
|
157
|
+
return { runtime: { ...persisted, env: { [TOKEN_ENV]: this.token } }, persisted };
|
|
158
|
+
}
|
|
159
|
+
accept(socket) {
|
|
160
|
+
let buf = Buffer.alloc(0);
|
|
161
|
+
let ready = false;
|
|
162
|
+
const timer = setTimeout(() => socket.destroy(), HELLO_TIMEOUT_MS);
|
|
163
|
+
timer.unref();
|
|
164
|
+
const refuse = () => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
this.rejected += 1;
|
|
167
|
+
socket.destroy();
|
|
168
|
+
};
|
|
169
|
+
socket.on("error", () => { });
|
|
170
|
+
socket.on("close", () => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
if (this.live === socket)
|
|
173
|
+
this.live = null;
|
|
174
|
+
});
|
|
175
|
+
socket.on("data", (chunk) => {
|
|
176
|
+
buf = Buffer.concat([buf, chunk]);
|
|
177
|
+
if (!ready) {
|
|
178
|
+
const nl = buf.indexOf(0x0a);
|
|
179
|
+
if (nl < 0) {
|
|
180
|
+
if (buf.length > HELLO_MAX)
|
|
181
|
+
refuse();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
let hello;
|
|
185
|
+
try {
|
|
186
|
+
hello = JSON.parse(buf.subarray(0, nl).toString("utf8"));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
refuse();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const token = hello && typeof hello === "object" ? hello.token : undefined;
|
|
193
|
+
// One relay at a time: while one is connected, nobody else is served, even with the secret.
|
|
194
|
+
if (typeof token !== "string" || !same(token, this.token) || (this.live && !this.live.destroyed)) {
|
|
195
|
+
refuse();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
ready = true;
|
|
200
|
+
this.live = socket;
|
|
201
|
+
this.authenticated = true;
|
|
202
|
+
buf = buf.subarray(nl + 1);
|
|
203
|
+
}
|
|
204
|
+
let nl;
|
|
205
|
+
while ((nl = buf.indexOf(0x0a)) !== -1) {
|
|
206
|
+
const line = buf.subarray(0, nl).toString("utf8").trim();
|
|
207
|
+
buf = buf.subarray(nl + 1);
|
|
208
|
+
if (!line)
|
|
209
|
+
continue;
|
|
210
|
+
let message;
|
|
211
|
+
try {
|
|
212
|
+
message = JSON.parse(line);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
void this.handle(message).then((reply) => {
|
|
218
|
+
if (reply && !socket.destroyed)
|
|
219
|
+
socket.write(JSON.stringify(reply) + "\n");
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async callHandler(spec, args) {
|
|
225
|
+
const work = Promise.resolve().then(() => spec.handler(args));
|
|
226
|
+
const limit = spec.timeoutMs === undefined ? 30_000 : spec.timeoutMs;
|
|
227
|
+
if (limit === null)
|
|
228
|
+
return work;
|
|
229
|
+
let timer;
|
|
230
|
+
try {
|
|
231
|
+
return await Promise.race([
|
|
232
|
+
work,
|
|
233
|
+
new Promise((_resolve, reject) => {
|
|
234
|
+
timer = setTimeout(() => reject(Object.assign(new Error("handler exceeded timeout"), { name: "TimeoutError" })), limit);
|
|
235
|
+
}),
|
|
236
|
+
]);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
if (timer)
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async handle(message) {
|
|
244
|
+
const method = String(message.method || "");
|
|
245
|
+
const id = message.id;
|
|
246
|
+
if (method === "initialize") {
|
|
247
|
+
return {
|
|
248
|
+
jsonrpc: "2.0", id,
|
|
249
|
+
result: {
|
|
250
|
+
protocolVersion: "2025-11-25",
|
|
251
|
+
capabilities: { tools: {} },
|
|
252
|
+
serverInfo: { name: "dgc-sdk", version: VERSION },
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (method === "notifications/initialized" || method === "notifications/cancelled")
|
|
257
|
+
return null;
|
|
258
|
+
if (method === "ping")
|
|
259
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
260
|
+
if (method === "tools/list") {
|
|
261
|
+
const tools = [...this.tools.values()].map((spec) => ({
|
|
262
|
+
name: spec.name,
|
|
263
|
+
description: spec.description,
|
|
264
|
+
inputSchema: spec.inputSchema && Object.keys(spec.inputSchema).length
|
|
265
|
+
? spec.inputSchema : { type: "object", properties: {} },
|
|
266
|
+
}));
|
|
267
|
+
return { jsonrpc: "2.0", id, result: { tools } };
|
|
268
|
+
}
|
|
269
|
+
if (method === "tools/call") {
|
|
270
|
+
const params = (message.params && typeof message.params === "object")
|
|
271
|
+
? message.params : {};
|
|
272
|
+
const name = String(params.name || "");
|
|
273
|
+
const args = (params.arguments && typeof params.arguments === "object")
|
|
274
|
+
? params.arguments : {};
|
|
275
|
+
const spec = this.tools.get(name);
|
|
276
|
+
if (!spec)
|
|
277
|
+
return { jsonrpc: "2.0", id, error: { code: -32601, message: `unknown tool ${name}` } };
|
|
278
|
+
try {
|
|
279
|
+
const result = await this.callHandler(spec, args);
|
|
280
|
+
const text = typeof result === "string" ? result : JSON.stringify(result ?? null);
|
|
281
|
+
return {
|
|
282
|
+
jsonrpc: "2.0", id,
|
|
283
|
+
result: { content: [{ type: "text", text: String(text).slice(0, 120_000) }], isError: false },
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
288
|
+
return {
|
|
289
|
+
jsonrpc: "2.0", id,
|
|
290
|
+
result: { content: [{ type: "text", text: `tool error: ${err.name}: ${err.message}` }], isError: true },
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (id !== undefined) {
|
|
295
|
+
return { jsonrpc: "2.0", id, error: { code: -32601, message: `unsupported method ${method}` } };
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function catalogProblem(catalog, expected) {
|
|
301
|
+
if (catalog.error)
|
|
302
|
+
return String(catalog.error);
|
|
303
|
+
const items = Array.isArray(catalog.items) ? catalog.items : [];
|
|
304
|
+
const entry = items.find((item) => item && typeof item === "object" && item.name === SERVER_NAME);
|
|
305
|
+
if (!entry)
|
|
306
|
+
return "the runtime did not register the tool server";
|
|
307
|
+
const state = String(entry.state || "");
|
|
308
|
+
if (entry.error || state !== "connected")
|
|
309
|
+
return String(entry.error || `the tool server is ${state || "not connected"}`);
|
|
310
|
+
const offered = Number(entry.tool_count || 0);
|
|
311
|
+
if (offered < expected)
|
|
312
|
+
return `the tool server offered ${offered} of ${expected} tools`;
|
|
313
|
+
return "";
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Start the host tool server, register it with DGC as MCP server `app`, and confirm it
|
|
317
|
+
* connected, authenticated and offered every tool. Throws DGCRuntimeError otherwise, rather than
|
|
318
|
+
* leaving a session whose model silently lacks the tools.
|
|
319
|
+
*/
|
|
320
|
+
export async function installTools(transport, tools, options) {
|
|
321
|
+
const hub = new ToolHub(tools);
|
|
322
|
+
await hub.start();
|
|
323
|
+
try {
|
|
324
|
+
const { runtime, persisted } = hub.serverSpec();
|
|
325
|
+
const catalog = await transport.request({
|
|
326
|
+
type: "upsert_mcp_server", request_id: options.requestId, name: SERVER_NAME, runtime, persisted,
|
|
327
|
+
}, "mcp_servers", { timeoutMs: options.timeoutMs });
|
|
328
|
+
let problem = catalogProblem(catalog, hub.toolCount);
|
|
329
|
+
if (!problem && !hub.authenticated)
|
|
330
|
+
problem = "the tool bridge never authenticated to the application";
|
|
331
|
+
if (problem)
|
|
332
|
+
throw new DGCRuntimeError(`custom tools failed to start: ${problem}`);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
hub.close();
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
return hub;
|
|
339
|
+
}
|