esoul-sdk 0.24.0 → 0.25.2

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.
@@ -39,6 +39,35 @@ it("the description never claims an emptiness it could not read", () => {
39
39
  A UI test renders once with a fixed state and checks the visible chrome and the empty state.
40
40
  Keep suites fast; they run beside the live preview on the same machine.
41
41
 
42
+ ## The testing kit — `esoul-sdk/testing`
43
+
44
+ Worlds your tests run in, so nothing real is touched and every run is the same:
45
+
46
+ | Helper | Gives you | Chapter |
47
+ |---|---|---|
48
+ | `memoryDb(manifest)` + `fakeViewer(kind, opts)` | your declared tables in memory, with your REAL row rules compiled; a viewer of any kind (`owner`, `member`, `visitor`, `anonymous`, `agent`, roles and attributes) | 14 |
49
+ | `runOp(pluginServer, "op", { viewer, args, db, apps })` | calls one op with a real-shaped `ctx`; returns its result plus what it `emit`ted and `notify`d | 14 |
50
+ | `fakeApps({ slot: { tools } })`, `capture()` | bound apps for `ctx.apps.<slot>` (calls recorded); a recorder for any callback | 16 |
51
+ | `memoryFiles(tree)` | the files door (`filesForOp`) over an in-memory tree: sources, list, read, write, transfers — with `crashAt` / `failItem` to prove a retry leaves no residue | 09 |
52
+ | `scriptedModel(policy)` | `llm(ctx)` answering from your script; `calls` records every request | 19 |
53
+ | `simGmail(opts)` | a Gmail API at the HTTP level (`fetch`, `install()`), with accounts, `deliver(...)`, personas that reply (`addPersona`), a cold-campaign `crowd(n)`, a virtual clock (`advance(ms)`), `faults` (429/5xx/401/network), `snapshot()` / restore; `sent` and `outbox` say what left | 08 |
54
+ | `fakeCredentials(slots)` | `credentials(ctx)` without accounts: per slot a `status`, the `hosts` it may reach and a `fetch` handler; the platform's refusals (undeclared slot, plain http, a host off the list, a slot not ready) and `calls` recording every request | 08 |
55
+ | `startMockOAuth()` | a local OAuth 2 provider for a connection's sign-in and refresh | 08 |
56
+ | `listFailedRequests()`, `subscribeFailedRequests`, `resetFailedRequests` | what `runOptimistic` reverted and named on the banner | 05 |
57
+
58
+ ```ts
59
+ import { memoryDb, fakeViewer, runOp, scriptedModel } from "esoul-sdk/testing";
60
+ import manifest from "./plugin.json";
61
+ import { pluginServer } from "./server";
62
+
63
+ it("a customer sees only their own orders", async () => {
64
+ const db = memoryDb(manifest);
65
+ await runOp(pluginServer, "place-order", { viewer: fakeViewer("visitor", { userId: "a" }), args: { sku: "x" }, db });
66
+ const b = await runOp(pluginServer, "list-orders", { viewer: fakeViewer("visitor", { userId: "b" }), db });
67
+ expect(b.result).toEqual({ orders: [] });
68
+ });
69
+ ```
70
+
42
71
  ## Tools
43
72
 
44
73
  Call `execute` with a recording `eventCallback` and assert the events it emitted and the text it
@@ -153,3 +153,25 @@ recorded as a conflict. `baseMerge` runs in every fold: keep it pure, exactly li
153
153
  platform and needs no merge — prefer operations to whole-value saves where you can.
154
154
  - Device view state (which page is open, scroll, selection) belongs in component state or
155
155
  `sessionStorage`, never in a shared event another device would follow.
156
+
157
+ ## 4. Declare `resolveConcurrent` for patch-shaped events
158
+
159
+ `baseMerge` is for events that write one whole value. An event that writes a PATCH — a list of sub-items it
160
+ changed, each with the version it built on — can do better: declare `merge` with `scope` (the sub-item ids),
161
+ and `resolveConcurrent`. The platform calls it only when the patch would land as a conflict (a writer it never
162
+ saw changed one of its sub-items), with the state it lands on; it merges what it can against the bases it
163
+ carries and returns the rewritten event:
164
+
165
+ ```ts
166
+ merge: { item: (_d, ctx) => `${ctx.applicationId}:cad:model`, scope: (d) => d.delta.ops.map(keyOf), describe: (d) => d.label },
167
+ resolveConcurrent: (state, eventData, { theirsSess }) => {
168
+ const r = resolveEdit(state, eventData); // pure: three-way per sub-item against the base each op carries
169
+ if (!r) return null; // cannot reason about it → the plain conflict (applied and marked)
170
+ return r.clashes.length === 0
171
+ ? { eventData: r.eventData } // everything merged: applied, no marker
172
+ : { eventData: r.eventData, detail: { kind: "cad-edit", clashes: r.clashes, theirsSess } };
173
+ },
174
+ ```
175
+
176
+ What it returns is what the fold applies, on every device and on the server. Keep it pure and symmetric; a
177
+ property test over randomised edit pairs is the cheapest proof.
@@ -1,5 +1,9 @@
1
1
  # 18 · Parts of your app that run on the person's computer
2
2
 
3
+ > Building a service that runs on the computer and talks to your app? Start with the
4
+ > walk-through, [20 · A service on your computer](20-a-service-on-your-computer.md); this page is
5
+ > the reference.
6
+
3
7
  Some apps need the person's own machine: a GPU for training, files that never
4
8
  leave a laptop, a tool installed there, a local network. The **device arm**
5
9
  lets an app run commands on a computer the owner connects, with the output
@@ -59,12 +63,17 @@ The panel lists connected computers (online, last seen, Disconnect) and a
59
63
  **Connect a computer** button. It shows one command to paste in a terminal:
60
64
 
61
65
  ```
62
- npx -y -p esoul-sdk@0.19.0 esoul-device connect https://externalsoul.com/api/device/m/offers/off_…
66
+ npx -y -p esoul-sdk@0.19.1 esoul-device connect https://externalsoul.com/api/device/m/offers/off_…
63
67
  ```
64
68
 
65
69
  The runtime ships inside `esoul-sdk` (the `esoul-device` command, and
66
70
  `esoul-sdk/machine` for code that runs on the computer). It uses only Node
67
- built-ins, so a computer needs nothing but Node 22.
71
+ built-ins, so a computer needs nothing but Node 22. The version in the command
72
+ is the runtime's own, pinned by the platform (it moves more slowly than the
73
+ package). A computer without Node uses the panel's other line, which brings a
74
+ pinned Node first, without sudo:
75
+ `curl -fsSL https://externalsoul.com/api/device/install | sh -s -- <link>`.
76
+ The link expires in 10 minutes.
68
77
 
69
78
  The command holds no secret. The computer makes its own key, prints a code
70
79
  (`K7Q-4XM`) and the list of commands; the page shows the same code with
@@ -134,8 +143,10 @@ const { run, finished } = useDeviceRun({ workspaceId, nodeId, commandId });
134
143
  // run.output streams in as the command prints (first 1 MB kept, then run.tail)
135
144
  ```
136
145
 
137
- `useDeviceActions({ nodeId })` gives the owner's buttons from the UI (run,
138
- cancel, disconnect, approve an update) without writing an op.
146
+ `useDeviceActions({ nodeId })` gives the owner's buttons from the UI (`run`,
147
+ `cancel`, `disconnect`, `approveUpdate`, `setWorkspace` for the reach, `sh`,
148
+ and `recent` runs) without writing an op. `useMyComputers()` and
149
+ `<YourComputers/>` list the person's computers across apps.
139
150
 
140
151
  ## 6. Local settings stay local
141
152
 
@@ -144,7 +155,7 @@ manifest declares under `device.config` are set on the computer and never sent
144
155
  to the platform:
145
156
 
146
157
  ```
147
- npx -y -p esoul-sdk@0.19.0 esoul-device config set "My app" HF_TOKEN
158
+ npx -y -p esoul-sdk@0.19.1 esoul-device config set "My app" HF_TOKEN
148
159
  ```
149
160
 
150
161
  The command receives them as environment variables. `redact` patterns are
@@ -163,9 +174,12 @@ applied on the computer before any output leaves it.
163
174
 
164
175
  ## 8. Testing
165
176
 
166
- In a Forge preview, `devices(ctx)` answers with a clear refusal and
167
- `<ConnectComputer/>` says computers connect to the installed app. Mock
168
- `esoul-sdk/server` in unit tests, as for any host function.
177
+ Computers connect to INSTALLED apps. In a Forge preview `devices(ctx).list()`
178
+ answers `[]`, every other `devices()` call throws "computers connect to
179
+ INSTALLED apps", `useDevices`/`useDeviceRun` stay empty, `useDeviceActions`
180
+ throws, and `<ConnectComputer/>` says so. Mock `esoul-sdk/server` in unit tests,
181
+ as for any host function, and test a program with a fake `p`
182
+ ([20 §5](20-a-service-on-your-computer.md)).
169
183
 
170
184
  ## 9. The workspace: agents working on the computer
171
185
 
@@ -181,9 +195,9 @@ dev server open — the owner gives the app a **workspace**:
181
195
  Two parties agree. **The computer** sets a ceiling when it connects:
182
196
 
183
197
  ```
184
- npx -y -p esoul-sdk@0.19.0 esoul-device connect <link> # this folder (the default)
185
- npx -y -p esoul-sdk@0.19.0 esoul-device connect <link> --folder ~/proj # another folder
186
- npx -y -p esoul-sdk@0.19.0 esoul-device connect <link> --scope computer # allow the whole computer
198
+ npx -y -p esoul-sdk@0.19.1 esoul-device connect <link> # this folder (the default)
199
+ npx -y -p esoul-sdk@0.19.1 esoul-device connect <link> --folder ~/proj # another folder
200
+ npx -y -p esoul-sdk@0.19.1 esoul-device connect <link> --scope computer # allow the whole computer
187
201
  ```
188
202
 
189
203
  **The owner** grants each app at most that ceiling from the app's page (the
@@ -320,8 +334,16 @@ The rules that keep it safe and quiet:
320
334
 
321
335
  Where each program stands on each computer — installing, each setup step,
322
336
  self-test, running, the program's own `p.status` words, why it failed —
323
- comes back on `devices(ctx).list()` as `programState`, and `<ConnectComputer/>`
324
- shows it. The reference app is **CPU monitor** (`src/plugins/cpu-monitor`).
337
+ comes back on `devices(ctx).list()` as `programState` (one computer:
338
+ `devices(ctx).program(linkId).state()`), and `<ConnectComputer/>` shows it.
339
+
340
+ Limits: a message (`send`) and its answer are each at most 1 MB of JSON; a
341
+ topic matches `[a-zA-Z][a-zA-Z0-9_.:-]{0,63}`; a message for an offline
342
+ computer expires (600 s default). `p.op` calls are resent when no answer has
343
+ come in about 4 s (up to 3 tries) — make those ops idempotent and pass
344
+ `{ key }`. `p.workspaceRoot` is null when the whole computer is granted.
345
+ Nothing reaches INTO the computer: there is no tunnel or open port; a program
346
+ reaches its own localhost and calls out. The reference app is **CPU monitor** (`src/plugins/cpu-monitor`).
325
347
 
326
348
  ### An agent on each computer
327
349
 
@@ -0,0 +1,291 @@
1
+ # 20 · A service on your computer, connected to your app
2
+
3
+ A guide, start to finish: an app built in the Forge that runs its own
4
+ long-lived service on the person's computer — a local model server, a
5
+ sampler, a bridge to a device on the desk, a trainer's supervisor — and
6
+ talks to it both ways. The reference for every piece is
7
+ [18 · Parts of your app that run on the person's computer](18-your-computer.md);
8
+ this page is the path through it.
9
+
10
+ ## The shape
11
+
12
+ ```
13
+ your app on ExternalSoul the person's computer
14
+ ───────────────────────── ─────────────────────────────
15
+ server.ts ── devices(ctx).program(linkId) device/main.ts (your PROGRAM,
16
+ .send("ask", data) ─────────▶ p.on("ask", …) kept running,
17
+ ◀───────── answer (≤ 1 MB) sandboxed)
18
+ ops.ts ◀── p.op("report", data) ──────── p.every / events │
19
+ (the op sees viewer.device) ▼
20
+ your service on localhost
21
+ (started by the program,
22
+ installed by setup steps)
23
+ ```
24
+
25
+ - **The program is the service's keeper.** Your app ships a `device/` folder;
26
+ the person approves it once on their computer; the runtime installs it,
27
+ runs your setup steps (a Python venv, `npm ci`, a model download), checks it
28
+ with your self-test, and keeps it running (`"resident": true`), restarting
29
+ it when it exits.
30
+ - **App → service:** your server code sends a message to the program; the
31
+ program's handler does the work — usually a `fetch("http://localhost:…")`
32
+ to the service it started — and returns the answer.
33
+ - **Service → app:** the program calls one of your app's own ops with
34
+ `p.op(...)`. The op knows it came from that computer (`ctx.viewer.device`).
35
+ - **Nothing reaches INTO the computer.** There is no tunnel and no open port:
36
+ the computer only calls out. The program can reach its own `localhost` and
37
+ the internet; your app reaches the program only through `send`.
38
+
39
+ ## 1. Declare the program (`plugin.json`)
40
+
41
+ ```json
42
+ {
43
+ "ops": ["ask", "report", "computers"],
44
+ "device": {
45
+ "program": {
46
+ "main": "device/main.ts",
47
+ "resident": true,
48
+ "setup": [
49
+ { "id": "venv", "describe": "Python environment", "run": ["python3", "-m", "venv", ".venv"] },
50
+ {
51
+ "id": "deps", "describe": "Install the server",
52
+ "run": [".venv/bin/pip", "install", "-r", "{program}/device/requirements.txt"],
53
+ "inputs": ["device/requirements.txt"]
54
+ }
55
+ ],
56
+ "selfTest": { "run": [".venv/bin/python", "{program}/device/self_test.py"] }
57
+ },
58
+ "config": { "MODEL_DIR": { "describe": "Where the model weights are on this computer" } },
59
+ "onAppDeleted": "kill"
60
+ }
61
+ }
62
+ ```
63
+
64
+ - `setup` runs once per step and per change of its `inputs` — an app update
65
+ that leaves `requirements.txt` alone does not reinstall. Steps are argv, never
66
+ a shell line; `{program}` is the program's folder. Relative paths (`.venv`)
67
+ live in the program's data folder, which is kept across versions.
68
+ - `selfTest` must exit 0 before the program counts as ready; make it the
69
+ thing your service needs (`claude --version`, a GPU check, an import).
70
+ - `config` names values that stay on the computer (`esoul-device config set
71
+ "<app name>" MODEL_DIR ~/models`); the program reads them as `p.config`.
72
+
73
+ ## 2. Write the program (`device/main.ts`)
74
+
75
+ ```ts
76
+ import type { Program } from "esoul-sdk/machine"; // types only — nothing is installed beside the program
77
+ import { spawn, type ChildProcess } from "node:child_process";
78
+ import { join } from "node:path";
79
+
80
+ const PORT = 8711;
81
+ let server: ChildProcess | null = null;
82
+
83
+ async function healthy(): Promise<boolean> {
84
+ try { return (await fetch(`http://127.0.0.1:${PORT}/health`)).ok; } catch { return false; }
85
+ }
86
+
87
+ export default {
88
+ async start(p) {
89
+ // Start the service: argv, no shell; its output goes to the program's log.
90
+ server = spawn(join(p.dataDir, ".venv/bin/python"), [join(p.programDir, "device/serve.py"), "--port", String(PORT)], {
91
+ cwd: p.dataDir,
92
+ env: { ...process.env, MODEL_DIR: p.config.MODEL_DIR ?? "" },
93
+ stdio: ["ignore", "inherit", "inherit"],
94
+ });
95
+ for (let i = 0; i < 60 && !(await healthy()); i++) await new Promise((r) => setTimeout(r, 1000));
96
+ p.status((await healthy()) ? "Model server ready" : "Model server did not start", { ready: await healthy() });
97
+
98
+ // App → service: the answer of the handler is the answer of send().
99
+ p.on("ask", async (data) => {
100
+ const r = await fetch(`http://127.0.0.1:${PORT}/ask`, { method: "POST", body: JSON.stringify(data), headers: { "content-type": "application/json" } });
101
+ return await r.json();
102
+ });
103
+
104
+ // Long work: answer at once, report when done (the app's op stores it).
105
+ p.on("job", async (data) => {
106
+ const { requestId } = data as { requestId: string };
107
+ void (async () => {
108
+ const r = await fetch(`http://127.0.0.1:${PORT}/job`, { method: "POST", body: JSON.stringify(data) });
109
+ await p.op("report", { requestId, result: await r.json() }, { key: requestId });
110
+ })();
111
+ return { accepted: true };
112
+ });
113
+
114
+ // Service → app on a clock.
115
+ p.every(60_000, async () => p.op("report", { kind: "heartbeat", ok: await healthy() }));
116
+ },
117
+ async stop() { server?.kill(); },
118
+ } satisfies Program;
119
+ ```
120
+
121
+ What a program may import: Node built-ins (`node:fs`, `node:child_process`, …),
122
+ files of its own `device/` folder, and `esoul-sdk/machine` **types only**. No
123
+ packages — install what the service needs in a setup step. At most 2 MB and
124
+ 200 files; download weights and datasets in setup or at start. Node 22.6+
125
+ runs a `.ts` entry directly (no enums, no namespaces).
126
+
127
+ ## 3. Talk to it from the app (`server.ts`)
128
+
129
+ ```ts
130
+ import { devices, type PluginOpContext } from "esoul-sdk/server";
131
+
132
+ // App → service. linkId names one connected computer (devices(ctx).list()).
133
+ async function ask(ctx: PluginOpContext, args: { linkId: string; prompt: string }) {
134
+ const r = await devices(ctx).program(args.linkId).send("ask", { prompt: args.prompt }, { waitSeconds: 30 });
135
+ if (!r.ok) return { ok: false, error: r.error ?? "the computer did not answer" }; // offline, not approved, handler threw
136
+ return { ok: true, answer: r.result };
137
+ }
138
+
139
+ // Service → app. Only the program on a computer may report.
140
+ async function report(ctx: PluginOpContext, args: { requestId?: string; result?: unknown; kind?: string }) {
141
+ const device = ctx.viewer.device;
142
+ if (!device) throw new Error("report comes from the program on a computer");
143
+ await ctx.emit("result_reported", { machineId: device.machineId, hostname: device.hostname, ...args });
144
+ return { ok: true };
145
+ }
146
+
147
+ // Which computers there are, and where each program stands.
148
+ async function computers(ctx: PluginOpContext) {
149
+ return (await devices(ctx).list()).map((d) => ({
150
+ linkId: d.linkId, hostname: d.machine?.hostname, online: d.machine?.online ?? false,
151
+ program: d.programState?.phase ?? (d.program ? "approved" : "not approved"),
152
+ status: d.programState?.status?.text ?? null,
153
+ }));
154
+ }
155
+ ```
156
+
157
+ Rules that matter:
158
+
159
+ - **`send` waits at most 55 s** (`waitSeconds`; `0` returns at once with the
160
+ `commandId`). For anything longer, use the job pattern above: answer
161
+ `accepted`, finish in the background, `p.op` the result.
162
+ - **A message and an answer are each at most 1 MB of JSON.** Topics match
163
+ `[a-zA-Z][a-zA-Z0-9_.:-]{0,63}`. For files, hand the program a link
164
+ (`(await filesForOp(ctx)).readGrant(...)` + `fileGrantUrl`) and let it
165
+ download; for results, store them through ops or save files.
166
+ - **A queued message for an offline computer expires** (600 s by default) —
167
+ a click on Monday never runs on Thursday.
168
+ - **`p.op` must be quick and idempotent.** The computer resends a call whose
169
+ answer has not arrived in about 4 s (up to 3 tries). Pass `{ key }` with a
170
+ stable id and make the op a no-op the second time (store by `requestId`).
171
+ - **Who can do what:** `send`, `run`, `sh` and `cancel` need a caller who may
172
+ change the workspace (`ctx.viewer.canWrite`). The program's `p.op` calls run
173
+ as the owner who approved that computer (`viewer.kind === "agent"`,
174
+ `viewer.device` set) through your app's own op and its own rules.
175
+
176
+ `devices(ctx).program(linkId).state()` gives that program's state on that
177
+ computer (installing, a setup step, self-test, running, its last `p.status`
178
+ words, why it failed) — the same that `list()` returns as `programState`.
179
+
180
+ ## 4. The page: connect a computer
181
+
182
+ ```tsx
183
+ import { ConnectComputer, useDevices } from "esoul-sdk/react";
184
+
185
+ export function Computers({ workspaceId, nodeId }: { workspaceId: string; nodeId: string }) {
186
+ const { devices } = useDevices({ workspaceId, nodeId }); // pick a linkId for ask()
187
+ return <ConnectComputer workspaceId={workspaceId} nodeId={nodeId} title="Computers" />;
188
+ }
189
+ ```
190
+
191
+ `<ConnectComputer/>` lists the computers (online, last seen, the program's
192
+ progress and words, Disconnect) and a **Connect a computer** button.
193
+
194
+ ## 5. Build it in the Forge — what you can prove before installing
195
+
196
+ The Forge box runs your app in a preview. **Computers connect to installed
197
+ apps only**: in the box `devices(ctx).list()` answers `[]`, every other
198
+ `devices()` call throws "computers connect to INSTALLED apps", and
199
+ `<ConnectComputer/>` shows a note saying so. Plan the work in that order:
200
+
201
+ 1. **Prove the program without a computer.** It is a plain object with
202
+ `start(p)`; give it a fake `p` in a test and call its handlers:
203
+
204
+ ```ts
205
+ import program from "../device/main";
206
+
207
+ test("ask goes to the local service and comes back", async () => {
208
+ const handlers: Record<string, (d: unknown) => unknown> = {};
209
+ const ops: Array<[string, unknown]> = [];
210
+ const p = {
211
+ computer: { hostname: "test", platform: "linux", arch: "x64", cpus: 4, memoryGb: 8 },
212
+ appName: "Model", programDir: __dirname, dataDir: "/tmp/model-test", workspaceRoot: null, config: {},
213
+ status: () => {}, log: () => {}, every: () => () => {},
214
+ on: (t: string, h: (d: unknown) => unknown) => { handlers[t] = h; },
215
+ op: async (name: string, args: unknown) => { ops.push([name, args]); return { ok: true }; },
216
+ };
217
+ // stub the service: global.fetch = jest.fn(...) answering /health and /ask
218
+ await program.start(p as never);
219
+ expect(await handlers.ask({ prompt: "hi" })).toEqual({ text: "hello" });
220
+ });
221
+ ```
222
+
223
+ 2. **Prove the app side** with `esoul-sdk/server` mocked (`devices` returning a
224
+ fake whose `program().send` answers), and call `report` with a faked
225
+ `viewer.device` to see the result land in the fold.
226
+ 3. **Run the service itself** in the box if it can run there (`run_in_app`:
227
+ `python3 device/serve.py --port 8711 &` then `curl localhost:8711/health`) —
228
+ the same code the program will start.
229
+ 4. `check_app` packs `device/` and refuses a program over 2 MB / 200 files or
230
+ one that imports a package.
231
+
232
+ What only the installed app can prove: the program installed and running on
233
+ a real computer, `send`, `p.op`, the approval card and the reach choice.
234
+
235
+ ## 6. Install, connect, approve
236
+
237
+ 1. `install_app` (the board's tool, or Install on the board). The app is now
238
+ on the platform.
239
+ 2. Open the app, press **Connect a computer**. The panel shows one command to
240
+ paste in a terminal on that computer:
241
+
242
+ ```
243
+ npx -y -p esoul-sdk@0.19.1 esoul-device connect https://externalsoul.com/api/device/m/offers/off_…
244
+ ```
245
+
246
+ No Node there? The panel's other line installs a pinned Node first, without
247
+ sudo: `curl -fsSL https://externalsoul.com/api/device/install | sh -s -- <link>`.
248
+ The link expires in 10 minutes and holds no secret.
249
+ 3. The computer prints a code (`K7Q-4XM`); the page shows the same code with
250
+ **Approve**, the program's size and version, its setup steps and
251
+ self-test, and **what the app may reach there**: commands only, its
252
+ folder, or the whole computer (never more than the computer allowed with
253
+ `--scope` / `--folder` when it connected).
254
+ 4. The runtime installs itself as a user service (systemd on Linux, a
255
+ LaunchAgent on macOS, a plain background service where neither exists),
256
+ so it comes back after a reboot. Linux and macOS; Windows via WSL2.
257
+ 5. The card shows setup progress, then the self-test, then your `p.status`
258
+ words. Your app's `computers` op (or `list()`) says `running`.
259
+
260
+ **Updates.** A new version that changes the program (any file, the setup, the
261
+ self-test) has a new digest and waits for **Approve** on the card; until then
262
+ the computer keeps running the version it has. Changing what the app may
263
+ reach there restarts the program under the new reach.
264
+
265
+ **Where it runs.** Unconfined only with the whole computer granted (needed
266
+ when the service uses the person's own logins, like Claude Code or git).
267
+ Otherwise in an OS sandbox (bubblewrap on Linux, Seatbelt on macOS): it reads
268
+ the system, writes only `p.dataDir` and the granted folder, and its home and
269
+ `/tmp` are private. No sandbox on the computer → refused, never run
270
+ unconfined. The network is not confined either way.
271
+
272
+ ## 7. Other ways in, and when to use them
273
+
274
+ | You need | Use |
275
+ |---|---|
276
+ | A long-lived service your app owns, two-way | **A program** (this page) |
277
+ | A known tool run with checked parameters (`nvidia-smi`, a training script) | **Declared commands** — `device.commands` + `devices(ctx).run` ([18 §1–4](18-your-computer.md)) |
278
+ | Agents that work IN a project folder (read, edit, patch, run tests, keep a dev server in a terminal) | **The workspace** — `devices(ctx).sh("term new dev npm run dev", { agent })` ([18 §9](18-your-computer.md)) |
279
+ | A process that is not your program pushes data to your app (a script, a device, a CI job) | **A token route** — declare the route `"access": "token"`, mint with `mintRouteToken(ctx, { route, ttlSeconds })`, the process POSTs with `Authorization: Bearer <token>` ([06 · Routes](06-server.md)). Works in the Forge box too (the box's public preview URL). |
280
+ | The person already runs the My Computer app and the work is one-off shell commands | **`computer(ctx, machineNodeId)`** — shell strings the owner approves, output ≤ 8,000 characters ([06](06-server.md)); works in the box through the board's tab |
281
+
282
+ ## 8. When it does not work
283
+
284
+ | You see | It means |
285
+ |---|---|
286
+ | `send` → `ok: false`, `expired` | the computer was offline for the message's whole life (600 s) |
287
+ | `send` → error "not approved" / no program in `list()` | the person has not approved the program (or its new version) on the card |
288
+ | `programState.phase === "failed"` | a setup step or the self-test failed, or the program exited five times in five minutes — the card shows the last log lines |
289
+ | "refused: no sandbox" on the card | the computer has neither bubblewrap nor Seatbelt; grant the whole computer or install bubblewrap |
290
+ | the same report stored twice | the op is not idempotent — key it by `requestId` (`p.op(..., { key })`) |
291
+ | `send` answers `the answer was larger than 1 MB` | return a summary; put the bulk in a file and send a link or an id |