@superhq/shuru 0.1.1 → 0.2.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 +40 -5
- package/package.json +6 -1
- package/src/index.ts +6 -1
- package/src/process.ts +47 -34
- package/src/sandbox.ts +36 -40
- package/src/types.ts +37 -11
package/README.md
CHANGED
|
@@ -19,7 +19,8 @@ const result = await sb.exec("echo hello");
|
|
|
19
19
|
console.log(result.stdout); // "hello\n"
|
|
20
20
|
|
|
21
21
|
await sb.writeFile("/tmp/app.ts", "console.log('hi')");
|
|
22
|
-
const
|
|
22
|
+
const data = await sb.readFile("/tmp/app.ts"); // Uint8Array
|
|
23
|
+
const text = new TextDecoder().decode(data);
|
|
23
24
|
|
|
24
25
|
await sb.checkpoint("my-env"); // saves disk state and stops the VM
|
|
25
26
|
```
|
|
@@ -41,6 +42,10 @@ const sb = await Sandbox.start({
|
|
|
41
42
|
allowNet: true,
|
|
42
43
|
ports: ["8080:80"],
|
|
43
44
|
mounts: { "./src": "/workspace" },
|
|
45
|
+
secrets: {
|
|
46
|
+
API_KEY: { from: "OPENAI_API_KEY", hosts: ["api.openai.com"] },
|
|
47
|
+
},
|
|
48
|
+
network: { allow: ["api.openai.com", "registry.npmjs.org"] },
|
|
44
49
|
});
|
|
45
50
|
```
|
|
46
51
|
|
|
@@ -53,6 +58,8 @@ const sb = await Sandbox.start({
|
|
|
53
58
|
| `allowNet` | `boolean` | Enable network access |
|
|
54
59
|
| `ports` | `string[]` | Port forwards (`"host:guest"`) |
|
|
55
60
|
| `mounts` | `Record<string, string>` | Directory mounts (`{ hostPath: guestPath }`) |
|
|
61
|
+
| `secrets` | `Record<string, SecretConfig>` | Secrets to inject via proxy (see below) |
|
|
62
|
+
| `network` | `NetworkConfig` | Network access policy (see below) |
|
|
56
63
|
| `shuruBin` | `string` | Path to shuru binary (default: `"shuru"`) |
|
|
57
64
|
|
|
58
65
|
## API
|
|
@@ -65,13 +72,13 @@ Boot a new microVM. Returns when the VM is ready.
|
|
|
65
72
|
|
|
66
73
|
Run a shell command in the VM. Returns `{ stdout, stderr, exitCode }`.
|
|
67
74
|
|
|
68
|
-
### `sandbox.readFile(path): Promise<
|
|
75
|
+
### `sandbox.readFile(path): Promise<Uint8Array>`
|
|
69
76
|
|
|
70
|
-
Read a file from the VM.
|
|
77
|
+
Read a file from the VM. Returns raw bytes. Use `new TextDecoder().decode(data)` for text files.
|
|
71
78
|
|
|
72
|
-
### `sandbox.writeFile(path, content): Promise<void>`
|
|
79
|
+
### `sandbox.writeFile(path, content: Uint8Array | string): Promise<void>`
|
|
73
80
|
|
|
74
|
-
Write a file to the VM.
|
|
81
|
+
Write a file to the VM. Accepts raw bytes or a string.
|
|
75
82
|
|
|
76
83
|
### `sandbox.checkpoint(name): Promise<void>`
|
|
77
84
|
|
|
@@ -81,6 +88,34 @@ Save the VM's disk state and stop the VM. To continue working, call `Sandbox.sta
|
|
|
81
88
|
|
|
82
89
|
Stop the VM without saving. All changes are discarded.
|
|
83
90
|
|
|
91
|
+
### Secrets
|
|
92
|
+
|
|
93
|
+
Secrets keep API keys on the host. The guest receives a random placeholder token; the proxy substitutes the real value only on HTTPS requests to the specified hosts.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const sb = await Sandbox.start({
|
|
97
|
+
allowNet: true,
|
|
98
|
+
secrets: {
|
|
99
|
+
API_KEY: { from: "OPENAI_API_KEY", hosts: ["api.openai.com"] },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
// Inside the VM, $API_KEY is a placeholder token.
|
|
103
|
+
// Requests to api.openai.com get the real key injected by the proxy.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Network policy
|
|
107
|
+
|
|
108
|
+
Restrict which domains the guest can reach:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const sb = await Sandbox.start({
|
|
112
|
+
allowNet: true,
|
|
113
|
+
network: { allow: ["api.openai.com", "*.npmjs.org"] },
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Omit `network.allow` to allow all domains.
|
|
118
|
+
|
|
84
119
|
## Requirements
|
|
85
120
|
|
|
86
121
|
- macOS 14+ on Apple Silicon
|
package/package.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@superhq/shuru",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": ["src"],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/superhq-ai/shuru",
|
|
11
|
+
"directory": "packages/sdk"
|
|
12
|
+
},
|
|
8
13
|
"scripts": {
|
|
9
14
|
"test": "bun test test/unit/",
|
|
10
15
|
"test:integration": "bun test test/integration/",
|
package/src/index.ts
CHANGED
package/src/process.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import type { Subprocess } from "bun";
|
|
2
|
-
import type {
|
|
2
|
+
import type { JsonRpcResponse, JsonRpcResult } from "./types";
|
|
3
3
|
|
|
4
4
|
interface PendingRequest {
|
|
5
|
-
resolve: (value:
|
|
5
|
+
resolve: (value: JsonRpcResult) => void;
|
|
6
6
|
reject: (reason: Error) => void;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export class ShuruProcess {
|
|
10
10
|
private proc: Subprocess<"pipe", "pipe", "inherit"> | null = null;
|
|
11
|
-
private pending = new Map<
|
|
11
|
+
private pending = new Map<number, PendingRequest>();
|
|
12
12
|
private idCounter = 0;
|
|
13
|
+
private onReady: (() => void) | null = null;
|
|
14
|
+
private onReadyError: ((err: Error) => void) | null = null;
|
|
13
15
|
|
|
14
16
|
async start(args: string[]): Promise<void> {
|
|
15
17
|
this.proc = Bun.spawn(args, {
|
|
@@ -25,27 +27,28 @@ export class ShuruProcess {
|
|
|
25
27
|
reject(new Error("shuru: timed out waiting for ready signal (30s)"));
|
|
26
28
|
}, 30_000);
|
|
27
29
|
|
|
28
|
-
this.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
},
|
|
37
|
-
});
|
|
30
|
+
this.onReady = () => {
|
|
31
|
+
clearTimeout(timeout);
|
|
32
|
+
resolve();
|
|
33
|
+
};
|
|
34
|
+
this.onReadyError = (err: Error) => {
|
|
35
|
+
clearTimeout(timeout);
|
|
36
|
+
reject(err);
|
|
37
|
+
};
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
send(
|
|
42
|
+
method: string,
|
|
43
|
+
params: Record<string, unknown>,
|
|
44
|
+
): Promise<JsonRpcResult> {
|
|
42
45
|
if (!this.proc) throw new Error("shuru process not started");
|
|
43
46
|
|
|
44
|
-
const id =
|
|
45
|
-
const line = `${JSON.stringify({ id,
|
|
47
|
+
const id = ++this.idCounter;
|
|
48
|
+
const line = `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
|
|
46
49
|
const proc = this.proc;
|
|
47
50
|
|
|
48
|
-
return new Promise<
|
|
51
|
+
return new Promise<JsonRpcResult>((resolve, reject) => {
|
|
49
52
|
this.pending.set(id, { resolve, reject });
|
|
50
53
|
proc.stdin.write(line);
|
|
51
54
|
proc.stdin.flush();
|
|
@@ -88,21 +91,26 @@ export class ShuruProcess {
|
|
|
88
91
|
|
|
89
92
|
const reader = this.proc.stdout.getReader();
|
|
90
93
|
const decoder = new TextDecoder();
|
|
91
|
-
let
|
|
94
|
+
let remainder = "";
|
|
92
95
|
|
|
93
96
|
try {
|
|
94
97
|
while (true) {
|
|
95
98
|
const { done, value } = await reader.read();
|
|
96
99
|
if (done) break;
|
|
97
100
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
remainder += decoder.decode(value, { stream: true });
|
|
102
|
+
|
|
103
|
+
while (true) {
|
|
104
|
+
const newlineIdx = remainder.indexOf("\n");
|
|
105
|
+
if (newlineIdx === -1) break;
|
|
106
|
+
const line = remainder.slice(0, newlineIdx);
|
|
107
|
+
remainder = remainder.slice(newlineIdx + 1);
|
|
101
108
|
|
|
102
|
-
for (const line of lines) {
|
|
103
109
|
if (!line) continue;
|
|
110
|
+
|
|
104
111
|
try {
|
|
105
|
-
|
|
112
|
+
const msg = JSON.parse(line) as JsonRpcResponse;
|
|
113
|
+
this.dispatch(msg);
|
|
106
114
|
} catch {
|
|
107
115
|
// malformed line
|
|
108
116
|
}
|
|
@@ -112,33 +120,38 @@ export class ShuruProcess {
|
|
|
112
120
|
// stream closed
|
|
113
121
|
}
|
|
114
122
|
|
|
123
|
+
if (this.onReadyError) {
|
|
124
|
+
this.onReadyError(new Error("shuru process exited unexpectedly"));
|
|
125
|
+
this.onReady = null;
|
|
126
|
+
this.onReadyError = null;
|
|
127
|
+
}
|
|
115
128
|
for (const [, req] of this.pending) {
|
|
116
129
|
req.reject(new Error("shuru process exited unexpectedly"));
|
|
117
130
|
}
|
|
118
131
|
this.pending.clear();
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
private dispatch(msg:
|
|
122
|
-
if (msg.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
this.
|
|
126
|
-
|
|
134
|
+
private dispatch(msg: JsonRpcResponse): void {
|
|
135
|
+
if ("method" in msg && msg.method === "ready") {
|
|
136
|
+
if (this.onReady) {
|
|
137
|
+
this.onReady();
|
|
138
|
+
this.onReady = null;
|
|
139
|
+
this.onReadyError = null;
|
|
127
140
|
}
|
|
128
141
|
return;
|
|
129
142
|
}
|
|
130
143
|
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
if (!("id" in msg) || msg.id == null) return;
|
|
145
|
+
const id = msg.id as number;
|
|
133
146
|
|
|
134
147
|
const req = this.pending.get(id);
|
|
135
148
|
if (!req) return;
|
|
136
149
|
this.pending.delete(id);
|
|
137
150
|
|
|
138
|
-
if (
|
|
139
|
-
req.reject(new Error(msg.error));
|
|
151
|
+
if ("error" in msg) {
|
|
152
|
+
req.reject(new Error(msg.error.message));
|
|
140
153
|
} else {
|
|
141
|
-
req.resolve(msg);
|
|
154
|
+
req.resolve(msg as JsonRpcResult);
|
|
142
155
|
}
|
|
143
156
|
}
|
|
144
157
|
}
|
package/src/sandbox.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { ShuruProcess } from "./process";
|
|
2
2
|
import type { ExecResult, StartOptions } from "./types";
|
|
3
3
|
|
|
4
|
+
const Method = {
|
|
5
|
+
EXEC: "exec",
|
|
6
|
+
READ_FILE: "read_file",
|
|
7
|
+
WRITE_FILE: "write_file",
|
|
8
|
+
CHECKPOINT: "checkpoint",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
4
11
|
export class Sandbox {
|
|
5
12
|
private proc: ShuruProcess;
|
|
6
13
|
private stopped = false;
|
|
@@ -20,57 +27,34 @@ export class Sandbox {
|
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
async exec(command: string): Promise<ExecResult> {
|
|
23
|
-
const resp = await this.proc.send({
|
|
24
|
-
type: "exec",
|
|
30
|
+
const resp = await this.proc.send(Method.EXEC, {
|
|
25
31
|
argv: ["sh", "-c", command],
|
|
26
32
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
const r = resp.result as {
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr: string;
|
|
36
|
+
exit_code: number;
|
|
37
|
+
};
|
|
30
38
|
return {
|
|
31
|
-
stdout:
|
|
32
|
-
stderr:
|
|
33
|
-
exitCode:
|
|
39
|
+
stdout: r.stdout,
|
|
40
|
+
stderr: r.stderr,
|
|
41
|
+
exitCode: r.exit_code,
|
|
34
42
|
};
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
async readFile(path: string): Promise<
|
|
38
|
-
const resp = await this.proc.send({
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
if (resp.type !== "exec") {
|
|
43
|
-
throw new Error(`unexpected response type: ${resp.type}`);
|
|
44
|
-
}
|
|
45
|
-
if (resp.exit_code !== 0) {
|
|
46
|
-
throw new Error(
|
|
47
|
-
`readFile failed (exit ${resp.exit_code}): ${resp.stderr}`,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
return resp.stdout;
|
|
45
|
+
async readFile(path: string): Promise<Uint8Array> {
|
|
46
|
+
const resp = await this.proc.send(Method.READ_FILE, { path });
|
|
47
|
+
const r = resp.result as { content: string };
|
|
48
|
+
return new Uint8Array(Buffer.from(r.content, "base64"));
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
async writeFile(path: string, content: string): Promise<void> {
|
|
54
|
-
const b64 =
|
|
55
|
-
|
|
56
|
-
type: "exec",
|
|
57
|
-
argv: ["sh", "-c", `echo '${b64}' | base64 -d > "$1"`, "--", path],
|
|
58
|
-
});
|
|
59
|
-
if (resp.type !== "exec") {
|
|
60
|
-
throw new Error(`unexpected response type: ${resp.type}`);
|
|
61
|
-
}
|
|
62
|
-
if (resp.exit_code !== 0) {
|
|
63
|
-
throw new Error(
|
|
64
|
-
`writeFile failed (exit ${resp.exit_code}): ${resp.stderr}`,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
51
|
+
async writeFile(path: string, content: Uint8Array | string): Promise<void> {
|
|
52
|
+
const b64 = Buffer.from(content).toString("base64");
|
|
53
|
+
await this.proc.send(Method.WRITE_FILE, { path, content: b64 });
|
|
67
54
|
}
|
|
68
55
|
|
|
69
56
|
async checkpoint(name: string): Promise<void> {
|
|
70
|
-
|
|
71
|
-
if (resp.type !== "checkpoint") {
|
|
72
|
-
throw new Error(`unexpected response type: ${resp.type}`);
|
|
73
|
-
}
|
|
57
|
+
await this.proc.send(Method.CHECKPOINT, { name });
|
|
74
58
|
this.stopped = true;
|
|
75
59
|
await this.proc.stop();
|
|
76
60
|
}
|
|
@@ -92,6 +76,18 @@ export function buildArgs(bin: string, opts: StartOptions): string[] {
|
|
|
92
76
|
if (opts.diskSize) args.push("--disk-size", String(opts.diskSize));
|
|
93
77
|
if (opts.allowNet) args.push("--allow-net");
|
|
94
78
|
|
|
79
|
+
if (opts.secrets) {
|
|
80
|
+
for (const [name, secret] of Object.entries(opts.secrets)) {
|
|
81
|
+
args.push("--secret", `${name}=${secret.from}@${secret.hosts.join(",")}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (opts.network?.allow) {
|
|
86
|
+
for (const host of opts.network.allow) {
|
|
87
|
+
args.push("--allow-host", host);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
95
91
|
if (opts.ports) {
|
|
96
92
|
for (const p of opts.ports) {
|
|
97
93
|
args.push("-p", p);
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
export interface SecretConfig {
|
|
2
|
+
/** Host environment variable containing the real value. */
|
|
3
|
+
from: string;
|
|
4
|
+
/** Domains where this secret may be sent (e.g. "api.openai.com"). */
|
|
5
|
+
hosts: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface NetworkConfig {
|
|
9
|
+
/** Allowed domain patterns. Omit to allow all. */
|
|
10
|
+
allow?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
1
13
|
export interface StartOptions {
|
|
2
14
|
from?: string;
|
|
3
15
|
cpus?: number;
|
|
@@ -6,6 +18,8 @@ export interface StartOptions {
|
|
|
6
18
|
allowNet?: boolean;
|
|
7
19
|
ports?: string[];
|
|
8
20
|
mounts?: Record<string, string>;
|
|
21
|
+
secrets?: Record<string, SecretConfig>;
|
|
22
|
+
network?: NetworkConfig;
|
|
9
23
|
shuruBin?: string;
|
|
10
24
|
}
|
|
11
25
|
|
|
@@ -15,14 +29,26 @@ export interface ExecResult {
|
|
|
15
29
|
exitCode: number;
|
|
16
30
|
}
|
|
17
31
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
// --- JSON-RPC 2.0 wire types (internal) ---
|
|
33
|
+
|
|
34
|
+
export interface JsonRpcResult {
|
|
35
|
+
jsonrpc: "2.0";
|
|
36
|
+
id: number;
|
|
37
|
+
result: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface JsonRpcError {
|
|
41
|
+
jsonrpc: "2.0";
|
|
42
|
+
id: number;
|
|
43
|
+
error: { code: number; message: string };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface JsonRpcNotification {
|
|
47
|
+
jsonrpc: "2.0";
|
|
48
|
+
method: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type JsonRpcResponse =
|
|
52
|
+
| JsonRpcResult
|
|
53
|
+
| JsonRpcError
|
|
54
|
+
| JsonRpcNotification;
|