@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 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 content = await sb.readFile("/tmp/app.ts");
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<string>`
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.1.1",
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
@@ -1,2 +1,7 @@
1
1
  export { Sandbox } from "./sandbox";
2
- export type { ExecResult, StartOptions, StdioResponse } from "./types";
2
+ export type {
3
+ ExecResult,
4
+ NetworkConfig,
5
+ SecretConfig,
6
+ StartOptions,
7
+ } from "./types";
package/src/process.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  import type { Subprocess } from "bun";
2
- import type { StdioResponse } from "./types";
2
+ import type { JsonRpcResponse, JsonRpcResult } from "./types";
3
3
 
4
4
  interface PendingRequest {
5
- resolve: (value: StdioResponse) => void;
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<string, PendingRequest>();
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.pending.set("__ready__", {
29
- resolve: () => {
30
- clearTimeout(timeout);
31
- resolve();
32
- },
33
- reject: (err: Error) => {
34
- clearTimeout(timeout);
35
- reject(err);
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
- async send(msg: Record<string, unknown>): Promise<StdioResponse> {
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 = String(++this.idCounter);
45
- const line = `${JSON.stringify({ id, ...msg })}\n`;
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<StdioResponse>((resolve, reject) => {
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 buffer = "";
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
- buffer += decoder.decode(value, { stream: true });
99
- const lines = buffer.split("\n");
100
- buffer = lines.pop() ?? "";
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
- this.dispatch(JSON.parse(line) as StdioResponse);
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: StdioResponse): void {
122
- if (msg.type === "ready") {
123
- const req = this.pending.get("__ready__");
124
- if (req) {
125
- this.pending.delete("__ready__");
126
- req.resolve(msg);
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
- const { id } = msg;
132
- if (!id) return;
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 (msg.type === "error") {
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
- if (resp.type !== "exec") {
28
- throw new Error(`unexpected response type: ${resp.type}`);
29
- }
33
+ const r = resp.result as {
34
+ stdout: string;
35
+ stderr: string;
36
+ exit_code: number;
37
+ };
30
38
  return {
31
- stdout: resp.stdout,
32
- stderr: resp.stderr,
33
- exitCode: resp.exit_code,
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<string> {
38
- const resp = await this.proc.send({
39
- type: "exec",
40
- argv: ["cat", path],
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 = btoa(content);
55
- const resp = await this.proc.send({
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
- const resp = await this.proc.send({ type: "checkpoint", name });
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
- export type StdioResponse =
19
- | { type: "ready" }
20
- | {
21
- type: "exec";
22
- id: string;
23
- stdout: string;
24
- stderr: string;
25
- exit_code: number;
26
- }
27
- | { type: "checkpoint"; id: string; ok: boolean }
28
- | { type: "error"; id: string; error: string };
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;