agenttwin 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -27
- package/dist/api.js +659 -0
- package/dist/cli.js +5165 -57
- package/dist/dashboard/assets/brand.css +2 -0
- package/dist/dashboard/assets/dashboard/dashboard.css +184 -0
- package/dist/dashboard/assets/dashboard/dashboard.js +421 -0
- package/dist/dashboard/assets/dashboard/hosted.js +45 -0
- package/dist/dashboard/assets/dashboard/inventory.json +1 -0
- package/dist/dashboard/assets/dashboard/runs.json +1 -0
- package/dist/dashboard/assets/fonts/Syne-OFL.txt +93 -0
- package/dist/dashboard/assets/fonts/inter-var.woff2 +0 -0
- package/dist/dashboard/assets/fonts/plexmono-400.woff2 +0 -0
- package/dist/dashboard/assets/fonts/plexmono-500.woff2 +0 -0
- package/dist/dashboard/assets/fonts/syne-400.ttf +0 -0
- package/dist/dashboard/assets/fonts/syne-500.ttf +0 -0
- package/dist/dashboard/assets/fonts/syne-600.ttf +0 -0
- package/dist/dashboard/assets/icons/ATTRIBUTION.md +7 -0
- package/dist/dashboard/assets/icons/attio.ico +0 -0
- package/dist/dashboard/assets/icons/aws.svg +1 -0
- package/dist/dashboard/assets/icons/bank.svg +5 -0
- package/dist/dashboard/assets/icons/box.svg +1 -0
- package/dist/dashboard/assets/icons/chat.svg +3 -0
- package/dist/dashboard/assets/icons/check.png +0 -0
- package/dist/dashboard/assets/icons/ci.svg +5 -0
- package/dist/dashboard/assets/icons/clay.png +0 -0
- package/dist/dashboard/assets/icons/cloudflare.svg +1 -0
- package/dist/dashboard/assets/icons/co-abridge.png +0 -0
- package/dist/dashboard/assets/icons/co-clay.png +0 -0
- package/dist/dashboard/assets/icons/co-cognition.png +0 -0
- package/dist/dashboard/assets/icons/co-intercom.svg +1 -0
- package/dist/dashboard/assets/icons/co-paloalto.png +0 -0
- package/dist/dashboard/assets/icons/co-pylon.png +0 -0
- package/dist/dashboard/assets/icons/co-ramp.png +0 -0
- package/dist/dashboard/assets/icons/co-sentry.svg +1 -0
- package/dist/dashboard/assets/icons/codes.svg +5 -0
- package/dist/dashboard/assets/icons/datadog.svg +1 -0
- package/dist/dashboard/assets/icons/discord.svg +1 -0
- package/dist/dashboard/assets/icons/docker.svg +1 -0
- package/dist/dashboard/assets/icons/documenso.png +0 -0
- package/dist/dashboard/assets/icons/dropbox.svg +1 -0
- package/dist/dashboard/assets/icons/edr.svg +4 -0
- package/dist/dashboard/assets/icons/ehr.svg +5 -0
- package/dist/dashboard/assets/icons/git.svg +1 -0
- package/dist/dashboard/assets/icons/github.svg +1 -0
- package/dist/dashboard/assets/icons/githubactions.svg +1 -0
- package/dist/dashboard/assets/icons/gitlab.svg +1 -0
- package/dist/dashboard/assets/icons/gmail.svg +7 -0
- package/dist/dashboard/assets/icons/google-calendar.svg +28 -0
- package/dist/dashboard/assets/icons/google.svg +1 -0
- package/dist/dashboard/assets/icons/googlecloud.svg +1 -0
- package/dist/dashboard/assets/icons/googledocs.svg +1 -0
- package/dist/dashboard/assets/icons/googledrive.svg +1 -0
- package/dist/dashboard/assets/icons/googlesheets.svg +1 -0
- package/dist/dashboard/assets/icons/hubspot.svg +1 -0
- package/dist/dashboard/assets/icons/intercom.svg +1 -0
- package/dist/dashboard/assets/icons/jira.svg +1 -0
- package/dist/dashboard/assets/icons/klaviyo.png +0 -0
- package/dist/dashboard/assets/icons/kubernetes.svg +1 -0
- package/dist/dashboard/assets/icons/linear.svg +1 -0
- package/dist/dashboard/assets/icons/linkedin.svg +1 -0
- package/dist/dashboard/assets/icons/mail.svg +4 -0
- package/dist/dashboard/assets/icons/mixpanel.svg +1 -0
- package/dist/dashboard/assets/icons/mongodb.svg +1 -0
- package/dist/dashboard/assets/icons/netsuite.png +0 -0
- package/dist/dashboard/assets/icons/nodedotjs.svg +1 -0
- package/dist/dashboard/assets/icons/notes.svg +5 -0
- package/dist/dashboard/assets/icons/notion.svg +1 -0
- package/dist/dashboard/assets/icons/npm.svg +1 -0
- package/dist/dashboard/assets/icons/okta.svg +1 -0
- package/dist/dashboard/assets/icons/openai.svg +1 -0
- package/dist/dashboard/assets/icons/outlook.ico +0 -0
- package/dist/dashboard/assets/icons/pagerduty.svg +1 -0
- package/dist/dashboard/assets/icons/policy.svg +5 -0
- package/dist/dashboard/assets/icons/postgres.svg +1 -0
- package/dist/dashboard/assets/icons/python.svg +1 -0
- package/dist/dashboard/assets/icons/quickbooks.svg +1 -0
- package/dist/dashboard/assets/icons/redis.svg +1 -0
- package/dist/dashboard/assets/icons/resend.svg +1 -0
- package/dist/dashboard/assets/icons/salesforce.svg +1 -0
- package/dist/dashboard/assets/icons/sentry.svg +1 -0
- package/dist/dashboard/assets/icons/shopify.svg +1 -0
- package/dist/dashboard/assets/icons/siem.svg +4 -0
- package/dist/dashboard/assets/icons/slack.svg +6 -0
- package/dist/dashboard/assets/icons/snowflake.svg +1 -0
- package/dist/dashboard/assets/icons/sparkle.svg +4 -0
- package/dist/dashboard/assets/icons/stripe.svg +1 -0
- package/dist/dashboard/assets/icons/supabase.svg +1 -0
- package/dist/dashboard/assets/icons/terraform.svg +1 -0
- package/dist/dashboard/assets/icons/trolley.ico +0 -0
- package/dist/dashboard/assets/icons/twilio.svg +1 -0
- package/dist/dashboard/assets/icons/ui/arrow-right.svg +1 -0
- package/dist/dashboard/assets/icons/ui/arrows-clockwise.svg +1 -0
- package/dist/dashboard/assets/icons/ui/arrows-left-right.svg +1 -0
- package/dist/dashboard/assets/icons/ui/caret-down.svg +1 -0
- package/dist/dashboard/assets/icons/ui/check.svg +1 -0
- package/dist/dashboard/assets/icons/ui/clock.svg +1 -0
- package/dist/dashboard/assets/icons/ui/cube.svg +1 -0
- package/dist/dashboard/assets/icons/ui/git-branch.svg +1 -0
- package/dist/dashboard/assets/icons/ui/github-logo.svg +1 -0
- package/dist/dashboard/assets/icons/ui/magnifying-glass.svg +1 -0
- package/dist/dashboard/assets/icons/ui/plugs-connected.svg +1 -0
- package/dist/dashboard/assets/icons/ui/seal-check.svg +1 -0
- package/dist/dashboard/assets/icons/ui/shield-check.svg +1 -0
- package/dist/dashboard/assets/icons/ui/stack-simple.svg +1 -0
- package/dist/dashboard/assets/icons/ui/terminal.svg +1 -0
- package/dist/dashboard/assets/icons/ui/warning-circle.svg +1 -0
- package/dist/dashboard/assets/icons/ui/x.svg +1 -0
- package/dist/dashboard/assets/icons/unified.png +0 -0
- package/dist/dashboard/assets/icons/unified.svg +23 -0
- package/dist/dashboard/assets/icons/unstructured.svg +3 -0
- package/dist/dashboard/assets/icons/vercel.svg +1 -0
- package/dist/dashboard/assets/icons/waterfall.jpg +0 -0
- package/dist/dashboard/assets/icons/xero.svg +1 -0
- package/dist/dashboard/assets/logo/favicon.svg +7 -0
- package/dist/dashboard/assets/logo/mark-stacked-worlds.svg +4 -0
- package/dist/dashboard/assets/logo/mark-twin-rings.svg +7 -0
- package/dist/dashboard/assets/logo/mark-worlds-overlap.svg +10 -0
- package/dist/dashboard/index.html +64 -0
- package/dist/gateway.js +666 -0
- package/dist/worker.js +341 -0
- package/package.json +8 -3
package/dist/api.js
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/runtime/gateway.ts
|
|
4
|
+
import { createServer } from "http";
|
|
5
|
+
import { createHash as createHash2, randomBytes as randomBytes2, randomUUID as randomUUID2, timingSafeEqual } from "crypto";
|
|
6
|
+
import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2, rename, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
|
|
7
|
+
import { resolve as resolve2, join as join4, dirname as dirname2, extname } from "path";
|
|
8
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
9
|
+
|
|
10
|
+
// src/runtime/spec.ts
|
|
11
|
+
var object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
12
|
+
var namePattern = /^[a-z][a-z0-9_]{0,39}$/;
|
|
13
|
+
function parseWorld(value) {
|
|
14
|
+
const invalid = (message) => {
|
|
15
|
+
throw new Error(`Invalid world: ${message}`);
|
|
16
|
+
};
|
|
17
|
+
if (!object(value) || value.schema !== "agenttwin/world-v1") invalid("schema must be agenttwin/world-v1");
|
|
18
|
+
const v = value;
|
|
19
|
+
if (typeof v.name !== "string" || !namePattern.test(v.name)) invalid("name must be lowercase letters, digits or underscores");
|
|
20
|
+
if (!Array.isArray(v.files) || !v.files.length || v.files.length > 100 || !v.files.every((f) => typeof f === "string" && f.length > 0)) invalid("files must explicitly list 1\u2013100 application files");
|
|
21
|
+
if (new Set(v.files).size !== v.files.length) invalid("files must be unique");
|
|
22
|
+
if (!Array.isArray(v.command) || !v.command.length || !v.command.every((c) => typeof c === "string" && c.length > 0) || v.command[0] !== "node") invalid("command must be an argv array starting with node; supply a bundled Node application");
|
|
23
|
+
if (!Number.isInteger(v.timeoutSeconds) || Number(v.timeoutSeconds) < 1 || Number(v.timeoutSeconds) > 300) invalid("timeoutSeconds must be 1\u2013300");
|
|
24
|
+
if (v.workflowId !== void 0 && (typeof v.workflowId !== "string" || !v.workflowId)) invalid("workflowId must be a nonempty string");
|
|
25
|
+
if (!Array.isArray(v.services) || !v.services.length || v.services.length > 10) invalid("services must contain 1\u201310 HTTP services");
|
|
26
|
+
const services = /* @__PURE__ */ new Map();
|
|
27
|
+
for (const service of v.services) {
|
|
28
|
+
if (!object(service) || typeof service.name !== "string" || !namePattern.test(service.name) || services.has(service.name)) invalid("service names must be unique lowercase identifiers");
|
|
29
|
+
const item = service;
|
|
30
|
+
if (!Array.isArray(item.collections) || !item.collections.length || item.collections.length > 20) invalid("each service needs 1\u201320 collections");
|
|
31
|
+
if (item.adapter !== void 0 && !["json-collection", "slack"].includes(String(item.adapter))) invalid("unsupported service adapter");
|
|
32
|
+
const paths = /* @__PURE__ */ new Set();
|
|
33
|
+
for (const entry of item.collections) {
|
|
34
|
+
if (!object(entry) || typeof entry.path !== "string" || !/^\/[a-zA-Z0-9_/-]+$/.test(entry.path) || entry.path.includes("//") || paths.has(entry.path)) invalid("collection paths must be unique absolute HTTP paths");
|
|
35
|
+
const collection = entry;
|
|
36
|
+
if (!Array.isArray(collection.seed) || collection.seed.length > 1e3 || !collection.seed.every(object)) invalid("seed must be an array of up to 1000 JSON records");
|
|
37
|
+
paths.add(collection.path);
|
|
38
|
+
}
|
|
39
|
+
if (item.adapter === "slack" && (!paths.has("/messages") || !paths.has("/channels") || paths.size !== 2)) invalid("slack adapter requires /messages and /channels collections");
|
|
40
|
+
services.set(item.name, paths);
|
|
41
|
+
}
|
|
42
|
+
const known = (entry) => typeof entry.service === "string" && typeof entry.path === "string" && services.get(entry.service)?.has(entry.path);
|
|
43
|
+
if (!Array.isArray(v.assertions) || !v.assertions.length || v.assertions.length > 100) invalid("at least one final-state assertion is required (maximum 100)");
|
|
44
|
+
for (const assertion of v.assertions) {
|
|
45
|
+
if (!object(assertion) || !known(assertion) || !Number.isSafeInteger(assertion.count) || Number(assertion.count) < 0 || assertion.where !== void 0 && !object(assertion.where)) invalid("assertions need an existing service/path, nonnegative count, and optional where object");
|
|
46
|
+
}
|
|
47
|
+
if (v.postgres !== void 0) {
|
|
48
|
+
if (!object(v.postgres) || v.postgres.seedFile !== void 0 && !v.files.includes(String(v.postgres.seedFile)) || !Array.isArray(v.postgres.assertions) || !v.postgres.assertions.length || v.postgres.assertions.length > 100) invalid("postgres requires assertions and seedFile must be listed in files");
|
|
49
|
+
for (const a of v.postgres.assertions) {
|
|
50
|
+
if (!object(a) || typeof a.query !== "string" || !/^select\s/i.test(a.query) || a.query.includes(";") || !Array.isArray(a.equals) || !a.equals.every(object)) invalid("postgres assertions require a SELECT query without semicolons and an equals array");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (v.redis !== void 0) {
|
|
54
|
+
if (!object(v.redis) || !Array.isArray(v.redis.assertions) || !v.redis.assertions.length || v.redis.assertions.length > 100) invalid("redis requires assertions");
|
|
55
|
+
for (const a of v.redis.assertions) {
|
|
56
|
+
if (!object(a) || typeof a.key !== "string" || !a.key || a.equals !== null && typeof a.equals !== "string") invalid("redis assertions require key and string/null equals");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (v.fault !== void 0 && (!object(v.fault) || !known(v.fault) || v.fault.type !== "timeout-after-write" || !Number.isInteger(v.fault.delayMs) || Number(v.fault.delayMs) < 1 || Number(v.fault.delayMs) > 1e4)) invalid("fault requires an existing service/path and delayMs 1\u201310000");
|
|
60
|
+
const keys = ["schema", "name", "workflowId", "files", "command", "timeoutSeconds", "services", "assertions", "postgres", "redis", "fault"];
|
|
61
|
+
if (Object.keys(v).some((k) => !keys.includes(k))) invalid("unknown world field");
|
|
62
|
+
return v;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/commands/run.ts
|
|
66
|
+
import { mkdtemp, mkdir, readFile as readFile3, writeFile, realpath, lstat, rm, copyFile } from "fs/promises";
|
|
67
|
+
import { tmpdir } from "os";
|
|
68
|
+
import { dirname, isAbsolute, join as join3, relative, resolve, sep } from "path";
|
|
69
|
+
import { fileURLToPath } from "url";
|
|
70
|
+
import { createHash, randomUUID } from "crypto";
|
|
71
|
+
|
|
72
|
+
// src/runtime/remote.ts
|
|
73
|
+
import { readFile } from "fs/promises";
|
|
74
|
+
import { join } from "path";
|
|
75
|
+
|
|
76
|
+
// src/lib/exit.ts
|
|
77
|
+
var EXIT = {
|
|
78
|
+
OK: 0,
|
|
79
|
+
ERROR: 1,
|
|
80
|
+
BLOCKED: 2,
|
|
81
|
+
USAGE: 3
|
|
82
|
+
};
|
|
83
|
+
var CliError = class extends Error {
|
|
84
|
+
code;
|
|
85
|
+
action;
|
|
86
|
+
resume;
|
|
87
|
+
constructor(message, opts = {}) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = "CliError";
|
|
90
|
+
this.code = opts.code ?? EXIT.ERROR;
|
|
91
|
+
this.action = opts.action;
|
|
92
|
+
this.resume = opts.resume;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/runtime/remote.ts
|
|
97
|
+
function apiBase(value) {
|
|
98
|
+
let url;
|
|
99
|
+
try {
|
|
100
|
+
url = new URL(value);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new CliError("Invalid AGENTTWIN_API_URL", { code: EXIT.USAGE });
|
|
103
|
+
}
|
|
104
|
+
if (url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) throw new CliError("AgentTwin API requires HTTPS (loopback HTTP allowed for development)", { code: EXIT.USAGE });
|
|
105
|
+
return value.replace(/\/$/, "");
|
|
106
|
+
}
|
|
107
|
+
async function executeRemote(stage, spec, log) {
|
|
108
|
+
const base = apiBase(process.env.AGENTTWIN_API_URL ?? "");
|
|
109
|
+
const token = process.env.AGENTTWIN_TOKEN;
|
|
110
|
+
if (!token) throw new CliError("AGENTTWIN_TOKEN is not configured", { code: EXIT.BLOCKED });
|
|
111
|
+
const files = /* @__PURE__ */ Object.create(null);
|
|
112
|
+
for (const file of spec.files) files[file] = (await readFile(join(stage, "app", file))).toString("base64");
|
|
113
|
+
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
114
|
+
const response = await fetch(`${base}/v1/runs`, { method: "POST", headers, body: JSON.stringify({ spec, files }), redirect: "error", signal: AbortSignal.timeout(15e3) });
|
|
115
|
+
if (!response.ok) throw new CliError(`AgentTwin API rejected run (${response.status}): ${await response.text()}`, { code: response.status === 401 || response.status === 429 ? EXIT.BLOCKED : EXIT.ERROR });
|
|
116
|
+
const job = await response.json();
|
|
117
|
+
if (!/^[a-f0-9-]{36}$/.test(job.id)) throw new CliError("API returned an invalid run ID");
|
|
118
|
+
log(`Hosted run queued: ${job.id}`);
|
|
119
|
+
const deadline = Date.now() + (spec.timeoutSeconds + 150) * 1e3;
|
|
120
|
+
while (Date.now() < deadline) {
|
|
121
|
+
const result = await fetch(`${base}/v1/runs/${job.id}`, { headers, redirect: "error", signal: AbortSignal.timeout(15e3) });
|
|
122
|
+
if (!result.ok) throw new CliError(`Cannot read hosted run (${result.status})`);
|
|
123
|
+
const state = await result.json();
|
|
124
|
+
if (state.receipt) {
|
|
125
|
+
if (state.receipt.apiJobId !== job.id) throw new CliError("Hosted receipt belongs to a different API job");
|
|
126
|
+
return state.receipt;
|
|
127
|
+
}
|
|
128
|
+
if (["error", "cancelled"].includes(state.status)) throw new CliError(state.error ?? "Hosted runtime failed");
|
|
129
|
+
await new Promise((resolve3) => setTimeout(resolve3, 1e3));
|
|
130
|
+
}
|
|
131
|
+
throw new CliError(`Timed out waiting for hosted run ${job.id}; inspect this run in the API before retrying`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/runtime/e2b.ts
|
|
135
|
+
import { Sandbox } from "e2b";
|
|
136
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
137
|
+
import { randomBytes } from "crypto";
|
|
138
|
+
import { homedir } from "os";
|
|
139
|
+
import { join as join2 } from "path";
|
|
140
|
+
async function executeE2B(stage, spec, log, signal) {
|
|
141
|
+
signal?.throwIfAborted();
|
|
142
|
+
const apiKey = process.env.E2B_API_KEY;
|
|
143
|
+
if (!apiKey) throw new CliError("E2B_API_KEY is not configured", { code: EXIT.BLOCKED, action: "Set E2B_API_KEY in your shell. It remains on the host and is never passed to the application." });
|
|
144
|
+
let saved = {};
|
|
145
|
+
try {
|
|
146
|
+
saved = JSON.parse(await readFile2(join2(homedir(), ".agenttwin/runtime.json"), "utf8"));
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
const template = process.env.E2B_TEMPLATE_ID ?? saved.templateId;
|
|
150
|
+
if (!template) throw new CliError("No hosted runtime template is configured", { code: EXIT.BLOCKED, action: "Run agenttwin setup once to build the E2B Node/PostgreSQL/Redis template." });
|
|
151
|
+
log(`Creating E2B sandbox from ${template}. Only the files listed in the world specification will be uploaded; outbound internet is disabled.`);
|
|
152
|
+
const sandbox = await Sandbox.create(template, { apiKey, timeoutMs: (spec.timeoutSeconds + 120) * 1e3, allowInternetAccess: false, network: { allowPublicTraffic: false, denyOut: ["0.0.0.0/0"] }, metadata: { product: "agenttwin", world: spec.name } });
|
|
153
|
+
const secrets = [apiKey];
|
|
154
|
+
const cleanup = () => {
|
|
155
|
+
void sandbox.kill().catch(() => {
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
process.once("SIGINT", cleanup);
|
|
159
|
+
process.once("SIGTERM", cleanup);
|
|
160
|
+
signal?.addEventListener("abort", cleanup, { once: true });
|
|
161
|
+
try {
|
|
162
|
+
signal?.throwIfAborted();
|
|
163
|
+
await sandbox.commands.run("mkdir -p /input/app && chmod 755 /input /input/app", { user: "root" });
|
|
164
|
+
for (const file of ["world.json", "worker.js", ...spec.files.map((f) => `app/${f}`)]) {
|
|
165
|
+
await sandbox.files.write(`/input/${file}`, new Uint8Array(await readFile2(join2(stage, file))).buffer, { user: "root" });
|
|
166
|
+
}
|
|
167
|
+
await sandbox.commands.run("chmod -R a+rX /input", { user: "root" });
|
|
168
|
+
const runtimeEnv = {};
|
|
169
|
+
if (spec.postgres) {
|
|
170
|
+
const password = randomBytes(24).toString("hex");
|
|
171
|
+
secrets.push(password);
|
|
172
|
+
await sandbox.commands.run("pg_ctlcluster --skip-systemctl-redirect 15 main start -o '-c ssl=off -c listen_addresses=127.0.0.1'", { user: "root" });
|
|
173
|
+
await sandbox.files.write("/tmp/agenttwin-init.sql", `CREATE ROLE agenttwin LOGIN PASSWORD '${password}';
|
|
174
|
+
CREATE DATABASE agenttwin OWNER agenttwin;
|
|
175
|
+
`, { user: "root" });
|
|
176
|
+
await sandbox.commands.run("chmod 644 /tmp/agenttwin-init.sql && runuser -u postgres -- psql -v ON_ERROR_STOP=1 -f /tmp/agenttwin-init.sql && rm /tmp/agenttwin-init.sql", { user: "root" });
|
|
177
|
+
runtimeEnv.DATABASE_URL = `postgresql://agenttwin:${password}@127.0.0.1:5432/agenttwin`;
|
|
178
|
+
if (spec.postgres.seedFile) {
|
|
179
|
+
const quote = (s) => "'" + s.replaceAll("'", `'"'"'`) + "'";
|
|
180
|
+
await sandbox.commands.run(`psql -v ON_ERROR_STOP=1 -f ${quote("/input/app/" + spec.postgres.seedFile)}`, { user: "node", envs: { PGHOST: "127.0.0.1", PGUSER: "agenttwin", PGDATABASE: "agenttwin", PGPASSWORD: password } });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (spec.redis) {
|
|
184
|
+
const password = randomBytes(24).toString("hex");
|
|
185
|
+
secrets.push(password);
|
|
186
|
+
await sandbox.files.write("/tmp/agenttwin-redis.conf", `bind 127.0.0.1
|
|
187
|
+
port 6379
|
|
188
|
+
requirepass ${password}
|
|
189
|
+
save ""
|
|
190
|
+
appendonly no
|
|
191
|
+
daemonize yes
|
|
192
|
+
logfile /tmp/agenttwin-redis.log
|
|
193
|
+
`, { user: "root" });
|
|
194
|
+
await sandbox.commands.run("redis-server /tmp/agenttwin-redis.conf", { user: "root" });
|
|
195
|
+
runtimeEnv.REDIS_URL = `redis://:${password}@127.0.0.1:6379`;
|
|
196
|
+
runtimeEnv.REDISCLI_AUTH = password;
|
|
197
|
+
}
|
|
198
|
+
await sandbox.files.write("/input/runtime.json", JSON.stringify(runtimeEnv), { user: "root" });
|
|
199
|
+
await sandbox.commands.run("chmod -R a+rX /input", { user: "root" });
|
|
200
|
+
await sandbox.commands.run("iptables -A OUTPUT -o lo -j ACCEPT && iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT && iptables -A OUTPUT -j REJECT && ip6tables -A OUTPUT -o lo -j ACCEPT && ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT && ip6tables -A OUTPUT -j REJECT", { user: "root" });
|
|
201
|
+
const result = await sandbox.commands.run("node /input/worker.js", { user: "root", cwd: "/input/app", timeoutMs: (spec.timeoutSeconds + 15) * 1e3 });
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
return {
|
|
204
|
+
result: { code: result.exitCode, stdout: result.stdout, stderr: result.stderr },
|
|
205
|
+
image: `e2b:${template}`,
|
|
206
|
+
templateBuildId: process.env.E2B_TEMPLATE_ID ? void 0 : saved.buildId,
|
|
207
|
+
sandboxId: sandbox.sandboxId,
|
|
208
|
+
isolation: { network: "E2B deny-all requested; guest IPv4/IPv6 OUTPUT firewall enforced", publicTraffic: false, productionCredentialsInherited: false, rootFilesystem: "ephemeral sandbox", user: "node", applicationFiles: spec.files }
|
|
209
|
+
};
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const failure = error;
|
|
212
|
+
let message = failure.stderr || failure.message || "Hosted execution failed";
|
|
213
|
+
for (const secret of secrets) message = message.split(secret).join("[redacted]");
|
|
214
|
+
throw new CliError(message.slice(0, 4e3));
|
|
215
|
+
} finally {
|
|
216
|
+
signal?.removeEventListener("abort", cleanup);
|
|
217
|
+
process.removeListener("SIGINT", cleanup);
|
|
218
|
+
process.removeListener("SIGTERM", cleanup);
|
|
219
|
+
await sandbox.kill().catch(() => log("Sandbox cleanup could not be confirmed; its configured expiration remains active."));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/commands/run.ts
|
|
224
|
+
async function stageApplication(root, files, destination) {
|
|
225
|
+
const canonicalRoot = await realpath(root);
|
|
226
|
+
const digests = {};
|
|
227
|
+
let bytes = 0;
|
|
228
|
+
for (const file of files) {
|
|
229
|
+
if (isAbsolute(file) || file.includes("\\") || file.split("/").some((p) => p === ".." || /^(\.env.*|\.git|\.ssh|\.aws|\.azure|\.config|\.npmrc|\.netrc|credentials.*)$/i.test(p)) || /\.(pem|key|p12|pfx)$/i.test(file)) throw new CliError(`Unsafe application file: ${file}`, { code: EXIT.USAGE });
|
|
230
|
+
const source = await realpath(resolve(canonicalRoot, file));
|
|
231
|
+
const rel = relative(canonicalRoot, source);
|
|
232
|
+
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel) || source !== resolve(canonicalRoot, file) || !(await lstat(source)).isFile()) throw new CliError(`Files must be regular files within the project, without symlinks: ${file}`, { code: EXIT.USAGE });
|
|
233
|
+
const contents = await readFile3(source);
|
|
234
|
+
bytes += contents.length;
|
|
235
|
+
if (bytes > 20 * 1024 * 1024) throw new CliError("Application files exceed 20 MiB; supply a smaller built bundle", { code: EXIT.USAGE });
|
|
236
|
+
const target = join3(destination, file);
|
|
237
|
+
await mkdir(dirname(target), { recursive: true });
|
|
238
|
+
await writeFile(target, contents, { mode: 420 });
|
|
239
|
+
digests[file] = createHash("sha256").update(contents).digest("hex");
|
|
240
|
+
}
|
|
241
|
+
return digests;
|
|
242
|
+
}
|
|
243
|
+
async function runWorld(cwd, opts) {
|
|
244
|
+
const file = resolve(cwd, opts.file ?? "agenttwin.world.json");
|
|
245
|
+
let spec;
|
|
246
|
+
try {
|
|
247
|
+
spec = parseWorld(JSON.parse(await readFile3(file, "utf8")));
|
|
248
|
+
} catch (error) {
|
|
249
|
+
throw new CliError(String(error), { code: EXIT.USAGE, action: "Create agenttwin.world.json; run agenttwin example --out ./agenttwin-example for a working example." });
|
|
250
|
+
}
|
|
251
|
+
if (spec.workflowId) {
|
|
252
|
+
let inventory;
|
|
253
|
+
try {
|
|
254
|
+
inventory = JSON.parse(await readFile3(join3(cwd, ".agenttwin/inventory.json"), "utf8"));
|
|
255
|
+
} catch {
|
|
256
|
+
throw new CliError("Linked workflow needs discovery inventory; run init first", { code: EXIT.BLOCKED });
|
|
257
|
+
}
|
|
258
|
+
if (!inventory.workflows?.some((w) => w.id === spec.workflowId)) throw new CliError("workflowId is absent from the current inventory", { code: EXIT.BLOCKED });
|
|
259
|
+
}
|
|
260
|
+
const runtime = opts.runtime ?? (process.env.AGENTTWIN_API_URL ? "hosted" : "e2b");
|
|
261
|
+
if (!["e2b", "hosted"].includes(runtime)) throw new CliError("runtime must be hosted or e2b; no local application execution", { code: EXIT.USAGE });
|
|
262
|
+
if (runtime === "e2b" && !process.env.E2B_API_KEY) throw new CliError("E2B_API_KEY is not configured", { code: EXIT.BLOCKED, action: "Configure your E2B key in the shell; it will not be uploaded." });
|
|
263
|
+
const stage = await mkdtemp(join3(tmpdir(), "agenttwin-world-"));
|
|
264
|
+
try {
|
|
265
|
+
const sourceDigests = await stageApplication(dirname(file), spec.files, join3(stage, "app"));
|
|
266
|
+
await writeFile(join3(stage, "world.json"), JSON.stringify(spec), { mode: 420 });
|
|
267
|
+
const worker = join3(dirname(fileURLToPath(import.meta.url)), "worker.js");
|
|
268
|
+
await copyFile(worker, join3(stage, "worker.js"));
|
|
269
|
+
if (runtime === "hosted") {
|
|
270
|
+
const receipt2 = await executeRemote(stage, spec, opts.log);
|
|
271
|
+
if (receipt2.schema !== "agenttwin/run-v1" || typeof receipt2.runId !== "string" || !/^[a-f0-9-]{36}$/.test(receipt2.runId) || !["passed", "failed", "error", "incomplete"].includes(String(receipt2.status))) throw new CliError("Invalid hosted receipt");
|
|
272
|
+
const expectedSpec = createHash("sha256").update(JSON.stringify(spec)).digest("hex");
|
|
273
|
+
const reportedSources = receipt2.sourceDigests;
|
|
274
|
+
if (receipt2.specDigest !== expectedSpec || !reportedSources || Object.keys(reportedSources).length !== Object.keys(sourceDigests).length || Object.entries(sourceDigests).some(([path, digest]) => reportedSources[path] !== digest)) throw new CliError("Hosted receipt does not match the submitted source/specification");
|
|
275
|
+
const out2 = resolve(cwd, opts.out ?? `.agenttwin/runs/${receipt2.runId}`);
|
|
276
|
+
await mkdir(out2, { recursive: true });
|
|
277
|
+
const evidencePath = join3(out2, "run.json");
|
|
278
|
+
const reportPath = join3(out2, "report.md");
|
|
279
|
+
await writeFile(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
|
|
280
|
+
await writeFile(reportPath, `# ${spec.name}: ${receipt2.status}
|
|
281
|
+
|
|
282
|
+
Run: ${receipt2.runId}
|
|
283
|
+
|
|
284
|
+
See run.json for assertions, service state, application output and isolation metadata.
|
|
285
|
+
`);
|
|
286
|
+
return { ...receipt2, name: spec.name, status: String(receipt2.status), evidencePath, reportPath };
|
|
287
|
+
}
|
|
288
|
+
const { result, image, templateBuildId, sandboxId, isolation } = await executeE2B(stage, spec, opts.log, opts.signal);
|
|
289
|
+
if (result.code !== 0) throw new CliError(`Isolated runtime failed: ${result.stderr || `exit ${result.code}`}`);
|
|
290
|
+
let report;
|
|
291
|
+
try {
|
|
292
|
+
report = JSON.parse(result.stdout);
|
|
293
|
+
} catch {
|
|
294
|
+
throw new CliError("Runtime did not produce a valid run receipt");
|
|
295
|
+
}
|
|
296
|
+
if (report.schema !== "agenttwin/run-v1") throw new CliError("Unexpected runtime receipt schema");
|
|
297
|
+
const runId = randomUUID();
|
|
298
|
+
const receipt = {
|
|
299
|
+
...report,
|
|
300
|
+
...spec.workflowId ? { workflowId: spec.workflowId } : {},
|
|
301
|
+
runId,
|
|
302
|
+
runtime,
|
|
303
|
+
sandboxId,
|
|
304
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
305
|
+
image,
|
|
306
|
+
templateBuildId,
|
|
307
|
+
sourceDigests,
|
|
308
|
+
specDigest: createHash("sha256").update(JSON.stringify(spec)).digest("hex"),
|
|
309
|
+
isolation
|
|
310
|
+
};
|
|
311
|
+
const out = resolve(cwd, opts.out ?? `.agenttwin/runs/${runId}`);
|
|
312
|
+
await mkdir(out, { recursive: true });
|
|
313
|
+
await writeFile(join3(out, "run.json"), JSON.stringify(receipt, null, 2) + "\n", { mode: 384 });
|
|
314
|
+
await writeFile(join3(out, "world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
|
|
315
|
+
const summary = [`# ${spec.name}: ${report.status}`, "", `Run: ${runId}`, `Image: ${image}`, "", "## Assertions", "", ...report.assertions.map((a) => `- ${a.passed ? "PASS" : "FAIL"} ${a.service}${a.path}: expected ${a.count}, observed ${a.actual}`), "", "## Infrastructure assertions", "", ...(report.infrastructureAssertions ?? []).map((a) => `- ${a.passed ? "PASS" : "FAIL"} ${a.service}: ${JSON.stringify(a.actual)}`), "", "## Coverage", "", report.coverage, "", "External networking disabled. Only explicitly listed source files were uploaded. New state and tokens are created for each run.", "", "See run.json for application output, faults, events, and initial/final state.", ""].join("\n");
|
|
316
|
+
await writeFile(join3(out, "report.md"), summary);
|
|
317
|
+
return { ...receipt, reportPath: join3(out, "report.md"), evidencePath: join3(out, "run.json") };
|
|
318
|
+
} finally {
|
|
319
|
+
await rm(stage, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/runtime/gateway.ts
|
|
324
|
+
var hash = (value) => createHash2("sha256").update(value).digest("hex");
|
|
325
|
+
function uploadPath(path) {
|
|
326
|
+
if (!path || path.startsWith("/") || path.includes("\\") || path.includes("\0") || path.split("/").some((p) => !p || p === "." || p === ".." || /^(\.env.*|\.git|\.ssh|\.aws|\.azure|\.config|\.npmrc|\.netrc|credentials.*)$/i.test(p)) || /\.(pem|key|p12|pfx)$/i.test(path)) throw new Error("Unsafe uploaded file path");
|
|
327
|
+
return path;
|
|
328
|
+
}
|
|
329
|
+
async function body(req) {
|
|
330
|
+
const chunks = [];
|
|
331
|
+
let bytes = 0;
|
|
332
|
+
for await (const chunk of req) {
|
|
333
|
+
bytes += chunk.length;
|
|
334
|
+
if (bytes > 28 * 1024 * 1024) throw new Error("Request exceeds 28 MiB");
|
|
335
|
+
chunks.push(Buffer.from(chunk));
|
|
336
|
+
}
|
|
337
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
338
|
+
}
|
|
339
|
+
function send(res, code, value) {
|
|
340
|
+
res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" });
|
|
341
|
+
res.end(JSON.stringify(value));
|
|
342
|
+
}
|
|
343
|
+
function equal(a, b) {
|
|
344
|
+
const x = Buffer.from(hash(a)), y = Buffer.from(hash(b));
|
|
345
|
+
return timingSafeEqual(x, y);
|
|
346
|
+
}
|
|
347
|
+
async function startGateway(opts) {
|
|
348
|
+
if (opts.adminToken.length < 40) throw new Error("AGENTTWIN_ADMIN_TOKEN must have at least 40 characters");
|
|
349
|
+
if (!process.env.E2B_API_KEY || !process.env.E2B_TEMPLATE_ID) throw new Error("Gateway requires E2B_API_KEY and E2B_TEMPLATE_ID");
|
|
350
|
+
const allowedOrigins = new Set(opts.allowedOrigins ?? []);
|
|
351
|
+
for (const origin of allowedOrigins) {
|
|
352
|
+
const u = new URL(origin);
|
|
353
|
+
if (u.origin !== origin || u.protocol !== "https:" && !(u.protocol === "http:" && ["127.0.0.1", "localhost"].includes(u.hostname))) throw new Error("Invalid dashboard origin");
|
|
354
|
+
}
|
|
355
|
+
const controllers = /* @__PURE__ */ new Map();
|
|
356
|
+
const directory = resolve2(opts.directory);
|
|
357
|
+
await mkdir2(directory, { recursive: true, mode: 448 });
|
|
358
|
+
const storePath = join4(directory, "state.json");
|
|
359
|
+
let store;
|
|
360
|
+
try {
|
|
361
|
+
store = JSON.parse(await readFile4(storePath, "utf8"));
|
|
362
|
+
if (store.schema !== "agenttwin/gateway-v1") throw new Error("Invalid store");
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error.code !== "ENOENT") throw error;
|
|
365
|
+
store = { schema: "agenttwin/gateway-v1", accounts: [], jobs: [] };
|
|
366
|
+
}
|
|
367
|
+
for (const job of store.jobs) if (job.status === "queued" || job.status === "running" || job.status === "cancelling") {
|
|
368
|
+
job.status = "error";
|
|
369
|
+
job.error = "Gateway restarted during run; sandbox expires automatically. Submit a fresh run.";
|
|
370
|
+
}
|
|
371
|
+
let writes = Promise.resolve();
|
|
372
|
+
let storageHealthy = true;
|
|
373
|
+
const save = () => {
|
|
374
|
+
const snapshot = JSON.stringify(store);
|
|
375
|
+
writes = writes.then(async () => {
|
|
376
|
+
const path = join4(directory, `state-${randomUUID2()}.tmp`);
|
|
377
|
+
await writeFile2(path, snapshot, { mode: 384 });
|
|
378
|
+
await rename(path, storePath);
|
|
379
|
+
}).catch((error) => {
|
|
380
|
+
storageHealthy = false;
|
|
381
|
+
throw error;
|
|
382
|
+
});
|
|
383
|
+
return writes;
|
|
384
|
+
};
|
|
385
|
+
await save();
|
|
386
|
+
store.jobs = store.jobs.filter((j) => Date.parse(j.createdAt) > Date.now() - 30 * 864e5);
|
|
387
|
+
const dailyLimit = opts.dailyLimit ?? 20;
|
|
388
|
+
const concurrency = opts.concurrency ?? 2;
|
|
389
|
+
if (!Number.isSafeInteger(dailyLimit) || dailyLimit < 1 || !Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 10) throw new Error("Invalid gateway limits");
|
|
390
|
+
let active = 0, stopping = false;
|
|
391
|
+
function busy(accountId) {
|
|
392
|
+
const pending = store.jobs.filter((j) => j.status === "queued" || j.status === "running" || j.status === "cancelling");
|
|
393
|
+
return pending.length >= 20 || pending.some((j) => j.accountId === accountId);
|
|
394
|
+
}
|
|
395
|
+
const queue = [];
|
|
396
|
+
const workers = /* @__PURE__ */ new Set();
|
|
397
|
+
function schedule() {
|
|
398
|
+
while (!stopping && storageHealthy && active < concurrency && queue.length) {
|
|
399
|
+
const item = queue.shift();
|
|
400
|
+
active++;
|
|
401
|
+
const worker = (async () => {
|
|
402
|
+
let workspace;
|
|
403
|
+
try {
|
|
404
|
+
if (!store.accounts.some((a) => a.id === item.job.accountId && a.active)) throw new Error("Account revoked before execution");
|
|
405
|
+
item.job.status = "running";
|
|
406
|
+
await save();
|
|
407
|
+
workspace = await mkdtemp2(join4(tmpdir2(), "agenttwin-api-run-"));
|
|
408
|
+
for (const [file, encoded] of Object.entries(item.files)) {
|
|
409
|
+
const destination = join4(workspace, uploadPath(file));
|
|
410
|
+
await mkdir2(dirname2(destination), { recursive: true });
|
|
411
|
+
await writeFile2(destination, Buffer.from(encoded, "base64"), { mode: 384 });
|
|
412
|
+
}
|
|
413
|
+
await writeFile2(join4(workspace, "agenttwin.world.json"), JSON.stringify(item.spec));
|
|
414
|
+
if (item.spec.workflowId) {
|
|
415
|
+
await mkdir2(join4(workspace, ".agenttwin"));
|
|
416
|
+
await writeFile2(join4(workspace, ".agenttwin/inventory.json"), JSON.stringify({ workflows: [{ id: item.spec.workflowId }] }));
|
|
417
|
+
}
|
|
418
|
+
const receipt = await runWorld(workspace, { runtime: "e2b", signal: controllers.get(item.job.id)?.signal, log: () => {
|
|
419
|
+
} });
|
|
420
|
+
controllers.get(item.job.id)?.signal.throwIfAborted();
|
|
421
|
+
const { reportPath: _report, evidencePath: _evidence, ...portable } = receipt;
|
|
422
|
+
item.job.receipt = { ...portable, apiJobId: item.job.id, provenance: "client-supplied workflow declaration" };
|
|
423
|
+
item.job.status = "completed";
|
|
424
|
+
} catch (error) {
|
|
425
|
+
item.job.status = controllers.get(item.job.id)?.signal.aborted ? "cancelled" : "error";
|
|
426
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
427
|
+
item.job.error = message.split(process.env.E2B_API_KEY ?? "\0").join("[redacted]").replace(/(?:postgresql|redis):\/\/[^\s]+/g, "[test-connection]").slice(0, 2e3);
|
|
428
|
+
} finally {
|
|
429
|
+
try {
|
|
430
|
+
if (workspace) await rm2(workspace, { recursive: true, force: true });
|
|
431
|
+
await save();
|
|
432
|
+
} finally {
|
|
433
|
+
controllers.delete(item.job.id);
|
|
434
|
+
active--;
|
|
435
|
+
schedule();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
})();
|
|
439
|
+
workers.add(worker);
|
|
440
|
+
void worker.finally(() => workers.delete(worker)).catch(() => {
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const server = createServer(async (req, res) => {
|
|
445
|
+
try {
|
|
446
|
+
const path = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
447
|
+
if (opts.dashboardDirectory && req.method === "GET" && (path === "/dashboard" || path.startsWith("/dashboard/"))) {
|
|
448
|
+
if (path === "/dashboard") {
|
|
449
|
+
res.writeHead(302, { location: "/dashboard/" });
|
|
450
|
+
res.end();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const relative2 = decodeURIComponent(path.slice("/dashboard/".length)) || "index.html";
|
|
454
|
+
const root = resolve2(opts.dashboardDirectory), file = resolve2(root, relative2);
|
|
455
|
+
if (!file.startsWith(root + "/") || relative2 !== "index.html" && !relative2.startsWith("assets/")) {
|
|
456
|
+
send(res, 404, { error: "not_found" });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
const bytes = await readFile4(file);
|
|
461
|
+
const types = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".ico": "image/x-icon", ".woff2": "font/woff2" };
|
|
462
|
+
res.writeHead(200, { "content-type": types[extname(file)] ?? "application/octet-stream", "cache-control": "no-store", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer" });
|
|
463
|
+
res.end(bytes);
|
|
464
|
+
} catch {
|
|
465
|
+
send(res, 404, { error: "not_found" });
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const origin = req.headers.origin;
|
|
470
|
+
if (origin) {
|
|
471
|
+
if (!allowedOrigins.has(origin)) {
|
|
472
|
+
send(res, 403, { error: "origin_not_allowed" });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
476
|
+
res.setHeader("Vary", "Origin");
|
|
477
|
+
if (req.method === "OPTIONS") {
|
|
478
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
479
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
480
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
481
|
+
res.writeHead(204);
|
|
482
|
+
res.end();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (path === "/healthz" && req.method === "GET") {
|
|
487
|
+
send(res, 200, { status: stopping ? "stopping" : "ok" });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (!storageHealthy) {
|
|
491
|
+
send(res, 503, { error: "storage_unavailable" });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const token = req.headers.authorization?.replace(/^Bearer /, "") ?? "";
|
|
495
|
+
const admin = equal(token, opts.adminToken);
|
|
496
|
+
if (path === "/v1/accounts" && req.method === "POST") {
|
|
497
|
+
if (!admin) {
|
|
498
|
+
send(res, 401, { error: "unauthorized" });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const data = await body(req);
|
|
502
|
+
if (typeof data.label !== "string" || !data.label || data.label.length > 100) {
|
|
503
|
+
send(res, 400, { error: "label_required" });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const accessToken = `at_user_${randomBytes2(32).toString("hex")}`;
|
|
507
|
+
const account2 = { id: randomUUID2(), tokenHash: hash(accessToken), label: data.label, active: true, usageDay: "", runs: 0 };
|
|
508
|
+
if (store.accounts.length >= 1e4) {
|
|
509
|
+
send(res, 429, { error: "account_capacity" });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
store.accounts.push(account2);
|
|
513
|
+
await save();
|
|
514
|
+
send(res, 201, { accountId: account2.id, token: accessToken });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const revoke = path.match(/^\/v1\/accounts\/([a-f0-9-]{36})$/);
|
|
518
|
+
if (revoke && req.method === "DELETE") {
|
|
519
|
+
if (!admin) {
|
|
520
|
+
send(res, 401, { error: "unauthorized" });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const account2 = store.accounts.find((a) => a.id === revoke[1]);
|
|
524
|
+
if (!account2) {
|
|
525
|
+
send(res, 404, { error: "not_found" });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
account2.active = false;
|
|
529
|
+
await save();
|
|
530
|
+
send(res, 200, { revoked: true });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const account = store.accounts.find((a) => a.active && a.tokenHash === hash(token));
|
|
534
|
+
if (!account) {
|
|
535
|
+
send(res, 401, { error: "unauthorized" });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (path === "/v1/runs" && req.method === "GET") {
|
|
539
|
+
send(res, 200, { jobs: store.jobs.filter((j) => j.accountId === account.id).slice(-100).reverse().map(({ id, status, createdAt }) => ({ id, status, createdAt })) });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (path === "/v1/runs" && req.method === "POST") {
|
|
543
|
+
if (stopping) {
|
|
544
|
+
send(res, 503, { error: "stopping" });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (busy(account.id)) {
|
|
548
|
+
send(res, 429, { error: "run_in_progress_or_queue_full" });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
552
|
+
if (account.usageDay !== today) {
|
|
553
|
+
account.usageDay = today;
|
|
554
|
+
account.runs = 0;
|
|
555
|
+
}
|
|
556
|
+
if (account.runs >= dailyLimit) {
|
|
557
|
+
send(res, 429, { error: "daily_run_limit" });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const input = await body(req);
|
|
561
|
+
const spec = parseWorld(input.spec);
|
|
562
|
+
if (!input.files || typeof input.files !== "object" || Array.isArray(input.files)) throw new Error("files object required");
|
|
563
|
+
if (Object.keys(input.files).length !== spec.files.length) throw new Error("Upload must match explicit file list");
|
|
564
|
+
let total = 0;
|
|
565
|
+
for (const file of spec.files) {
|
|
566
|
+
uploadPath(file);
|
|
567
|
+
const encoded = input.files[file];
|
|
568
|
+
if (typeof encoded !== "string" || Buffer.from(encoded, "base64").toString("base64") !== encoded) throw new Error("Files must use canonical base64");
|
|
569
|
+
total += Buffer.from(encoded, "base64").length;
|
|
570
|
+
}
|
|
571
|
+
if (total > 20 * 1024 * 1024) throw new Error("Application exceeds 20 MiB");
|
|
572
|
+
if (!account.active) {
|
|
573
|
+
send(res, 401, { error: "unauthorized" });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (busy(account.id)) {
|
|
577
|
+
send(res, 429, { error: "run_in_progress_or_queue_full" });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const submissionDay = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
581
|
+
if (account.usageDay !== submissionDay) {
|
|
582
|
+
account.usageDay = submissionDay;
|
|
583
|
+
account.runs = 0;
|
|
584
|
+
}
|
|
585
|
+
if (account.runs >= dailyLimit || store.jobs.filter((j) => j.createdAt.startsWith(submissionDay)).length >= 200) {
|
|
586
|
+
send(res, 429, { error: "daily_run_limit" });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
store.jobs = store.jobs.filter((j) => Date.parse(j.createdAt) > Date.now() - 30 * 864e5);
|
|
590
|
+
const job = { id: randomUUID2(), accountId: account.id, status: "queued", createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
591
|
+
account.runs++;
|
|
592
|
+
store.jobs.push(job);
|
|
593
|
+
await save();
|
|
594
|
+
controllers.set(job.id, new AbortController());
|
|
595
|
+
queue.push({ job, spec, files: input.files });
|
|
596
|
+
schedule();
|
|
597
|
+
send(res, 202, { id: job.id, status: job.status });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const match = path.match(/^\/v1\/runs\/([a-f0-9-]{36})$/);
|
|
601
|
+
if (match && req.method === "DELETE") {
|
|
602
|
+
const job = store.jobs.find((j) => j.id === match[1] && j.accountId === account.id);
|
|
603
|
+
if (!job) {
|
|
604
|
+
send(res, 404, { error: "not_found" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (!["queued", "running", "cancelling"].includes(job.status)) {
|
|
608
|
+
send(res, 409, { error: "run_already_finished" });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const index = queue.findIndex((item) => item.job.id === job.id);
|
|
612
|
+
if (index >= 0) {
|
|
613
|
+
queue.splice(index, 1);
|
|
614
|
+
job.status = "cancelled";
|
|
615
|
+
controllers.delete(job.id);
|
|
616
|
+
} else {
|
|
617
|
+
job.status = "cancelling";
|
|
618
|
+
controllers.get(job.id)?.abort(new Error("Run cancelled by account owner"));
|
|
619
|
+
}
|
|
620
|
+
await save();
|
|
621
|
+
send(res, 202, { id: job.id, status: job.status });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (match && req.method === "GET") {
|
|
625
|
+
const job = store.jobs.find((j) => j.id === match[1] && j.accountId === account.id);
|
|
626
|
+
if (!job) {
|
|
627
|
+
send(res, 404, { error: "not_found" });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
send(res, 200, { id: job.id, status: job.status, receipt: job.receipt, error: job.error });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
send(res, 404, { error: "not_found" });
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (!storageHealthy) {
|
|
636
|
+
send(res, 503, { error: "storage_unavailable" });
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
send(res, 400, { error: "invalid_request", message: error instanceof Error ? error.message : "Invalid request" });
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
server.requestTimeout = 3e4;
|
|
643
|
+
server.headersTimeout = 1e4;
|
|
644
|
+
await new Promise((done, reject) => {
|
|
645
|
+
server.once("error", reject);
|
|
646
|
+
server.listen(opts.port, opts.host ?? "127.0.0.1", done);
|
|
647
|
+
});
|
|
648
|
+
return { server, async close() {
|
|
649
|
+
stopping = true;
|
|
650
|
+
server.close();
|
|
651
|
+
await Promise.allSettled([...workers]);
|
|
652
|
+
await writes;
|
|
653
|
+
server.closeAllConnections();
|
|
654
|
+
} };
|
|
655
|
+
}
|
|
656
|
+
export {
|
|
657
|
+
startGateway,
|
|
658
|
+
uploadPath
|
|
659
|
+
};
|