Mike0021 commited on
Commit
bc0cd17
·
verified ·
1 Parent(s): 6ffbf79

init pinned pi sandbox runtime

Browse files
Files changed (5) hide show
  1. Dockerfile +21 -0
  2. README.md +11 -5
  3. bridge.mjs +232 -0
  4. fake-pi.mjs +40 -0
  5. runtime-health.mjs +9 -0
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-bookworm-slim
2
+
3
+ ARG PI_VERSION=0.84.2
4
+
5
+ RUN apt-get update \
6
+ && apt-get install -y --no-install-recommends ca-certificates curl git openssh-client ripgrep \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /opt/chat-ui-agent
10
+ RUN npm init -y \
11
+ && npm install --omit=dev --no-audit --no-fund \
12
+ "@earendil-works/pi-coding-agent@${PI_VERSION}" \
13
+ "ws@8.18.3" \
14
+ && ln -s /opt/chat-ui-agent/node_modules/.bin/pi /usr/local/bin/pi
15
+
16
+ COPY bridge.mjs runtime-health.mjs /opt/chat-ui-agent/
17
+
18
+ ENV PI_TELEMETRY=0
19
+ EXPOSE 7860
20
+ CMD ["node", "/opt/chat-ui-agent/runtime-health.mjs"]
21
+
README.md CHANGED
@@ -1,10 +1,16 @@
1
  ---
2
- title: Chat Ui Agent Runtime
3
- emoji: ⚡
4
  colorFrom: yellow
5
- colorTo: pink
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
1
  ---
2
+ title: Chat UI Agent Runtime
3
+ emoji: 🧰
4
  colorFrom: yellow
5
+ colorTo: indigo
6
  sdk: docker
7
+ short_description: Pinned pi runtime image for chat-ui sandboxes
8
  ---
9
 
10
+ # Chat UI Agent Runtime
11
+
12
+ Prebuilt Node, git, ripgrep, pi, and reconnect-safe RPC bridge image used by chat-ui Agent
13
+ workspaces. The Space itself exposes a small health endpoint; Hugging Face Jobs overrides the
14
+ command to start `sbx-server`, then chat-ui spawns `bridge.mjs` inside the dedicated sandbox.
15
+
16
+ No Hugging Face or provider credential is baked into this image.
bridge.mjs ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import process from "node:process";
4
+ import { WebSocketServer, WebSocket } from "ws";
5
+
6
+ const PORT = parsePositiveInt(process.env.BRIDGE_PORT, 4777);
7
+ const HOST = process.env.BRIDGE_HOST || "127.0.0.1";
8
+ const WORKSPACE = process.env.WORKSPACE_DIR || "/workspace";
9
+ const PI_BIN = process.env.PI_BIN || "pi";
10
+ const MAX_EVENTS = parsePositiveInt(process.env.BRIDGE_MAX_EVENTS, 5_000);
11
+ const MAX_BUFFER_BYTES = parsePositiveInt(process.env.BRIDGE_MAX_BUFFER_BYTES, 8 * 1024 * 1024);
12
+ const MAX_LINE_BYTES = parsePositiveInt(process.env.BRIDGE_MAX_LINE_BYTES, 1024 * 1024);
13
+ const WS_HIGH_WATER_BYTES = parsePositiveInt(process.env.BRIDGE_WS_HIGH_WATER_BYTES, 4 * 1024 * 1024);
14
+
15
+ const piPrefixArgs = parseStringArray(process.env.PI_BIN_PREFIX_ARGS_JSON);
16
+ const piArgs = [...piPrefixArgs, "--mode", "rpc", "--approve"];
17
+ if (process.env.PI_PROVIDER) piArgs.push("--provider", process.env.PI_PROVIDER);
18
+ if (process.env.PI_MODEL) piArgs.push("--model", process.env.PI_MODEL);
19
+ if (process.env.PI_SESSION_DIR) piArgs.push("--session-dir", process.env.PI_SESSION_DIR);
20
+ if (process.env.PI_SESSION_NAME) piArgs.push("--name", process.env.PI_SESSION_NAME);
21
+ if (process.env.PI_CONTINUE === "1") piArgs.push("--continue");
22
+
23
+ let nextSequence = 1;
24
+ let bufferedBytes = 0;
25
+ let stdoutBuffer = "";
26
+ const replayBuffer = [];
27
+ const sockets = new Set();
28
+
29
+ const pi = spawn(PI_BIN, piArgs, {
30
+ cwd: WORKSPACE,
31
+ env: {
32
+ ...process.env,
33
+ PI_TELEMETRY: process.env.PI_TELEMETRY ?? "0",
34
+ },
35
+ stdio: ["pipe", "pipe", "pipe"],
36
+ });
37
+
38
+ const server = http.createServer((request, response) => {
39
+ if (request.url === "/health") {
40
+ response.writeHead(pi.exitCode === null ? 200 : 503, { "Content-Type": "application/json" });
41
+ response.end(
42
+ JSON.stringify({
43
+ status: pi.exitCode === null ? "ready" : "stopped",
44
+ pid: pi.pid,
45
+ sequence: nextSequence - 1,
46
+ })
47
+ );
48
+ return;
49
+ }
50
+ response.writeHead(404).end();
51
+ });
52
+
53
+ const webSockets = new WebSocketServer({ server, path: "/ws", maxPayload: MAX_LINE_BYTES });
54
+
55
+ webSockets.on("connection", (socket, request) => {
56
+ sockets.add(socket);
57
+ const requestUrl = new URL(request.url ?? "/ws", "http://bridge.local");
58
+ const fromSequence = parseNonNegativeInt(requestUrl.searchParams.get("fromSeq"), 0);
59
+
60
+ send(socket, {
61
+ type: "bridge_ready",
62
+ pid: pi.pid,
63
+ fromSeq: replayBuffer.at(0)?.seq ?? nextSequence,
64
+ toSeq: nextSequence - 1,
65
+ piExited: pi.exitCode !== null,
66
+ });
67
+ for (const record of replayBuffer) {
68
+ if (record.seq > fromSequence) sendRecord(socket, record);
69
+ }
70
+
71
+ socket.on("message", (raw, isBinary) => {
72
+ if (isBinary) {
73
+ send(socket, { type: "bridge_error", message: "Binary RPC commands are not supported" });
74
+ return;
75
+ }
76
+ const text = raw.toString("utf8");
77
+ if (Buffer.byteLength(text) > MAX_LINE_BYTES) {
78
+ send(socket, { type: "bridge_error", message: "RPC command exceeds bridge limit" });
79
+ return;
80
+ }
81
+ let command;
82
+ try {
83
+ command = JSON.parse(text);
84
+ } catch {
85
+ send(socket, { type: "bridge_error", message: "RPC command must be valid JSON" });
86
+ return;
87
+ }
88
+ if (!command || typeof command !== "object" || Array.isArray(command)) {
89
+ send(socket, { type: "bridge_error", message: "RPC command must be an object" });
90
+ return;
91
+ }
92
+ if (command.type === "bridge_ping") {
93
+ send(socket, { type: "bridge_pong", sequence: nextSequence - 1 });
94
+ return;
95
+ }
96
+ if (command.type === "bridge_state") {
97
+ send(socket, {
98
+ type: "bridge_state",
99
+ pid: pi.pid,
100
+ fromSeq: replayBuffer.at(0)?.seq ?? nextSequence,
101
+ toSeq: nextSequence - 1,
102
+ piExited: pi.exitCode !== null,
103
+ });
104
+ return;
105
+ }
106
+ if (!pi.stdin.writable || pi.exitCode !== null) {
107
+ send(socket, { type: "bridge_error", message: "pi is not running" });
108
+ return;
109
+ }
110
+ pi.stdin.write(`${JSON.stringify(command)}\n`);
111
+ });
112
+
113
+ socket.on("close", () => {
114
+ sockets.delete(socket);
115
+ });
116
+ });
117
+
118
+ pi.stdout.on("data", (chunk) => {
119
+ stdoutBuffer += chunk.toString("utf8");
120
+ let newline = stdoutBuffer.indexOf("\n");
121
+ while (newline !== -1) {
122
+ const line = stdoutBuffer.slice(0, newline).replace(/\r$/, "");
123
+ stdoutBuffer = stdoutBuffer.slice(newline + 1);
124
+ handlePiLine(line);
125
+ newline = stdoutBuffer.indexOf("\n");
126
+ }
127
+ if (Buffer.byteLength(stdoutBuffer) > MAX_LINE_BYTES) {
128
+ record({ type: "bridge_error", message: "pi emitted an oversized JSONL record" });
129
+ stdoutBuffer = "";
130
+ }
131
+ });
132
+
133
+ pi.stderr.on("data", (chunk) => {
134
+ const data = chunk.toString("utf8");
135
+ record({
136
+ type: "bridge_stderr",
137
+ data: truncateUtf8(data, Math.min(MAX_LINE_BYTES, 64 * 1024)),
138
+ });
139
+ });
140
+
141
+ pi.on("error", (error) => {
142
+ record({ type: "bridge_error", message: `Unable to start pi: ${error.message}` });
143
+ });
144
+
145
+ pi.on("exit", (code, signal) => {
146
+ record({ type: "bridge_exit", exitCode: code, signal });
147
+ });
148
+
149
+ server.listen(PORT, HOST);
150
+
151
+ function handlePiLine(line) {
152
+ if (!line) return;
153
+ if (Buffer.byteLength(line) > MAX_LINE_BYTES) {
154
+ record({ type: "bridge_error", message: "pi emitted an oversized JSONL record" });
155
+ return;
156
+ }
157
+ try {
158
+ record(JSON.parse(line));
159
+ } catch {
160
+ record({
161
+ type: "bridge_error",
162
+ message: "pi emitted invalid JSONL",
163
+ preview: truncateUtf8(line, 2_000),
164
+ });
165
+ }
166
+ }
167
+
168
+ function record(payload) {
169
+ const record = { seq: nextSequence++, payload };
170
+ const bytes = Buffer.byteLength(JSON.stringify(record));
171
+ replayBuffer.push({ ...record, bytes });
172
+ bufferedBytes += bytes;
173
+ while (replayBuffer.length > MAX_EVENTS || bufferedBytes > MAX_BUFFER_BYTES) {
174
+ const removed = replayBuffer.shift();
175
+ bufferedBytes -= removed?.bytes ?? 0;
176
+ }
177
+ for (const socket of sockets) {
178
+ if (socket.readyState === WebSocket.OPEN) sendRecord(socket, record);
179
+ }
180
+ }
181
+
182
+ function sendRecord(socket, record) {
183
+ if (socket.bufferedAmount > WS_HIGH_WATER_BYTES) {
184
+ // The replay buffer remains authoritative. Closing forces a reconnect with
185
+ // fromSeq instead of letting unbounded WebSocket backpressure consume RAM.
186
+ socket.close(1013, "Controller is too slow; reconnect to replay");
187
+ return;
188
+ }
189
+ send(socket, { type: "bridge_event", seq: record.seq, payload: record.payload });
190
+ }
191
+
192
+ function send(socket, value) {
193
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(value));
194
+ }
195
+
196
+ function truncateUtf8(value, maxBytes) {
197
+ const bytes = Buffer.from(value);
198
+ if (bytes.length <= maxBytes) return value;
199
+ return `${bytes.subarray(0, maxBytes).toString("utf8")}\n… output truncated by bridge`;
200
+ }
201
+
202
+ function parsePositiveInt(value, fallback) {
203
+ const parsed = Number.parseInt(value ?? "", 10);
204
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
205
+ }
206
+
207
+ function parseNonNegativeInt(value, fallback) {
208
+ const parsed = Number.parseInt(value ?? "", 10);
209
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
210
+ }
211
+
212
+ function parseStringArray(value) {
213
+ if (!value) return [];
214
+ try {
215
+ const parsed = JSON.parse(value);
216
+ return Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string") ? parsed : [];
217
+ } catch {
218
+ return [];
219
+ }
220
+ }
221
+
222
+ function shutdown() {
223
+ webSockets.close();
224
+ server.close();
225
+ if (pi.exitCode === null) pi.kill("SIGTERM");
226
+ setTimeout(() => {
227
+ if (pi.exitCode === null) pi.kill("SIGKILL");
228
+ }, 5_000).unref();
229
+ }
230
+
231
+ process.once("SIGTERM", shutdown);
232
+ process.once("SIGINT", shutdown);
fake-pi.mjs ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let buffer = "";
2
+
3
+ process.stdin.setEncoding("utf8");
4
+ process.stdin.on("data", (chunk) => {
5
+ buffer += chunk;
6
+ let newline = buffer.indexOf("\n");
7
+ while (newline !== -1) {
8
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
9
+ buffer = buffer.slice(newline + 1);
10
+ if (line) handle(line);
11
+ newline = buffer.indexOf("\n");
12
+ }
13
+ });
14
+
15
+ function handle(line) {
16
+ const command = JSON.parse(line);
17
+ if (command.type === "emit_later") {
18
+ write({
19
+ id: command.id,
20
+ type: "response",
21
+ command: command.type,
22
+ success: true,
23
+ });
24
+ setTimeout(() => {
25
+ write({ type: "queue_update", steering: [command.message], followUp: [] });
26
+ }, 75);
27
+ return;
28
+ }
29
+ write({
30
+ id: command.id,
31
+ type: "response",
32
+ command: command.type,
33
+ success: true,
34
+ data: { echo: command.message },
35
+ });
36
+ }
37
+
38
+ function write(value) {
39
+ process.stdout.write(`${JSON.stringify(value)}\n`);
40
+ }
runtime-health.mjs ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import http from "node:http";
2
+
3
+ const server = http.createServer((_request, response) => {
4
+ response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
5
+ response.end("chat-ui agent runtime image is ready\n");
6
+ });
7
+
8
+ server.listen(7860, "0.0.0.0");
9
+