@fungi.computer/computer 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/Dockerfile +85 -0
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/container/computer-init.mjs +119 -0
- package/container/computer-start.sh +24 -0
- package/container/package.json +14 -0
- package/container/pnpm-lock.yaml +378 -0
- package/container/pnpm-workspace.yaml +5 -0
- package/container/resource-bounds.mjs +54 -0
- package/container/runtime-privileges.cc +37 -0
- package/container/runtime-privileges.mjs +11 -0
- package/container/terminal-assets/README.md +35 -0
- package/container/terminal-assets/bashrc +49 -0
- package/container/terminal-assets/git-prompt.sh +586 -0
- package/container/terminal-protocol.mjs +58 -0
- package/container/terminal-runtime.mjs +10 -0
- package/container/terminal-server.mjs +243 -0
- package/dist/apply-patch/LICENSE.codex +201 -0
- package/dist/apply-patch/build.mjs +98 -0
- package/dist/apply-patch/cache.mjs +168 -0
- package/dist/apply-patch/compile.mjs +199 -0
- package/dist/apply-patch/docker/darwin.Dockerfile +15 -0
- package/dist/apply-patch/docker/linux-arm64.Dockerfile +38 -0
- package/dist/apply-patch/docker/linux-x64.Dockerfile +23 -0
- package/dist/apply-patch/main.rs +320 -0
- package/dist/apply-patch/notices.mjs +44 -0
- package/dist/apply-patch/pin.mjs +8 -0
- package/dist/apply-patch/toolchain.mjs +39 -0
- package/dist/computer-backends.d.ts +94 -0
- package/dist/computer-backends.js +103 -0
- package/dist/computerd.cjs +18010 -0
- package/dist/container-policy.d.ts +7 -0
- package/dist/container-policy.js +24 -0
- package/dist/container-system-trust.d.ts +8 -0
- package/dist/container-system-trust.js +66 -0
- package/dist/egress-policy.d.ts +89 -0
- package/dist/egress-policy.js +240 -0
- package/dist/execution-result.d.ts +3 -0
- package/dist/execution-result.js +28 -0
- package/dist/files-patch.d.ts +20 -0
- package/dist/files-patch.js +108 -0
- package/dist/files.d.ts +75 -0
- package/dist/files.js +136 -0
- package/dist/fleet/admission.d.ts +14 -0
- package/dist/fleet/admission.js +10 -0
- package/dist/fleet/backing.d.ts +52 -0
- package/dist/fleet/backing.js +242 -0
- package/dist/fleet/computers.d.ts +62 -0
- package/dist/fleet/computers.js +35 -0
- package/dist/fleet/index.d.ts +11 -0
- package/dist/fleet/index.js +11 -0
- package/dist/fleet/inventory-wire.d.ts +29 -0
- package/dist/fleet/inventory-wire.js +15 -0
- package/dist/fleet/inventory.d.ts +24 -0
- package/dist/fleet/inventory.js +152 -0
- package/dist/fleet/lifecycle.d.ts +99 -0
- package/dist/fleet/lifecycle.js +423 -0
- package/dist/fleet/runtime-usage.d.ts +50 -0
- package/dist/fleet/runtime-usage.js +145 -0
- package/dist/fleet/slots.d.ts +30 -0
- package/dist/fleet/slots.js +46 -0
- package/dist/fleet/wire.d.ts +228 -0
- package/dist/fleet/wire.js +127 -0
- package/dist/host-config.d.ts +12 -0
- package/dist/host-config.js +10 -0
- package/dist/index.d.ts +282 -0
- package/dist/index.js +1237 -0
- package/dist/light-shell.d.ts +44 -0
- package/dist/light-shell.js +376 -0
- package/dist/local-backend.d.ts +52 -0
- package/dist/local-backend.js +143 -0
- package/dist/metered-container-host.d.ts +26 -0
- package/dist/metered-container-host.js +48 -0
- package/dist/on-box-vm.d.ts +119 -0
- package/dist/on-box-vm.js +294 -0
- package/dist/operation-contract.d.ts +456 -0
- package/dist/operation-contract.js +452 -0
- package/dist/operation-record.d.ts +50 -0
- package/dist/operation-record.js +162 -0
- package/dist/preview.d.ts +55 -0
- package/dist/preview.js +151 -0
- package/dist/process-inventory.d.ts +80 -0
- package/dist/process-inventory.js +427 -0
- package/dist/pull-pending.d.ts +26 -0
- package/dist/pull-pending.js +35 -0
- package/dist/team-candidate-builder.mjs +28170 -0
- package/dist/transport-backend.d.ts +3 -0
- package/dist/transport-backend.js +99 -0
- package/dist/workspace-stream.d.ts +2 -0
- package/dist/workspace-stream.js +31 -0
- package/package.json +126 -3
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const computerContainerSleepAfterMs: number;
|
|
2
|
+
export declare function hostedComputerContainersPaused(hostedComputers: string | undefined): boolean;
|
|
3
|
+
export declare class ComputerContainersPausedError extends Error {
|
|
4
|
+
constructor();
|
|
5
|
+
}
|
|
6
|
+
export declare function assertComputerContainersEnabled(hostedComputers: string | undefined): void;
|
|
7
|
+
export declare function configureComputerContainer(container: Pick<Container, "setInactivityTimeout"> | undefined): Promise<void>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const computerContainerSleepAfterMs = 15 * 60 * 1_000;
|
|
3
|
+
const hostedComputersSchema = z
|
|
4
|
+
.enum(["enabled", "paused"])
|
|
5
|
+
.default("enabled")
|
|
6
|
+
.catch("paused");
|
|
7
|
+
export function hostedComputerContainersPaused(hostedComputers) {
|
|
8
|
+
return hostedComputersSchema.parse(hostedComputers) === "paused";
|
|
9
|
+
}
|
|
10
|
+
export class ComputerContainersPausedError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("Hosted Computer containers are paused");
|
|
13
|
+
this.name = "ComputerContainersPausedError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function assertComputerContainersEnabled(hostedComputers) {
|
|
17
|
+
if (hostedComputerContainersPaused(hostedComputers))
|
|
18
|
+
throw new ComputerContainersPausedError();
|
|
19
|
+
}
|
|
20
|
+
export async function configureComputerContainer(container) {
|
|
21
|
+
if (container === undefined)
|
|
22
|
+
throw new Error("Computer Durable Object is not container-enabled");
|
|
23
|
+
await container.setInactivityTimeout(computerContainerSleepAfterMs);
|
|
24
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BackendHandle } from "@fungi.computer/cloudflare-computer";
|
|
2
|
+
import { CloudflareContainerBackend, type CloudflareContainerBackendOptions } from "@fungi.computer/cloudflare-computer/backends/container";
|
|
3
|
+
/** Prepare intercepted HTTPS trust after the native backend installs its CA. */
|
|
4
|
+
export declare class SystemTrustContainerBackend extends CloudflareContainerBackend {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(options: CloudflareContainerBackendOptions);
|
|
7
|
+
connect(): Promise<BackendHandle>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { CloudflareContainerBackend, } from "@fungi.computer/cloudflare-computer/backends/container";
|
|
2
|
+
/** Prepare intercepted HTTPS trust after the native backend installs its CA. */
|
|
3
|
+
export class SystemTrustContainerBackend extends CloudflareContainerBackend {
|
|
4
|
+
#intercepted;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
super(options);
|
|
7
|
+
this.#intercepted = options.egress?.mode === "http-gateway";
|
|
8
|
+
}
|
|
9
|
+
async connect() {
|
|
10
|
+
const handle = await super.connect();
|
|
11
|
+
if (!this.#intercepted)
|
|
12
|
+
return handle;
|
|
13
|
+
try {
|
|
14
|
+
await installContainerSystemTrust(handle.rpc.shell);
|
|
15
|
+
return handle;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
await handle.close().catch((cause) => {
|
|
19
|
+
console.error("Computer trust setup cleanup failed", cause);
|
|
20
|
+
return undefined;
|
|
21
|
+
});
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function installContainerSystemTrust(shell) {
|
|
27
|
+
// computerd runs without root, so create its OpenSSL bundle in /tmp. The
|
|
28
|
+
// atomic rename lets simultaneous connections prepare the same CA safely.
|
|
29
|
+
// Await it before Workspace can dispatch caller commands.
|
|
30
|
+
const execution = await shell.exec({
|
|
31
|
+
source: "set -eu; " +
|
|
32
|
+
"test -r /etc/cloudflare/certs/cloudflare-containers-ca.crt || exit 78; " +
|
|
33
|
+
"trust_tmp=$(mktemp /tmp/fungi-ca-XXXXXX); " +
|
|
34
|
+
'cat /etc/ssl/certs/ca-certificates.crt /etc/cloudflare/certs/cloudflare-containers-ca.crt > "$trust_tmp"; ' +
|
|
35
|
+
'chmod 0644 "$trust_tmp"; ' +
|
|
36
|
+
'mv "$trust_tmp" /tmp/fungi-ca-bundle.crt',
|
|
37
|
+
cwd: "/",
|
|
38
|
+
timeoutMs: 10_000,
|
|
39
|
+
});
|
|
40
|
+
const reader = execution.events.getReader();
|
|
41
|
+
try {
|
|
42
|
+
let exitCode;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const event = await reader.read();
|
|
45
|
+
if (event.done)
|
|
46
|
+
break;
|
|
47
|
+
if (event.value.name === "exit")
|
|
48
|
+
exitCode = event.value.code;
|
|
49
|
+
}
|
|
50
|
+
if (exitCode === 78)
|
|
51
|
+
throw new Error("Computer runtime interception CA is missing or unreadable");
|
|
52
|
+
if (exitCode !== 0)
|
|
53
|
+
throw new Error(`Computer intercepted HTTPS trust setup failed (exit ${String(exitCode)})`);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
await shell.disposeExec({ id: execution.id }).catch((cause) => {
|
|
57
|
+
console.error("Computer trust setup cleanup failed", cause);
|
|
58
|
+
return undefined;
|
|
59
|
+
});
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
reader.releaseLock();
|
|
64
|
+
}
|
|
65
|
+
await shell.disposeExec({ id: execution.id });
|
|
66
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type PublicGitRoute = Readonly<{
|
|
2
|
+
readonly host: "github.com" | "gitlab.com" | "codeberg.org";
|
|
3
|
+
readonly repository: string;
|
|
4
|
+
/** `push` is GitHub receive-pack only, and never tried anonymously. */
|
|
5
|
+
readonly access: "read" | "push";
|
|
6
|
+
}>;
|
|
7
|
+
export type ComputerEgressDecision = Readonly<{
|
|
8
|
+
kind: "team-git";
|
|
9
|
+
route: PublicGitRoute;
|
|
10
|
+
}> | Readonly<{
|
|
11
|
+
kind: "public";
|
|
12
|
+
}> | Readonly<{
|
|
13
|
+
kind: "internal-git";
|
|
14
|
+
}> | Readonly<{
|
|
15
|
+
kind: "origin-git";
|
|
16
|
+
}> | Readonly<{
|
|
17
|
+
kind: "denied";
|
|
18
|
+
reason: "origin" | "method" | "path";
|
|
19
|
+
}>;
|
|
20
|
+
/** An HTTPS URL with the default port and no userinfo or fragment. */
|
|
21
|
+
export declare function egressHttpsUrl(url: URL): boolean;
|
|
22
|
+
/** Whether `url` is plain HTTPS on the host's configured root origin. An
|
|
23
|
+
* absent origin admits nothing; a malformed one is a configuration error. */
|
|
24
|
+
export declare function sameOriginHttps(url: URL, origin: string | undefined): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The host's own Git, as one more host entry: its configured origin and the
|
|
27
|
+
* smart-HTTP grammar its Git door serves. Classification grants no transport
|
|
28
|
+
* authority; the host's door still authenticates every exchange.
|
|
29
|
+
*/
|
|
30
|
+
export type OriginGitHost = Readonly<{
|
|
31
|
+
origin: string | undefined;
|
|
32
|
+
route(request: Pick<Request, "method">, url: URL): object | null;
|
|
33
|
+
}>;
|
|
34
|
+
/** One owner for origin, method and route decisions at Computer egress. */
|
|
35
|
+
export declare function decideComputerEgress(request: Pick<Request, "method">, url: URL, host?: OriginGitHost): ComputerEgressDecision;
|
|
36
|
+
/** Remove all caller credentials and forward only transport headers needed by
|
|
37
|
+
* anonymous registries and Git smart HTTP. `body: "move"` hands the caller's
|
|
38
|
+
* stream over once (a push, which never has an anonymous attempt). */
|
|
39
|
+
export declare function anonymousRequest(request: Request, url: URL, options?: Readonly<{
|
|
40
|
+
body: "clone" | "move";
|
|
41
|
+
}>): Request;
|
|
42
|
+
export declare function anonymousResponse(response: Response): Response;
|
|
43
|
+
export declare function disabledTeamIds(raw: string | undefined): ReadonlySet<string>;
|
|
44
|
+
export declare class ComputerEgressConfigError extends Error {
|
|
45
|
+
readonly issues: readonly string[];
|
|
46
|
+
readonly name = "ComputerEgressConfigError";
|
|
47
|
+
constructor(issues: readonly string[]);
|
|
48
|
+
}
|
|
49
|
+
/** Public egress is on by default; only the explicit global kill switch
|
|
50
|
+
* (`COMPUTER_PUBLIC_EGRESS=disabled`) turns it off. Any other configured
|
|
51
|
+
* value fails closed with a typed config error rather than silently denying
|
|
52
|
+
* or enabling egress. */
|
|
53
|
+
export declare function publicEgressEnabled(raw: string | undefined): boolean;
|
|
54
|
+
export type PublicLaneDecision = Readonly<{
|
|
55
|
+
kind: "allowed";
|
|
56
|
+
}> | Readonly<{
|
|
57
|
+
kind: "denied";
|
|
58
|
+
reason: "globally-disabled" | "team-disabled" | "computer-disabled";
|
|
59
|
+
}>;
|
|
60
|
+
/** The public lane's one on/off decision, in the order its switches are
|
|
61
|
+
* owned: the global kill switch, then the per-Team off list, then this
|
|
62
|
+
* Computer's own switch. Either of the first two overrides this Computer's
|
|
63
|
+
* own "on". */
|
|
64
|
+
export declare function decidePublicLane(input: Readonly<{
|
|
65
|
+
publicEgressEnabled: boolean;
|
|
66
|
+
teamDisabled: boolean;
|
|
67
|
+
computerInternetAccessEnabled: boolean;
|
|
68
|
+
}>): PublicLaneDecision;
|
|
69
|
+
/** One structured line per egress decision, whichever host serves it. */
|
|
70
|
+
export type ComputerEgressEvent = Readonly<{
|
|
71
|
+
event: "computer.egress";
|
|
72
|
+
computerId: string;
|
|
73
|
+
teamId: string | null;
|
|
74
|
+
lane: "team-git" | "public" | "internal-git" | "origin-git" | "denied";
|
|
75
|
+
host: string;
|
|
76
|
+
method: string;
|
|
77
|
+
status: number;
|
|
78
|
+
decision: string;
|
|
79
|
+
contentLength: string | null;
|
|
80
|
+
}>;
|
|
81
|
+
export declare function recordComputerEgress(event: ComputerEgressEvent): void;
|
|
82
|
+
/** The policy refusal every host returns; the reason stays in the log. */
|
|
83
|
+
export declare function computerEgressDenied(input: Omit<ComputerEgressEvent, "event" | "lane" | "contentLength">): Response;
|
|
84
|
+
/**
|
|
85
|
+
* An admitted origin-git exchange as a fresh request to the host's own Git
|
|
86
|
+
* door. Only the guest's own credential (an owner key or workflow Git pass
|
|
87
|
+
* Shed sets as `http.<remote>.extraHeader`) travels; the gateway adds none.
|
|
88
|
+
*/
|
|
89
|
+
export declare function originGitRequest(request: Request, url: URL): Request;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one host policy for a Computer's network egress: which origin, method
|
|
3
|
+
* and route each outbound request names. Every host's egress gateway decides
|
|
4
|
+
* here and refuses with the same response; a host serves only the lanes it
|
|
5
|
+
* offers (Hub: team, public and its own App Git; standalone Oyster: its own
|
|
6
|
+
* origin's Git) and refuses every other decision.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
const publicGitHosts = new Set(["github.com", "gitlab.com", "codeberg.org"]);
|
|
10
|
+
const packageHosts = new Set([
|
|
11
|
+
"registry.npmjs.org",
|
|
12
|
+
"registry.yarnpkg.com",
|
|
13
|
+
"pypi.org",
|
|
14
|
+
"files.pythonhosted.org",
|
|
15
|
+
"crates.io",
|
|
16
|
+
"static.crates.io",
|
|
17
|
+
"proxy.golang.org",
|
|
18
|
+
"deb.debian.org",
|
|
19
|
+
"archive.ubuntu.com",
|
|
20
|
+
]);
|
|
21
|
+
const releaseHosts = new Set([
|
|
22
|
+
"objects.githubusercontent.com",
|
|
23
|
+
"release-assets.githubusercontent.com",
|
|
24
|
+
]);
|
|
25
|
+
const anonymousRequestHeaders = [
|
|
26
|
+
"accept",
|
|
27
|
+
"accept-encoding",
|
|
28
|
+
"content-type",
|
|
29
|
+
"if-modified-since",
|
|
30
|
+
"if-none-match",
|
|
31
|
+
"range",
|
|
32
|
+
"user-agent",
|
|
33
|
+
"git-protocol",
|
|
34
|
+
];
|
|
35
|
+
const publicResponseHeaders = [
|
|
36
|
+
"accept-ranges",
|
|
37
|
+
"cache-control",
|
|
38
|
+
"content-encoding",
|
|
39
|
+
"content-length",
|
|
40
|
+
"content-range",
|
|
41
|
+
"content-type",
|
|
42
|
+
"etag",
|
|
43
|
+
"last-modified",
|
|
44
|
+
"location",
|
|
45
|
+
];
|
|
46
|
+
/** An HTTPS URL with the default port and no userinfo or fragment. */
|
|
47
|
+
export function egressHttpsUrl(url) {
|
|
48
|
+
return (url.protocol === "https:" &&
|
|
49
|
+
url.port === "" &&
|
|
50
|
+
url.username === "" &&
|
|
51
|
+
url.password === "" &&
|
|
52
|
+
url.hash === "");
|
|
53
|
+
}
|
|
54
|
+
function publicGitRoute(url, method) {
|
|
55
|
+
if (!publicGitHosts.has(url.hostname))
|
|
56
|
+
return null;
|
|
57
|
+
const match = /^\/([A-Za-z0-9_.-]{1,128})\/([A-Za-z0-9_.-]{1,128})\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/.exec(url.pathname);
|
|
58
|
+
const owner = match?.[1];
|
|
59
|
+
const repository = match?.[2];
|
|
60
|
+
const endpoint = match?.[3];
|
|
61
|
+
if (owner === undefined || repository === undefined || endpoint === undefined)
|
|
62
|
+
return null;
|
|
63
|
+
const exchange = (service) => (method === "GET" &&
|
|
64
|
+
endpoint === "info/refs" &&
|
|
65
|
+
url.search === `?service=${service}`) ||
|
|
66
|
+
(method === "POST" && endpoint === service && url.search === "");
|
|
67
|
+
const host = url.hostname;
|
|
68
|
+
if (host !== "github.com" && host !== "gitlab.com" && host !== "codeberg.org")
|
|
69
|
+
return null;
|
|
70
|
+
const access = exchange("git-upload-pack")
|
|
71
|
+
? "read"
|
|
72
|
+
: // Only a Team's GitHub connection can push; other hosts stay read-only.
|
|
73
|
+
host === "github.com" && exchange("git-receive-pack")
|
|
74
|
+
? "push"
|
|
75
|
+
: null;
|
|
76
|
+
if (access === null)
|
|
77
|
+
return null;
|
|
78
|
+
return { host, repository: `${owner}/${repository}`, access };
|
|
79
|
+
}
|
|
80
|
+
function isGetOrHead(method) {
|
|
81
|
+
return method === "GET" || method === "HEAD";
|
|
82
|
+
}
|
|
83
|
+
/** Whether `url` is plain HTTPS on the host's configured root origin. An
|
|
84
|
+
* absent origin admits nothing; a malformed one is a configuration error. */
|
|
85
|
+
export function sameOriginHttps(url, origin) {
|
|
86
|
+
if (origin === undefined || !egressHttpsUrl(url))
|
|
87
|
+
return false;
|
|
88
|
+
const configured = new URL(z.url().parse(origin));
|
|
89
|
+
return (configured.pathname === "/" &&
|
|
90
|
+
configured.search === "" &&
|
|
91
|
+
configured.hash === "" &&
|
|
92
|
+
configured.username === "" &&
|
|
93
|
+
configured.password === "" &&
|
|
94
|
+
url.origin === configured.origin);
|
|
95
|
+
}
|
|
96
|
+
/** One owner for origin, method and route decisions at Computer egress. */
|
|
97
|
+
export function decideComputerEgress(request, url, host) {
|
|
98
|
+
if (!egressHttpsUrl(url))
|
|
99
|
+
return { kind: "denied", reason: "origin" };
|
|
100
|
+
if (url.hostname === "git.team.internal" ||
|
|
101
|
+
url.hostname === "build.team.internal")
|
|
102
|
+
return { kind: "internal-git" };
|
|
103
|
+
if (host !== undefined &&
|
|
104
|
+
sameOriginHttps(url, host.origin) &&
|
|
105
|
+
host.route(request, url) !== null)
|
|
106
|
+
return { kind: "origin-git" };
|
|
107
|
+
const gitRoute = publicGitRoute(url, request.method);
|
|
108
|
+
if (gitRoute !== null)
|
|
109
|
+
return { kind: "team-git", route: gitRoute };
|
|
110
|
+
if (!isGetOrHead(request.method))
|
|
111
|
+
return { kind: "denied", reason: "method" };
|
|
112
|
+
if (packageHosts.has(url.hostname) || releaseHosts.has(url.hostname))
|
|
113
|
+
return { kind: "public" };
|
|
114
|
+
if (url.hostname === "github.com" &&
|
|
115
|
+
/^\/[A-Za-z0-9_.-]{1,128}\/[A-Za-z0-9_.-]{1,128}\/releases\/download\/[^/]+\/[^/]+(?:\/.*)?$/.test(url.pathname))
|
|
116
|
+
return { kind: "public" };
|
|
117
|
+
if (url.hostname === "codeload.github.com" &&
|
|
118
|
+
/^\/[A-Za-z0-9_.-]{1,128}\/[A-Za-z0-9_.-]{1,128}\/(?:legacy\.(?:zip|tar\.gz)|archive)\//.test(url.pathname))
|
|
119
|
+
return { kind: "public" };
|
|
120
|
+
return { kind: "denied", reason: "origin" };
|
|
121
|
+
}
|
|
122
|
+
/** Remove all caller credentials and forward only transport headers needed by
|
|
123
|
+
* anonymous registries and Git smart HTTP. `body: "move"` hands the caller's
|
|
124
|
+
* stream over once (a push, which never has an anonymous attempt). */
|
|
125
|
+
export function anonymousRequest(request, url, options = { body: "clone" }) {
|
|
126
|
+
const headers = new Headers();
|
|
127
|
+
for (const name of anonymousRequestHeaders) {
|
|
128
|
+
const value = request.headers.get(name);
|
|
129
|
+
if (value !== null)
|
|
130
|
+
headers.set(name, value);
|
|
131
|
+
}
|
|
132
|
+
const init = {
|
|
133
|
+
method: request.method,
|
|
134
|
+
headers,
|
|
135
|
+
redirect: "manual",
|
|
136
|
+
signal: request.signal,
|
|
137
|
+
};
|
|
138
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
139
|
+
// A GitHub public read can return 401/404 before Team custody is tried;
|
|
140
|
+
// retain a second stream for that existing authorized fallback.
|
|
141
|
+
const body = options.body === "move" ? request.body : request.clone().body;
|
|
142
|
+
Object.assign(init, { body, duplex: "half" });
|
|
143
|
+
}
|
|
144
|
+
return new Request(url, init);
|
|
145
|
+
}
|
|
146
|
+
export function anonymousResponse(response) {
|
|
147
|
+
const headers = new Headers();
|
|
148
|
+
for (const name of publicResponseHeaders) {
|
|
149
|
+
const value = response.headers.get(name);
|
|
150
|
+
if (value !== null)
|
|
151
|
+
headers.set(name, value);
|
|
152
|
+
}
|
|
153
|
+
return new Response(response.body, {
|
|
154
|
+
status: response.status,
|
|
155
|
+
statusText: response.statusText,
|
|
156
|
+
headers,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
export function disabledTeamIds(raw) {
|
|
160
|
+
return new Set((raw ?? "")
|
|
161
|
+
.split(",")
|
|
162
|
+
.map((teamId) => teamId.trim())
|
|
163
|
+
.filter((teamId) => teamId.length > 0));
|
|
164
|
+
}
|
|
165
|
+
export class ComputerEgressConfigError extends Error {
|
|
166
|
+
issues;
|
|
167
|
+
name = "ComputerEgressConfigError";
|
|
168
|
+
constructor(issues) {
|
|
169
|
+
super(`Invalid Computer egress configuration: ${issues.join("; ")}`);
|
|
170
|
+
this.issues = issues;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const publicEgressSwitchSchema = z.enum(["enabled", "disabled"]).optional();
|
|
174
|
+
/** Public egress is on by default; only the explicit global kill switch
|
|
175
|
+
* (`COMPUTER_PUBLIC_EGRESS=disabled`) turns it off. Any other configured
|
|
176
|
+
* value fails closed with a typed config error rather than silently denying
|
|
177
|
+
* or enabling egress. */
|
|
178
|
+
export function publicEgressEnabled(raw) {
|
|
179
|
+
const parsed = publicEgressSwitchSchema.safeParse(raw);
|
|
180
|
+
if (!parsed.success)
|
|
181
|
+
throw new ComputerEgressConfigError(parsed.error.issues.map(({ message }) => message));
|
|
182
|
+
return parsed.data !== "disabled";
|
|
183
|
+
}
|
|
184
|
+
/** The public lane's one on/off decision, in the order its switches are
|
|
185
|
+
* owned: the global kill switch, then the per-Team off list, then this
|
|
186
|
+
* Computer's own switch. Either of the first two overrides this Computer's
|
|
187
|
+
* own "on". */
|
|
188
|
+
export function decidePublicLane(input) {
|
|
189
|
+
if (!input.publicEgressEnabled)
|
|
190
|
+
return { kind: "denied", reason: "globally-disabled" };
|
|
191
|
+
if (input.teamDisabled)
|
|
192
|
+
return { kind: "denied", reason: "team-disabled" };
|
|
193
|
+
if (!input.computerInternetAccessEnabled)
|
|
194
|
+
return { kind: "denied", reason: "computer-disabled" };
|
|
195
|
+
return { kind: "allowed" };
|
|
196
|
+
}
|
|
197
|
+
export function recordComputerEgress(event) {
|
|
198
|
+
console.info(JSON.stringify(event));
|
|
199
|
+
}
|
|
200
|
+
/** The policy refusal every host returns; the reason stays in the log. */
|
|
201
|
+
export function computerEgressDenied(input) {
|
|
202
|
+
recordComputerEgress({
|
|
203
|
+
event: "computer.egress",
|
|
204
|
+
...input,
|
|
205
|
+
lane: "denied",
|
|
206
|
+
contentLength: null,
|
|
207
|
+
});
|
|
208
|
+
return new Response("Computer egress denied by policy", {
|
|
209
|
+
status: input.status,
|
|
210
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** Smart-HTTP transport headers plus the guest's own credential. */
|
|
214
|
+
const originGitRequestHeaders = [
|
|
215
|
+
...anonymousRequestHeaders,
|
|
216
|
+
"authorization",
|
|
217
|
+
"content-encoding",
|
|
218
|
+
];
|
|
219
|
+
/**
|
|
220
|
+
* An admitted origin-git exchange as a fresh request to the host's own Git
|
|
221
|
+
* door. Only the guest's own credential (an owner key or workflow Git pass
|
|
222
|
+
* Shed sets as `http.<remote>.extraHeader`) travels; the gateway adds none.
|
|
223
|
+
*/
|
|
224
|
+
export function originGitRequest(request, url) {
|
|
225
|
+
const headers = new Headers();
|
|
226
|
+
for (const name of originGitRequestHeaders) {
|
|
227
|
+
const value = request.headers.get(name);
|
|
228
|
+
if (value !== null)
|
|
229
|
+
headers.set(name, value);
|
|
230
|
+
}
|
|
231
|
+
const init = {
|
|
232
|
+
method: request.method,
|
|
233
|
+
headers,
|
|
234
|
+
redirect: "manual",
|
|
235
|
+
signal: request.signal,
|
|
236
|
+
};
|
|
237
|
+
if (request.method !== "GET" && request.method !== "HEAD")
|
|
238
|
+
Object.assign(init, { body: request.body, duplex: "half" });
|
|
239
|
+
return new Request(url, init);
|
|
240
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { WorkspaceRuntimeExecHandle } from "@fungi.computer/cloudflare-computer";
|
|
2
|
+
/** A host deadline cancels the owned execution, rather than merely abandoning its result. */
|
|
3
|
+
export declare function computerExecutionResult(run: WorkspaceRuntimeExecHandle<"utf8">, signal?: AbortSignal): Promise<import("@fungi.computer/cloudflare-computer").WorkspaceRuntimeResult<"utf8">>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** A host deadline cancels the owned execution, rather than merely abandoning its result. */
|
|
2
|
+
export async function computerExecutionResult(run, signal) {
|
|
3
|
+
if (signal === undefined)
|
|
4
|
+
return await run.result();
|
|
5
|
+
let interrupt = () => undefined;
|
|
6
|
+
const interrupted = new Promise((resolve) => {
|
|
7
|
+
interrupt = () => {
|
|
8
|
+
resolve({ kind: "interrupted" });
|
|
9
|
+
};
|
|
10
|
+
signal.addEventListener("abort", interrupt, { once: true });
|
|
11
|
+
if (signal.aborted)
|
|
12
|
+
interrupt();
|
|
13
|
+
});
|
|
14
|
+
try {
|
|
15
|
+
const result = await Promise.race([
|
|
16
|
+
run.result().then((value) => ({ kind: "result", value })),
|
|
17
|
+
interrupted,
|
|
18
|
+
]);
|
|
19
|
+
if (result.kind === "result")
|
|
20
|
+
return result.value;
|
|
21
|
+
await run.kill("SIGKILL");
|
|
22
|
+
signal.throwIfAborted();
|
|
23
|
+
throw new Error("Computer execution interrupted");
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
signal.removeEventListener("abort", interrupt);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Workspace } from "@fungi.computer/cloudflare-computer";
|
|
2
|
+
/** Await Workspace synchronization even when the native engine reports a partial failure. */
|
|
3
|
+
export declare function applyWorkspacePatch(workspace: Pick<Workspace, "runtime">, backend: string, input: {
|
|
4
|
+
patch: string;
|
|
5
|
+
}): Promise<{
|
|
6
|
+
status: "completed" | "failed" | "cancelled";
|
|
7
|
+
exitCode: number;
|
|
8
|
+
stdout: string;
|
|
9
|
+
stderr: string;
|
|
10
|
+
truncated: boolean;
|
|
11
|
+
} | {
|
|
12
|
+
status: "completed" | "failed" | "cancelled";
|
|
13
|
+
exitCode: number;
|
|
14
|
+
stdout: string;
|
|
15
|
+
stderr: string;
|
|
16
|
+
truncated: boolean;
|
|
17
|
+
error: {
|
|
18
|
+
kind: "mismatch";
|
|
19
|
+
};
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
2
|
+
if (value !== null && value !== void 0) {
|
|
3
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
4
|
+
var dispose, inner;
|
|
5
|
+
if (async) {
|
|
6
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
7
|
+
dispose = value[Symbol.asyncDispose];
|
|
8
|
+
}
|
|
9
|
+
if (dispose === void 0) {
|
|
10
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
11
|
+
dispose = value[Symbol.dispose];
|
|
12
|
+
if (async) inner = dispose;
|
|
13
|
+
}
|
|
14
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
15
|
+
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
16
|
+
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
17
|
+
}
|
|
18
|
+
else if (async) {
|
|
19
|
+
env.stack.push({ async: true });
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
24
|
+
return function (env) {
|
|
25
|
+
function fail(e) {
|
|
26
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
27
|
+
env.hasError = true;
|
|
28
|
+
}
|
|
29
|
+
var r, s = 0;
|
|
30
|
+
function next() {
|
|
31
|
+
while (r = env.stack.pop()) {
|
|
32
|
+
try {
|
|
33
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
34
|
+
if (r.dispose) {
|
|
35
|
+
var result = r.dispose.call(r.value);
|
|
36
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
37
|
+
}
|
|
38
|
+
else s |= 1;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
fail(e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
45
|
+
if (env.hasError) throw env.error;
|
|
46
|
+
}
|
|
47
|
+
return next();
|
|
48
|
+
};
|
|
49
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
50
|
+
var e = new Error(message);
|
|
51
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
+
});
|
|
53
|
+
import { patchInputSchema, patchOutputSchema, maximumStreamBytes, } from "./operation-contract.js";
|
|
54
|
+
/** The packaged, pinned Codex engine owns patch parsing and the Workspace jail. */
|
|
55
|
+
const nativePatch = {
|
|
56
|
+
executable: "/opt/fungi/fungi-apply-patch",
|
|
57
|
+
cwd: "/workspace",
|
|
58
|
+
timeoutMs: 30_000,
|
|
59
|
+
mismatchExitCode: 23,
|
|
60
|
+
};
|
|
61
|
+
function bounded(value) {
|
|
62
|
+
const bytes = new TextEncoder().encode(value);
|
|
63
|
+
return bytes.length <= maximumStreamBytes
|
|
64
|
+
? { value, truncated: false }
|
|
65
|
+
: {
|
|
66
|
+
value: new TextDecoder().decode(bytes.slice(0, maximumStreamBytes)),
|
|
67
|
+
truncated: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Await Workspace synchronization even when the native engine reports a partial failure. */
|
|
71
|
+
export async function applyWorkspacePatch(workspace, backend, input) {
|
|
72
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
73
|
+
try {
|
|
74
|
+
const { patch } = patchInputSchema.parse(input);
|
|
75
|
+
const run = __addDisposableResource(env_1, await workspace.runtime.exec(nativePatch.executable, {
|
|
76
|
+
backend,
|
|
77
|
+
cwd: nativePatch.cwd,
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
stdin: patch,
|
|
80
|
+
timeoutMs: nativePatch.timeoutMs,
|
|
81
|
+
}), false);
|
|
82
|
+
const result = await run.result();
|
|
83
|
+
const synchronizationFailure = result.sync.status === "pending" ? result.sync.error : null;
|
|
84
|
+
const stdout = bounded(result.stdout);
|
|
85
|
+
const stderr = bounded(synchronizationFailure !== null
|
|
86
|
+
? `${result.stderr}\nPatch filesystem synchronization failed: ${synchronizationFailure}. Earlier patch writes may remain.`
|
|
87
|
+
: result.stderr);
|
|
88
|
+
const output = {
|
|
89
|
+
status: synchronizationFailure !== null ? "failed" : result.status,
|
|
90
|
+
exitCode: synchronizationFailure !== null && result.exitCode === 0
|
|
91
|
+
? 1
|
|
92
|
+
: result.exitCode,
|
|
93
|
+
stdout: stdout.value,
|
|
94
|
+
stderr: stderr.value,
|
|
95
|
+
truncated: stdout.truncated || stderr.truncated,
|
|
96
|
+
};
|
|
97
|
+
return patchOutputSchema.parse(result.exitCode === nativePatch.mismatchExitCode
|
|
98
|
+
? { ...output, error: { kind: "mismatch" } }
|
|
99
|
+
: output);
|
|
100
|
+
}
|
|
101
|
+
catch (e_1) {
|
|
102
|
+
env_1.error = e_1;
|
|
103
|
+
env_1.hasError = true;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
__disposeResources(env_1);
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SQLiteWorkspaceProvider, WorkspaceClient } from "@fungi.computer/cloudflare-computer";
|
|
2
|
+
/** Files need only the Workspace filesystem, local or leased through a stub. */
|
|
3
|
+
export type WorkspaceFiles = Pick<WorkspaceClient, "fs">;
|
|
4
|
+
import { type ComputerFileReplacement, type ComputerFileReplacementResult } from "./operation-contract.js";
|
|
5
|
+
export type WorkspaceFilesOwner = Readonly<{
|
|
6
|
+
replaceFileIfUnchanged: (input: ComputerFileReplacement) => Promise<ComputerFileReplacementResult>;
|
|
7
|
+
}>;
|
|
8
|
+
export type Utf8Page = Readonly<{
|
|
9
|
+
content: string;
|
|
10
|
+
byteLength: number;
|
|
11
|
+
}>;
|
|
12
|
+
/** Decode a bounded byte page without consuming an incomplete final UTF-8 character. */
|
|
13
|
+
export declare function decodeUtf8Page(bytes: Uint8Array, path: string, moreBytesRemain: boolean): Utf8Page;
|
|
14
|
+
/** Read raw bounded bytes without Workspace.fs's BOM-stripping UTF-8 path. */
|
|
15
|
+
export declare function readBoundedWorkspaceText(provider: SQLiteWorkspaceProvider, path: string, maximumBytes: number): string;
|
|
16
|
+
/** Adapt native Workspace.fs at the one real file boundary. */
|
|
17
|
+
export declare function createWorkspaceFilesPort(workspace: WorkspaceFiles, owner: WorkspaceFilesOwner): Readonly<{
|
|
18
|
+
read(input: {
|
|
19
|
+
path: string;
|
|
20
|
+
offset: number;
|
|
21
|
+
limit: number;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
path: string;
|
|
24
|
+
content: string;
|
|
25
|
+
offset: number;
|
|
26
|
+
nextOffset: number | null;
|
|
27
|
+
truncated: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
list(input: {
|
|
30
|
+
path: string;
|
|
31
|
+
limit: number;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
path: string;
|
|
34
|
+
entries: {
|
|
35
|
+
path: string;
|
|
36
|
+
name: string;
|
|
37
|
+
type: "file" | "directory" | "symlink";
|
|
38
|
+
size: number;
|
|
39
|
+
modifiedAt: number;
|
|
40
|
+
}[];
|
|
41
|
+
truncated: boolean;
|
|
42
|
+
}>;
|
|
43
|
+
glob(input: {
|
|
44
|
+
path: string;
|
|
45
|
+
pattern: string;
|
|
46
|
+
limit: number;
|
|
47
|
+
}): Promise<{
|
|
48
|
+
path: string;
|
|
49
|
+
matches: string[];
|
|
50
|
+
truncated: boolean;
|
|
51
|
+
}>;
|
|
52
|
+
grep(input: {
|
|
53
|
+
path: string;
|
|
54
|
+
pattern: string;
|
|
55
|
+
regex: boolean;
|
|
56
|
+
ignoreCase: boolean;
|
|
57
|
+
limit: number;
|
|
58
|
+
}): Promise<{
|
|
59
|
+
path: string;
|
|
60
|
+
matches: {
|
|
61
|
+
path: string;
|
|
62
|
+
line: number;
|
|
63
|
+
text: string;
|
|
64
|
+
}[];
|
|
65
|
+
truncated: boolean;
|
|
66
|
+
}>;
|
|
67
|
+
replace(input: Readonly<{
|
|
68
|
+
path: string;
|
|
69
|
+
expectedContent: string;
|
|
70
|
+
content: string;
|
|
71
|
+
}>): Promise<{
|
|
72
|
+
path: string;
|
|
73
|
+
bytes: number;
|
|
74
|
+
}>;
|
|
75
|
+
}>;
|