@fungi.computer/shed 0.0.0-stage → 0.0.1

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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -2
  3. package/backend/gills.ts +74 -0
  4. package/backend/index.ts +15 -0
  5. package/backend/methods.ts +278 -0
  6. package/backend/process-page.ts +44 -0
  7. package/backend/runs.ts +580 -0
  8. package/backend/test/cloudflare-workers.fixture.ts +1 -0
  9. package/backend/test/methods.test.ts +662 -0
  10. package/backend/test/runs.node.test.ts +1770 -0
  11. package/backend/tsconfig.json +10 -0
  12. package/backend/vitest.config.ts +16 -0
  13. package/dist/assets/Anton-5.3.0-latin-Byf51wtH.woff2 +0 -0
  14. package/dist/assets/BricolageGrotesque-VF-latin-Cre6nC2_.woff2 +0 -0
  15. package/dist/assets/DepartureMono-Regular-2BZob_Zz.woff2 +0 -0
  16. package/dist/assets/Fraunces-Italic-VF-latin-D2JnYqd-.woff2 +0 -0
  17. package/dist/assets/Fraunces-VF-latin-CFFu7zhK.woff2 +0 -0
  18. package/dist/assets/GrenzeGotisch-VF-latin-BhUVw2FV.woff2 +0 -0
  19. package/dist/assets/InstrumentSans-Italic-VF-latin-BlcgWI5J.woff2 +0 -0
  20. package/dist/assets/InstrumentSans-VF-latin-BbzFLZTg.woff2 +0 -0
  21. package/dist/assets/MapleMono-VF-CCDrizxw.woff2 +0 -0
  22. package/dist/assets/NothingYouCouldDo-Regular-B7VfwEBS.woff2 +0 -0
  23. package/dist/assets/Nunito-VF-8c6a9bb-cyrillic-BsWUdSU9.woff2 +0 -0
  24. package/dist/assets/Nunito-VF-8c6a9bb-cyrillic-ext-Cb1j0nGJ.woff2 +0 -0
  25. package/dist/assets/Nunito-VF-8c6a9bb-latin-BYs3GYNf.woff2 +0 -0
  26. package/dist/assets/Nunito-VF-8c6a9bb-latin-ext-0RvtKGDy.woff2 +0 -0
  27. package/dist/assets/Nunito-VF-8c6a9bb-vietnamese-cS2dauUA.woff2 +0 -0
  28. package/dist/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
  29. package/dist/assets/ghostty-vt-DOMeXDrv.wasm +0 -0
  30. package/dist/assets/index-C5jExGkL.js +1764 -0
  31. package/dist/assets/index-Dm2F0ptq.css +1 -0
  32. package/dist/index.html +15 -0
  33. package/package.json +158 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,123 @@
1
- # Temporary Holding Version
1
+ # Shed (`@fungi.computer/shed`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shed opens a terminal on your Computer, with its open ports and running
4
+ processes one click away.
5
+
6
+ ![Shed's terminal on the Computer: the Fungi banner, the light shell prompt in /workspace, and the Ports, Processes and Access controls in the footer](docs/media/terminal.png)
7
+
8
+ Shed is one of the starter Apps of
9
+ [Oyster](https://github.com/fungi-computer/oyster), the open App host that runs
10
+ on your own Cloudflare account. On Oyster, the Computer is a Linux workspace in
11
+ a Cloudflare Container; Shed runs commands there, not on your laptop.
12
+
13
+ ## What you can do
14
+
15
+ - Type commands at an instant light prompt (`ls`, `rg TODO`, `jq . package.json`
16
+ and the like). A command that needs a real Linux process, such as `npm`,
17
+ `node` or an interactive program, opens the full terminal in its place.
18
+ - Search and recall your last 500 prompt commands while Shed is open.
19
+ - Open **Processes** to see what is running, what has exited and what is
20
+ uncertain, and stop a running process.
21
+ - Open **Ports**, expose the port your dev server listens on, and preview it
22
+ beside the terminal.
23
+ - Open **Access** to see which Computer permissions Shed holds.
24
+
25
+ The Computer is always there as a durable workspace. Its container may be
26
+ stopped; running a command starts it again. Processes end when the container
27
+ stops and do not come back when it starts. Closing Shed does not stop a process.
28
+
29
+ <img alt="Shed on a phone in the light theme: the light shell prompt in /workspace with the footer controls below" src="docs/media/terminal-phone.png" width="300">
30
+
31
+ ## Get it
32
+
33
+ Shed installs with the other starter Apps. On a deployed Oyster (see the
34
+ [Oyster quick start](https://github.com/fungi-computer/oyster#quick-start-deploy-your-own-oyster)),
35
+ run:
36
+
37
+ ```sh
38
+ export OYSTER_URL=https://oyster.<your-subdomain>.workers.dev
39
+ export OYSTER_OWNER_KEY=<your owner key>
40
+ npx oyster install-apps
41
+ ```
42
+
43
+ The installer clones this repository and the six others, seeds each on your
44
+ Oyster with `git push`, builds them on your Computer and installs them. Sign in
45
+ at `$OYSTER_URL/login` with the owner key and open Shed from the Tourtois
46
+ desktop. Run `install-apps` again to update; each App keeps its data.
47
+
48
+ Port previews open on a second origin that never carries your sign-in. Oyster
49
+ turns them on when its host sets `OYSTER_PREVIEW_ORIGIN`; without it, **Ports**
50
+ still exposes and removes ports and says that previews are off.
51
+
52
+ ## Change it
53
+
54
+ Every installed App is also a template you can fork on your own Oyster. Fork
55
+ Shed into a Bonsai repository and clone it with ordinary `git`:
56
+
57
+ ```sh
58
+ curl -X POST -H "Authorization: Bearer $OYSTER_OWNER_KEY" -H 'content-type: application/json' \
59
+ -d '{"appId":"app_shed","name":"my-shed"}' \
60
+ $OYSTER_URL/apps/bonsai/methods/apps.fork
61
+ git -c http.extraHeader="Authorization: Bearer $OYSTER_OWNER_KEY" \
62
+ clone $OYSTER_URL/git/bonsai/my-shed.git
63
+ ```
64
+
65
+ The reply names your fork's App id and repository. Push your edits, open the
66
+ repository in Bonsai and choose **Settings**, then **Build & install** with that
67
+ App id and your commit as `git:<sha>`. Install the Release at `shed` to replace
68
+ the running Shed, or at a new id to keep both.
69
+
70
+ To work from GitHub instead, fork this repository and point the installer at
71
+ your forks with `--repositories <base URL>` and `--ref <branch or tag>`. The
72
+ installer reads all seven starter Apps from that base, so fork them under one
73
+ account.
74
+
75
+ ## Agent tools
76
+
77
+ Agents call Shed through Oyster's MCP endpoint, or at
78
+ `POST $OYSTER_URL/apps/shed/methods/<name>` with a key; see
79
+ [Git, keys and MCP](https://github.com/fungi-computer/oyster#git-keys-and-mcp).
80
+
81
+ | Method | What it does |
82
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------- |
83
+ | `runCommand({command, cwd?, timeoutMs?})` | Runs a shell command for up to 45 seconds and returns its exit status and output (64 KiB per stream). |
84
+ | `listProcesses({})` | Lists up to 64 processes on the Computer. Starts nothing. |
85
+ | `listPorts({})` | Lists up to 64 forwarded ports. Starts nothing. |
86
+
87
+ Workflows (see [Trellis](https://github.com/fungi-computer/trellis)) use a
88
+ second set for longer work:
89
+
90
+ | Method | What it does |
91
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
92
+ | `run({command, cwd})` | Starts one process, logs its output under `/workspace/.fungi/runs/`, and waits until it exits. A retry reuses the same process. |
93
+ | `start` / `stop` | Start a process without waiting for it, or stop one. |
94
+ | `logs({run, offset, length})` | Reads up to 128 KiB of a run's log. |
95
+ | `expose({port})` | Returns a preview URL for a port that is already exposed. |
96
+
97
+ A workflow step can be given push or read access to one repository. Its command
98
+ then finds a credential-free remote in `$FUNGI_GIT_REMOTE`, for example
99
+ `git push "$FUNGI_GIT_REMOTE" HEAD:refs/heads/main`. Arguments never choose the
100
+ Computer; the host does.
101
+
102
+ ## Build and check
103
+
104
+ Shed is a React App built with Vite. Its terminal renders with Ghostty and its
105
+ controls come from [Caps](https://github.com/fungi-computer/caps).
106
+
107
+ ```sh
108
+ pnpm install
109
+ pnpm build
110
+ pnpm typecheck
111
+ pnpm test
112
+ ```
113
+
114
+ [src/shed-app.tsx](src/shed-app.tsx) owns the selected Computer, permissions and
115
+ the process and port lists. [src/light-shell/](src/light-shell/) is the light
116
+ prompt, [src/terminal-panel.tsx](src/terminal-panel.tsx) the full terminal, and
117
+ [src/shed-trays.tsx](src/shed-trays.tsx) the Ports and Processes trays. The
118
+ agent tools are in [backend/methods.ts](backend/methods.ts), and
119
+ [package.json](package.json) declares the permissions the App asks for.
120
+
121
+ ## License
122
+
123
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,74 @@
1
+ import * as z from "zod";
2
+ import { defineAppGill, type AppBackendGill } from "@fungi.computer/agar/gills";
3
+ import { relativeTime } from "@fungi.computer/gills/presentation";
4
+ import { processPageInputSchema, processPageSchema } from "./process-page.js";
5
+
6
+ const refused = z.object({ error: z.string() });
7
+ type GillRow = ReturnType<AppBackendGill["rows"]>["rows"][number];
8
+
9
+ /** Inventory only: this read never starts or stops a process. */
10
+ export const processesGill = defineAppGill({
11
+ id: "processes",
12
+ title: "Processes",
13
+ source: "listProcesses",
14
+ limit: 20,
15
+ sizes: ["M", "L"],
16
+ params: [
17
+ { key: "computer", label: "Computer", kind: "computer", required: true },
18
+ ],
19
+ input: ({ limit, after }): ReturnType<AppBackendGill["input"]> => {
20
+ const cursor: unknown = after === undefined ? undefined : JSON.parse(after);
21
+ const input = processPageInputSchema.parse(
22
+ cursor === undefined ? { limit } : { limit, after: cursor },
23
+ );
24
+ return input.after === undefined
25
+ ? { limit: input.limit }
26
+ : { limit: input.limit, after: input.after };
27
+ },
28
+ rows: (output) => {
29
+ const identity = { viewId: "processes", title: "Processes" };
30
+ const refusal = refused.safeParse(output);
31
+ if (refusal.success)
32
+ return refusal.data.error === "not_granted"
33
+ ? {
34
+ ...identity,
35
+ state: "error",
36
+ rows: [],
37
+ error: "Choose a Computer and allow Shed to run commands.",
38
+ recovery: { kind: "configure", param: "computer" },
39
+ }
40
+ : {
41
+ ...identity,
42
+ state: "error",
43
+ rows: [],
44
+ error:
45
+ "The process inventory couldn't be read. Open Shed to check access and refresh.",
46
+ recovery: { kind: "open" },
47
+ };
48
+ const page = processPageSchema.parse(output);
49
+ if (page.processes.length === 0)
50
+ return { ...identity, state: "empty", rows: [] };
51
+ const rows = page.processes.map((process): GillRow => ({
52
+ id: process.id,
53
+ title: (process.command || "Process").slice(0, 4096),
54
+ detail:
55
+ `${process.cwd ?? "Unknown directory"} · ${relativeTime(process.startedAt)}${process.state === "exited" && process.exitCode !== null ? ` · Exit ${String(process.exitCode)}` : ""}`.slice(
56
+ 0,
57
+ 1024,
58
+ ),
59
+ status: {
60
+ label: process.state,
61
+ tone:
62
+ process.state === "running"
63
+ ? "success"
64
+ : process.state === "uncertain"
65
+ ? "warning"
66
+ : "neutral",
67
+ },
68
+ open: { params: [{ key: "process", value: process.id }] },
69
+ }));
70
+ return page.next === undefined
71
+ ? { ...identity, state: "full", rows }
72
+ : { ...identity, state: "full", rows, next: JSON.stringify(page.next) };
73
+ },
74
+ });
@@ -0,0 +1,15 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import { appBackend, type ShedMethodContext } from "./methods.js";
3
+ import { createShedRuns } from "./runs.js";
4
+ class ShedFacet extends DurableObject<ShedMethodContext["env"]> {
5
+ declare public readonly env: ShedMethodContext["env"];
6
+ readonly runs;
7
+ constructor(ctx: DurableObjectState, env: ShedMethodContext["env"]) {
8
+ super(ctx, env);
9
+ this.runs = createShedRuns(ctx.storage.sql);
10
+ }
11
+ override fetch(): Promise<Response> {
12
+ return Promise.resolve(new Response(null, { status: 405 }));
13
+ }
14
+ }
15
+ export const Shed = appBackend.implement(ShedFacet);
@@ -0,0 +1,278 @@
1
+ import { processesGill } from "./gills.js";
2
+ import {
3
+ processPageInputSchema,
4
+ processPageSchema,
5
+ pageProcesses,
6
+ } from "./process-page.js";
7
+ import * as z from "zod";
8
+ import { Base64 } from "effect/encoding";
9
+ import { computerPortInputSchema } from "@fungi.computer/computer/operations";
10
+ import {
11
+ runShedCommand,
12
+ shedRunCommandInputSchema,
13
+ shedRunInputSchema,
14
+ shedStartInputSchema,
15
+ type ShedComputer,
16
+ type ShedRuns,
17
+ } from "./runs.js";
18
+ import {
19
+ runCommandInputSchema,
20
+ runCommandResultSchema,
21
+ shedComputerRefusalSchema,
22
+ } from "../src/light-shell/run-command-contract.js";
23
+ import {
24
+ defineAppBackend,
25
+ defineAppMethod,
26
+ } from "@fungi.computer/agar/backend-definition";
27
+ import type { WhistleJsonValue } from "@fungi.computer/whistle";
28
+ import {
29
+ computerPortsOutputSchema,
30
+ computerProcessSchema,
31
+ processIdInputSchema,
32
+ processKillOutputSchema,
33
+ } from "@fungi.computer/computer/operations";
34
+ const empty = z.strictObject({});
35
+ const command = runCommandInputSchema;
36
+ const refusal = shedComputerRefusalSchema;
37
+ const portsReply = z.union([computerPortsOutputSchema, refusal]);
38
+ const processesReply = z.union([processPageSchema, refusal]);
39
+ const commandReply = runCommandResultSchema;
40
+ /** Only scoped inventory and execution bindings enter this runtime-neutral definition. */
41
+ export type ShedMethodContext = Readonly<{
42
+ runs?: ShedRuns;
43
+ env: Readonly<{
44
+ COMPUTER?: Partial<ShedComputer> &
45
+ Readonly<{
46
+ ports?(input: Record<string, never>): Promise<WhistleJsonValue>;
47
+ preview?(input: { port: number }): Promise<z.core.util.JSONType>;
48
+ processKill?(
49
+ input: z.output<typeof processIdInputSchema>,
50
+ ): Promise<z.core.util.JSONType>;
51
+ read?(input: {
52
+ path: string;
53
+ byteOffset: number;
54
+ byteLength: number;
55
+ }): Promise<z.core.util.JSONType>;
56
+ exec?(input: z.output<typeof command>): Promise<WhistleJsonValue>;
57
+ }>;
58
+ }>;
59
+ }>;
60
+ const preview = z.strictObject({
61
+ previewUrl: z.url(),
62
+ expiresAt: z.number().int().positive(),
63
+ });
64
+ const bytes = z.strictObject({
65
+ path: z.string(),
66
+ byteOffset: z.number().int().nonnegative(),
67
+ content: z.string(),
68
+ encoding: z.literal("base64"),
69
+ truncated: z.boolean(),
70
+ });
71
+ export const appBackend = defineAppBackend<ShedMethodContext>({
72
+ gills: [processesGill],
73
+ methods: {
74
+ expose: defineAppMethod({
75
+ description:
76
+ "Get an admitted preview URL for a port already exposed on the pinned Computer.",
77
+ effect: "write",
78
+ requires: ["computer.execute"],
79
+ input: computerPortInputSchema,
80
+ output: preview,
81
+ handler: async (input, context: ShedMethodContext, caller) => {
82
+ if (!caller.computerId || !context.env.COMPUTER?.preview)
83
+ throw new Error("Computer preview access is not granted");
84
+ // Project the issued URL; preview-row presentation policy stays with the host.
85
+ return z
86
+ .object(preview.shape)
87
+ .parse(await context.env.COMPUTER.preview(input));
88
+ },
89
+ }),
90
+ logs: defineAppMethod({
91
+ description:
92
+ "Read up to 128 KiB from a run log, identified by its host call key; offsets and lengths are bytes.",
93
+ effect: "read",
94
+ requires: ["computer.readFile"],
95
+ input: z.strictObject({
96
+ run: z.string().min(1).max(128),
97
+ offset: z.number().int().nonnegative().default(0),
98
+ length: z.number().int().min(1).max(131072).default(131072),
99
+ }),
100
+ output: z.union([
101
+ bytes.extend({ run: z.string() }),
102
+ z.strictObject({ error: z.enum(["not_found", "ambiguous"]) }),
103
+ ]),
104
+ handler: async (input, context: ShedMethodContext, caller) => {
105
+ if (!caller.computerId || !context.env.COMPUTER?.read || !context.runs)
106
+ throw new Error("Computer file access is not granted");
107
+ const log = context.runs.logPath(
108
+ input.run,
109
+ caller.computerId,
110
+ caller.actor,
111
+ );
112
+ if ("error" in log) return log;
113
+ const path = log.path;
114
+ const reply = bytes.parse(
115
+ await context.env.COMPUTER.read({
116
+ path,
117
+ byteOffset: input.offset,
118
+ byteLength: input.length,
119
+ }),
120
+ );
121
+ const decoded = Base64.decode(reply.content);
122
+ if (
123
+ reply.path !== path ||
124
+ reply.byteOffset !== input.offset ||
125
+ decoded._tag === "Failure" ||
126
+ decoded.success.byteLength > input.length
127
+ )
128
+ throw new Error("Invalid Computer log reply");
129
+ return { ...reply, run: input.run };
130
+ },
131
+ }),
132
+ start: defineAppMethod({
133
+ description:
134
+ "Start one process on the pinned Computer and return its process and log path without waiting for exit.",
135
+ effect: "write",
136
+ requires: ["computer.execute", "computer.writeFile"],
137
+ pending: true,
138
+ input: shedStartInputSchema,
139
+ output: z.union([
140
+ z.strictObject({
141
+ process: computerProcessSchema,
142
+ logPath: z.string(),
143
+ port: computerPortInputSchema.shape.port.optional(),
144
+ }),
145
+ refusal,
146
+ ]),
147
+ handler: (input, context: ShedMethodContext, caller) => {
148
+ const computer = context.env.COMPUTER;
149
+ if (
150
+ !caller.computerId ||
151
+ !computer?.processStart ||
152
+ !computer.processes ||
153
+ !context.runs
154
+ )
155
+ throw new Error("Computer access is not granted");
156
+ if (!caller.idempotencyKey)
157
+ throw new Error(
158
+ "A host idempotency key is required to start a process",
159
+ );
160
+ const { processStart, processes, workflowGitPass, write } = computer;
161
+ return context.runs.start(
162
+ input,
163
+ caller.computerId,
164
+ caller.idempotencyKey,
165
+ caller.actor,
166
+ {
167
+ processStart,
168
+ processes,
169
+ workflowGitPass,
170
+ write,
171
+ },
172
+ );
173
+ },
174
+ }),
175
+ stop: defineAppMethod({
176
+ description: "Signal a retained process on the pinned Computer to stop.",
177
+ effect: "write",
178
+ requires: ["computer.execute"],
179
+ input: processIdInputSchema,
180
+ output: z.union([processKillOutputSchema, refusal]),
181
+ handler: async (input, context: ShedMethodContext, caller) => {
182
+ if (!caller.computerId || !context.env.COMPUTER?.processKill)
183
+ throw new Error("Computer access is not granted");
184
+ if (!context.runs?.owns(input.id, caller.computerId, caller.actor))
185
+ return refusal.parse({ error: "not_found" });
186
+ return z
187
+ .union([processKillOutputSchema, refusal])
188
+ .parse(await context.env.COMPUTER.processKill(input));
189
+ },
190
+ }),
191
+ run: defineAppMethod({
192
+ description:
193
+ "Run one command on the pinned Computer, retaining a log and polling until it exits.",
194
+ effect: "write",
195
+ requires: ["computer.execute", "computer.writeFile"],
196
+ pending: true,
197
+ input: shedRunInputSchema,
198
+ output: z.union([
199
+ z.strictObject({
200
+ exitCode: z.number().int(),
201
+ logPath: z.string(),
202
+ }),
203
+ refusal,
204
+ ]),
205
+ handler: (input, context: ShedMethodContext, caller) => {
206
+ const computer = context.env.COMPUTER;
207
+ if (
208
+ !caller.computerId ||
209
+ !computer?.processStart ||
210
+ !computer.processes ||
211
+ !context.runs
212
+ )
213
+ throw new Error("Computer access is not granted");
214
+ const { processStart, processes, workflowGitPass, write } = computer;
215
+ if (!caller.idempotencyKey)
216
+ throw new Error("A host idempotency key is required to start a run");
217
+ return context.runs.run(
218
+ input,
219
+ caller.computerId,
220
+ caller.idempotencyKey,
221
+ caller.actor,
222
+ { processStart, processes, workflowGitPass, write },
223
+ );
224
+ },
225
+ }),
226
+
227
+ listPorts: defineAppMethod({
228
+ description:
229
+ "List up to 64 forwarded ports on the attached Computer. This read-only inventory requires computer.execute and starts nothing.",
230
+ effect: "read",
231
+ requires: ["computer.execute"],
232
+ input: empty,
233
+ output: portsReply,
234
+ handler: async (input, context, caller) =>
235
+ caller.computerId === null || !context.env.COMPUTER?.ports
236
+ ? refusal.parse({ error: "not_granted" })
237
+ : portsReply.parse(await context.env.COMPUTER.ports(input)),
238
+ }),
239
+ listProcesses: defineAppMethod({
240
+ description:
241
+ "List up to 64 retained processes on the attached Computer. This read-only inventory requires computer.execute and starts nothing.",
242
+ effect: "read",
243
+ requires: ["computer.execute"],
244
+ input: processPageInputSchema,
245
+ output: processesReply,
246
+ handler: async (input, context, caller) => {
247
+ if (caller.computerId === null || !context.env.COMPUTER?.processes)
248
+ return refusal.parse({ error: "not_granted" });
249
+ if (
250
+ input.after !== undefined &&
251
+ input.after.computerId !== caller.computerId
252
+ )
253
+ return refusal.parse({ error: "invalid" });
254
+ const inventory = processesReply.parse(
255
+ await context.env.COMPUTER.processes({}),
256
+ );
257
+ return "error" in inventory
258
+ ? inventory
259
+ : pageProcesses(inventory, input, caller.computerId);
260
+ },
261
+ }),
262
+ runCommand: defineAppMethod({
263
+ description:
264
+ "Execute a shell command on the attached Computer, with an optional absolute cwd and a timeout up to 45 seconds. A Workflow step's admitted gitAccess reaches the command as $FUNGI_GIT_REMOTE with command-local Git credentials. The host mints request identity, owns the per-attachment execution budget and attribution. Each output stream is bounded to the Computer’s 64 KiB byte slice, with up to two extra UTF-8 bytes from a replacement character, and may be truncated.",
265
+ effect: "write",
266
+ requires: ["computer.execute", "computer.writeFile"],
267
+ input: shedRunCommandInputSchema,
268
+ output: commandReply,
269
+ handler: async (input, context, caller) => {
270
+ const computer = context.env.COMPUTER;
271
+ if (caller.computerId === null || !computer?.exec)
272
+ return refusal.parse({ error: "not_granted" });
273
+ const { exec, workflowGitPass, write } = computer;
274
+ return runShedCommand(input, { exec, workflowGitPass, write });
275
+ },
276
+ }),
277
+ },
278
+ });
@@ -0,0 +1,44 @@
1
+ import * as z from "zod";
2
+ import { processListOutputSchema } from "@fungi.computer/computer/operations";
3
+
4
+ const process = processListOutputSchema.shape.processes.element;
5
+ const processCursorSchema = z.strictObject({
6
+ computerId: z.string().min(1).max(256),
7
+ startedAt: process.shape.startedAt,
8
+ id: process.shape.id,
9
+ });
10
+ export const processPageInputSchema = z.strictObject({
11
+ limit: z.number().int().min(1).max(64).default(64),
12
+ after: processCursorSchema.optional(),
13
+ });
14
+ export const processPageSchema = processListOutputSchema.extend({
15
+ processes: z
16
+ .array(process.extend({ command: z.string().max(16384) }))
17
+ .max(64),
18
+ next: processCursorSchema.optional(),
19
+ });
20
+
21
+ /** Keyset pages preserve the Computer inventory's newest-first ordering. */
22
+ export function pageProcesses(
23
+ inventory: z.output<typeof processPageSchema>,
24
+ input: z.output<typeof processPageInputSchema>,
25
+ computerId: string,
26
+ ): z.output<typeof processPageSchema> {
27
+ const after = input.after;
28
+ const remaining =
29
+ after === undefined
30
+ ? inventory.processes
31
+ : inventory.processes.filter(
32
+ (item) =>
33
+ item.startedAt < after.startedAt ||
34
+ (item.startedAt === after.startedAt && item.id < after.id),
35
+ );
36
+ const processes = remaining.slice(0, input.limit);
37
+ const last = processes.at(-1);
38
+ return remaining.length > input.limit && last !== undefined
39
+ ? {
40
+ processes,
41
+ next: { computerId, startedAt: last.startedAt, id: last.id },
42
+ }
43
+ : { processes };
44
+ }