@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.
- package/LICENSE +21 -0
- package/README.md +122 -2
- package/backend/gills.ts +74 -0
- package/backend/index.ts +15 -0
- package/backend/methods.ts +278 -0
- package/backend/process-page.ts +44 -0
- package/backend/runs.ts +580 -0
- package/backend/test/cloudflare-workers.fixture.ts +1 -0
- package/backend/test/methods.test.ts +662 -0
- package/backend/test/runs.node.test.ts +1770 -0
- package/backend/tsconfig.json +10 -0
- package/backend/vitest.config.ts +16 -0
- package/dist/assets/Anton-5.3.0-latin-Byf51wtH.woff2 +0 -0
- package/dist/assets/BricolageGrotesque-VF-latin-Cre6nC2_.woff2 +0 -0
- package/dist/assets/DepartureMono-Regular-2BZob_Zz.woff2 +0 -0
- package/dist/assets/Fraunces-Italic-VF-latin-D2JnYqd-.woff2 +0 -0
- package/dist/assets/Fraunces-VF-latin-CFFu7zhK.woff2 +0 -0
- package/dist/assets/GrenzeGotisch-VF-latin-BhUVw2FV.woff2 +0 -0
- package/dist/assets/InstrumentSans-Italic-VF-latin-BlcgWI5J.woff2 +0 -0
- package/dist/assets/InstrumentSans-VF-latin-BbzFLZTg.woff2 +0 -0
- package/dist/assets/MapleMono-VF-CCDrizxw.woff2 +0 -0
- package/dist/assets/NothingYouCouldDo-Regular-B7VfwEBS.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-cyrillic-BsWUdSU9.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-cyrillic-ext-Cb1j0nGJ.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-latin-BYs3GYNf.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-latin-ext-0RvtKGDy.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-vietnamese-cS2dauUA.woff2 +0 -0
- package/dist/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
- package/dist/assets/ghostty-vt-DOMeXDrv.wasm +0 -0
- package/dist/assets/index-C5jExGkL.js +1764 -0
- package/dist/assets/index-Dm2F0ptq.css +1 -0
- package/dist/index.html +15 -0
- 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
|
-
#
|
|
1
|
+
# Shed (`@fungi.computer/shed`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shed opens a terminal on your Computer, with its open ports and running
|
|
4
|
+
processes one click away.
|
|
5
|
+
|
|
6
|
+

|
|
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).
|
package/backend/gills.ts
ADDED
|
@@ -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
|
+
});
|
package/backend/index.ts
ADDED
|
@@ -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
|
+
}
|