hyperbrane 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/agent/agentService.d.ts +31 -0
- package/dist/agent/agentService.js +388 -0
- package/dist/agent/agentStorage.d.ts +27 -0
- package/dist/agent/agentStorage.js +52 -0
- package/dist/agent/api.d.ts +264 -0
- package/dist/agent/api.js +14 -0
- package/dist/agent/gatewaySignIns.d.ts +5 -0
- package/dist/agent/gatewaySignIns.js +11 -0
- package/dist/agent/session/client.d.ts +14 -0
- package/dist/agent/session/client.js +47 -0
- package/dist/agent/session/sessionStorage.d.ts +66 -0
- package/dist/agent/session/sessionStorage.js +89 -0
- package/dist/agent/session/sharedConnection.d.ts +13 -0
- package/dist/agent/session/sharedConnection.js +55 -0
- package/dist/agent/session/worker/jsonRpc.d.ts +39 -0
- package/dist/agent/session/worker/jsonRpc.js +102 -0
- package/dist/agent/session/worker/protocol.d.ts +120 -0
- package/dist/agent/session/worker/protocol.js +32 -0
- package/dist/agent/session/workerConnection.d.ts +29 -0
- package/dist/agent/session/workerConnection.js +44 -0
- package/dist/agent/session/workerSseConnection.d.ts +15 -0
- package/dist/agent/session/workerSseConnection.js +26 -0
- package/dist/agent/workerBundle.d.ts +19 -0
- package/dist/agent/workerBundle.generated.d.ts +1 -0
- package/dist/agent/workerBundle.generated.js +2 -0
- package/dist/agent/workerBundle.js +31 -0
- package/dist/cli/agent.d.ts +3 -0
- package/dist/cli/agent.js +423 -0
- package/dist/cli/command.d.ts +46 -0
- package/dist/cli/command.js +98 -0
- package/dist/cli/dimension.d.ts +2 -0
- package/dist/cli/dimension.js +85 -0
- package/dist/cli/gateway.d.ts +6 -0
- package/dist/cli/gateway.js +245 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +125 -0
- package/dist/cli/portal.d.ts +2 -0
- package/dist/cli/portal.js +39 -0
- package/dist/cli/services.d.ts +18 -0
- package/dist/cli/services.js +33 -0
- package/dist/cli/streamPrinter.d.ts +23 -0
- package/dist/cli/streamPrinter.js +251 -0
- package/dist/cli/table.d.ts +7 -0
- package/dist/cli/table.js +54 -0
- package/dist/dimension/api.d.ts +67 -0
- package/dist/dimension/api.js +81 -0
- package/dist/dimension/http.d.ts +7 -0
- package/dist/dimension/http.js +77 -0
- package/dist/dimension/image.d.ts +13 -0
- package/dist/dimension/image.js +43 -0
- package/dist/dimension/imageFiles.generated.d.ts +1 -0
- package/dist/dimension/imageFiles.generated.js +5 -0
- package/dist/dimension/paths.d.ts +5 -0
- package/dist/dimension/paths.js +7 -0
- package/dist/dimension/service.d.ts +22 -0
- package/dist/dimension/service.js +242 -0
- package/dist/dimension/storage.d.ts +39 -0
- package/dist/dimension/storage.js +78 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +25 -0
- package/dist/lib/docker-api.d.ts +13787 -0
- package/dist/lib/docker-api.js +5 -0
- package/dist/lib/docker.d.ts +77 -0
- package/dist/lib/docker.js +214 -0
- package/dist/lib/image.d.ts +14 -0
- package/dist/lib/image.js +29 -0
- package/dist/lib/jsonDirectory.d.ts +28 -0
- package/dist/lib/jsonDirectory.js +102 -0
- package/dist/lib/sleep.d.ts +2 -0
- package/dist/lib/sleep.js +4 -0
- package/dist/lib/tar.d.ts +2 -0
- package/dist/lib/tar.js +65 -0
- package/dist/lib/version.d.ts +1 -0
- package/dist/lib/version.js +1 -0
- package/dist/portal-gateway/api.d.ts +98 -0
- package/dist/portal-gateway/api.js +12 -0
- package/dist/portal-gateway/fakes.d.ts +3 -0
- package/dist/portal-gateway/fakes.js +11 -0
- package/dist/portal-gateway/gatewayService.d.ts +37 -0
- package/dist/portal-gateway/gatewayService.js +380 -0
- package/dist/portal-gateway/gatewayStorage.d.ts +66 -0
- package/dist/portal-gateway/gatewayStorage.js +125 -0
- package/dist/portal-gateway/image.d.ts +6 -0
- package/dist/portal-gateway/image.js +13 -0
- package/dist/portal-gateway/imageFiles.generated.d.ts +1 -0
- package/dist/portal-gateway/imageFiles.generated.js +8 -0
- package/dist/portal-gateway/portalService.d.ts +48 -0
- package/dist/portal-gateway/portalService.js +327 -0
- package/dist/portal-gateway/portalStorage.d.ts +34 -0
- package/dist/portal-gateway/portalStorage.js +44 -0
- package/dist/portal-gateway/upstream.d.ts +157 -0
- package/dist/portal-gateway/upstream.js +4 -0
- package/dist/sandbox/api.d.ts +184 -0
- package/dist/sandbox/api.js +14 -0
- package/dist/sandbox/boatService.d.ts +52 -0
- package/dist/sandbox/boatService.js +335 -0
- package/dist/sandbox/containerService.d.ts +15 -0
- package/dist/sandbox/containerService.js +177 -0
- package/dist/sandbox/endpointProxy.d.ts +90 -0
- package/dist/sandbox/endpointProxy.js +701 -0
- package/dist/sandbox/exeDevAuth.d.ts +16 -0
- package/dist/sandbox/exeDevAuth.js +49 -0
- package/dist/sandbox/localService.d.ts +19 -0
- package/dist/sandbox/localService.js +182 -0
- package/dist/sandbox/sseConnection.d.ts +41 -0
- package/dist/sandbox/sseConnection.js +166 -0
- package/dist/sandbox/sshService.d.ts +30 -0
- package/dist/sandbox/sshService.js +647 -0
- package/dist/upstreams/anthropic.d.ts +7 -0
- package/dist/upstreams/anthropic.js +27 -0
- package/dist/upstreams/index.d.ts +6 -0
- package/dist/upstreams/index.js +7 -0
- package/dist/upstreams/openai.d.ts +7 -0
- package/dist/upstreams/openai.js +40 -0
- package/dist/vendor-types/claude/agent-sdk/agentSdkTypes.d.ts +1 -0
- package/dist/vendor-types/claude/agent-sdk/bridge.d.ts +388 -0
- package/dist/vendor-types/claude/agent-sdk/browser-sdk.d.ts +184 -0
- package/dist/vendor-types/claude/agent-sdk/core.d.ts +20 -0
- package/dist/vendor-types/claude/agent-sdk/extractFromBunfs.d.ts +1 -0
- package/dist/vendor-types/claude/agent-sdk/sdk-tools.d.ts +4153 -0
- package/dist/vendor-types/claude/agent-sdk/sdk.d.ts +9833 -0
- package/package.json +55 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { components, operations } from "./docker-api.ts";
|
|
2
|
+
import type { Sandbox } from "../sandbox/api.ts";
|
|
3
|
+
/** Engine API version the client speaks. After changing it, run `bun run generate:docker-api`. */
|
|
4
|
+
export declare const DOCKER_API_VERSION = "v1.44";
|
|
5
|
+
type Schemas = components["schemas"];
|
|
6
|
+
export type ContainerInspect = operations["ContainerInspect"]["responses"][200]["content"]["application/json"];
|
|
7
|
+
export type ContainerStatus = NonNullable<NonNullable<Schemas["ContainerState"]>["Status"]>;
|
|
8
|
+
export type ContainerSummary = Schemas["ContainerSummary"];
|
|
9
|
+
export type ContainerCreateSpec = operations["ContainerCreate"]["requestBody"]["content"]["application/json"];
|
|
10
|
+
export type ImageSummary = Schemas["ImageSummary"];
|
|
11
|
+
export type DockerEvent = Schemas["EventMessage"];
|
|
12
|
+
export type ContainerResources = Pick<Schemas["Resources"], "Memory" | "MemorySwap" | "NanoCpus">;
|
|
13
|
+
export type ContainerStats = {
|
|
14
|
+
/** Bytes, excluding reclaimable page cache (same number `docker stats` shows). */
|
|
15
|
+
memoryUsage: number;
|
|
16
|
+
/** Cores in use, e.g. 0.25 = a quarter of one core. */
|
|
17
|
+
cpuUsage: number;
|
|
18
|
+
};
|
|
19
|
+
export type ExecOptions = {
|
|
20
|
+
cmd: string[];
|
|
21
|
+
user?: string;
|
|
22
|
+
env?: Record<string, string>;
|
|
23
|
+
/** Defaults to the container's working directory. */
|
|
24
|
+
workdir?: string;
|
|
25
|
+
};
|
|
26
|
+
export type ExecResult = {
|
|
27
|
+
exitCode: number;
|
|
28
|
+
stdout: string;
|
|
29
|
+
stderr: string;
|
|
30
|
+
};
|
|
31
|
+
export type BuildOptions = {
|
|
32
|
+
tag: string;
|
|
33
|
+
/** Build context: file path inside the context -> file contents. Must include "Dockerfile". */
|
|
34
|
+
context: Record<string, string>;
|
|
35
|
+
labels?: Record<string, string>;
|
|
36
|
+
};
|
|
37
|
+
export interface DockerClient {
|
|
38
|
+
imageExists(image: string): Promise<boolean>;
|
|
39
|
+
buildImage(options: BuildOptions): Promise<void>;
|
|
40
|
+
/** Filters use docker's syntax, e.g. { label: ["hyperbrane"], reference: ["hyperbrane-dimension"] }. */
|
|
41
|
+
listImages(filters: Record<string, string[]>): Promise<ImageSummary[]>;
|
|
42
|
+
/** Removes a tag (and the image, once untagged). No-op if missing; DockerError 409 while a container uses it. */
|
|
43
|
+
removeImage(image: string): Promise<void>;
|
|
44
|
+
inspectContainer(name: string): Promise<ContainerInspect | null>;
|
|
45
|
+
listContainers(label: string): Promise<ContainerSummary[]>;
|
|
46
|
+
/** Throws DockerError with status 409 if a container with this name already exists. */
|
|
47
|
+
createContainer(name: string, spec: ContainerCreateSpec): Promise<void>;
|
|
48
|
+
/** No-op if the container is already running. */
|
|
49
|
+
startContainer(name: string): Promise<void>;
|
|
50
|
+
/** No-op if the container is already stopped. */
|
|
51
|
+
stopContainer(name: string): Promise<void>;
|
|
52
|
+
/** Stops the container (if running) and starts it again. */
|
|
53
|
+
restartContainer(name: string): Promise<void>;
|
|
54
|
+
/** Force-removes the container and its anonymous volumes. No-op if it doesn't exist. */
|
|
55
|
+
removeContainer(name: string): Promise<void>;
|
|
56
|
+
containerStats(name: string): Promise<ContainerStats>;
|
|
57
|
+
/** Changes a container's resource limits in place: no restart. */
|
|
58
|
+
updateContainer(name: string, resources: ContainerResources): Promise<void>;
|
|
59
|
+
exec(name: string, options: ExecOptions): Promise<ExecResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Extracts `files` (path relative to `directory` -> contents) into the container, owned by root.
|
|
62
|
+
* `directory` must exist. DockerError 404 if the container or the directory doesn't.
|
|
63
|
+
*/
|
|
64
|
+
putArchive(name: string, directory: string, files: Record<string, string | Uint8Array>): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Streams events as they happen, e.g. filters { type: ["container"], event: ["start"] }. Ends when
|
|
67
|
+
* `signal` aborts (quietly) or the daemon drops the connection.
|
|
68
|
+
*/
|
|
69
|
+
events(filters: Record<string, string[]>, signal: AbortSignal): AsyncIterable<DockerEvent>;
|
|
70
|
+
}
|
|
71
|
+
export declare class DockerError extends Error {
|
|
72
|
+
readonly status: number;
|
|
73
|
+
constructor(status: number, message: string);
|
|
74
|
+
}
|
|
75
|
+
/** Talks to the docker daemon whose API socket is at `socketPath` inside `sandbox`. */
|
|
76
|
+
export declare function createDockerClient(sandbox: Sandbox, socketPath?: string): DockerClient;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import createClient from "openapi-fetch";
|
|
2
|
+
import { createTar } from "./tar.js";
|
|
3
|
+
/** Engine API version the client speaks. After changing it, run `bun run generate:docker-api`. */
|
|
4
|
+
export const DOCKER_API_VERSION = "v1.44";
|
|
5
|
+
export class DockerError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = "DockerError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Returns the data of a successful call (or of one whose status is listed in `allowed`), else throws DockerError. */
|
|
14
|
+
function unwrap({ data, error, response }, allowed = []) {
|
|
15
|
+
if (response.ok || allowed.includes(response.status))
|
|
16
|
+
return data;
|
|
17
|
+
const message = error?.message ?? response.statusText;
|
|
18
|
+
throw new DockerError(response.status, message);
|
|
19
|
+
}
|
|
20
|
+
/** Talks to the docker daemon whose API socket is at `socketPath` inside `sandbox`. */
|
|
21
|
+
export function createDockerClient(sandbox, socketPath = "/var/run/docker.sock") {
|
|
22
|
+
function sandboxFetch(timeout) {
|
|
23
|
+
return async (request) => sandbox.fetch(request.url, {
|
|
24
|
+
method: request.method,
|
|
25
|
+
headers: Object.fromEntries(request.headers),
|
|
26
|
+
body: request.body ? new Uint8Array(await request.arrayBuffer()) : undefined,
|
|
27
|
+
unix: socketPath,
|
|
28
|
+
signal: request.signal,
|
|
29
|
+
timeout,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const api = createClient({ baseUrl: `http://docker/${DOCKER_API_VERSION}`, fetch: sandboxFetch() });
|
|
33
|
+
async function imageExists(image) {
|
|
34
|
+
const result = await api.GET("/images/{name}/json", { params: { path: { name: image } } });
|
|
35
|
+
unwrap(result, [404]);
|
|
36
|
+
return result.response.status !== 404;
|
|
37
|
+
}
|
|
38
|
+
async function buildImage({ tag, context, labels = {} }) {
|
|
39
|
+
const tar = createTar(context);
|
|
40
|
+
const result = await api.POST("/build", {
|
|
41
|
+
params: { query: { t: tag, labels: JSON.stringify(labels), rm: true, forcerm: true } },
|
|
42
|
+
headers: { "Content-type": "application/x-tar" },
|
|
43
|
+
// The spec models the binary tar stream as a string.
|
|
44
|
+
body: tar,
|
|
45
|
+
bodySerializer: (body) => body,
|
|
46
|
+
parseAs: "text",
|
|
47
|
+
});
|
|
48
|
+
// The build streams JSON lines; failures arrive in-band with HTTP 200.
|
|
49
|
+
const log = [];
|
|
50
|
+
for (const line of (unwrap(result) ?? "").split("\n").filter(Boolean)) {
|
|
51
|
+
const event = JSON.parse(line);
|
|
52
|
+
if (event.stream)
|
|
53
|
+
log.push(event.stream);
|
|
54
|
+
if (event.error) {
|
|
55
|
+
throw new DockerError(500, `build ${tag} failed: ${event.error}\n${log.join("").slice(-2000)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function listImages(filters) {
|
|
60
|
+
const result = await api.GET("/images/json", { params: { query: { filters: JSON.stringify(filters) } } });
|
|
61
|
+
return unwrap(result);
|
|
62
|
+
}
|
|
63
|
+
async function removeImage(image) {
|
|
64
|
+
unwrap(await api.DELETE("/images/{name}", { params: { path: { name: image } } }), [404]);
|
|
65
|
+
}
|
|
66
|
+
async function inspectContainer(name) {
|
|
67
|
+
const result = await api.GET("/containers/{id}/json", { params: { path: { id: name } } });
|
|
68
|
+
if (result.response.status === 404)
|
|
69
|
+
return null;
|
|
70
|
+
return unwrap(result);
|
|
71
|
+
}
|
|
72
|
+
async function listContainers(label) {
|
|
73
|
+
const filters = JSON.stringify({ label: [label] });
|
|
74
|
+
return unwrap(await api.GET("/containers/json", { params: { query: { all: true, filters } } }));
|
|
75
|
+
}
|
|
76
|
+
async function createContainer(name, spec) {
|
|
77
|
+
unwrap(await api.POST("/containers/create", { params: { query: { name } }, body: spec }));
|
|
78
|
+
}
|
|
79
|
+
async function startContainer(name) {
|
|
80
|
+
unwrap(await api.POST("/containers/{id}/start", { params: { path: { id: name } } }), [304]);
|
|
81
|
+
}
|
|
82
|
+
async function stopContainer(name) {
|
|
83
|
+
unwrap(await api.POST("/containers/{id}/stop", { params: { path: { id: name } } }), [304]);
|
|
84
|
+
}
|
|
85
|
+
async function restartContainer(name) {
|
|
86
|
+
unwrap(await api.POST("/containers/{id}/restart", { params: { path: { id: name } } }));
|
|
87
|
+
}
|
|
88
|
+
async function removeContainer(name) {
|
|
89
|
+
const params = { path: { id: name }, query: { force: true, v: true } };
|
|
90
|
+
unwrap(await api.DELETE("/containers/{id}", { params }), [404]);
|
|
91
|
+
}
|
|
92
|
+
async function updateContainer(name, resources) {
|
|
93
|
+
unwrap(await api.POST("/containers/{id}/update", { params: { path: { id: name } }, body: resources }));
|
|
94
|
+
}
|
|
95
|
+
async function containerStats(name) {
|
|
96
|
+
// stream=false without one-shot waits for two samples, which the CPU delta needs.
|
|
97
|
+
const result = await api.GET("/containers/{id}/stats", {
|
|
98
|
+
params: { path: { id: name }, query: { stream: false } },
|
|
99
|
+
});
|
|
100
|
+
return toContainerStats(unwrap(result));
|
|
101
|
+
}
|
|
102
|
+
async function exec(name, { cmd, user, env = {}, workdir }) {
|
|
103
|
+
const created = await api.POST("/containers/{id}/exec", {
|
|
104
|
+
params: { path: { id: name } },
|
|
105
|
+
body: {
|
|
106
|
+
Cmd: cmd,
|
|
107
|
+
User: user,
|
|
108
|
+
WorkingDir: workdir,
|
|
109
|
+
Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
|
|
110
|
+
AttachStdout: true,
|
|
111
|
+
AttachStderr: true,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const { Id } = unwrap(created);
|
|
115
|
+
const started = await api.POST("/exec/{id}/start", {
|
|
116
|
+
params: { path: { id: Id } },
|
|
117
|
+
body: { Detach: false, Tty: false },
|
|
118
|
+
parseAs: "arrayBuffer",
|
|
119
|
+
});
|
|
120
|
+
const { stdout, stderr } = demuxStream(new Uint8Array(unwrap(started)));
|
|
121
|
+
const inspected = unwrap(await api.GET("/exec/{id}/json", { params: { path: { id: Id } } }));
|
|
122
|
+
return { exitCode: inspected.ExitCode ?? -1, stdout, stderr };
|
|
123
|
+
}
|
|
124
|
+
async function putArchive(name, directory, files) {
|
|
125
|
+
const tar = createTar(files);
|
|
126
|
+
const result = await api.PUT("/containers/{id}/archive", {
|
|
127
|
+
params: { path: { id: name }, query: { path: directory } },
|
|
128
|
+
headers: { "Content-type": "application/x-tar" },
|
|
129
|
+
// The spec models the binary tar stream as a string.
|
|
130
|
+
body: tar,
|
|
131
|
+
bodySerializer: (body) => body,
|
|
132
|
+
parseAs: "text",
|
|
133
|
+
});
|
|
134
|
+
unwrap(result);
|
|
135
|
+
}
|
|
136
|
+
async function* events(filters, signal) {
|
|
137
|
+
try {
|
|
138
|
+
const result = await api.GET("/events", {
|
|
139
|
+
params: { query: { filters: JSON.stringify(filters) } },
|
|
140
|
+
parseAs: "stream",
|
|
141
|
+
signal,
|
|
142
|
+
// The stream stays silent while nothing happens; an idle timeout would cut it.
|
|
143
|
+
fetch: sandboxFetch(false),
|
|
144
|
+
});
|
|
145
|
+
const body = unwrap(result);
|
|
146
|
+
if (!body)
|
|
147
|
+
return;
|
|
148
|
+
// One JSON object per line.
|
|
149
|
+
const decoder = new TextDecoder();
|
|
150
|
+
let buffered = "";
|
|
151
|
+
for await (const chunk of body) {
|
|
152
|
+
buffered += decoder.decode(chunk, { stream: true });
|
|
153
|
+
let newline;
|
|
154
|
+
while ((newline = buffered.indexOf("\n")) >= 0) {
|
|
155
|
+
const line = buffered.slice(0, newline).trim();
|
|
156
|
+
buffered = buffered.slice(newline + 1);
|
|
157
|
+
if (line)
|
|
158
|
+
yield JSON.parse(line);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (!signal.aborted)
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
imageExists,
|
|
169
|
+
buildImage,
|
|
170
|
+
listImages,
|
|
171
|
+
removeImage,
|
|
172
|
+
inspectContainer,
|
|
173
|
+
listContainers,
|
|
174
|
+
createContainer,
|
|
175
|
+
startContainer,
|
|
176
|
+
stopContainer,
|
|
177
|
+
restartContainer,
|
|
178
|
+
removeContainer,
|
|
179
|
+
containerStats,
|
|
180
|
+
updateContainer,
|
|
181
|
+
exec,
|
|
182
|
+
putArchive,
|
|
183
|
+
events,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function toContainerStats(raw) {
|
|
187
|
+
const memory = raw.memory_stats;
|
|
188
|
+
const cache = memory.stats?.inactive_file ?? memory.stats?.total_inactive_file ?? 0;
|
|
189
|
+
const memoryUsage = Math.max(0, (memory.usage ?? 0) - cache);
|
|
190
|
+
const cpuDelta = raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
|
|
191
|
+
const systemDelta = (raw.cpu_stats.system_cpu_usage ?? 0) - (raw.precpu_stats.system_cpu_usage ?? 0);
|
|
192
|
+
const cpus = raw.cpu_stats.online_cpus ?? raw.cpu_stats.cpu_usage.percpu_usage?.length ?? 1;
|
|
193
|
+
const cpuUsage = systemDelta > 0 && cpuDelta > 0 ? (cpuDelta / systemDelta) * cpus : 0;
|
|
194
|
+
return { memoryUsage, cpuUsage };
|
|
195
|
+
}
|
|
196
|
+
/** Splits docker's multiplexed attach stream: 8-byte header (stream id, 3 pad, uint32 BE size) per frame. */
|
|
197
|
+
function demuxStream(bytes) {
|
|
198
|
+
const decoder = new TextDecoder();
|
|
199
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
200
|
+
let stdout = "";
|
|
201
|
+
let stderr = "";
|
|
202
|
+
let offset = 0;
|
|
203
|
+
while (offset + 8 <= bytes.length) {
|
|
204
|
+
const stream = bytes[offset];
|
|
205
|
+
const size = view.getUint32(offset + 4);
|
|
206
|
+
const chunk = decoder.decode(bytes.subarray(offset + 8, offset + 8 + size));
|
|
207
|
+
if (stream === 2)
|
|
208
|
+
stderr += chunk;
|
|
209
|
+
else
|
|
210
|
+
stdout += chunk;
|
|
211
|
+
offset += 8 + size;
|
|
212
|
+
}
|
|
213
|
+
return { stdout, stderr };
|
|
214
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type DockerClient } from "./docker.ts";
|
|
2
|
+
export type ImageSpec = {
|
|
3
|
+
repository: string;
|
|
4
|
+
/** Bump whenever the build context changes; the tag is how an existing build is recognised. */
|
|
5
|
+
version: string;
|
|
6
|
+
/** Build context: file path -> contents. Must include "Dockerfile". */
|
|
7
|
+
context: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
export interface ManagedImage {
|
|
10
|
+
readonly tag: string;
|
|
11
|
+
/** Builds the image if it isn't present yet, removes outdated versions, and returns its tag. */
|
|
12
|
+
ensure(): Promise<string>;
|
|
13
|
+
}
|
|
14
|
+
export declare function createManagedImage(docker: DockerClient, { repository, version, context }: ImageSpec): ManagedImage;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { DockerError } from "./docker.js";
|
|
2
|
+
export function createManagedImage(docker, { repository, version, context }) {
|
|
3
|
+
const tag = `${repository}:${version}`;
|
|
4
|
+
// Concurrent builds of the same tag (from any process) are harmless: they produce the same image.
|
|
5
|
+
async function ensure() {
|
|
6
|
+
if (!(await docker.imageExists(tag))) {
|
|
7
|
+
await docker.buildImage({ tag, context, labels: { hyperbrane: "true", "hyperbrane.image.version": version } });
|
|
8
|
+
}
|
|
9
|
+
await removeOutdatedImages().catch((error) => console.warn(`removing outdated ${repository} images failed:`, error));
|
|
10
|
+
return tag;
|
|
11
|
+
}
|
|
12
|
+
// Images still used by a container are skipped (docker answers 409) and retried on a later ensure().
|
|
13
|
+
async function removeOutdatedImages() {
|
|
14
|
+
const images = await docker.listImages({ label: ["hyperbrane"], reference: [repository] });
|
|
15
|
+
const outdated = images
|
|
16
|
+
.flatMap((image) => image.RepoTags ?? [])
|
|
17
|
+
.filter((imageTag) => imageTag.startsWith(`${repository}:`) && imageTag !== tag);
|
|
18
|
+
for (const imageTag of outdated) {
|
|
19
|
+
try {
|
|
20
|
+
await docker.removeImage(imageTag);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!(error instanceof DockerError && error.status === 409))
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { tag, ensure };
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Sandbox } from "../sandbox/api.ts";
|
|
2
|
+
/**
|
|
3
|
+
* One JSON file per record in a sandbox directory, so no operation rewrites a shared file and every
|
|
4
|
+
* write is a single atomic sandbox operation. Safe to use from several processes at once.
|
|
5
|
+
*/
|
|
6
|
+
export interface JsonDirectory<T> {
|
|
7
|
+
/** Null if missing. */
|
|
8
|
+
read(name: string): Promise<T | null>;
|
|
9
|
+
/** Stores the record unless one with that name exists; returns whichever record is stored afterwards. */
|
|
10
|
+
insert(name: string, record: T): Promise<T>;
|
|
11
|
+
/** Replaces the record. */
|
|
12
|
+
save(name: string, record: T): Promise<void>;
|
|
13
|
+
/** No-op if missing. */
|
|
14
|
+
remove(name: string): Promise<void>;
|
|
15
|
+
/** Empty if the directory doesn't exist yet. */
|
|
16
|
+
list(): Promise<T[]>;
|
|
17
|
+
/** list, each record with its name. */
|
|
18
|
+
entries(): Promise<[string, T][]>;
|
|
19
|
+
}
|
|
20
|
+
export declare class InvalidNameError extends Error {
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
/** Throws InvalidNameError unless `name` is safe to use as a path segment. */
|
|
24
|
+
export declare function checkName(name: string): string;
|
|
25
|
+
/** `dir` resolves to a sandbox path; it's re-resolved per call so it can depend on the sandbox's home. */
|
|
26
|
+
export declare function createJsonDirectory<T>(sandbox: Sandbox, dir: () => Promise<string>): JsonDirectory<T>;
|
|
27
|
+
/** ~/.local/share/hyperbrane of the sandbox's user. */
|
|
28
|
+
export declare function defaultDataDir(sandbox: Sandbox): Promise<string>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { SandboxNotFoundError } from "../sandbox/api.js";
|
|
2
|
+
const EXTENSION = ".json";
|
|
3
|
+
// Same alphabet as dimension names; keeps names safe to use as file and directory names.
|
|
4
|
+
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/;
|
|
5
|
+
export class InvalidNameError extends Error {
|
|
6
|
+
name = "InvalidNameError";
|
|
7
|
+
}
|
|
8
|
+
/** Throws InvalidNameError unless `name` is safe to use as a path segment. */
|
|
9
|
+
export function checkName(name) {
|
|
10
|
+
if (!NAME_PATTERN.test(name)) {
|
|
11
|
+
throw new InvalidNameError(`Invalid name "${name}": use letters, digits, "_", "." and "-", up to 63 characters`);
|
|
12
|
+
}
|
|
13
|
+
return name;
|
|
14
|
+
}
|
|
15
|
+
/** `dir` resolves to a sandbox path; it's re-resolved per call so it can depend on the sandbox's home. */
|
|
16
|
+
export function createJsonDirectory(sandbox, dir) {
|
|
17
|
+
async function pathFor(name) {
|
|
18
|
+
return `${await dir()}/${checkName(name)}${EXTENSION}`;
|
|
19
|
+
}
|
|
20
|
+
function serialize(record) {
|
|
21
|
+
return JSON.stringify(record, null, 2) + "\n";
|
|
22
|
+
}
|
|
23
|
+
async function readPath(path) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(new TextDecoder().decode(await sandbox.readFile(path)));
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof SandboxNotFoundError)
|
|
29
|
+
return null;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function read(name) {
|
|
34
|
+
return readPath(await pathFor(name));
|
|
35
|
+
}
|
|
36
|
+
async function insert(name, record) {
|
|
37
|
+
const path = await pathFor(name);
|
|
38
|
+
if (await sandbox.writeFileExclusive(path, serialize(record)))
|
|
39
|
+
return record;
|
|
40
|
+
// Lost to an existing record; if that one was removed in the meantime, try again.
|
|
41
|
+
return (await readPath(path)) ?? insert(name, record);
|
|
42
|
+
}
|
|
43
|
+
async function save(name, record) {
|
|
44
|
+
await sandbox.writeFile(await pathFor(name), serialize(record));
|
|
45
|
+
}
|
|
46
|
+
async function remove(name) {
|
|
47
|
+
await sandbox.removeFile(await pathFor(name));
|
|
48
|
+
}
|
|
49
|
+
// One command for the whole directory: over SSH or Boat, a call per record adds up. Falls back to
|
|
50
|
+
// listing and reading where the command can't run.
|
|
51
|
+
async function entries() {
|
|
52
|
+
const path = await dir();
|
|
53
|
+
const all = await sandbox.runCommand(["sh", "-c", LIST_SCRIPT, "sh", path]).catch(() => null);
|
|
54
|
+
if (all?.exitCode === MISSING)
|
|
55
|
+
return [];
|
|
56
|
+
if (all?.exitCode === 0) {
|
|
57
|
+
return all.stdout
|
|
58
|
+
.split(RECORD_END)
|
|
59
|
+
.filter((text) => text.trim())
|
|
60
|
+
.flatMap((text) => {
|
|
61
|
+
const newline = text.indexOf("\n");
|
|
62
|
+
try {
|
|
63
|
+
return [[text.slice(0, newline).slice(0, -EXTENSION.length), JSON.parse(text.slice(newline + 1))]];
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
let files;
|
|
71
|
+
try {
|
|
72
|
+
files = await sandbox.listDirectory(path);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error instanceof SandboxNotFoundError)
|
|
76
|
+
return [];
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
// A record removed between listing and reading is simply skipped.
|
|
80
|
+
const records = await Promise.all(files
|
|
81
|
+
.filter((file) => file.endsWith(EXTENSION))
|
|
82
|
+
.map(async (file) => [file.slice(0, -EXTENSION.length), await readPath(`${path}/${file}`)]));
|
|
83
|
+
return records.filter((entry) => entry[1] !== null);
|
|
84
|
+
}
|
|
85
|
+
async function list() {
|
|
86
|
+
return (await entries()).map(([, record]) => record);
|
|
87
|
+
}
|
|
88
|
+
return { read, insert, save, remove, list, entries };
|
|
89
|
+
}
|
|
90
|
+
/** list's command exits with it when the directory doesn't exist. */
|
|
91
|
+
const MISSING = 44;
|
|
92
|
+
/** Ends each record list's command prints (its file name, a newline, its JSON): a record separator, which JSON escapes inside strings. */
|
|
93
|
+
const RECORD_END = "\n\x1e\n";
|
|
94
|
+
const LIST_SCRIPT = `cd "$1" 2>/dev/null || exit ${MISSING}
|
|
95
|
+
for file in *${EXTENSION}; do
|
|
96
|
+
[ -f "$file" ] || continue
|
|
97
|
+
printf '%s\\n' "$file" && cat "$file" && printf '\\n\\036\\n'
|
|
98
|
+
done`;
|
|
99
|
+
/** ~/.local/share/hyperbrane of the sandbox's user. */
|
|
100
|
+
export async function defaultDataDir(sandbox) {
|
|
101
|
+
return `${await sandbox.homeDirectory()}/.local/share/hyperbrane`;
|
|
102
|
+
}
|
package/dist/lib/tar.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A minimal tar (ustar) writer: regular files only, which is all a docker build context or
|
|
2
|
+
// putArchive needs (docker creates missing parent directories). Portable stand-in for Bun.Archive,
|
|
3
|
+
// so the services run under Node too.
|
|
4
|
+
const BLOCK = 512;
|
|
5
|
+
function octal(value, width) {
|
|
6
|
+
// width includes the trailing NUL.
|
|
7
|
+
return value.toString(8).padStart(width - 1, "0") + "\0";
|
|
8
|
+
}
|
|
9
|
+
function header(name, size, mode, mtime) {
|
|
10
|
+
const encoder = new TextEncoder();
|
|
11
|
+
const block = new Uint8Array(BLOCK);
|
|
12
|
+
const put = (text, offset, width) => {
|
|
13
|
+
const bytes = encoder.encode(text);
|
|
14
|
+
if (bytes.length > width)
|
|
15
|
+
throw new Error(`tar: "${name}" is too long for the header`);
|
|
16
|
+
block.set(bytes, offset);
|
|
17
|
+
};
|
|
18
|
+
let path = name;
|
|
19
|
+
let prefix = "";
|
|
20
|
+
if (encoder.encode(name).length > 100) {
|
|
21
|
+
// ustar splits long paths at a "/" into prefix (155) and name (100).
|
|
22
|
+
const cut = name.lastIndexOf("/", 155);
|
|
23
|
+
if (cut <= 0)
|
|
24
|
+
throw new Error(`tar: "${name}" is too long`);
|
|
25
|
+
prefix = name.slice(0, cut);
|
|
26
|
+
path = name.slice(cut + 1);
|
|
27
|
+
}
|
|
28
|
+
put(path, 0, 100);
|
|
29
|
+
put(octal(mode, 8), 100, 8);
|
|
30
|
+
put(octal(0, 8), 108, 8); // uid
|
|
31
|
+
put(octal(0, 8), 116, 8); // gid
|
|
32
|
+
put(octal(size, 12), 124, 12);
|
|
33
|
+
put(octal(mtime, 12), 136, 12);
|
|
34
|
+
put(" ", 148, 8); // checksum placeholder: eight spaces while summing
|
|
35
|
+
put("0", 156, 1); // regular file
|
|
36
|
+
put("ustar\0", 257, 6);
|
|
37
|
+
put("00", 263, 2);
|
|
38
|
+
put(prefix, 345, 155);
|
|
39
|
+
let sum = 0;
|
|
40
|
+
for (const byte of block)
|
|
41
|
+
sum += byte;
|
|
42
|
+
put(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8);
|
|
43
|
+
return block;
|
|
44
|
+
}
|
|
45
|
+
/** Packs `files` (path -> contents) into a tar archive. Files are mode 0644, owned by root. */
|
|
46
|
+
export function createTar(files) {
|
|
47
|
+
const encoder = new TextEncoder();
|
|
48
|
+
const mtime = Math.floor(Date.now() / 1000);
|
|
49
|
+
const parts = [];
|
|
50
|
+
for (const [name, content] of Object.entries(files)) {
|
|
51
|
+
const data = typeof content === "string" ? encoder.encode(content) : content;
|
|
52
|
+
parts.push(header(name.replace(/^\/+/, ""), data.length, 0o644, mtime), data);
|
|
53
|
+
const padding = (BLOCK - (data.length % BLOCK)) % BLOCK;
|
|
54
|
+
if (padding)
|
|
55
|
+
parts.push(new Uint8Array(padding));
|
|
56
|
+
}
|
|
57
|
+
parts.push(new Uint8Array(BLOCK * 2)); // end of archive
|
|
58
|
+
const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
59
|
+
let offset = 0;
|
|
60
|
+
for (const part of parts) {
|
|
61
|
+
out.set(part, offset);
|
|
62
|
+
offset += part.length;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const HYPERBRANE_VERSION = "v1";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const HYPERBRANE_VERSION = "v1";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { UpstreamDefinition } from "./upstream.ts";
|
|
2
|
+
export type Portal = {
|
|
3
|
+
name: string;
|
|
4
|
+
dimensionName: string;
|
|
5
|
+
gatewayName: string;
|
|
6
|
+
};
|
|
7
|
+
export type Gateway = {
|
|
8
|
+
name: string;
|
|
9
|
+
/**
|
|
10
|
+
* Registered when the gateway is created: the built-in upstreams (upstreams/) unless given, `[]`
|
|
11
|
+
* for none. A gateway that doesn't know an upstream passes its tokens to dimensions untouched.
|
|
12
|
+
*/
|
|
13
|
+
upstreams?: Upstream[];
|
|
14
|
+
};
|
|
15
|
+
export type Upstream = UpstreamDefinition;
|
|
16
|
+
/**
|
|
17
|
+
* Never the secret: "<upstream>.oauth" for a captured sign-in, "<upstream>.key.<key>" per API key,
|
|
18
|
+
* with the key's fake, which is what a client in a dimension is given in place of the key.
|
|
19
|
+
*/
|
|
20
|
+
export type GatewayCredential = {
|
|
21
|
+
name: string;
|
|
22
|
+
gatewayName: string;
|
|
23
|
+
type: "oauth" | "apiKey";
|
|
24
|
+
fake?: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Applies to traffic arriving through the gateway's portals. "tcp://<address>:<port>" covers every
|
|
28
|
+
* TCP connection by its destination, before any byte is sent: address is "*", an IPv4 address or a
|
|
29
|
+
* CIDR, port is "*" or a number ("tcp://10.0.0.0/8:*", "tcp://*:22"). Anything else is a glob over
|
|
30
|
+
* every HTTP(S) request's host:port/path?query, whatever its scheme: "https://github.com/*",
|
|
31
|
+
* "http://github.com/*" and "github.com" all cover github.com over HTTP and HTTPS, since a mask
|
|
32
|
+
* without a port matches any port and one without a path any path. "github.com:80/*" is plain HTTP
|
|
33
|
+
* only; "*.github.com/api/*" the API on any subdomain. Rules are checked in name order and the first
|
|
34
|
+
* match decides.
|
|
35
|
+
*/
|
|
36
|
+
export type TrafficRule = {
|
|
37
|
+
name: string;
|
|
38
|
+
urlMask: string;
|
|
39
|
+
action: "BLOCK" | "DELAY" | "ASK" | "INTERCEPT";
|
|
40
|
+
delayMs: number;
|
|
41
|
+
};
|
|
42
|
+
export interface PortalGatewayClient {
|
|
43
|
+
/** POST /v1/gateways — also replaces a container made from an older gateway image, keeping its state. */
|
|
44
|
+
createGateway(gateway: Gateway): Promise<Gateway>;
|
|
45
|
+
/** DELETE /v1/gateways/:gatewayName */
|
|
46
|
+
destroyGateway(gatewayName: string): Promise<void>;
|
|
47
|
+
/** GET /v1/gateways */
|
|
48
|
+
listGateways(): Promise<Gateway[]>;
|
|
49
|
+
/** POST /v1/gateways/:gatewayName/restart — resolves once it intercepts again; its portals are re-wired by the portal watcher. */
|
|
50
|
+
restartGateway(gatewayName: string): Promise<void>;
|
|
51
|
+
/** GET /v1/gateways/:gatewayName/credentials */
|
|
52
|
+
listCredentials(gatewayName: string): Promise<GatewayCredential[]>;
|
|
53
|
+
/** DELETE /v1/gateways/:gatewayName/credentials/:credentialName */
|
|
54
|
+
deleteCredential(gatewayName: string, credentialName: string): Promise<void>;
|
|
55
|
+
/** PUT /v1/gateways/:gatewayName/upstreams/:upstreamName — adds or replaces the definition. */
|
|
56
|
+
registerUpstream(gatewayName: string, upstream: Upstream): Promise<Upstream>;
|
|
57
|
+
/** GET /v1/gateways/:gatewayName/upstreams */
|
|
58
|
+
listUpstreams(gatewayName: string): Promise<Upstream[]>;
|
|
59
|
+
/** DELETE /v1/gateways/:gatewayName/upstreams/:upstreamName — with its credentials. */
|
|
60
|
+
removeUpstream(gatewayName: string, upstreamName: string): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* PUT /v1/gateways/:gatewayName/upstreams/:upstreamName/api-keys/:keyName — returns the key's fake,
|
|
63
|
+
* to give the client in the dimension (e.g. as its API key env var) in place of the key.
|
|
64
|
+
*/
|
|
65
|
+
setApiKey(gatewayName: string, upstreamName: string, keyName: string, key: string): Promise<{
|
|
66
|
+
fake: string;
|
|
67
|
+
}>;
|
|
68
|
+
/** DELETE /v1/gateways/:gatewayName/upstreams/:upstreamName/api-keys/:keyName */
|
|
69
|
+
deleteApiKey(gatewayName: string, upstreamName: string, keyName: string): Promise<void>;
|
|
70
|
+
/** POST /v1/gateways/:gatewayName/traffic-rules */
|
|
71
|
+
createTrafficRule(gatewayName: string, rule: TrafficRule): Promise<TrafficRule>;
|
|
72
|
+
/** PUT /v1/gateways/:gatewayName/traffic-rules/:ruleName */
|
|
73
|
+
updateTrafficRule(gatewayName: string, rule: TrafficRule): Promise<TrafficRule>;
|
|
74
|
+
/** DELETE /v1/gateways/:gatewayName/traffic-rules/:ruleName */
|
|
75
|
+
deleteTrafficRule(gatewayName: string, ruleName: string): Promise<void>;
|
|
76
|
+
/** GET /v1/gateways/:gatewayName/traffic-rules */
|
|
77
|
+
listTrafficRules(gatewayName: string): Promise<TrafficRule[]>;
|
|
78
|
+
/** POST /v1/gateways/:gatewayName/traffic-rules/:ruleName/respond — releases the requests an ASK rule is holding. */
|
|
79
|
+
trafficRuleRespond(gatewayName: string, ruleName: string, action: "ALLOW" | "DENY"): Promise<void>;
|
|
80
|
+
/** POST /v1/portals */
|
|
81
|
+
createPortal(portal: Portal): Promise<Portal>;
|
|
82
|
+
/** DELETE /v1/portals/:portalName */
|
|
83
|
+
destroyPortal(portalName: string): Promise<void>;
|
|
84
|
+
/** GET /v1/portals */
|
|
85
|
+
listPortals(): Promise<Portal[]>;
|
|
86
|
+
}
|
|
87
|
+
/** The request can't be satisfied as sent (bad name, unsupported action, ...). */
|
|
88
|
+
export declare class GatewayInvalidError extends Error {
|
|
89
|
+
name: string;
|
|
90
|
+
}
|
|
91
|
+
/** The name is taken with a different spec, or by a container hyperbrane doesn't own. */
|
|
92
|
+
export declare class GatewayConflictError extends Error {
|
|
93
|
+
name: string;
|
|
94
|
+
}
|
|
95
|
+
/** A gateway, rule, dimension or portal the request refers to doesn't exist. */
|
|
96
|
+
export declare class GatewayNotFoundError extends Error {
|
|
97
|
+
name: string;
|
|
98
|
+
}
|