@odla-ai/harness 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/LICENSE +21 -0
- package/README.md +178 -0
- package/dist/chunk-ATKV6VTU.js +168 -0
- package/dist/chunk-ATKV6VTU.js.map +1 -0
- package/dist/chunk-GE6CCN7W.js +93 -0
- package/dist/chunk-GE6CCN7W.js.map +1 -0
- package/dist/chunk-GMVZ4LZH.js +1769 -0
- package/dist/chunk-GMVZ4LZH.js.map +1 -0
- package/dist/chunk-PHXQH4YM.js +550 -0
- package/dist/chunk-PHXQH4YM.js.map +1 -0
- package/dist/chunk-PTXZVYD4.js +81 -0
- package/dist/chunk-PTXZVYD4.js.map +1 -0
- package/dist/chunk-QTUEF2HZ.js +9 -0
- package/dist/chunk-QTUEF2HZ.js.map +1 -0
- package/dist/cli.cjs +795 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +98 -0
- package/dist/cli.js.map +1 -0
- package/dist/code-runtime-cli.cjs +2341 -0
- package/dist/code-runtime-cli.cjs.map +1 -0
- package/dist/code-runtime-cli.d.cts +1 -0
- package/dist/code-runtime-cli.d.ts +1 -0
- package/dist/code-runtime-cli.js +133 -0
- package/dist/code-runtime-cli.js.map +1 -0
- package/dist/index.cjs +228 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/node.cjs +2580 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +544 -0
- package/dist/node.d.ts +544 -0
- package/dist/node.js +71 -0
- package/dist/node.js.map +1 -0
- package/dist/testing.cjs +106 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +25 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +79 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-D12vK3K9.d.cts +249 -0
- package/dist/types-D12vK3K9.d.ts +249 -0
- package/package.json +84 -0
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
var import_node_path4 = require("path");
|
|
6
|
+
|
|
7
|
+
// src/client.ts
|
|
8
|
+
var HarnessControlError = class extends Error {
|
|
9
|
+
constructor(message, status, code = "control_error") {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
status;
|
|
15
|
+
code;
|
|
16
|
+
name = "HarnessControlError";
|
|
17
|
+
};
|
|
18
|
+
function createHarnessControlClient(options) {
|
|
19
|
+
const endpoint = options.endpoint.replace(/\/+$/, "");
|
|
20
|
+
let endpointUrl;
|
|
21
|
+
try {
|
|
22
|
+
endpointUrl = new URL(endpoint);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new TypeError("endpoint must be an HTTPS URL");
|
|
25
|
+
}
|
|
26
|
+
const loopback = endpointUrl.hostname === "localhost" || endpointUrl.hostname === "127.0.0.1" || endpointUrl.hostname === "[::1]";
|
|
27
|
+
if (endpointUrl.username || endpointUrl.password || endpointUrl.protocol !== "https:" && !(loopback && endpointUrl.protocol === "http:")) {
|
|
28
|
+
throw new TypeError("endpoint must use HTTPS (HTTP is allowed only for loopback testing)");
|
|
29
|
+
}
|
|
30
|
+
if (!/^odla_hrn_[0-9a-f]{64}$/.test(options.token)) throw new TypeError("invalid harness runner credential");
|
|
31
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
32
|
+
if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 1e3 || requestTimeoutMs > 12e4) {
|
|
33
|
+
throw new TypeError("requestTimeoutMs must be an integer from 1000 to 120000");
|
|
34
|
+
}
|
|
35
|
+
const request = options.fetch ?? fetch;
|
|
36
|
+
const call = async (path, body, allowEmpty = false) => {
|
|
37
|
+
const timeout = AbortSignal.timeout(requestTimeoutMs);
|
|
38
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
39
|
+
const response = await request(`${endpoint}${path}`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { authorization: `Bearer ${options.token}`, "content-type": "application/json" },
|
|
42
|
+
body: JSON.stringify(body),
|
|
43
|
+
redirect: "error",
|
|
44
|
+
signal
|
|
45
|
+
});
|
|
46
|
+
if (allowEmpty && response.status === 204) return null;
|
|
47
|
+
const value = await response.json().catch(() => null);
|
|
48
|
+
if (!response.ok) throw new HarnessControlError(
|
|
49
|
+
value?.error?.message ?? `harness control request failed (${response.status})`,
|
|
50
|
+
response.status,
|
|
51
|
+
value?.error?.code
|
|
52
|
+
);
|
|
53
|
+
return value;
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
lease: async (workspaces) => {
|
|
57
|
+
const body = await call("/registry/harness/lease", { workspaces }, true);
|
|
58
|
+
return body?.lease ?? null;
|
|
59
|
+
},
|
|
60
|
+
heartbeat: async (attemptId, leaseId) => {
|
|
61
|
+
const body = await call(
|
|
62
|
+
`/registry/harness/attempts/${encodeURIComponent(attemptId)}/heartbeat`,
|
|
63
|
+
{ leaseId }
|
|
64
|
+
);
|
|
65
|
+
return body;
|
|
66
|
+
},
|
|
67
|
+
appendEvents: async (attemptId, leaseId, events) => {
|
|
68
|
+
await call(`/registry/harness/attempts/${encodeURIComponent(attemptId)}/events`, { leaseId, events });
|
|
69
|
+
},
|
|
70
|
+
infer: async (attemptId, leaseId, inference) => {
|
|
71
|
+
const body = await call(
|
|
72
|
+
`/registry/harness/attempts/${encodeURIComponent(attemptId)}/inference`,
|
|
73
|
+
{ leaseId, ...inference }
|
|
74
|
+
);
|
|
75
|
+
return body;
|
|
76
|
+
},
|
|
77
|
+
complete: async (attemptId, leaseId, completion) => {
|
|
78
|
+
await call(`/registry/harness/attempts/${encodeURIComponent(attemptId)}/complete`, { leaseId, ...completion });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/container.ts
|
|
84
|
+
var import_node_child_process = require("child_process");
|
|
85
|
+
var import_node_fs = require("fs");
|
|
86
|
+
var import_promises = require("fs/promises");
|
|
87
|
+
var import_node_path = require("path");
|
|
88
|
+
var import_node_process = require("process");
|
|
89
|
+
|
|
90
|
+
// src/types.ts
|
|
91
|
+
var HARNESS_PROTOCOL_VERSION = 1;
|
|
92
|
+
|
|
93
|
+
// src/protocol.ts
|
|
94
|
+
var CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
|
95
|
+
var HarnessProtocolError = class extends Error {
|
|
96
|
+
name = "HarnessProtocolError";
|
|
97
|
+
};
|
|
98
|
+
function record(value) {
|
|
99
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
100
|
+
}
|
|
101
|
+
function boundedText(value, label, max) {
|
|
102
|
+
if (typeof value !== "string" || !value || value.length > max || CONTROL.test(value)) {
|
|
103
|
+
throw new HarnessProtocolError(`${label} must be a non-empty string of at most ${max} characters`);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
function parseAgentOutput(line) {
|
|
108
|
+
if (Buffer.byteLength(line, "utf8") > 1e6) throw new HarnessProtocolError("agent message exceeds 1 MB");
|
|
109
|
+
let value;
|
|
110
|
+
try {
|
|
111
|
+
value = JSON.parse(line);
|
|
112
|
+
} catch {
|
|
113
|
+
throw new HarnessProtocolError("agent emitted invalid JSON");
|
|
114
|
+
}
|
|
115
|
+
const message = record(value);
|
|
116
|
+
if (!message || message.protocolVersion !== HARNESS_PROTOCOL_VERSION) {
|
|
117
|
+
throw new HarnessProtocolError(`agent protocolVersion must be ${HARNESS_PROTOCOL_VERSION}`);
|
|
118
|
+
}
|
|
119
|
+
if (message.type === "event") {
|
|
120
|
+
return {
|
|
121
|
+
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
122
|
+
type: "event",
|
|
123
|
+
kind: boundedText(message.kind, "event.kind", 120),
|
|
124
|
+
...message.payload === void 0 ? {} : { payload: message.payload }
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (message.type === "inference.request") {
|
|
128
|
+
const call = record(message.call);
|
|
129
|
+
if (!call || !Array.isArray(call.messages) || !Number.isSafeInteger(call.maxTokens)) {
|
|
130
|
+
throw new HarnessProtocolError("inference.request.call requires messages and maxTokens");
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
134
|
+
type: "inference.request",
|
|
135
|
+
requestId: boundedText(message.requestId, "requestId", 180),
|
|
136
|
+
call
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (message.type === "tool.request") {
|
|
140
|
+
const input = record(message.input);
|
|
141
|
+
const tool = String(message.tool);
|
|
142
|
+
if (!input || !["sandbox.read", "sandbox.apply_patch", "sandbox.run_recipe"].includes(tool)) {
|
|
143
|
+
throw new HarnessProtocolError("tool.request requires a registered tool and object input");
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
147
|
+
type: "tool.request",
|
|
148
|
+
requestId: boundedText(message.requestId, "requestId", 180),
|
|
149
|
+
tool,
|
|
150
|
+
input
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (message.type === "attempt.complete") {
|
|
154
|
+
if (!(/* @__PURE__ */ new Set(["completed", "failed", "cancelled"])).has(String(message.status))) {
|
|
155
|
+
throw new HarnessProtocolError("attempt.complete.status is invalid");
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
159
|
+
type: "attempt.complete",
|
|
160
|
+
status: message.status,
|
|
161
|
+
...message.result === void 0 ? {} : { result: message.result }
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
throw new HarnessProtocolError("agent message type is unsupported");
|
|
165
|
+
}
|
|
166
|
+
function encodeAgentInput(message) {
|
|
167
|
+
return `${JSON.stringify(message)}
|
|
168
|
+
`;
|
|
169
|
+
}
|
|
170
|
+
function makeHarnessEvent(kind, actor, payload, now = Date.now(), id = crypto.randomUUID()) {
|
|
171
|
+
boundedText(kind, "event.kind", 120);
|
|
172
|
+
return { eventId: id, kind, actor, payload, createdAt: now };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/container.ts
|
|
176
|
+
var DIGEST_IMAGE = /^[a-z0-9][a-z0-9._/-]*(?::[a-zA-Z0-9._-]+)?@sha256:[0-9a-f]{64}$/;
|
|
177
|
+
function assertPinnedImage(image) {
|
|
178
|
+
if (!DIGEST_IMAGE.test(image)) throw new TypeError("container image must be pinned by sha256 digest");
|
|
179
|
+
}
|
|
180
|
+
async function commandAvailable(engine) {
|
|
181
|
+
for (const directory of (process.env.PATH ?? "").split(import_node_path.delimiter).filter(Boolean)) {
|
|
182
|
+
try {
|
|
183
|
+
await (0, import_promises.access)((0, import_node_path.join)(directory, engine), import_node_fs.constants.X_OK);
|
|
184
|
+
return true;
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
async function selectContainerEngine(requested = "auto", options = {}) {
|
|
191
|
+
const platform = options.platform ?? process.platform;
|
|
192
|
+
const arch = options.arch ?? process.arch;
|
|
193
|
+
const uid = options.uid ?? (typeof import_node_process.getuid === "function" ? (0, import_node_process.getuid)() : 1e3);
|
|
194
|
+
const available = options.available ?? commandAvailable;
|
|
195
|
+
const validate = async (engine) => {
|
|
196
|
+
if (engine === "container" && (platform !== "darwin" || arch !== "arm64")) {
|
|
197
|
+
throw new TypeError("Apple container requires Apple Silicon macOS");
|
|
198
|
+
}
|
|
199
|
+
if (engine === "podman" && platform === "linux" && uid === 0) {
|
|
200
|
+
throw new TypeError("the Linux harness requires rootless Podman; do not run the runner as root");
|
|
201
|
+
}
|
|
202
|
+
if (!await available(engine)) throw new TypeError(`${engine} is not installed or executable`);
|
|
203
|
+
return engine;
|
|
204
|
+
};
|
|
205
|
+
if (requested !== "auto") return validate(requested);
|
|
206
|
+
const candidates = platform === "darwin" ? arch === "arm64" ? ["container", "podman"] : ["podman"] : platform === "linux" ? ["podman"] : [];
|
|
207
|
+
for (const engine of candidates) {
|
|
208
|
+
if (await available(engine)) return validate(engine);
|
|
209
|
+
}
|
|
210
|
+
if (platform === "linux") {
|
|
211
|
+
throw new TypeError("no rootless Podman found; install Podman or explicitly choose --engine docker after reviewing its daemon boundary");
|
|
212
|
+
}
|
|
213
|
+
if (platform === "darwin") {
|
|
214
|
+
throw new TypeError("Apple container is not installed; on Apple Silicon macOS 26 run `brew install container`, then retry (Podman Machine is the fallback)");
|
|
215
|
+
}
|
|
216
|
+
throw new TypeError("no supported container engine found");
|
|
217
|
+
}
|
|
218
|
+
function inspectRootlessPodman() {
|
|
219
|
+
return new Promise((resolve3, reject) => {
|
|
220
|
+
(0, import_node_child_process.execFile)(
|
|
221
|
+
"podman",
|
|
222
|
+
["info", "--format", "{{.Host.Security.Rootless}}"],
|
|
223
|
+
{ encoding: "utf8", maxBuffer: 16 * 1024, timeout: 1e4 },
|
|
224
|
+
(error, stdout) => {
|
|
225
|
+
if (error) {
|
|
226
|
+
reject(new TypeError("could not verify that the active Podman service is rootless"));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
resolve3(stdout.trim() === "true");
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
async function verifyContainerEngineBoundary(engine, options = {}) {
|
|
235
|
+
const platform = options.platform ?? process.platform;
|
|
236
|
+
const arch = options.arch ?? process.arch;
|
|
237
|
+
const uid = options.uid ?? (typeof import_node_process.getuid === "function" ? (0, import_node_process.getuid)() : 1e3);
|
|
238
|
+
if (engine === "container" && (platform !== "darwin" || arch !== "arm64")) {
|
|
239
|
+
throw new TypeError("Apple container requires Apple Silicon macOS");
|
|
240
|
+
}
|
|
241
|
+
if (engine !== "podman" || platform !== "linux") return;
|
|
242
|
+
if (uid === 0) throw new TypeError("the Linux harness requires rootless Podman; do not run the runner as root");
|
|
243
|
+
const rootless = await (options.podmanRootless ?? inspectRootlessPodman)();
|
|
244
|
+
if (!rootless) throw new TypeError("the active Podman service is not rootless; refusing to run the harness");
|
|
245
|
+
}
|
|
246
|
+
function buildContainerRunArgs(options) {
|
|
247
|
+
if (!options.allowUnpinnedImage) assertPinnedImage(options.image);
|
|
248
|
+
if (/[,\r\n]/.test(options.workspaceDir)) throw new TypeError("workspace path contains unsupported mount characters");
|
|
249
|
+
const uid = typeof import_node_process.getuid === "function" ? (0, import_node_process.getuid)() : 1e3;
|
|
250
|
+
const gid = typeof import_node_process.getgid === "function" ? (0, import_node_process.getgid)() : 1e3;
|
|
251
|
+
const safeAttempt = options.task.attemptId.toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40);
|
|
252
|
+
const name = `odla-harness-${safeAttempt}-${crypto.randomUUID().slice(0, 8)}`;
|
|
253
|
+
const limits = options.limits ?? {};
|
|
254
|
+
const access2 = options.workspaceAccess ?? "read-write";
|
|
255
|
+
const appleMount = access2 === "none" ? [] : [`--mount=type=bind,source=${options.workspaceDir},target=/workspace${access2 === "read-only" ? ",readonly" : ""}`];
|
|
256
|
+
const ociMount = access2 === "none" ? [] : [`--mount=type=bind,src=${options.workspaceDir},dst=/workspace${access2 === "read-only" ? ",readonly" : ""}`];
|
|
257
|
+
if (options.engine === "container") {
|
|
258
|
+
return [
|
|
259
|
+
"run",
|
|
260
|
+
"--rm",
|
|
261
|
+
"--interactive",
|
|
262
|
+
`--name=${name}`,
|
|
263
|
+
"--network=none",
|
|
264
|
+
"--read-only",
|
|
265
|
+
"--cap-drop=ALL",
|
|
266
|
+
`--memory=${limits.memory ?? "1g"}`,
|
|
267
|
+
`--cpus=${limits.cpus ?? 1}`,
|
|
268
|
+
`--user=${uid}:${gid}`,
|
|
269
|
+
"--tmpfs=/tmp",
|
|
270
|
+
...appleMount,
|
|
271
|
+
"--workdir=/workspace",
|
|
272
|
+
`--env=ODLA_HARNESS_PROTOCOL=${HARNESS_PROTOCOL_VERSION}`,
|
|
273
|
+
`--label=ai.odla.harness.attempt=${options.task.attemptId}`,
|
|
274
|
+
options.image
|
|
275
|
+
];
|
|
276
|
+
}
|
|
277
|
+
return [
|
|
278
|
+
"run",
|
|
279
|
+
"--rm",
|
|
280
|
+
"--interactive",
|
|
281
|
+
`--name=${name}`,
|
|
282
|
+
"--pull=never",
|
|
283
|
+
"--network=none",
|
|
284
|
+
"--read-only",
|
|
285
|
+
"--cap-drop=ALL",
|
|
286
|
+
"--security-opt=no-new-privileges",
|
|
287
|
+
`--pids-limit=${limits.pids ?? 256}`,
|
|
288
|
+
`--memory=${limits.memory ?? "1g"}`,
|
|
289
|
+
`--cpus=${limits.cpus ?? 1}`,
|
|
290
|
+
`--user=${uid}:${gid}`,
|
|
291
|
+
`--tmpfs=/tmp:rw,noexec,nosuid,nodev,size=${limits.tmpfsBytes ?? 64 * 1024 * 1024}`,
|
|
292
|
+
...ociMount,
|
|
293
|
+
"--workdir=/workspace",
|
|
294
|
+
`--env=ODLA_HARNESS_PROTOCOL=${HARNESS_PROTOCOL_VERSION}`,
|
|
295
|
+
`--label=ai.odla.harness.attempt=${options.task.attemptId}`,
|
|
296
|
+
options.image
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
function containerName(args) {
|
|
300
|
+
return args.find((arg) => arg.startsWith("--name=")).slice("--name=".length);
|
|
301
|
+
}
|
|
302
|
+
async function runContainerAttempt(options) {
|
|
303
|
+
if (options.signal?.aborted) return { exitCode: 1, status: "cancelled", stderr: "" };
|
|
304
|
+
await verifyContainerEngineBoundary(options.engine);
|
|
305
|
+
const args = buildContainerRunArgs(options);
|
|
306
|
+
const name = containerName(args);
|
|
307
|
+
const child = (0, import_node_child_process.spawn)(options.engine, args, { stdio: ["pipe", "pipe", "pipe"], shell: false });
|
|
308
|
+
let stderr = "";
|
|
309
|
+
let outputBytes = 0;
|
|
310
|
+
let complete = null;
|
|
311
|
+
let stopped = false;
|
|
312
|
+
let exited = false;
|
|
313
|
+
child.stderr.setEncoding("utf8");
|
|
314
|
+
child.stderr.on("data", (text) => {
|
|
315
|
+
if (stderr.length < 64 * 1024) stderr += text.slice(0, 64 * 1024 - stderr.length);
|
|
316
|
+
});
|
|
317
|
+
const stop = (reason) => {
|
|
318
|
+
if (stopped || exited) return;
|
|
319
|
+
stopped = true;
|
|
320
|
+
if (!child.stdin.destroyed) {
|
|
321
|
+
const cancel = { protocolVersion: HARNESS_PROTOCOL_VERSION, type: "attempt.cancel", reason };
|
|
322
|
+
child.stdin.write(encodeAgentInput(cancel));
|
|
323
|
+
}
|
|
324
|
+
const removeArgs = options.engine === "container" ? ["delete", "--force", name] : ["rm", "-f", name];
|
|
325
|
+
const killer = (0, import_node_child_process.spawn)(options.engine, removeArgs, { stdio: "ignore", shell: false });
|
|
326
|
+
killer.unref();
|
|
327
|
+
};
|
|
328
|
+
const abort = () => stop("runner_cancelled");
|
|
329
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
330
|
+
const timeout = setTimeout(() => stop("timeout"), options.task.policy.timeoutMs);
|
|
331
|
+
const start = { protocolVersion: HARNESS_PROTOCOL_VERSION, type: "task.start", task: options.task };
|
|
332
|
+
if (!stopped && !options.signal?.aborted) child.stdin.write(encodeAgentInput(start));
|
|
333
|
+
const consume = (async () => {
|
|
334
|
+
let pending = Buffer.alloc(0);
|
|
335
|
+
const handleLine = async (raw) => {
|
|
336
|
+
const bytes = raw.at(-1) === 13 ? raw.subarray(0, -1) : raw;
|
|
337
|
+
if (bytes.byteLength > 1e6) throw new Error("agent message exceeds 1 MB");
|
|
338
|
+
const line = bytes.toString("utf8");
|
|
339
|
+
if (!line.trim()) return;
|
|
340
|
+
const message = parseAgentOutput(line);
|
|
341
|
+
if (message.type === "attempt.complete") complete = message;
|
|
342
|
+
const response = await options.onMessage(message);
|
|
343
|
+
if (response && !child.stdin.destroyed) child.stdin.write(encodeAgentInput(response));
|
|
344
|
+
};
|
|
345
|
+
try {
|
|
346
|
+
for await (const raw of child.stdout) {
|
|
347
|
+
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
348
|
+
outputBytes += chunk.byteLength;
|
|
349
|
+
if (outputBytes > options.task.policy.maxOutputBytes) {
|
|
350
|
+
throw new Error(`agent output exceeds ${options.task.policy.maxOutputBytes} bytes`);
|
|
351
|
+
}
|
|
352
|
+
pending = Buffer.concat([pending, chunk]);
|
|
353
|
+
let newline = pending.indexOf(10);
|
|
354
|
+
while (newline >= 0) {
|
|
355
|
+
await handleLine(pending.subarray(0, newline));
|
|
356
|
+
pending = pending.subarray(newline + 1);
|
|
357
|
+
newline = pending.indexOf(10);
|
|
358
|
+
}
|
|
359
|
+
if (pending.byteLength > 1e6) throw new Error("agent message exceeds 1 MB");
|
|
360
|
+
}
|
|
361
|
+
if (pending.byteLength) await handleLine(pending);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
stop("protocol_error");
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
})();
|
|
367
|
+
const exit = new Promise((accept, reject) => {
|
|
368
|
+
child.once("error", reject);
|
|
369
|
+
child.once("exit", (code) => {
|
|
370
|
+
exited = true;
|
|
371
|
+
accept(code ?? 1);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
try {
|
|
375
|
+
const [exitCode] = await Promise.all([exit, consume]);
|
|
376
|
+
if (stderr && options.onStderr) await options.onStderr(stderr);
|
|
377
|
+
if (options.signal?.aborted) return { exitCode, status: "cancelled", stderr };
|
|
378
|
+
const terminal = complete;
|
|
379
|
+
if (!terminal) return { exitCode, status: "failed", result: { error: "agent exited without completion" }, stderr };
|
|
380
|
+
return { exitCode, status: exitCode === 0 ? terminal.status : "failed", result: terminal.result, stderr };
|
|
381
|
+
} catch (error) {
|
|
382
|
+
stop("runner_error");
|
|
383
|
+
await exit.catch(() => 1);
|
|
384
|
+
throw error;
|
|
385
|
+
} finally {
|
|
386
|
+
clearTimeout(timeout);
|
|
387
|
+
options.signal?.removeEventListener("abort", abort);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/runner.ts
|
|
392
|
+
var import_promises3 = require("timers/promises");
|
|
393
|
+
|
|
394
|
+
// src/workspace.ts
|
|
395
|
+
var import_promises2 = require("fs/promises");
|
|
396
|
+
var import_node_os = require("os");
|
|
397
|
+
var import_node_path3 = require("path");
|
|
398
|
+
var import_node_child_process2 = require("child_process");
|
|
399
|
+
|
|
400
|
+
// src/workspace-policy.ts
|
|
401
|
+
var import_node_path2 = require("path");
|
|
402
|
+
var SKIP_WORKSPACE_DIRS = /* @__PURE__ */ new Set([
|
|
403
|
+
".git",
|
|
404
|
+
".odla",
|
|
405
|
+
".wrangler",
|
|
406
|
+
"node_modules",
|
|
407
|
+
"dist",
|
|
408
|
+
"coverage"
|
|
409
|
+
]);
|
|
410
|
+
var SECRET_WORKSPACE_FILE = /^(?:\.env(?:\..+)?|\.dev\.vars|\.dev-token(?:\..+)?|credentials(?:\..+)?\.json|dev-token(?:\..+)?(?:\.json)?)$/i;
|
|
411
|
+
function allowedWorkspacePath(relativePath) {
|
|
412
|
+
const parts = relativePath.split("/");
|
|
413
|
+
return !(0, import_node_path2.isAbsolute)(relativePath) && !relativePath.includes("\\") && !relativePath.includes("\0") && !parts.some((part) => !part || part === "." || part === ".." || SKIP_WORKSPACE_DIRS.has(part)) && !SECRET_WORKSPACE_FILE.test(parts.at(-1) ?? "");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/workspace.ts
|
|
417
|
+
async function sourceFiles(sourceDir, maxFiles, maxBytes) {
|
|
418
|
+
const files = [];
|
|
419
|
+
let bytes = 0;
|
|
420
|
+
const walk = async (dir) => {
|
|
421
|
+
for (const entry of await (0, import_promises2.readdir)(dir, { withFileTypes: true })) {
|
|
422
|
+
if (entry.isDirectory() && SKIP_WORKSPACE_DIRS.has(entry.name)) continue;
|
|
423
|
+
if (!entry.isDirectory() && SECRET_WORKSPACE_FILE.test(entry.name)) continue;
|
|
424
|
+
const path = (0, import_node_path3.join)(dir, entry.name);
|
|
425
|
+
if (entry.isSymbolicLink()) continue;
|
|
426
|
+
if (entry.isDirectory()) {
|
|
427
|
+
await walk(path);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (!entry.isFile()) continue;
|
|
431
|
+
const metadata = await (0, import_promises2.stat)(path);
|
|
432
|
+
bytes += metadata.size;
|
|
433
|
+
if (files.length + 1 > maxFiles) throw new Error(`workspace exceeds ${maxFiles} files`);
|
|
434
|
+
if (bytes > maxBytes) throw new Error(`workspace exceeds ${maxBytes} bytes`);
|
|
435
|
+
files.push({
|
|
436
|
+
source: path,
|
|
437
|
+
relativePath: (0, import_node_path3.relative)(sourceDir, path),
|
|
438
|
+
mode: metadata.mode & 511,
|
|
439
|
+
bytes: metadata.size
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
await walk(sourceDir);
|
|
444
|
+
return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
445
|
+
}
|
|
446
|
+
async function gitSourceFiles(sourceDir, maxFiles, maxBytes) {
|
|
447
|
+
const child = (0, import_node_child_process2.spawn)("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
448
|
+
cwd: sourceDir,
|
|
449
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
450
|
+
shell: false
|
|
451
|
+
});
|
|
452
|
+
const stdout = [];
|
|
453
|
+
const stderr = [];
|
|
454
|
+
let outputBytes = 0;
|
|
455
|
+
child.stdout.on("data", (chunk) => {
|
|
456
|
+
outputBytes += chunk.byteLength;
|
|
457
|
+
if (outputBytes > 8 * 1024 * 1024) child.kill("SIGKILL");
|
|
458
|
+
else stdout.push(chunk);
|
|
459
|
+
});
|
|
460
|
+
child.stderr.on("data", (chunk) => {
|
|
461
|
+
if (stderr.reduce((sum, value) => sum + value.byteLength, 0) < 16384) stderr.push(chunk);
|
|
462
|
+
});
|
|
463
|
+
const code = await new Promise((accept, reject) => {
|
|
464
|
+
child.once("error", reject);
|
|
465
|
+
child.once("exit", accept);
|
|
466
|
+
});
|
|
467
|
+
if (outputBytes > 8 * 1024 * 1024) throw new Error("git file inventory exceeds 8 MiB");
|
|
468
|
+
if (code !== 0) throw new Error(`git file inventory failed: ${Buffer.concat(stderr).toString("utf8").slice(0, 1e3)}`);
|
|
469
|
+
const paths = Buffer.concat(stdout).toString("utf8").split("\0").filter(Boolean).sort();
|
|
470
|
+
if (paths.length > maxFiles) throw new Error(`workspace exceeds ${maxFiles} files`);
|
|
471
|
+
const root = (0, import_node_path3.resolve)(sourceDir);
|
|
472
|
+
const files = [];
|
|
473
|
+
let bytes = 0;
|
|
474
|
+
for (const relativePath of paths) {
|
|
475
|
+
if (!allowedWorkspacePath(relativePath)) continue;
|
|
476
|
+
const source = (0, import_node_path3.resolve)(root, relativePath);
|
|
477
|
+
if (!source.startsWith(`${root}${import_node_path3.sep}`)) throw new TypeError("git file path escapes workspace");
|
|
478
|
+
let metadata;
|
|
479
|
+
try {
|
|
480
|
+
metadata = await (0, import_promises2.lstat)(source);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if (error.code === "ENOENT") continue;
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) continue;
|
|
486
|
+
bytes += metadata.size;
|
|
487
|
+
if (bytes > maxBytes) throw new Error(`workspace exceeds ${maxBytes} bytes`);
|
|
488
|
+
files.push({ source, relativePath, mode: metadata.mode & 511, bytes: metadata.size });
|
|
489
|
+
}
|
|
490
|
+
return files;
|
|
491
|
+
}
|
|
492
|
+
async function copyTree(files, destination) {
|
|
493
|
+
for (const file of files) {
|
|
494
|
+
const target = (0, import_node_path3.join)(destination, file.relativePath);
|
|
495
|
+
await (0, import_promises2.mkdir)((0, import_node_path3.resolve)(target, ".."), { recursive: true });
|
|
496
|
+
await (0, import_promises2.copyFile)(file.source, target);
|
|
497
|
+
await (0, import_promises2.chmod)(target, file.mode);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async function captureGitDiff(root, maxBytes) {
|
|
501
|
+
const child = (0, import_node_child_process2.spawn)("git", [
|
|
502
|
+
"diff",
|
|
503
|
+
"--no-index",
|
|
504
|
+
"--binary",
|
|
505
|
+
"--no-ext-diff",
|
|
506
|
+
"--src-prefix=a/",
|
|
507
|
+
"--dst-prefix=b/",
|
|
508
|
+
"--",
|
|
509
|
+
"baseline",
|
|
510
|
+
"workspace"
|
|
511
|
+
], { cwd: root, stdio: ["ignore", "pipe", "pipe"], shell: false });
|
|
512
|
+
const stdout = [];
|
|
513
|
+
const stderr = [];
|
|
514
|
+
let bytes = 0;
|
|
515
|
+
child.stdout.on("data", (chunk) => {
|
|
516
|
+
bytes += chunk.byteLength;
|
|
517
|
+
if (bytes > maxBytes) child.kill("SIGKILL");
|
|
518
|
+
else stdout.push(chunk);
|
|
519
|
+
});
|
|
520
|
+
child.stderr.on("data", (chunk) => {
|
|
521
|
+
if (stderr.reduce((sum, value) => sum + value.byteLength, 0) < 16384) stderr.push(chunk);
|
|
522
|
+
});
|
|
523
|
+
const code = await new Promise((accept, reject) => {
|
|
524
|
+
child.once("error", reject);
|
|
525
|
+
child.once("exit", accept);
|
|
526
|
+
});
|
|
527
|
+
if (bytes > maxBytes) throw new Error(`patch exceeds ${maxBytes} bytes`);
|
|
528
|
+
if (code !== 0 && code !== 1) {
|
|
529
|
+
throw new Error(`git diff failed: ${Buffer.concat(stderr).toString("utf8").slice(0, 1e3)}`);
|
|
530
|
+
}
|
|
531
|
+
return Buffer.concat(stdout).toString("utf8").replaceAll("a/baseline/", "a/").replaceAll("a/workspace/", "a/").replaceAll("b/baseline/", "b/").replaceAll("b/workspace/", "b/").replaceAll("--- a/baseline", "--- a").replaceAll("+++ b/workspace", "+++ b");
|
|
532
|
+
}
|
|
533
|
+
async function stageWorkspace(source, options = {}) {
|
|
534
|
+
const sourceDir = await (0, import_promises2.realpath)((0, import_node_path3.resolve)(source));
|
|
535
|
+
const sourceStat = await (0, import_promises2.stat)(sourceDir);
|
|
536
|
+
if (!sourceStat.isDirectory()) throw new TypeError("workspace source must be a directory");
|
|
537
|
+
const root = await (0, import_promises2.mkdtemp)((0, import_node_path3.join)(options.tempRoot ?? (0, import_node_os.tmpdir)(), "odla-harness-"));
|
|
538
|
+
const baselineDir = (0, import_node_path3.join)(root, "baseline");
|
|
539
|
+
const workspaceDir = (0, import_node_path3.join)(root, "workspace");
|
|
540
|
+
await Promise.all([(0, import_promises2.mkdir)(baselineDir), (0, import_promises2.mkdir)(workspaceDir)]);
|
|
541
|
+
try {
|
|
542
|
+
const maxFiles = options.maxFiles ?? 2e4;
|
|
543
|
+
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
|
|
544
|
+
const files = options.gitTrackedAndUnignored ? await gitSourceFiles(sourceDir, maxFiles, maxBytes) : await sourceFiles(sourceDir, maxFiles, maxBytes);
|
|
545
|
+
await Promise.all([copyTree(files, baselineDir), copyTree(files, workspaceDir)]);
|
|
546
|
+
return {
|
|
547
|
+
root,
|
|
548
|
+
baselineDir,
|
|
549
|
+
workspaceDir,
|
|
550
|
+
fileCount: files.length,
|
|
551
|
+
byteCount: files.reduce((sum, file) => sum + file.bytes, 0),
|
|
552
|
+
patch: (maxBytes2) => captureGitDiff(root, maxBytes2),
|
|
553
|
+
cleanup: () => (0, import_promises2.rm)(root, { recursive: true, force: true })
|
|
554
|
+
};
|
|
555
|
+
} catch (error) {
|
|
556
|
+
await (0, import_promises2.rm)(root, { recursive: true, force: true });
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/runner.ts
|
|
562
|
+
async function append(control, lease, kind, actor, payload) {
|
|
563
|
+
await control.appendEvents(lease.task.attemptId, lease.leaseId, [makeHarnessEvent(kind, actor, payload)]);
|
|
564
|
+
}
|
|
565
|
+
async function runLeasedAttempt(lease, options) {
|
|
566
|
+
const source = options.workspaces[lease.task.workspace];
|
|
567
|
+
if (!source) {
|
|
568
|
+
await options.control.complete(lease.task.attemptId, lease.leaseId, {
|
|
569
|
+
status: "failed",
|
|
570
|
+
error: `runner does not expose workspace "${lease.task.workspace}"`
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
let staged = null;
|
|
575
|
+
const controller = new AbortController();
|
|
576
|
+
const cancelFromParent = () => controller.abort(options.signal?.reason);
|
|
577
|
+
options.signal?.addEventListener("abort", cancelFromParent, { once: true });
|
|
578
|
+
if (options.signal?.aborted) controller.abort(options.signal.reason);
|
|
579
|
+
let heartbeat;
|
|
580
|
+
let heartbeatInFlight = false;
|
|
581
|
+
const pulse = async () => {
|
|
582
|
+
if (heartbeatInFlight || controller.signal.aborted) return;
|
|
583
|
+
heartbeatInFlight = true;
|
|
584
|
+
try {
|
|
585
|
+
const value = await options.control.heartbeat(lease.task.attemptId, lease.leaseId);
|
|
586
|
+
if (value.cancelRequested) controller.abort("cancel_requested");
|
|
587
|
+
} catch {
|
|
588
|
+
controller.abort("heartbeat_failed");
|
|
589
|
+
} finally {
|
|
590
|
+
heartbeatInFlight = false;
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
try {
|
|
594
|
+
await pulse();
|
|
595
|
+
heartbeat = setInterval(() => {
|
|
596
|
+
void pulse();
|
|
597
|
+
}, options.heartbeatMs ?? 15e3);
|
|
598
|
+
if (controller.signal.aborted) throw new Error("lease was cancelled before workspace staging");
|
|
599
|
+
staged = await stageWorkspace(source);
|
|
600
|
+
if (controller.signal.aborted) throw new Error("lease was cancelled during workspace staging");
|
|
601
|
+
const stagedWorkspace = staged;
|
|
602
|
+
await append(options.control, lease, "runner.workspace_staged", "runner", {
|
|
603
|
+
workspace: lease.task.workspace,
|
|
604
|
+
files: stagedWorkspace.fileCount,
|
|
605
|
+
bytes: stagedWorkspace.byteCount
|
|
606
|
+
});
|
|
607
|
+
const result = await runContainerAttempt({
|
|
608
|
+
engine: options.engine,
|
|
609
|
+
image: options.image,
|
|
610
|
+
workspaceDir: stagedWorkspace.workspaceDir,
|
|
611
|
+
task: lease.task,
|
|
612
|
+
limits: options.limits,
|
|
613
|
+
allowUnpinnedImage: options.allowUnpinnedImage,
|
|
614
|
+
workspaceAccess: options.workspaceAccess ?? (options.toolBroker ? "none" : "read-write"),
|
|
615
|
+
signal: controller.signal,
|
|
616
|
+
onStderr: async (text) => {
|
|
617
|
+
await append(options.control, lease, "agent.stderr", "agent", { text: text.slice(0, 64 * 1024) });
|
|
618
|
+
},
|
|
619
|
+
onMessage: async (message) => {
|
|
620
|
+
if (message.type === "event") {
|
|
621
|
+
await append(options.control, lease, message.kind, "agent", message.payload ?? null);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (message.type === "attempt.complete") {
|
|
625
|
+
await append(options.control, lease, "agent.completed", "agent", {
|
|
626
|
+
status: message.status,
|
|
627
|
+
result: message.result ?? null
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (message.type === "tool.request") {
|
|
632
|
+
await append(options.control, lease, "tool.requested", "agent", {
|
|
633
|
+
requestId: message.requestId,
|
|
634
|
+
tool: message.tool
|
|
635
|
+
});
|
|
636
|
+
const response2 = options.toolBroker ? await options.toolBroker.execute({
|
|
637
|
+
lease,
|
|
638
|
+
workspaceDir: stagedWorkspace.workspaceDir,
|
|
639
|
+
signal: controller.signal
|
|
640
|
+
}, message) : { requestId: message.requestId, ok: false, content: "tool denied: no trusted broker configured" };
|
|
641
|
+
await append(options.control, lease, "tool.responded", "system", {
|
|
642
|
+
requestId: message.requestId,
|
|
643
|
+
tool: message.tool,
|
|
644
|
+
ok: response2.ok,
|
|
645
|
+
contentBytes: Buffer.byteLength(response2.content)
|
|
646
|
+
});
|
|
647
|
+
return { protocolVersion: HARNESS_PROTOCOL_VERSION, type: "tool.response", ...response2 };
|
|
648
|
+
}
|
|
649
|
+
await append(options.control, lease, "model.requested", "agent", {
|
|
650
|
+
requestId: message.requestId,
|
|
651
|
+
messages: message.call.messages.length,
|
|
652
|
+
maxTokens: message.call.maxTokens
|
|
653
|
+
});
|
|
654
|
+
const response = await options.control.infer(lease.task.attemptId, lease.leaseId, {
|
|
655
|
+
requestId: message.requestId,
|
|
656
|
+
call: message.call
|
|
657
|
+
});
|
|
658
|
+
await append(options.control, lease, "model.responded", "model", {
|
|
659
|
+
requestId: message.requestId,
|
|
660
|
+
response: response.response,
|
|
661
|
+
receipt: response.receipt
|
|
662
|
+
});
|
|
663
|
+
return {
|
|
664
|
+
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
665
|
+
type: "inference.response",
|
|
666
|
+
requestId: message.requestId,
|
|
667
|
+
response: response.response
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
const patch = await stagedWorkspace.patch(lease.task.policy.maxPatchBytes);
|
|
672
|
+
await options.control.complete(lease.task.attemptId, lease.leaseId, {
|
|
673
|
+
status: result.status,
|
|
674
|
+
result: result.result,
|
|
675
|
+
patch,
|
|
676
|
+
...result.status === "failed" ? { error: result.stderr.slice(0, 4e3) || "container attempt failed" } : {}
|
|
677
|
+
});
|
|
678
|
+
} catch (reason) {
|
|
679
|
+
const message = reason instanceof Error ? reason.message : "runner failed";
|
|
680
|
+
try {
|
|
681
|
+
await append(options.control, lease, "runner.failed", "runner", { message });
|
|
682
|
+
await options.control.complete(lease.task.attemptId, lease.leaseId, {
|
|
683
|
+
status: controller.signal.aborted ? "cancelled" : "failed",
|
|
684
|
+
error: message
|
|
685
|
+
});
|
|
686
|
+
} catch {
|
|
687
|
+
}
|
|
688
|
+
} finally {
|
|
689
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
690
|
+
options.signal?.removeEventListener("abort", cancelFromParent);
|
|
691
|
+
if (staged && !options.preserveWorkspace) await staged.cleanup();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
async function runHarnessRunner(options) {
|
|
695
|
+
const workspaceNames = Object.keys(options.workspaces).sort();
|
|
696
|
+
if (!workspaceNames.length) throw new TypeError("at least one workspace mapping is required");
|
|
697
|
+
do {
|
|
698
|
+
if (options.signal?.aborted) return;
|
|
699
|
+
const lease = await options.control.lease(workspaceNames);
|
|
700
|
+
if (lease) {
|
|
701
|
+
options.log?.(`leased ${lease.task.taskId}/${lease.task.attemptId}`);
|
|
702
|
+
await runLeasedAttempt(lease, options);
|
|
703
|
+
if (options.once) return;
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
if (options.once) return;
|
|
707
|
+
await (0, import_promises3.setTimeout)(options.pollMs ?? 2e3, void 0, { signal: options.signal }).catch(() => {
|
|
708
|
+
});
|
|
709
|
+
} while (!options.signal?.aborted);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/cli.ts
|
|
713
|
+
function usage() {
|
|
714
|
+
return `Usage:
|
|
715
|
+
ODLA_HARNESS_TOKEN=odla_hrn_... odla-harness runner \\
|
|
716
|
+
--endpoint https://odla.ai \\
|
|
717
|
+
--workspace my-repo=/absolute/path \\
|
|
718
|
+
--image registry.example/agent@sha256:<digest> [--engine auto|container|podman|docker] [--once]
|
|
719
|
+
|
|
720
|
+
The token is read only from ODLA_HARNESS_TOKEN so it does not enter shell history.
|
|
721
|
+
Images must be digest-pinned. Network is disabled inside the container.
|
|
722
|
+
Auto prefers Apple container on macOS and rootless Podman on Linux.`;
|
|
723
|
+
}
|
|
724
|
+
function parse(argv) {
|
|
725
|
+
if (argv[0] !== "runner") throw new TypeError(usage());
|
|
726
|
+
const values = {};
|
|
727
|
+
const flags = /* @__PURE__ */ new Set();
|
|
728
|
+
for (let index = 1; index < argv.length; index++) {
|
|
729
|
+
const arg = argv[index];
|
|
730
|
+
if (["--once", "--preserve-workspace", "--allow-unpinned-image"].includes(arg)) {
|
|
731
|
+
flags.add(arg);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (!arg.startsWith("--") || !argv[index + 1]) throw new TypeError(`missing value for ${arg}`);
|
|
735
|
+
(values[arg] ??= []).push(argv[++index]);
|
|
736
|
+
}
|
|
737
|
+
const endpoint = values["--endpoint"]?.at(-1);
|
|
738
|
+
const image = values["--image"]?.at(-1);
|
|
739
|
+
const engine = values["--engine"]?.at(-1) ?? "auto";
|
|
740
|
+
if (!endpoint || !image || !["auto", "container", "podman", "docker"].includes(engine)) throw new TypeError(usage());
|
|
741
|
+
const workspaces = {};
|
|
742
|
+
for (const mapping of values["--workspace"] ?? []) {
|
|
743
|
+
const equals = mapping.indexOf("=");
|
|
744
|
+
const key = mapping.slice(0, equals);
|
|
745
|
+
const path = mapping.slice(equals + 1);
|
|
746
|
+
if (equals < 1 || !/^[a-z0-9][a-z0-9_-]{0,79}$/.test(key) || !path) {
|
|
747
|
+
throw new TypeError(`invalid workspace mapping: ${mapping}`);
|
|
748
|
+
}
|
|
749
|
+
workspaces[key] = (0, import_node_path4.resolve)(path);
|
|
750
|
+
}
|
|
751
|
+
if (!Object.keys(workspaces).length) throw new TypeError("at least one --workspace key=/absolute/path is required");
|
|
752
|
+
const allowUnpinnedImage = flags.has("--allow-unpinned-image");
|
|
753
|
+
if (allowUnpinnedImage && process.env.ODLA_HARNESS_UNSAFE_TESTING !== "1") {
|
|
754
|
+
throw new TypeError("--allow-unpinned-image requires ODLA_HARNESS_UNSAFE_TESTING=1");
|
|
755
|
+
}
|
|
756
|
+
const pollMs = Number(values["--poll-ms"]?.at(-1) ?? 2e3);
|
|
757
|
+
if (!Number.isSafeInteger(pollMs) || pollMs < 250 || pollMs > 6e4) {
|
|
758
|
+
throw new TypeError("--poll-ms must be an integer from 250 to 60000");
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
endpoint,
|
|
762
|
+
image,
|
|
763
|
+
engine,
|
|
764
|
+
workspaces,
|
|
765
|
+
once: flags.has("--once"),
|
|
766
|
+
pollMs,
|
|
767
|
+
preserveWorkspace: flags.has("--preserve-workspace"),
|
|
768
|
+
allowUnpinnedImage
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
async function main() {
|
|
772
|
+
const token = process.env.ODLA_HARNESS_TOKEN;
|
|
773
|
+
if (!token) throw new TypeError("ODLA_HARNESS_TOKEN is required");
|
|
774
|
+
const options = parse(process.argv.slice(2));
|
|
775
|
+
const controller = new AbortController();
|
|
776
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => controller.abort(signal));
|
|
777
|
+
const engine = await selectContainerEngine(options.engine);
|
|
778
|
+
if (process.platform === "linux" && engine === "docker") {
|
|
779
|
+
process.stderr.write("[odla-harness] warning: explicit Docker on Linux may use a rootful daemon; rootless Podman is preferred\n");
|
|
780
|
+
}
|
|
781
|
+
await runHarnessRunner({
|
|
782
|
+
...options,
|
|
783
|
+
engine,
|
|
784
|
+
control: createHarnessControlClient({ endpoint: options.endpoint, token, signal: controller.signal }),
|
|
785
|
+
signal: controller.signal,
|
|
786
|
+
log: (message) => process.stderr.write(`[odla-harness] ${message}
|
|
787
|
+
`)
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
main().catch((error) => {
|
|
791
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
792
|
+
`);
|
|
793
|
+
process.exitCode = 1;
|
|
794
|
+
});
|
|
795
|
+
//# sourceMappingURL=cli.cjs.map
|