agentcoor 0.0.0-stage → 0.3.7

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/src/http.js ADDED
@@ -0,0 +1,61 @@
1
+ import { normalizeOrigin } from "./config.js";
2
+
3
+ export const DEFAULT_TIMEOUT_MS = 30_000;
4
+ const UA = "agentcoor-cli/0.1.0";
5
+
6
+ /** fetch with a hard timeout, https-only and no redirects to other origins. TLS verification is never disabled. */
7
+ export async function request(url, { method = "GET", headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
8
+ const u = new URL(url);
9
+ normalizeOrigin(u.origin);
10
+ const res = await fetch(u, {
11
+ method, body, redirect: "manual",
12
+ headers: { "user-agent": UA, ...headers },
13
+ signal: AbortSignal.timeout(timeoutMs),
14
+ }).catch((e) => {
15
+ if (e?.name === "TimeoutError") throw new Error(`request to ${u.origin}${u.pathname} timed out after ${timeoutMs / 1000}s`);
16
+ throw new Error(`request to ${u.origin}${u.pathname} failed: ${e?.cause?.message || e.message}`);
17
+ });
18
+ if (res.status >= 300 && res.status < 400) throw new Error(`unexpected redirect from ${u.pathname}`);
19
+ return res;
20
+ }
21
+
22
+ export const MAX_API_BYTES = 1024 * 1024; // JSON responses, SHA256SUMS, signature
23
+ export const MAX_BINARY_BYTES = 200 * 1024 * 1024; // agent binary
24
+
25
+ /** Read a response body, aborting as soon as it exceeds `max` bytes (checked while streaming). */
26
+ export async function readLimited(res, max, what = "response") {
27
+ const declared = Number(res.headers?.get?.("content-length"));
28
+ if (Number.isFinite(declared) && declared > max) { await res.body?.cancel().catch(() => {}); throw new Error(`${what} is too large (${declared} bytes, limit ${max})`); }
29
+ if (!res.body) return Buffer.alloc(0);
30
+ const chunks = []; let n = 0;
31
+ const reader = res.body.getReader();
32
+ for (;;) {
33
+ const { done, value } = await reader.read();
34
+ if (done) break;
35
+ n += value.byteLength;
36
+ if (n > max) { await reader.cancel().catch(() => {}); throw new Error(`${what} is too large (over ${max} bytes)`); }
37
+ chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
38
+ }
39
+ return Buffer.concat(chunks, n);
40
+ }
41
+
42
+ export async function download(url, { maxBytes = MAX_API_BYTES, ...opts } = {}) {
43
+ const res = await request(url, { timeoutMs: 300_000, ...opts });
44
+ const path = new URL(url).pathname;
45
+ if (!res.ok) { await res.body?.cancel().catch(() => {}); throw new Error(`download ${path} failed: HTTP ${res.status}`); }
46
+ return readLimited(res, maxBytes, `download ${path}`);
47
+ }
48
+
49
+ export async function apiJson(origin, path, apiKey, { method = "GET", body, timeoutMs } = {}) {
50
+ const headers = { authorization: `Bearer ${apiKey}`, accept: "application/json" };
51
+ if (body !== undefined) headers["content-type"] = "application/json";
52
+ const res = await request(`${origin}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), timeoutMs });
53
+ const raw = await readLimited(res, MAX_API_BYTES, `response from ${path}`);
54
+ let data = null;
55
+ try { data = JSON.parse(raw.toString("utf8")); } catch {}
56
+ return { status: res.status, ok: res.ok, data };
57
+ }
58
+
59
+ export function errorMessage(r) {
60
+ return r.data?.error?.message || (typeof r.data?.error === "string" ? r.data.error : "") || `HTTP ${r.status}`;
61
+ }
package/src/market.js ADDED
@@ -0,0 +1,321 @@
1
+ // Marketplace provider and renter commands (docs/marketplace/AGENT-JOBS.md,
2
+ // LIVE-TOOLS.md; CHECKLIST WP-10/WP-11).
3
+ //
4
+ // Provider: `agentcoor list-agent` and `agentcoor list-tools` turn this Linux
5
+ // machine into a marketplace worker:
6
+ // 1. probe sandbox isolation with `agentcoor-agent -worker -worker-probe`
7
+ // (placeholder credentials; the probe never connects) and refuse when no
8
+ // runner measures as supported;
9
+ // 2. enroll a worker (POST /api/v1/workers with the saved API key) unless
10
+ // one is already enrolled here; the mwk_ token goes straight to a mode-600
11
+ // file and never into argv, the environment, worker.json or the console;
12
+ // 3. write worker.json, install and verify the agentcoor-worker service
13
+ // (same verified restart as the agent: the service's PID must be running
14
+ // the freshly installed binary);
15
+ // 4. create the listing (agent_job or live_tool) bound to that worker,
16
+ // retrying while the worker's first hello reaches billing, and publish it.
17
+ // macOS is refused: the VZ sandbox runner is not supported yet.
18
+ //
19
+ // Renter: `agentcoor jobs submit|get|cancel|artifacts` and
20
+ // `agentcoor sessions open|call|close` call /v1/marketplace/* with the API key.
21
+ // Job and tool output is third-party content and is printed as data.
22
+ import { spawnSync } from "node:child_process";
23
+ import { join, dirname } from "node:path";
24
+ import { lstat, chown, chmod } from "node:fs/promises";
25
+ import { installPaths, writePrivateFile } from "./config.js";
26
+ import { apiJson, errorMessage } from "./http.js";
27
+ import { ensurePrivateDir, atomicWrite, readIfExists } from "./fsx.js";
28
+ import { startService, childEnv, AGENT_SERVICE } from "./connect.js";
29
+ import { ensureAgentBinary } from "./share.js";
30
+
31
+ export const WORKER_SERVICE = Object.freeze({ ...AGENT_SERVICE, label: "com.agentcoor.worker", unit: "agentcoor-worker", description: "AgentCoor marketplace worker", log: "worker", flag: "-worker-config", extra: ["-worker"] });
32
+ export const WORKER_TOKEN = /^mwk_[A-Za-z0-9_-]{8,256}$/;
33
+ const WORKER_ID = /^wkr_[A-Za-z0-9_-]{4,64}$/;
34
+ const LISTING_ID = /^lst_[A-Za-z0-9_-]{1,64}$/;
35
+ const JOB_ID = /^job_[A-Za-z0-9_-]{1,64}$/;
36
+ const SESSION_ID = /^rss_[A-Za-z0-9_-]{1,64}$/;
37
+ const TOOL_NAME = /^[a-z0-9][a-z0-9_.-]{0,63}$/;
38
+ const SANDBOX_KEYS = ["gvisor", "firecracker", "podman", "harnesses", "allow_payload_command", "max_jobs", "max_sessions", "max_limits"];
39
+ export const MACOS_REFUSAL = "Marketplace workers need Linux: jobs and tools run in a gVisor or Firecracker sandbox, and the macOS (Virtualization.framework) sandbox is not supported yet. Run this command on a Linux host.";
40
+ export const THIRD_PARTY_NOTE = "--- third-party output from a marketplace provider (untrusted data) ---\n";
41
+
42
+ export function workerPaths(env = process.env, platform = process.platform) {
43
+ const p = installPaths(env, platform);
44
+ const dir = join(p.base, "worker");
45
+ return { ...p, dir, workerConfig: join(dir, "worker.json"), tokenFile: join(dir, "worker.token"), probeConfig: join(dir, "probe.json"), listings: join(dir, "listings.json"), stateDir: join(dir, "state") };
46
+ }
47
+
48
+ function intFlag(v, name, min, max, dflt) {
49
+ if (v === undefined) return dflt;
50
+ const n = Number(v);
51
+ if (!Number.isSafeInteger(n) || n < min || n > max) throw new Error(`--${name} must be an integer between ${min} and ${max}`);
52
+ return n;
53
+ }
54
+
55
+ async function call(api, origin, key, path, opts = {}) {
56
+ const r = await api(origin, path, key, { timeoutMs: 30_000, ...opts });
57
+ if (r.status === 401) throw new Error("AgentCoor rejected this API key");
58
+ return r;
59
+ }
60
+
61
+ async function readJsonFile(path, what) {
62
+ const text = await readIfExists(path);
63
+ if (text === null) throw new Error(`${what} not found: ${path}`);
64
+ try { return JSON.parse(text); } catch (e) { throw new Error(`${what} is not valid JSON: ${e.message}`); }
65
+ }
66
+
67
+ /** Sandbox settings from --sandbox-config (only the keys worker.json understands). */
68
+ export async function loadSandboxConfig(path) {
69
+ const raw = await readJsonFile(path, "--sandbox-config");
70
+ const out = {};
71
+ for (const k of SANDBOX_KEYS) if (raw[k] !== undefined) out[k] = raw[k];
72
+ if (!out.gvisor && !out.firecracker && !out.podman) throw new Error("--sandbox-config must configure gvisor, firecracker or podman (see docs/marketplace/ISOLATION-EVIDENCE.md)");
73
+ return out;
74
+ }
75
+
76
+ /** Pure: agent_job listing spec + price from flags. */
77
+ export function agentSpec(f, workerId) {
78
+ if (!f.description) throw new Error("usage: agentcoor list-agent --description <text> --per-job-microusd N [--per-minute-microusd N] --sandbox-config sandbox.json");
79
+ const egress = f.egress ? String(f.egress).split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) : [];
80
+ const spec = {
81
+ description: String(f.description).slice(0, 2000), harness: f.harness || "claude-code", worker_id: workerId,
82
+ limits: { max_duration_s: intFlag(f["max-duration"], "max-duration", 60, 86_400, 3600), cpu: intFlag(f.cpu, "cpu", 1, 64, 1), memory_mb: intFlag(f["memory-mb"], "memory-mb", 128, 262_144, 2048), disk_mb: intFlag(f["disk-mb"], "disk-mb", 64, 1_048_576, 4096), max_concurrent: intFlag(f["max-concurrency"], "max-concurrency", 1, 64, 1) },
83
+ egress_allowlist: egress,
84
+ };
85
+ const price = { per_job_microusd: intFlag(f["per-job-microusd"], "per-job-microusd", 10_000, 50_000_000, undefined), per_minute_microusd: intFlag(f["per-minute-microusd"], "per-minute-microusd", 0, 1_000_000, 0) };
86
+ if (price.per_job_microusd === undefined) throw new Error("--per-job-microusd is required (10000 to 50000000; 1 USD = 1000000)");
87
+ if (price.per_minute_microusd && price.per_minute_microusd < 1000) throw new Error("--per-minute-microusd must be 0 or at least 1000");
88
+ return { spec, price };
89
+ }
90
+
91
+ /**
92
+ * Pure: live_tool listing from tools.json. Returns the billing spec/price and
93
+ * the worker's tool table (name → {description, input_schema, command}).
94
+ */
95
+ export function toolsSpec(cfg, workerId) {
96
+ if (!cfg || typeof cfg !== "object") throw new Error("tools.json must be an object");
97
+ if (!cfg.description) throw new Error("tools.json needs a description");
98
+ if (!Array.isArray(cfg.tools) || !cfg.tools.length || cfg.tools.length > 64) throw new Error("tools.json needs 1 to 64 tools");
99
+ const workerTools = {};
100
+ const tools = cfg.tools.map((t, i) => {
101
+ if (!TOOL_NAME.test(String(t?.name || ""))) throw new Error(`tools[${i}].name must match ${TOOL_NAME}`);
102
+ if (workerTools[t.name]) throw new Error(`duplicate tool name ${t.name}`);
103
+ if (!t.input_schema || typeof t.input_schema !== "object" || Array.isArray(t.input_schema)) throw new Error(`tools[${i}].input_schema must be a JSON schema object`);
104
+ if (!Array.isArray(t.command) || !t.command.length || !t.command.every((a) => typeof a === "string")) throw new Error(`tools[${i}].command must be an argv array (it runs inside the sandbox)`);
105
+ workerTools[t.name] = { description: String(t.description || ""), input_schema: t.input_schema, command: t.command };
106
+ const out = { name: t.name, description: String(t.description || "").slice(0, 1000), input_schema: t.input_schema };
107
+ if (t.timeout_ms !== undefined) out.timeout_ms = t.timeout_ms;
108
+ if (t.output_max_bytes !== undefined) out.output_max_bytes = t.output_max_bytes;
109
+ if (t.idempotent !== undefined) out.idempotent = t.idempotent === true;
110
+ return out;
111
+ });
112
+ const spec = { description: String(cfg.description).slice(0, 2000), worker_id: workerId, tools, egress_allowlist: Array.isArray(cfg.egress_allowlist) ? cfg.egress_allowlist : [] };
113
+ if (cfg.session) spec.session = cfg.session;
114
+ if (cfg.max_concurrent_sessions !== undefined) spec.max_concurrent_sessions = cfg.max_concurrent_sessions;
115
+ const price = cfg.price || {};
116
+ if (!price.per_session_minute_microusd && !Object.keys(price.per_call_microusd || {}).length) throw new Error("tools.json price needs per_call_microusd and/or per_session_minute_microusd");
117
+ return { spec, price, workerTools, visibility: cfg.visibility === "public" ? "public" : "private" };
118
+ }
119
+
120
+ /** Default probe: run the installed agent in probe mode and parse its JSON. */
121
+ export function runProbe({ bin, config, env }) {
122
+ const r = spawnSync(bin, ["-worker", "-worker-probe", "-worker-config", config], { encoding: "utf8", env: childEnv(env), timeout: 120_000 });
123
+ let data = null;
124
+ try { data = JSON.parse(String(r.stdout || "").trim().split("\n").pop()); } catch {}
125
+ if (!data) throw new Error(`isolation probe failed${r.stderr ? `: ${String(r.stderr).trim().split("\n").pop().slice(0, 300)}` : ""}`);
126
+ return { status: r.status, isolation_class: data.isolation_class || "unsupported", measurements: data.measurements || [] };
127
+ }
128
+
129
+ function workerBase(origin, p, sandbox) {
130
+ return { gateway_url: origin, jwks_url: `${origin}/v1/marketplace/keys`, inference_hosts: [new URL(origin).hostname], state_dir: p.stateDir, ...sandbox };
131
+ }
132
+
133
+ /**
134
+ * Probe, enroll (or reuse) the worker and (re)start its service. Returns
135
+ * {workerId, isolationClass, config}. `mutate(cfg)` edits worker.json first.
136
+ */
137
+ export const ROOT_GUIDANCE = "gVisor and Firecracker need root on this machine (cgroup v2 subtree, network namespaces for the egress proxy, /dev/kvm). Re-run with sudo (for example `sudo -E agentcoor list-agent ...`): that installs the system unit /etc/systemd/system/agentcoor-worker.service, which starts at boot with a reduced capability set and a root-owned mode-600 token file. A rootless worker needs a delegated cgroup v2 subtree (systemd Delegate=yes) and a runner that measures as supported without root; see docs/marketplace/AGENT-JOBS.md \"Worker service\".";
138
+
139
+ export async function ensureWorker({ origin, apiKey, flags, env, log, api, platform = process.platform, probe = runProbe, start = startService, ensureBinary = ensureAgentBinary, mutate = (c) => c, rootOwn = rootOwnPrivate, uid = typeof process.getuid === "function" ? process.getuid() : -1 }) {
140
+ if (platform === "darwin") throw new Error(MACOS_REFUSAL);
141
+ if (platform !== "linux") throw new Error(`Marketplace workers need Linux (this is ${platform}).`);
142
+ const p = workerPaths(env, platform);
143
+ await ensurePrivateDir(p.dir);
144
+ const existingText = await readIfExists(p.workerConfig);
145
+ const existing = existingText === null ? null : JSON.parse(existingText);
146
+ let sandbox = {};
147
+ if (flags["sandbox-config"]) sandbox = await loadSandboxConfig(flags["sandbox-config"]);
148
+ else if (existing) for (const k of SANDBOX_KEYS) { if (existing[k] !== undefined) sandbox[k] = existing[k]; }
149
+ else throw new Error("--sandbox-config <file> is required the first time (gvisor/firecracker/podman settings; see docs/marketplace/ISOLATION-EVIDENCE.md)");
150
+
151
+ await ensureBinary({ origin, p, log });
152
+ // 1. Probe with placeholder credentials (the probe measures runners and exits; it never dials).
153
+ await writePrivateFile(p.probeConfig, JSON.stringify({ ...workerBase(origin, p, sandbox), worker_id: "wkr_probe0000", token: "mwk_probe_placeholder_only" }, null, 2) + "\n");
154
+ const measured = await probe({ bin: p.bin, config: p.probeConfig, env });
155
+ log(`==> Measured isolation class: ${measured.isolation_class}`);
156
+ for (const m of measured.measurements || []) log(` ${m.runner}: ${m.supported ? m.class : "unsupported"}${m.reason ? ` (${m.reason})` : ""}`);
157
+ if (measured.isolation_class === "unsupported" || measured.status === 3) {
158
+ throw new Error(`no sandbox runner on this machine measured as supported; fix the sandbox configuration and try again (nothing was enrolled)${uid === 0 ? "" : `. ${ROOT_GUIDANCE}`}`);
159
+ }
160
+ // Root installs a system unit; otherwise the measured runner works rootless and a user unit is used.
161
+ const system = uid === 0;
162
+ log(system ? "==> Running as root: the worker will be a system service (agentcoor-worker.service)" : "==> The sandbox runner works without root: the worker will be a user service");
163
+
164
+ // 2. Enroll once; reuse the worker on later runs (list-tools uses the same worker).
165
+ let workerId = existing?.worker_id;
166
+ if (!WORKER_ID.test(String(workerId || "")) || !(await lstat(p.tokenFile).catch(() => null))) {
167
+ const r = await call(api, origin, apiKey, "/api/v1/workers", { method: "POST", body: {} });
168
+ if (!r.ok) throw new Error(`enroll worker: ${errorMessage(r)}`);
169
+ workerId = r.data?.worker_id;
170
+ const token = r.data?.token;
171
+ if (!WORKER_ID.test(String(workerId || "")) || !WORKER_TOKEN.test(String(token || ""))) throw new Error("the gateway returned no valid worker credential");
172
+ await ensurePrivateDir(dirname(p.tokenFile));
173
+ await atomicWrite(p.tokenFile, `${token}\n`, { mode: 0o600 });
174
+ log(`==> Enrolled worker ${workerId}; token saved to ${p.tokenFile} (mode 600)`);
175
+ } else log(`==> Reusing worker ${workerId}`);
176
+ if (uid === 0) await rootOwn(p.tokenFile);
177
+
178
+ // 3. worker.json (no secrets) and the verified service restart.
179
+ const cfg = mutate({ ...(existing || {}), ...workerBase(origin, p, sandbox), worker_id: workerId, token_file: p.tokenFile });
180
+ delete cfg.token;
181
+ await writePrivateFile(p.workerConfig, JSON.stringify(cfg, null, 2) + "\n");
182
+ log(`==> Worker configuration written to ${p.workerConfig}`);
183
+ await ensurePrivateDir(p.state); await ensurePrivateDir(p.logs);
184
+ await start({ bin: p.bin, config: p.workerConfig, logs: p.logs, state: p.state, log, env, service: WORKER_SERVICE, system });
185
+ return { workerId, isolationClass: measured.isolation_class, config: cfg, paths: p };
186
+ }
187
+
188
+ /** The root worker's token file must be root-owned and 0600 even if a non-root run created it. */
189
+ async function rootOwnPrivate(path) {
190
+ const st = await lstat(path);
191
+ if (!st.isFile()) throw new Error(`${path} is not a regular file`);
192
+ if (st.uid !== 0 || st.gid !== 0) await chown(path, 0, 0);
193
+ if ((st.mode & 0o777) !== 0o600) await chmod(path, 0o600);
194
+ }
195
+
196
+ /** Create + publish a listing, retrying while the worker's hello reaches billing. */
197
+ async function createAndPublish({ api, origin, apiKey, product, visibility, spec, price, log, sleep, tries = 20 }) {
198
+ let r = await call(api, origin, apiKey, "/v1/marketplace/providers", { method: "POST", body: {} });
199
+ if (!r.ok && r.status !== 409) throw new Error(`provider profile: ${errorMessage(r)}`);
200
+ for (let i = 0; ; i++) {
201
+ r = await call(api, origin, apiKey, "/v1/marketplace/listings", { method: "POST", body: { product, visibility, spec, price } });
202
+ if (r.ok) break;
203
+ const code = r.data?.error?.code;
204
+ if ((code === "worker_not_found" || r.status === 404) && i < tries - 1) { await sleep(3000); continue; }
205
+ throw new Error(`create listing: ${errorMessage(r)}${code ? ` (${code})` : ""}`);
206
+ }
207
+ const listingId = r.data?.listing_id || r.data?.id;
208
+ if (!LISTING_ID.test(String(listingId || ""))) throw new Error("billing returned no listing id");
209
+ log(`==> Created ${product} listing ${listingId} (${visibility})`);
210
+ r = await call(api, origin, apiKey, `/v1/marketplace/listings/${listingId}`, { method: "PATCH", body: { action: "publish" } });
211
+ if (!r.ok) throw new Error(`publish ${listingId}: ${errorMessage(r)} (withdraw it with: agentcoor withdraw ${listingId})`);
212
+ log(`==> Published ${listingId}: state ${r.data?.state || r.data?.listing?.state || "probing"}`);
213
+ return listingId;
214
+ }
215
+
216
+ async function recordListing(p, kind, listingId) {
217
+ const text = await readIfExists(p.listings);
218
+ const all = text ? JSON.parse(text) : {};
219
+ all[kind] = listingId;
220
+ await writePrivateFile(p.listings, JSON.stringify(all, null, 2) + "\n");
221
+ }
222
+
223
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
224
+
225
+ export async function listAgent({ origin, apiKey, flags, env = process.env, log = () => {}, api = apiJson, sleep = defaultSleep, ...opts }) {
226
+ agentSpec(flags, "wkr_check"); // validate flags before touching anything
227
+ const w = await ensureWorker({ origin, apiKey, flags, env, log, api, ...opts });
228
+ const { spec, price } = agentSpec(flags, w.workerId);
229
+ const visibility = flags.public ? "public" : "private";
230
+ const listingId = await createAndPublish({ api, origin, apiKey, product: "agent_job", visibility, spec, price, log, sleep });
231
+ await recordListing(w.paths, "agent_job", listingId);
232
+ log(`==> Done. Worker ${w.workerId} (${w.isolationClass}) serves ${listingId}. Renters submit with: agentcoor jobs submit --listing ${listingId} --task "..." --max-spend <microusd>`);
233
+ return { listingId, workerId: w.workerId, isolationClass: w.isolationClass };
234
+ }
235
+
236
+ export async function listTools({ origin, apiKey, flags, env = process.env, log = () => {}, api = apiJson, sleep = defaultSleep, ...opts }) {
237
+ if (!flags["tool-config"]) throw new Error("usage: agentcoor list-tools --tool-config tools.json [--sandbox-config sandbox.json]");
238
+ const raw = await readJsonFile(flags["tool-config"], "--tool-config");
239
+ toolsSpec(raw, "wkr_check");
240
+ let tools;
241
+ const w = await ensureWorker({ origin, apiKey, flags, env, log, api, ...opts, mutate: (c) => { tools = toolsSpec(raw, c.worker_id); return { ...c, tools: { ...(c.tools || {}), ...tools.workerTools } }; } });
242
+ const visibility = flags.public ? "public" : tools.visibility;
243
+ const listingId = await createAndPublish({ api, origin, apiKey, product: "live_tool", visibility, spec: tools.spec, price: tools.price, log, sleep });
244
+ await recordListing(w.paths, "live_tool", listingId);
245
+ log(`==> Done. Worker ${w.workerId} (${w.isolationClass}) serves ${listingId}. Renters open a session with: agentcoor sessions open --listing ${listingId} --max-spend <microusd>`);
246
+ return { listingId, workerId: w.workerId, isolationClass: w.isolationClass };
247
+ }
248
+
249
+ /* ---------------------------------------------------------------- renter */
250
+
251
+ async function taskText(f, readFile) {
252
+ if (f["task-file"]) return String(await readFile(f["task-file"])).trim();
253
+ return f.task ? String(f.task) : "";
254
+ }
255
+
256
+ const pretty = (v) => JSON.stringify(v, null, 2) + "\n";
257
+
258
+ export async function jobsCommand(sub, arg, { origin, apiKey, flags: f, out = () => {}, api = apiJson, readFile }) {
259
+ switch (sub) {
260
+ case "submit": {
261
+ const task = await taskText(f, readFile);
262
+ const spend = intFlag(f["max-spend"], "max-spend", 1, 1_000_000_000_000, undefined);
263
+ if (!LISTING_ID.test(String(f.listing || "")) || !task || spend === undefined) throw new Error("usage: agentcoor jobs submit --listing lst_... (--task <text> | --task-file <path>) --max-spend <microusd>");
264
+ const body = { listing: f.listing, task, attachments: [], max_spend_microusd: spend, idempotency_key: f["idempotency-key"] || `cli-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` };
265
+ const r = await call(api, origin, apiKey, "/v1/marketplace/jobs", { method: "POST", body });
266
+ if (r.status === 402) throw new Error(`jobs submit: not enough paid credit (free credit cannot fund jobs). Add credit at ${origin}`);
267
+ if (!r.ok) throw new Error(`jobs submit: ${errorMessage(r)}`);
268
+ out(`Job ${r.data?.job_id}: ${r.data?.state || "reserved"}, reserved ${r.data?.reserved_microusd} microusd\nFollow it with: agentcoor jobs get ${r.data?.job_id}\n`);
269
+ return 0;
270
+ }
271
+ case "get": case "cancel": case "artifacts": {
272
+ if (!JOB_ID.test(String(arg || ""))) throw new Error(`usage: agentcoor jobs ${sub} job_...`);
273
+ const path = sub === "get" ? `/v1/marketplace/jobs/${arg}` : `/v1/marketplace/jobs/${arg}/${sub}`;
274
+ const r = await call(api, origin, apiKey, path, sub === "cancel" ? { method: "POST", body: {} } : {});
275
+ if (r.status === 404) throw new Error(`job ${arg} not found`);
276
+ if (!r.ok) throw new Error(`jobs ${sub}: ${errorMessage(r)}`);
277
+ out((sub === "cancel" ? "" : THIRD_PARTY_NOTE) + pretty(r.data));
278
+ return 0;
279
+ }
280
+ default:
281
+ throw new Error("usage: agentcoor jobs <submit|get|cancel|artifacts>");
282
+ }
283
+ }
284
+
285
+ export async function sessionsCommand(sub, arg, { origin, apiKey, flags: f, out = () => {}, api = apiJson, readFile }) {
286
+ switch (sub) {
287
+ case "open": {
288
+ const spend = intFlag(f["max-spend"], "max-spend", 1, 1_000_000_000_000, undefined);
289
+ if (!LISTING_ID.test(String(f.listing || "")) || spend === undefined) throw new Error("usage: agentcoor sessions open --listing lst_... --max-spend <microusd>");
290
+ const r = await call(api, origin, apiKey, "/v1/marketplace/sessions", { method: "POST", body: { listing: f.listing, max_spend_microusd: spend }, timeoutMs: 60_000 });
291
+ if (r.status === 402) throw new Error(`sessions open: not enough paid credit. Add credit at ${origin}`);
292
+ if (!r.ok) throw new Error(`sessions open: ${errorMessage(r)}`);
293
+ out(`Session ${r.data?.session_id} open. Tools:\n`);
294
+ for (const t of r.data?.tools || []) out(` ${String(t.name)}\n`);
295
+ out(`Call one with: agentcoor sessions call ${r.data?.session_id} --tool <name> --args '{...}'\n`);
296
+ return 0;
297
+ }
298
+ case "call": {
299
+ if (!SESSION_ID.test(String(arg || "")) || !f.tool) throw new Error("usage: agentcoor sessions call rss_... --tool <name> [--args '<json>' | --args-file <path>]");
300
+ let args = {};
301
+ const rawArgs = f["args-file"] ? String(await readFile(f["args-file"])) : f.args;
302
+ if (rawArgs) { try { args = JSON.parse(rawArgs); } catch (e) { throw new Error(`--args is not valid JSON: ${e.message}`); } }
303
+ const r = await call(api, origin, apiKey, `/v1/marketplace/sessions/${arg}/calls`, { method: "POST", body: { tool: f.tool, arguments: args }, timeoutMs: 150_000 });
304
+ if (r.status === 402) throw new Error("sessions call: the session's reservation cannot cover this call (402); close it and open a new one with a higher --max-spend");
305
+ if (!r.ok) throw new Error(`sessions call: ${errorMessage(r)}`);
306
+ out(THIRD_PARTY_NOTE);
307
+ for (const c of r.data?.content || []) out(c.type === "text" ? `${c.text}\n` : `[${c.type} content]\n`);
308
+ if (r.data?.isError) out("(the tool reported an error)\n");
309
+ return r.data?.isError ? 1 : 0;
310
+ }
311
+ case "close": {
312
+ if (!SESSION_ID.test(String(arg || ""))) throw new Error("usage: agentcoor sessions close rss_...");
313
+ const r = await call(api, origin, apiKey, `/v1/marketplace/sessions/${arg}`, { method: "DELETE" });
314
+ if (!r.ok) throw new Error(`sessions close: ${errorMessage(r)}`);
315
+ out(pretty(r.data));
316
+ return 0;
317
+ }
318
+ default:
319
+ throw new Error("usage: agentcoor sessions <open|call|close>");
320
+ }
321
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "min_epoch": 1794676256
3
+ }
package/src/release.js ADDED
@@ -0,0 +1,184 @@
1
+ // Signed release manifest (release.json + release.json.sig) and rollback protection.
2
+ //
3
+ // release.json is produced and signed by scripts/sign-release.sh:
4
+ // { "schema": 1, "version": "0.1.0", "epoch": 1794600000,
5
+ // "issued_at": "2026-11-13T00:00:00Z", "expires_at": "2027-05-12T00:00:00Z",
6
+ // "artifacts": [ { "platform": "linux-amd64", "name": "agentcoor-agent-linux-amd64", "sha256": "<hex>", "size": 123 }, ... ],
7
+ // "installer": { "name": "install.sh", "sha256": "<hex>", "size": 456 } }
8
+ //
9
+ // A binary is installed only when: the signature verifies against the embedded
10
+ // release key, the manifest has not expired, its epoch is >= MIN_RELEASE_EPOCH
11
+ // AND >= the highest epoch this machine has accepted before (kept in a private
12
+ // 0600 state file shared with install.sh), and the binary matches the listed
13
+ // size and SHA-256. SHA256SUMS may still be published for humans but is never trusted.
14
+ import { open, lstat, unlink } from "node:fs/promises";
15
+ import { constants, readFileSync } from "node:fs";
16
+ import { timingSafeEqual } from "node:crypto";
17
+ import { join } from "node:path";
18
+ import { verifyReleaseSignature, RELEASE_PUBLIC_KEY } from "./signature.js";
19
+ import { sha256Hex } from "./sha.js";
20
+ import { atomicWrite, ensurePrivateDir } from "./fsx.js";
21
+
22
+ /**
23
+ * Lowest epoch this CLI accepts. `make release` stamps the epoch it signs into
24
+ * src/release-floor.json (and install.sh), so every published CLI version refuses
25
+ * any release older than the one current when it was published, even on a fresh
26
+ * machine with no install state.
27
+ */
28
+ export function loadReleaseFloor(text = readFileSync(new URL("./release-floor.json", import.meta.url), "utf8")) {
29
+ const f = JSON.parse(text);
30
+ if (!f || !Number.isSafeInteger(f.min_epoch) || f.min_epoch < 1) throw new Error("release-floor.json is invalid");
31
+ return f.min_epoch;
32
+ }
33
+ export const MIN_RELEASE_EPOCH = loadReleaseFloor();
34
+ export const PLATFORMS = ["darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64"];
35
+ export const artifactName = (platform) => `agentcoor-agent-${platform}`;
36
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
37
+ const CLOCK_SKEW_MS = 24 * 3600 * 1000;
38
+
39
+ function parseTime(v, field) {
40
+ if (typeof v !== "string" || !ISO.test(v)) throw new Error(`release.json: ${field} is not an RFC 3339 UTC timestamp`);
41
+ const t = Date.parse(v);
42
+ if (!Number.isFinite(t)) throw new Error(`release.json: ${field} is invalid`);
43
+ return t;
44
+ }
45
+
46
+ /**
47
+ * Verify the signature over the exact manifest bytes, then its structure, expiry and epoch.
48
+ * Returns the parsed manifest. Throws (fails closed) on anything unexpected.
49
+ */
50
+ export function verifyManifest(manifestBuf, sigBuf, { publicKey = RELEASE_PUBLIC_KEY, minEpoch = MIN_RELEASE_EPOCH, acceptedEpoch = 0, now = Date.now() } = {}) {
51
+ verifyReleaseSignature(manifestBuf, sigBuf, publicKey, "release.json");
52
+ let m;
53
+ try { m = JSON.parse(Buffer.from(manifestBuf).toString("utf8")); } catch { throw new Error("release.json is not valid JSON: refusing to install"); }
54
+ if (!m || typeof m !== "object" || Array.isArray(m) || m.schema !== 1) throw new Error("release.json has an unsupported schema: refusing to install");
55
+ if (typeof m.version !== "string" || !/^[0-9A-Za-z.+-]{1,64}$/.test(m.version)) throw new Error("release.json: invalid version");
56
+ if (!Number.isSafeInteger(m.epoch) || m.epoch < 1) throw new Error("release.json: invalid epoch");
57
+ const issued = parseTime(m.issued_at, "issued_at"), expires = parseTime(m.expires_at, "expires_at");
58
+ if (expires <= issued) throw new Error("release.json: expires_at is before issued_at");
59
+ if (issued > now + CLOCK_SKEW_MS) throw new Error("release.json is issued in the future (check this machine's clock): refusing to install");
60
+ if (now >= expires) throw new Error(`release.json expired at ${m.expires_at}: refusing to install a stale release (it may be a rollback)`);
61
+ if (m.epoch < minEpoch) throw new Error(`release epoch ${m.epoch} is older than the minimum ${minEpoch} this CLI accepts: refusing to install`);
62
+ if (m.epoch < acceptedEpoch) throw new Error(`release epoch ${m.epoch} is older than epoch ${acceptedEpoch} already installed on this machine: refusing a rollback`);
63
+ if (!Array.isArray(m.artifacts) || m.artifacts.length !== PLATFORMS.length) throw new Error("release.json: unexpected artifact list");
64
+ const seen = new Set();
65
+ for (const a of m.artifacts) {
66
+ if (!a || !PLATFORMS.includes(a.platform) || a.name !== artifactName(a.platform) || seen.has(a.platform)) throw new Error("release.json: unexpected artifact entry");
67
+ if (typeof a.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(a.sha256)) throw new Error(`release.json: invalid sha256 for ${a.name}`);
68
+ if (!Number.isSafeInteger(a.size) || a.size < 1) throw new Error(`release.json: invalid size for ${a.name}`);
69
+ seen.add(a.platform);
70
+ }
71
+ if (m.installer !== undefined) {
72
+ const i = m.installer;
73
+ if (!i || typeof i !== "object" || i.name !== "install.sh" || typeof i.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(i.sha256) || !Number.isSafeInteger(i.size) || i.size < 1)
74
+ throw new Error("release.json: invalid installer entry");
75
+ }
76
+ return m;
77
+ }
78
+
79
+ /** The manifest entry for `name`, or a fail-closed error. */
80
+ export function artifactFor(manifest, name) {
81
+ const a = manifest.artifacts.find((x) => x.name === name);
82
+ if (!a) throw new Error(`release.json lists no artifact named ${name}`);
83
+ return a;
84
+ }
85
+
86
+ /** The signed install.sh entry ({name, sha256, size}), or a fail-closed error. */
87
+ export function installerFor(manifest) {
88
+ if (!manifest.installer) throw new Error("release.json does not list install.sh: refusing to trust it");
89
+ return manifest.installer;
90
+ }
91
+
92
+ /** Check a downloaded binary against its manifest entry. Returns the hex digest. */
93
+ export function verifyArtifact(buf, entry) {
94
+ if (buf.length !== entry.size) throw new Error(`size mismatch for ${entry.name}: refusing to install`);
95
+ const got = sha256Hex(buf);
96
+ if (!timingSafeEqual(Buffer.from(got), Buffer.from(entry.sha256))) throw new Error(`checksum mismatch for ${entry.name}: refusing to install`);
97
+ return got;
98
+ }
99
+
100
+ export const releaseStatePath = (base) => join(base, "release-state.json");
101
+
102
+ /**
103
+ * Highest epoch previously accepted on this machine. Missing file = 0. A state file
104
+ * that is a symlink, not ours, not mode 600, oversized or malformed is an error
105
+ * (refusing is safer than silently resetting rollback protection).
106
+ */
107
+ export async function readAcceptedEpoch(base) {
108
+ const p = releaseStatePath(base);
109
+ let fh;
110
+ try { fh = await open(p, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0)); }
111
+ catch (e) { if (e.code === "ENOENT") return 0; throw new Error(`cannot read ${p}: ${e.code || e.message}`); }
112
+ try {
113
+ const st = await fh.stat();
114
+ if (!st.isFile() || st.size > 4096) throw new Error(`${p} is not a small regular file`);
115
+ if (typeof process.getuid === "function" && st.uid !== process.getuid()) throw new Error(`${p} is not owned by you`);
116
+ if (st.mode & 0o077) throw new Error(`${p} must be mode 600`);
117
+ const buf = Buffer.alloc(4097);
118
+ const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
119
+ const s = JSON.parse(buf.subarray(0, bytesRead).toString("utf8"));
120
+ if (!s || !Number.isSafeInteger(s.epoch) || s.epoch < 0) throw new Error("bad epoch");
121
+ return s.epoch;
122
+ } catch (e) {
123
+ throw new Error(`${p} is unreadable (${e.message}); remove it only if you trust the current release`);
124
+ } finally { await fh.close(); }
125
+ }
126
+
127
+ /** Persist max(previous, epoch) in the private state file (0600, atomic). Call it under withInstallLock: the previous value is re-read there and never lowered. */
128
+ export async function recordAcceptedEpoch(base, epoch) {
129
+ await ensurePrivateDir(base);
130
+ const prev = await readAcceptedEpoch(base);
131
+ if (epoch <= prev) return prev;
132
+ await atomicWrite(releaseStatePath(base), JSON.stringify({ epoch }) + "\n", { mode: 0o600 });
133
+ return epoch;
134
+ }
135
+
136
+ export const installLockPath = (base) => join(base, "install.lock");
137
+
138
+ /**
139
+ * Run `fn` holding the per-install-dir lock: `<base>/install.lock`, created with
140
+ * O_CREAT|O_EXCL|O_NOFOLLOW and holding the owner's PID. install.sh uses the same
141
+ * file and protocol. A lock whose PID is no longer running is stale and removed
142
+ * (only if it is still the same file we inspected); a live holder is waited for up
143
+ * to `timeoutMs`, then the install fails rather than racing it.
144
+ */
145
+ export async function withInstallLock(base, fn, { timeoutMs = 120_000, pollMs = 200, pid = process.pid } = {}) {
146
+ await ensurePrivateDir(base);
147
+ const p = installLockPath(base);
148
+ const deadline = Date.now() + timeoutMs;
149
+ for (;;) {
150
+ let fh;
151
+ try { fh = await open(p, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600); }
152
+ catch (e) {
153
+ if (e.code !== "EEXIST") throw new Error(`cannot create ${p}: ${e.code || e.message}`);
154
+ if (await removeStaleLock(p)) continue;
155
+ if (Date.now() >= deadline) throw new Error(`another AgentCoor install is running (${p}); wait for it to finish, or remove the file if no install is running`);
156
+ await new Promise((r) => setTimeout(r, pollMs));
157
+ continue;
158
+ }
159
+ try { await fh.writeFile(`${pid}\n`); await fh.sync(); } finally { await fh.close(); }
160
+ try { return await fn(); }
161
+ finally { await unlink(p).catch(() => {}); }
162
+ }
163
+ }
164
+
165
+ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
166
+
167
+ /** Remove `p` if it names a dead (or unparseable, and older than 10 s) holder. Returns true if removed. */
168
+ async function removeStaleLock(p) {
169
+ let fh;
170
+ try { fh = await open(p, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0)); }
171
+ catch (e) { if (e.code === "ENOENT") return true; throw new Error(`${p} is not a regular lock file (${e.code}); remove it`); }
172
+ let st, text;
173
+ try {
174
+ st = await fh.stat();
175
+ if (!st.isFile() || st.size > 32) throw new Error(`${p} is not a regular lock file; remove it`);
176
+ text = (await fh.readFile("utf8")).trim();
177
+ } finally { await fh.close(); }
178
+ const pid = /^[1-9][0-9]{0,9}$/.test(text) ? Number(text) : 0;
179
+ if (pid ? pidAlive(pid) : Date.now() - st.mtimeMs < 10_000) return false;
180
+ const now = await lstat(p).catch(() => null);
181
+ if (!now || now.ino !== st.ino || now.dev !== st.dev) return !now;
182
+ await unlink(p).catch(() => {});
183
+ return true;
184
+ }
package/src/sha.js ADDED
@@ -0,0 +1,24 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+
3
+ export function sha256Hex(buf) { return createHash("sha256").update(buf).digest("hex"); }
4
+
5
+ /** Find the checksum for `name` in a SHA256SUMS file (accepts "hash name" and "hash *name"). */
6
+ export function expectedSum(sumsText, name) {
7
+ let found = null;
8
+ for (const line of String(sumsText).split(/\r?\n/)) {
9
+ const m = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(\S+)$/);
10
+ if (!m || m[2] !== name) continue;
11
+ if (found && found !== m[1].toLowerCase()) throw new Error(`conflicting checksums published for ${name}`);
12
+ found = m[1].toLowerCase();
13
+ }
14
+ return found;
15
+ }
16
+
17
+ /** Fail closed: throws unless a checksum is published for `name` and matches `buf`. */
18
+ export function verifySha256(buf, sumsText, name) {
19
+ const want = expectedSum(sumsText, name);
20
+ if (!want) throw new Error(`no checksum published for ${name}`);
21
+ const got = sha256Hex(buf);
22
+ if (!timingSafeEqual(Buffer.from(got), Buffer.from(want))) throw new Error(`checksum mismatch for ${name}`);
23
+ return got;
24
+ }