@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/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
+ }