@nexus-aethra/dsh-box-sandbox 0.1.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/LICENSE +21 -0
- package/README.md +88 -0
- package/cordis.patch.yml +11 -0
- package/dist/index.d.mts +47 -0
- package/dist/index.mjs +2231 -0
- package/dist/rpc.d.mts +139 -0
- package/dist/rpc.mjs +186 -0
- package/package.json +62 -0
package/dist/rpc.d.mts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
//#region src/rpc.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* dshbox daemon client: discovery record + loopback JSON-RPC transport.
|
|
4
|
+
*
|
|
5
|
+
* dshboxd binds a DYNAMIC loopback port and authenticates every call with a
|
|
6
|
+
* token minted at launch. Both facts live in a discovery record the daemon
|
|
7
|
+
* writes atomically to <config-dir>/server/discovery.json and removes on
|
|
8
|
+
* shutdown. Nothing here is hardcoded: the record is re-read at call time, so
|
|
9
|
+
* a daemon restart (new port, new token) is transparent to callers instead
|
|
10
|
+
* of leaving them holding a stale endpoint.
|
|
11
|
+
*
|
|
12
|
+
* Wire contract, verified against dshboxd/src/main.rs:
|
|
13
|
+
*
|
|
14
|
+
* - POST /rpc is matched EXACTLY (path != "/rpc" -> 404), so the path
|
|
15
|
+
* carries no query string.
|
|
16
|
+
* - The token travels in the JSON BODY, not in an Authorization header. The
|
|
17
|
+
* Rust client sends a Bearer header too, but the daemon never reads it --
|
|
18
|
+
* sending it is harmless, relying on it silently fails.
|
|
19
|
+
* - Params are FLATTENED into the top-level request object alongside token
|
|
20
|
+
* and method; there is no nested params field.
|
|
21
|
+
* - Replies are {ok: true, result: ...} for sync methods and
|
|
22
|
+
* {ok: true, task: ...} for async ones, or {ok: false, error: ...}.
|
|
23
|
+
* The daemon closes the connection after each frame.
|
|
24
|
+
* - GET /events?token=... is the SSE stream, which takes the token from the
|
|
25
|
+
* query string instead.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-box-sandbox/rpc
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* The launch record dshboxd publishes while it is alive.
|
|
31
|
+
*
|
|
32
|
+
* 'endpoint' is a legacy Unix-domain-socket field kept only so that stale
|
|
33
|
+
* records from older daemon builds still deserialize; the daemon never
|
|
34
|
+
* writes it, so this interface does not model it.
|
|
35
|
+
*/
|
|
36
|
+
interface Discovery {
|
|
37
|
+
/** Bearer-equivalent secret minted at launch; required on every call. */
|
|
38
|
+
token: string;
|
|
39
|
+
/** Owning process id, for liveness checks and diagnostics. */
|
|
40
|
+
pid: number;
|
|
41
|
+
/** Unix seconds at launch; a client can use it to spot a stale record. */
|
|
42
|
+
startedAt: number;
|
|
43
|
+
/** Dynamic loopback port the daemon is listening on. */
|
|
44
|
+
port: number;
|
|
45
|
+
}
|
|
46
|
+
/** Constructor options for DshboxRpc. */
|
|
47
|
+
interface DshboxRpcOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Directory holding server/discovery.json. Defaults to DSHBOX_CONFIG_DIR
|
|
50
|
+
* when set, otherwise ~/.dsh-box -- the same precedence that
|
|
51
|
+
* box_foundation::config_path applies on the Rust side.
|
|
52
|
+
*/
|
|
53
|
+
configDir?: string;
|
|
54
|
+
/** Per-attempt request timeout in milliseconds. Default 30000. */
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Raised when no usable daemon can be reached: the discovery record is
|
|
59
|
+
* absent, unreadable, or describes an endpoint that refuses connections.
|
|
60
|
+
*
|
|
61
|
+
* It is distinct from a daemon-reported failure -- those come back as a
|
|
62
|
+
* plain Error carrying the daemon's own message, because the daemon was
|
|
63
|
+
* reached and answered.
|
|
64
|
+
*/
|
|
65
|
+
declare class DshboxUnavailableError extends Error {
|
|
66
|
+
readonly name = "DshboxUnavailableError";
|
|
67
|
+
constructor(message: string, options?: {
|
|
68
|
+
cause?: unknown;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the config directory, honouring DSHBOX_CONFIG_DIR.
|
|
73
|
+
*
|
|
74
|
+
* An empty override counts as absent: the plugin config defaults
|
|
75
|
+
* configDir to the empty string to mean "resolve it the way the daemon
|
|
76
|
+
* does", and treating that as a real path would look for discovery.json
|
|
77
|
+
* relative to the process working directory.
|
|
78
|
+
*/
|
|
79
|
+
declare function resolveConfigDir(override?: string): string;
|
|
80
|
+
/** Absolute path of the discovery record for a config directory. */
|
|
81
|
+
declare function discoveryPath(configDir?: string): string;
|
|
82
|
+
/**
|
|
83
|
+
* Read the discovery record, or null when the daemon is not running.
|
|
84
|
+
*
|
|
85
|
+
* A missing file and a malformed one are treated the same way on purpose:
|
|
86
|
+
* both mean "there is no endpoint to talk to", and the caller surfaces one
|
|
87
|
+
* actionable message instead of two different failure modes.
|
|
88
|
+
*/
|
|
89
|
+
declare function readDiscovery(configDir?: string): Discovery | null;
|
|
90
|
+
/** Transport for a single discovered daemon. */
|
|
91
|
+
declare class DshboxRpc {
|
|
92
|
+
readonly configDir: string;
|
|
93
|
+
private readonly timeoutMs;
|
|
94
|
+
constructor(options?: DshboxRpcOptions);
|
|
95
|
+
/**
|
|
96
|
+
* Return a usable record or explain why there is none.
|
|
97
|
+
*
|
|
98
|
+
* The message names the exact path looked at: "dshbox is not running" is
|
|
99
|
+
* the most common first-run failure, and the user needs to know WHICH
|
|
100
|
+
* profile directory was consulted before they start the app.
|
|
101
|
+
*/
|
|
102
|
+
discovery(): Discovery;
|
|
103
|
+
/** Loopback base URL for the currently discovered endpoint. */
|
|
104
|
+
endpointUrl(record: Discovery): string;
|
|
105
|
+
/** SSE stream URL. The token goes in the query string, not a header. */
|
|
106
|
+
eventsUrl(record: Discovery): string;
|
|
107
|
+
private send;
|
|
108
|
+
/**
|
|
109
|
+
* Invoke one daemon method.
|
|
110
|
+
*
|
|
111
|
+
* A transport failure is retried exactly once against a freshly read
|
|
112
|
+
* discovery record. That single retry is what absorbs a daemon restart:
|
|
113
|
+
* the first attempt uses the port from the old record, fails to connect,
|
|
114
|
+
* and the second uses the new one. Failures the daemon itself reports are
|
|
115
|
+
* never retried -- they are answers, not outages.
|
|
116
|
+
*/
|
|
117
|
+
call<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
|
|
118
|
+
/** Liveness probe. Resolves to the daemon's own status frame. */
|
|
119
|
+
ping(): Promise<{
|
|
120
|
+
pid: number;
|
|
121
|
+
status: string;
|
|
122
|
+
runtime: string;
|
|
123
|
+
startedAt: number;
|
|
124
|
+
}>;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Configuration for the lazily created singleton. Kept structural so rpc.ts
|
|
128
|
+
* does not have to import the plugin entry that owns the validated Config.
|
|
129
|
+
*/
|
|
130
|
+
interface RpcConfig {
|
|
131
|
+
configDir?: string;
|
|
132
|
+
timeoutMs?: number;
|
|
133
|
+
}
|
|
134
|
+
/** The active client, constructed from the given config on first use. */
|
|
135
|
+
declare function getRpc(config?: RpcConfig): DshboxRpc;
|
|
136
|
+
/** Drop the singleton so the next getRpc() picks up fresh config. */
|
|
137
|
+
declare function resetRpc(): void;
|
|
138
|
+
//#endregion
|
|
139
|
+
export { Discovery, DshboxRpc, DshboxRpcOptions, DshboxUnavailableError, RpcConfig, discoveryPath, getRpc, readDiscovery, resetRpc, resolveConfigDir };
|
package/dist/rpc.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region src/rpc.ts
|
|
5
|
+
/**
|
|
6
|
+
* dshbox daemon client: discovery record + loopback JSON-RPC transport.
|
|
7
|
+
*
|
|
8
|
+
* dshboxd binds a DYNAMIC loopback port and authenticates every call with a
|
|
9
|
+
* token minted at launch. Both facts live in a discovery record the daemon
|
|
10
|
+
* writes atomically to <config-dir>/server/discovery.json and removes on
|
|
11
|
+
* shutdown. Nothing here is hardcoded: the record is re-read at call time, so
|
|
12
|
+
* a daemon restart (new port, new token) is transparent to callers instead
|
|
13
|
+
* of leaving them holding a stale endpoint.
|
|
14
|
+
*
|
|
15
|
+
* Wire contract, verified against dshboxd/src/main.rs:
|
|
16
|
+
*
|
|
17
|
+
* - POST /rpc is matched EXACTLY (path != "/rpc" -> 404), so the path
|
|
18
|
+
* carries no query string.
|
|
19
|
+
* - The token travels in the JSON BODY, not in an Authorization header. The
|
|
20
|
+
* Rust client sends a Bearer header too, but the daemon never reads it --
|
|
21
|
+
* sending it is harmless, relying on it silently fails.
|
|
22
|
+
* - Params are FLATTENED into the top-level request object alongside token
|
|
23
|
+
* and method; there is no nested params field.
|
|
24
|
+
* - Replies are {ok: true, result: ...} for sync methods and
|
|
25
|
+
* {ok: true, task: ...} for async ones, or {ok: false, error: ...}.
|
|
26
|
+
* The daemon closes the connection after each frame.
|
|
27
|
+
* - GET /events?token=... is the SSE stream, which takes the token from the
|
|
28
|
+
* query string instead.
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-box-sandbox/rpc
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Raised when no usable daemon can be reached: the discovery record is
|
|
34
|
+
* absent, unreadable, or describes an endpoint that refuses connections.
|
|
35
|
+
*
|
|
36
|
+
* It is distinct from a daemon-reported failure -- those come back as a
|
|
37
|
+
* plain Error carrying the daemon's own message, because the daemon was
|
|
38
|
+
* reached and answered.
|
|
39
|
+
*/
|
|
40
|
+
var DshboxUnavailableError = class extends Error {
|
|
41
|
+
name = "DshboxUnavailableError";
|
|
42
|
+
constructor(message, options) {
|
|
43
|
+
super(message, options);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the config directory, honouring DSHBOX_CONFIG_DIR.
|
|
48
|
+
*
|
|
49
|
+
* An empty override counts as absent: the plugin config defaults
|
|
50
|
+
* configDir to the empty string to mean "resolve it the way the daemon
|
|
51
|
+
* does", and treating that as a real path would look for discovery.json
|
|
52
|
+
* relative to the process working directory.
|
|
53
|
+
*/
|
|
54
|
+
function resolveConfigDir(override) {
|
|
55
|
+
if (override !== void 0 && override !== "") return override;
|
|
56
|
+
return process.env.DSHBOX_CONFIG_DIR ?? join(homedir(), ".dsh-box");
|
|
57
|
+
}
|
|
58
|
+
/** Absolute path of the discovery record for a config directory. */
|
|
59
|
+
function discoveryPath(configDir) {
|
|
60
|
+
return join(resolveConfigDir(configDir), "server", "discovery.json");
|
|
61
|
+
}
|
|
62
|
+
function isDiscovery(value) {
|
|
63
|
+
if (typeof value !== "object" || value === null) return false;
|
|
64
|
+
const d = value;
|
|
65
|
+
return typeof d.token === "string" && d.token.length > 0 && typeof d.port === "number" && Number.isInteger(d.port) && d.port > 0 && d.port <= 65535 && typeof d.pid === "number" && typeof d.startedAt === "number";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Read the discovery record, or null when the daemon is not running.
|
|
69
|
+
*
|
|
70
|
+
* A missing file and a malformed one are treated the same way on purpose:
|
|
71
|
+
* both mean "there is no endpoint to talk to", and the caller surfaces one
|
|
72
|
+
* actionable message instead of two different failure modes.
|
|
73
|
+
*/
|
|
74
|
+
function readDiscovery(configDir) {
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
text = readFileSync(discoveryPath(configDir), "utf8");
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(text);
|
|
83
|
+
return isDiscovery(parsed) ? parsed : null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** True when a failure means "the discovery record is probably stale". */
|
|
89
|
+
function isStaleEndpoint(error) {
|
|
90
|
+
if (error instanceof DshboxUnavailableError) return true;
|
|
91
|
+
if (!(error instanceof Error)) return false;
|
|
92
|
+
const code = error.cause?.code;
|
|
93
|
+
return code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE";
|
|
94
|
+
}
|
|
95
|
+
/** Transport for a single discovered daemon. */
|
|
96
|
+
var DshboxRpc = class {
|
|
97
|
+
configDir;
|
|
98
|
+
timeoutMs;
|
|
99
|
+
constructor(options = {}) {
|
|
100
|
+
this.configDir = resolveConfigDir(options.configDir);
|
|
101
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Return a usable record or explain why there is none.
|
|
105
|
+
*
|
|
106
|
+
* The message names the exact path looked at: "dshbox is not running" is
|
|
107
|
+
* the most common first-run failure, and the user needs to know WHICH
|
|
108
|
+
* profile directory was consulted before they start the app.
|
|
109
|
+
*/
|
|
110
|
+
discovery() {
|
|
111
|
+
const path = discoveryPath(this.configDir);
|
|
112
|
+
const record = readDiscovery(this.configDir);
|
|
113
|
+
if (record === null) throw new DshboxUnavailableError("dshbox daemon is not running (no usable discovery record at " + path + "); start DSH Box first");
|
|
114
|
+
return record;
|
|
115
|
+
}
|
|
116
|
+
/** Loopback base URL for the currently discovered endpoint. */
|
|
117
|
+
endpointUrl(record) {
|
|
118
|
+
return "http://127.0.0.1:" + record.port;
|
|
119
|
+
}
|
|
120
|
+
/** SSE stream URL. The token goes in the query string, not a header. */
|
|
121
|
+
eventsUrl(record) {
|
|
122
|
+
return this.endpointUrl(record) + "/events?token=" + encodeURIComponent(record.token);
|
|
123
|
+
}
|
|
124
|
+
async send(record, method, params) {
|
|
125
|
+
const response = await fetch(this.endpointUrl(record) + "/rpc", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "Content-Type": "application/json" },
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
token: record.token,
|
|
130
|
+
method,
|
|
131
|
+
...params
|
|
132
|
+
}),
|
|
133
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
134
|
+
});
|
|
135
|
+
if (response.status === 401) throw new DshboxUnavailableError("dshbox daemon rejected the discovery token (it may have restarted)");
|
|
136
|
+
if (!response.ok) throw new Error("dshbox daemon returned HTTP " + response.status + " for " + method);
|
|
137
|
+
const frame = await response.json();
|
|
138
|
+
if (!frame.ok) throw new Error(frame.error ?? "dshbox daemon reported an unknown error for " + method);
|
|
139
|
+
return frame.result ?? frame.task ?? null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Invoke one daemon method.
|
|
143
|
+
*
|
|
144
|
+
* A transport failure is retried exactly once against a freshly read
|
|
145
|
+
* discovery record. That single retry is what absorbs a daemon restart:
|
|
146
|
+
* the first attempt uses the port from the old record, fails to connect,
|
|
147
|
+
* and the second uses the new one. Failures the daemon itself reports are
|
|
148
|
+
* never retried -- they are answers, not outages.
|
|
149
|
+
*/
|
|
150
|
+
async call(method, params) {
|
|
151
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
152
|
+
const record = this.discovery();
|
|
153
|
+
try {
|
|
154
|
+
return await this.send(record, method, params);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (attempt === 0 && isStaleEndpoint(error)) continue;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/* c8 ignore next 2 */
|
|
161
|
+
throw new DshboxUnavailableError("dshbox daemon unreachable for " + method);
|
|
162
|
+
}
|
|
163
|
+
/** Liveness probe. Resolves to the daemon's own status frame. */
|
|
164
|
+
ping() {
|
|
165
|
+
return this.call("ping");
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* One daemon per host, so the client is a module-level singleton.
|
|
170
|
+
*
|
|
171
|
+
* It is created lazily and torn down by resetRpc() on remount: cordis
|
|
172
|
+
* re-applies a plugin whenever its config changes, and a stale client would
|
|
173
|
+
* keep pointing at the previous config directory.
|
|
174
|
+
*/
|
|
175
|
+
let current = null;
|
|
176
|
+
/** The active client, constructed from the given config on first use. */
|
|
177
|
+
function getRpc(config) {
|
|
178
|
+
if (current === null) current = new DshboxRpc(config ?? {});
|
|
179
|
+
return current;
|
|
180
|
+
}
|
|
181
|
+
/** Drop the singleton so the next getRpc() picks up fresh config. */
|
|
182
|
+
function resetRpc() {
|
|
183
|
+
current = null;
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
export { DshboxRpc, DshboxUnavailableError, discoveryPath, getRpc, readDiscovery, resetRpc, resolveConfigDir };
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nexus-aethra/dsh-box-sandbox",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Host-side bridge from a DSH session to the DSH Box daemon: typed box_* tools an agent can call without a shell.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-box",
|
|
8
|
+
"cordis",
|
|
9
|
+
"agent",
|
|
10
|
+
"box",
|
|
11
|
+
"tools"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/aethra-tech/DSHBox/tree/main/plugins/dsh-box-sandbox",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/aethra-tech/DSHBox/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/aethra-tech/DSHBox.git",
|
|
20
|
+
"directory": "plugins/dsh-box-sandbox"
|
|
21
|
+
},
|
|
22
|
+
"author": "Nexus Aethra",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "dist/index.mjs",
|
|
26
|
+
"types": "dist/index.d.mts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.mts",
|
|
30
|
+
"default": "./dist/index.mjs"
|
|
31
|
+
},
|
|
32
|
+
"./rpc": {
|
|
33
|
+
"types": "./dist/rpc.d.mts",
|
|
34
|
+
"default": "./dist/rpc.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist/",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"dsh": {
|
|
45
|
+
"bundle": {
|
|
46
|
+
"patch": "./cordis.patch.yml"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.10.0",
|
|
54
|
+
"tsdown": "^0.22.2",
|
|
55
|
+
"typescript": "^5.6.0"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsdown",
|
|
59
|
+
"watch": "tsdown --watch",
|
|
60
|
+
"typecheck": "tsc --noEmit"
|
|
61
|
+
}
|
|
62
|
+
}
|