@nexus-aethra/dsh-box-sandbox 0.1.0 → 0.1.9

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 CHANGED
@@ -81,8 +81,48 @@ container.
81
81
 
82
82
  pnpm install
83
83
  pnpm typecheck
84
+ pnpm check:outputs # static: schema vs what the daemon returns
85
+ pnpm check:tools # live: every tool called against a running Box
84
86
  pnpm build
85
- node smoke.mjs # 10 checks against a running DSH Box
86
87
 
87
- `smoke.mjs` needs a live daemon: it reads the real discovery record, pings
88
- it, forces a stale-port failure, and verifies recovery.
88
+ `check:tools` needs a running daemon: it imports the built plugin with a stub
89
+ host context, calls every tool's `execute` for real, and validates each answer
90
+ against that tool's own output schema — the same check the host applies, at the
91
+ moment the answer comes back. It exists because a tool whose schema disagrees
92
+ with its result does not fail at build time; it fails when someone presses the
93
+ button, and the only way to see that is to press it.
94
+
95
+ `check:tools` is a local check and does not run in CI, which has no daemon.
96
+ The tag pipeline runs `check:outputs` instead, because that one needs nothing
97
+ but the repository.
98
+
99
+ node smoke.mjs # transport-level: ping, stale-port failure, recovery
100
+
101
+ `smoke.mjs` reads the real discovery record and checks the wire contract.
102
+
103
+ ## Releasing
104
+
105
+ This package's version is the DSH Box app's version. One tag, one release, and
106
+ `release.yml` checks all four manifests against it before anything is built —
107
+ bumping the app and forgetting this one fails the pipeline rather than
108
+ publishing a plugin whose version claims to match a build it was never part of.
109
+
110
+ To release:
111
+
112
+ 1. Bump `version` in this `package.json`, the root `package.json`,
113
+ `src-tauri/tauri.conf.json` and `src-tauri/Cargo.toml` to the same number.
114
+ 2. Run `pnpm check:tools` against a real Box. CI cannot — it has no daemon —
115
+ so this is the only place the live check happens.
116
+ 3. Commit, tag `v<version>`, push the tag.
117
+
118
+ The `publish-plugin` job runs after the installers are built, so a release
119
+ whose app build failed does not put a version on npm. It typechecks, runs the
120
+ schema reconciliation, builds `dist/` (gitignored, so never restored from the
121
+ commit), prints the tarball contents, skips a version npm already has, refuses
122
+ to publish below what npm has, and publishes with provenance.
123
+
124
+ Auth is npm trusted publishing over OIDC — there is no long-lived token to
125
+ leak. One-time setup on npmjs.com, under the package's Settings → Trusted
126
+ Publishers: provider **GitHub Actions**, account `aethra-tech`, repository
127
+ `DSHBox`, workflow filename `release.yml`. Rename that file and npm has to be
128
+ told again.
package/dist/index.d.mts CHANGED
@@ -4,9 +4,9 @@ import { Context } from "@deepseek-ai/cordis";
4
4
  /** Cordis plugin name used by loader diagnostics and the patch overlay. */
5
5
  declare const name = "dsh-box-sandbox";
6
6
  /**
7
- * Services this plugin depends on. Empty for now: the client is pure
8
- * transport and needs no host service. Tools built on top of it will
9
- * declare their own.
7
+ * Services this plugin depends on. `tools` to register the box_* surface and
8
+ * `systemPrompt` for the cross-tool workflow section; the client itself is
9
+ * pure transport and needs nothing.
10
10
  */
11
11
  declare const inject: string[];
12
12
  /**
package/dist/index.mjs CHANGED
@@ -165,11 +165,6 @@ async function withSession(id, method, params) {
165
165
  });
166
166
  }
167
167
  }
168
- function base64Of(value) {
169
- const data = asRecord(value).data;
170
- if (typeof data !== "string") throw new Error("screenshot result carried no image data");
171
- return data;
172
- }
173
168
  /**
174
169
  * Register one tool against the host registry.
175
170
  *
@@ -211,6 +206,14 @@ function nextHint(next) {
211
206
  return "\nnext: " + next;
212
207
  }
213
208
  /**
209
+ * A string that may legitimately be absent.
210
+ *
211
+ * The daemon reports these as ${Option<String>}: a miss is null, not
212
+ * an empty string, and a schema that says $string alone rejects it — so a
213
+ * click that worked is reported as a failed tool call.
214
+ */
215
+ const nullable = { oneOf: [{ type: "string" }, { type: "null" }] };
216
+ /**
214
217
  * Render a click, distinguishing the two outcomes an agent must not confuse.
215
218
  *
216
219
  * A dispatched event is not a delivered one. When something covers the target
@@ -236,7 +239,7 @@ function renderClick(value, target) {
236
239
  function applyBoxTools(ctx) {
237
240
  registerTool(ctx, {
238
241
  name: "box_screenshot",
239
- description: "Save what a running dshbox container currently renders as a PNG, and report where.This is the one tool whose picture you do not get: a tool result is assistant-side contentand the host sends text only on that side, so you receive the dimensions and nothing else.Asking yourself to look at the image asks for something that cannot arrive, and re-readingthe file path does not change that. So take one to leave something a person can look at,and use box_page_text to find out what is on the page. In practice: describe the page inyour answer, and save a PNG when the layout or a rendering is what is worth showingrather than describing.",
242
+ description: "Save what a running dshbox container currently renders as a PNG, and report where.You get the file path and the image size, not the picture: a tool result is assistant-sidecontent and the host sends text only on that side, so an image cannot arrive. Hand the pathto a person to look at, and use box_page_text to find out what is on the page. In practice:describe the page in your answer from box_page_text, and take a screenshot when the layoutor a rendering is what is worth showing rather than describing.",
240
243
  parameters: {
241
244
  type: "object",
242
245
  properties: {
@@ -258,35 +261,38 @@ function applyBoxTools(ctx) {
258
261
  additionalProperties: false,
259
262
  properties: {
260
263
  containerId: { type: "string" },
261
- attachment: { type: "object" }
264
+ path: { type: "string" },
265
+ width: { type: "number" },
266
+ height: { type: "number" },
267
+ bytes: { type: "number" }
262
268
  },
263
- required: ["containerId", "attachment"]
269
+ required: [
270
+ "containerId",
271
+ "path",
272
+ "width",
273
+ "height",
274
+ "bytes"
275
+ ]
264
276
  },
265
277
  render: (_args, value) => {
266
278
  const record = asRecord(value);
267
- const shot = asRecord(record.attachment);
268
279
  return [{
269
280
  type: "text",
270
- text: "Screenshot of container " + String(record.containerId) + " at " + String(shot.width) + "x" + String(shot.height) + "."
271
- }, {
272
- type: "image",
273
- attachment: record.attachment
281
+ text: "Screenshot of container " + String(record.containerId) + " (" + String(record.width) + "x" + String(record.height) + ", " + String(record.bytes) + " bytes) saved to " + String(record.path) + "."
274
282
  }];
275
283
  }
276
284
  },
277
285
  async execute(args) {
278
286
  const containerId = containerIdOf(args);
279
- const raw = await withSession(containerId, "debug_capture_screenshot", {
280
- format: "png",
281
- fullPage: asRecord(args).fullPage === true
282
- });
283
- const data = Buffer.from(base64Of(raw), "base64");
287
+ const result = await withSession(containerId, "debug_capture_screenshot", { fullPage: asRecord(args).fullPage === true });
288
+ const path = result.path;
289
+ if (typeof path !== "string" || path === "") throw new Error("screenshot result carried no file path");
284
290
  return {
285
291
  containerId,
286
- attachment: await hostServices(ctx).attachments.saveImage({
287
- data: new Uint8Array(data),
288
- mediaType: "image/png"
289
- })
292
+ path,
293
+ width: result.width,
294
+ height: result.height,
295
+ bytes: result.bytes
290
296
  };
291
297
  }
292
298
  });
@@ -327,12 +333,12 @@ function applyBoxTools(ctx) {
327
333
  properties: {
328
334
  containerId: { type: "string" },
329
335
  name: { type: "string" },
330
- matched: { type: "string" },
336
+ matched: nullable,
331
337
  landed: { type: "boolean" },
332
- hitTag: { type: "string" },
333
- hitText: { type: "string" },
334
- occludedBy: { type: "string" },
335
- selector: { type: "string" },
338
+ hitTag: nullable,
339
+ hitText: nullable,
340
+ occludedBy: nullable,
341
+ selector: nullable,
336
342
  x: { type: "number" },
337
343
  y: { type: "number" },
338
344
  tag: { type: "string" },
@@ -406,12 +412,17 @@ function applyBoxTools(ctx) {
406
412
  properties: {
407
413
  containerId: { type: "string" },
408
414
  x: { type: "number" },
409
- y: { type: "number" }
415
+ y: { type: "number" },
416
+ landed: { type: "boolean" },
417
+ hitTag: nullable,
418
+ hitText: nullable,
419
+ occludedBy: nullable
410
420
  },
411
421
  required: [
412
422
  "containerId",
413
423
  "x",
414
- "y"
424
+ "y",
425
+ "landed"
415
426
  ]
416
427
  },
417
428
  render: (_args, value) => renderClick(value, "at (" + String(asRecord(value).x) + ", " + String(asRecord(value).y) + ")")
@@ -533,7 +544,9 @@ function applyBoxTools(ctx) {
533
544
  scrollHeight: { type: "number" },
534
545
  viewportHeight: { type: "number" },
535
546
  screensBelow: { type: "number" },
536
- moved: { type: "boolean" }
547
+ moved: { type: "boolean" },
548
+ scroller: nullable,
549
+ scrollerIsDocument: { type: "boolean" }
537
550
  },
538
551
  required: [
539
552
  "containerId",
@@ -587,7 +600,7 @@ function applyBoxTools(ctx) {
587
600
  properties: {
588
601
  containerId: { type: "string" },
589
602
  inserted: { type: "number" },
590
- focused: { type: "string" },
603
+ focused: nullable,
591
604
  landed: { type: "boolean" }
592
605
  },
593
606
  required: [
@@ -608,9 +621,97 @@ function applyBoxTools(ctx) {
608
621
  const containerId = containerIdOf(args);
609
622
  const record = asRecord(args);
610
623
  if (typeof record.text !== "string" || record.text === "") throw new Error("text is required and must be non-empty");
624
+ const result = await withSession(containerId, "debug_type_text", { text: record.text });
611
625
  return {
612
626
  containerId,
613
- ...await withSession(containerId, "debug_type_text", { text: record.text })
627
+ inserted: result.inserted,
628
+ focused: result.focused,
629
+ landed: result.focused !== null
630
+ };
631
+ }
632
+ });
633
+ registerTool(ctx, {
634
+ name: "box_console",
635
+ description: "Read what the page has logged, and clear the buffer — a second call reports only what arrived since the first. Uncaught errors are included whether or not any code caught them, which is the point: a client plugin that fails to register leaves a page that looks fine and says nothing, and this is the only tool that sees it. Repeated identical lines are collapsed into one entry with a count. The levels default to error and warning because log-level traffic is usually dozens of lines per page load; pass levels to ask for the rest when the failures are not the problem.",
636
+ parameters: {
637
+ type: "object",
638
+ properties: {
639
+ containerId: {
640
+ type: "string",
641
+ description: "Container whose console to read."
642
+ },
643
+ levels: {
644
+ type: "array",
645
+ items: {
646
+ type: "string",
647
+ enum: [
648
+ "log",
649
+ "info",
650
+ "debug",
651
+ "warning",
652
+ "error",
653
+ "assert"
654
+ ]
655
+ },
656
+ description: "Which severities to return. Defaults to error and warning."
657
+ },
658
+ limit: {
659
+ type: "number",
660
+ description: "Most recent entries to return. Defaults to 50, capped at 500."
661
+ }
662
+ },
663
+ required: ["containerId"]
664
+ },
665
+ timeoutMs: WARM_BUDGET_MS,
666
+ output: {
667
+ schema: {
668
+ type: "object",
669
+ additionalProperties: false,
670
+ properties: {
671
+ containerId: { type: "string" },
672
+ entries: { type: "array" },
673
+ total: { type: "number" },
674
+ truncated: { type: "boolean" },
675
+ raw: { type: "number" },
676
+ methods: { type: "array" }
677
+ },
678
+ required: [
679
+ "containerId",
680
+ "entries",
681
+ "total",
682
+ "truncated",
683
+ "raw",
684
+ "methods"
685
+ ]
686
+ },
687
+ render: (_args, value) => {
688
+ const record = asRecord(value);
689
+ const entries = Array.isArray(record.entries) ? record.entries : [];
690
+ if (entries.length === 0) return [{
691
+ type: "text",
692
+ text: "The page in container " + String(record.containerId) + " logged nothing at the levels asked for, and logged nothing since the last read." + nextHint("if the page was just loaded, try again after the failing step, or widen levels to log")
693
+ }];
694
+ const lines = entries.map((entry) => {
695
+ const item = asRecord(entry);
696
+ const where = item.source ? " (" + String(item.source) + ")" : "";
697
+ const times = Number(item.count ?? 1) > 1 ? " x" + String(item.count) : "";
698
+ return "[" + String(item.level) + "]" + where + " " + String(item.text) + times;
699
+ });
700
+ return [{
701
+ type: "text",
702
+ text: "Console of container " + String(record.containerId) + " — " + String(record.total) + " distinct message(s), oldest first" + (record.truncated === true ? " (truncated to the most recent)" : "") + ". Reading this cleared the buffer.\n" + lines.join("\n")
703
+ }];
704
+ }
705
+ },
706
+ async execute(args) {
707
+ const containerId = containerIdOf(args);
708
+ const record = asRecord(args);
709
+ const request = {};
710
+ if (Array.isArray(record.levels) && record.levels.length > 0) request.levels = record.levels;
711
+ if (typeof record.limit === "number") request.limit = record.limit;
712
+ return {
713
+ containerId,
714
+ ...await withSession(containerId, "debug_console", request)
614
715
  };
615
716
  }
616
717
  });
@@ -681,8 +782,8 @@ function applyBoxTools(ctx) {
681
782
  kind: { type: "string" },
682
783
  path: { type: "string" },
683
784
  configured: { type: "boolean" },
684
- configuredPath: { type: "string" },
685
- problem: { type: "string" }
785
+ configuredPath: nullable,
786
+ problem: nullable
686
787
  },
687
788
  required: ["available"]
688
789
  },
@@ -739,7 +840,7 @@ function applyBoxTools(ctx) {
739
840
  const containerId = containerIdOf(args);
740
841
  return {
741
842
  containerId,
742
- ...await getRpc().call("debug_close", { id: containerId })
843
+ closed: (await getRpc().call("debug_close", { id: containerId })).closed === true
743
844
  };
744
845
  }
745
846
  });
@@ -1260,6 +1361,7 @@ function registerManageTools(ctx) {
1260
1361
  };
1261
1362
  const title = optional(record, "title");
1262
1363
  if (title !== void 0) params.title = title;
1364
+ if (flag(record, "restart")) params.restart = true;
1263
1365
  const added = await rpc.call("add_container_workspace", params);
1264
1366
  return {
1265
1367
  action,
@@ -1725,7 +1827,7 @@ function registerManageTools(ctx) {
1725
1827
  "from-version",
1726
1828
  "describe",
1727
1829
  "url",
1728
- "browse"
1830
+ "browse-paths"
1729
1831
  ]),
1730
1832
  name: {
1731
1833
  type: "string",
@@ -1741,7 +1843,7 @@ function registerManageTools(ctx) {
1741
1843
  },
1742
1844
  containerId: {
1743
1845
  type: "string",
1744
- description: "Which container, for describe, url and browse."
1846
+ description: "Which container, for describe, url and browse-paths."
1745
1847
  },
1746
1848
  profile: {
1747
1849
  type: "string",
@@ -1815,6 +1917,7 @@ function registerManageTools(ctx) {
1815
1917
  action,
1816
1918
  result: asObject(await rpc.call("container_url", { id: needId() }))
1817
1919
  };
1920
+ case "browse-paths":
1818
1921
  case "browse": return {
1819
1922
  action,
1820
1923
  result: asObject(await rpc.call("browse_container_paths", { id: needId() }))
@@ -1824,7 +1927,7 @@ function registerManageTools(ctx) {
1824
1927
  "from-version",
1825
1928
  "describe",
1826
1929
  "url",
1827
- "browse"
1930
+ "browse-paths"
1828
1931
  ]);
1829
1932
  }
1830
1933
  }
@@ -2124,15 +2227,11 @@ function asArray(value) {
2124
2227
  /** Cordis plugin name used by loader diagnostics and the patch overlay. */
2125
2228
  const name = "dsh-box-sandbox";
2126
2229
  /**
2127
- * Services this plugin depends on. Empty for now: the client is pure
2128
- * transport and needs no host service. Tools built on top of it will
2129
- * declare their own.
2230
+ * Services this plugin depends on. `tools` to register the box_* surface and
2231
+ * `systemPrompt` for the cross-tool workflow section; the client itself is
2232
+ * pure transport and needs nothing.
2130
2233
  */
2131
- const inject = [
2132
- "tools",
2133
- "attachments",
2134
- "systemPrompt"
2135
- ];
2234
+ const inject = ["tools", "systemPrompt"];
2136
2235
  /**
2137
2236
  * Effective configuration after defaults.
2138
2237
  *
@@ -2195,7 +2294,7 @@ async function status(config) {
2195
2294
  * anything a tool already reports belongs in that tool.
2196
2295
  */
2197
2296
  const WORKFLOW_PROMPT_SECTION = "dsh-box-sandbox:page-workflow";
2198
- const WORKFLOW_PROMPT_ORDER = 3050;
2297
+ const WORKFLOW_PROMPT_ORDER = 3350;
2199
2298
  function pageWorkflowPrompt() {
2200
2299
  return [
2201
2300
  "Debugging a page with the box_* tools:",
@@ -2211,7 +2310,11 @@ function pageWorkflowPrompt() {
2211
2310
  "Before typing, click the field. box_type_text reports the focused element and inserts nothing",
2212
2311
  "when no editable field holds focus.",
2213
2312
  "If a page tool cannot find a browser, box_set_browser reports which one is in use and pins a",
2214
- "different one."
2313
+ "different one.",
2314
+ "When a page looks right but behaves wrongly — a client bundle that never registered, a control",
2315
+ "that does nothing — read box_console. A page that failed to load its own assets says so only",
2316
+ "there; the rest of the surface reports a page that is present, which is exactly what a broken",
2317
+ "one also is."
2215
2318
  ].join("\n");
2216
2319
  }
2217
2320
  /** Register the plugin against a freshly mounted Cordis context. */
package/dist/rpc.d.mts CHANGED
@@ -115,6 +115,15 @@ declare class DshboxRpc {
115
115
  * never retried -- they are answers, not outages.
116
116
  */
117
117
  call<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
118
+ /**
119
+ * The message for "discovery.json names a daemon that is not there".
120
+ *
121
+ * Staleness has two causes needing different fixes: the file may be
122
+ * missing (the daemon was never started) or it may name a pid that has
123
+ * exited (it crashed, or the machine rebooted). Both arrive here as a
124
+ * refused connection, so the record is read again to tell them apart.
125
+ */
126
+ private unreachable;
118
127
  /** Liveness probe. Resolves to the daemon's own status frame. */
119
128
  ping(): Promise<{
120
129
  pid: number;
package/dist/rpc.mjs CHANGED
@@ -148,17 +148,34 @@ var DshboxRpc = class {
148
148
  * never retried -- they are answers, not outages.
149
149
  */
150
150
  async call(method, params) {
151
+ let lastStale = null;
151
152
  for (let attempt = 0; attempt < 2; attempt++) {
152
153
  const record = this.discovery();
153
154
  try {
154
155
  return await this.send(record, method, params);
155
156
  } catch (error) {
156
- if (attempt === 0 && isStaleEndpoint(error)) continue;
157
- throw error;
157
+ if (!isStaleEndpoint(error)) throw error;
158
+ lastStale = error;
159
+ if (attempt === 0) continue;
160
+ throw this.unreachable(method, lastStale);
158
161
  }
159
162
  }
160
- /* c8 ignore next 2 */
161
- throw new DshboxUnavailableError("dshbox daemon unreachable for " + method);
163
+ /* c8 ignore next */
164
+ throw this.unreachable(method, lastStale);
165
+ }
166
+ /**
167
+ * The message for "discovery.json names a daemon that is not there".
168
+ *
169
+ * Staleness has two causes needing different fixes: the file may be
170
+ * missing (the daemon was never started) or it may name a pid that has
171
+ * exited (it crashed, or the machine rebooted). Both arrive here as a
172
+ * refused connection, so the record is read again to tell them apart.
173
+ */
174
+ unreachable(method, cause) {
175
+ const path = discoveryPath(this.configDir);
176
+ const record = readDiscovery(this.configDir);
177
+ if (record === null) return new DshboxUnavailableError("dshbox is not running, so " + method + " has nothing to talk to. Start it from the desktop app, or run `dshbox` once, then retry. No discovery record at " + path + ".", { cause });
178
+ return new DshboxUnavailableError("dshbox is not running, so " + method + " has nothing to talk to. The discovery record at " + path + " is stale: it names pid " + record.pid + " on port " + record.port + ", and nothing is listening there. Start the desktop app (or run `dshbox`) to bring up a new daemon; the stale record is replaced on startup.", { cause });
162
179
  }
163
180
  /** Liveness probe. Resolves to the daemon's own status frame. */
164
181
  ping() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexus-aethra/dsh-box-sandbox",
3
- "version": "0.1.0",
3
+ "version": "0.1.9",
4
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
5
  "keywords": [
6
6
  "dsh",
@@ -10,13 +10,13 @@
10
10
  "box",
11
11
  "tools"
12
12
  ],
13
- "homepage": "https://github.com/aethra-tech/DSHBox/tree/main/plugins/dsh-box-sandbox",
13
+ "homepage": "https://github.com/Nexus-Aethra/DSHBox/tree/main/plugins/dsh-box-sandbox",
14
14
  "bugs": {
15
- "url": "https://github.com/aethra-tech/DSHBox/issues"
15
+ "url": "https://github.com/Nexus-Aethra/DSHBox/issues"
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",
19
- "url": "git+https://github.com/aethra-tech/DSHBox.git",
19
+ "url": "git+https://github.com/Nexus-Aethra/DSHBox.git",
20
20
  "directory": "plugins/dsh-box-sandbox"
21
21
  },
22
22
  "author": "Nexus Aethra",
@@ -46,6 +46,14 @@
46
46
  "patch": "./cordis.patch.yml"
47
47
  }
48
48
  },
49
+ "scripts": {
50
+ "build": "tsdown",
51
+ "watch": "tsdown --watch",
52
+ "typecheck": "tsc --noEmit",
53
+ "check:outputs": "node scripts/check-output-schemas.mjs",
54
+ "check:tools": "node scripts/check-tool-outputs.mjs",
55
+ "deploy": "node scripts/deploy.mjs"
56
+ },
49
57
  "peerDependencies": {
50
58
  "@deepseek-ai/cordis": "^4.0.1"
51
59
  },
@@ -53,10 +61,5 @@
53
61
  "@types/node": "^22.10.0",
54
62
  "tsdown": "^0.22.2",
55
63
  "typescript": "^5.6.0"
56
- },
57
- "scripts": {
58
- "build": "tsdown",
59
- "watch": "tsdown --watch",
60
- "typecheck": "tsc --noEmit"
61
64
  }
62
- }
65
+ }