agenttwin 0.3.6 → 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/README.md +17 -6
- package/dist/api.js +1515 -217
- package/dist/cli.js +2757 -642
- package/dist/dashboard/assets/dashboard/agent-home.css +130 -0
- package/dist/dashboard/assets/dashboard/golden-gate-portrait.png +0 -0
- package/dist/dashboard/assets/next/static/P_sIw5q7daW7e4IJEGveg/_buildManifest.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/549-f8859485ba130d9c.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/787-e53be69d23ff02f5.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/_not-found/{page-5dc6a60918e8652d.js → page-95856eb81db7799b.js} +1 -1
- package/dist/dashboard/assets/next/static/chunks/app/account/password/page-ff06be670b822cfe.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/dashboard/layout-64afdd8a8798e3b6.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/dashboard/page-a11f5eeef34fdff9.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/demo/{page-760a47a5e4dee43f.js → page-8a71f24127eb1e63.js} +1 -1
- package/dist/dashboard/assets/next/static/chunks/app/docs/[[...slug]]/page-df1468fc904db4f7.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/layout-9da6df58ef40be60.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/login/page-caa5c579f14ec911.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/onboarding/page-e58bc195fd4bc31c.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/page-df1d4cae513d5eed.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/pricing/page-21130c3524f7e45e.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/signup/page-caa5c579f14ec911.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/dashboard/layout-2936df21408f38fd.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/dashboard/page-04e82fd8f3044022.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/layout-dc60420b4d88dd63.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/login/page-d3a27a2684498e13.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/onboarding/page-b48a6cab88bb7f78.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/page-ca672c49c2235ce2.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/signup/page-d3a27a2684498e13.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/main-app-def27ec5d496405f.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/webpack-29cc60ad58d8aefe.js +1 -0
- package/dist/dashboard/assets/next/static/css/0071f19b19d3d0c1.css +1 -0
- package/dist/dashboard/assets/next/static/css/138b575a572b2958.css +1 -0
- package/dist/dashboard/assets/next/static/css/1f0bd0855bbd1a14.css +1 -0
- package/dist/dashboard/assets/next/static/css/311920319a51fc90.css +1 -0
- package/dist/dashboard/assets/next/static/css/32d869ddee6d2dc1.css +1 -0
- package/dist/dashboard/assets/next/static/css/3f003235629d65a0.css +1 -0
- package/dist/dashboard/assets/next/static/css/4af3c8ca0da0f48b.css +1 -0
- package/dist/dashboard/assets/next/static/css/4fa51ab33fbc7e1b.css +1 -0
- package/dist/dashboard/assets/next/static/css/807e07644631d8e2.css +1 -0
- package/dist/dashboard/assets/next/static/css/97d686ac80c4018f.css +1 -0
- package/dist/dashboard/assets/next/static/css/98cc7bbbb43b84df.css +1 -0
- package/dist/dashboard/assets/next/static/css/c1117da59daa6ba9.css +1 -0
- package/dist/dashboard/assets/next/static/css/d99eecb7633d1200.css +1 -0
- package/dist/dashboard/assets/next/static/media/golden-gate-portrait.7941528f.png +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/gateway.js +1534 -218
- package/dist/worker.js +316 -22
- package/dist/world-host.js +1090 -0
- package/package.json +6 -1
- package/dist/dashboard/assets/next/static/HZwGKKvvx_oO6caYggjFh/_buildManifest.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/dashboard/layout-74603c2c8eaf0760.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/dashboard/page-b79ed135f6bac5f7.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/docs/[[...slug]]/page-80a0c2c82d5b4efd.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/layout-1a40c59e535513d4.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/page-de4735052adab906.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/pricing/page-7703fc31d6dff0f7.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/main-app-99295a8cc37e8a47.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/webpack-f3b6dd27998e84ce.js +0 -1
- package/dist/dashboard/assets/next/static/css/1323d7da89b3fa5e.css +0 -1
- package/dist/dashboard/assets/next/static/css/9282330a2fd1ab73.css +0 -1
- package/dist/dashboard/assets/next/static/css/96e4f356a1159071.css +0 -1
- /package/dist/dashboard/assets/next/static/{HZwGKKvvx_oO6caYggjFh → P_sIw5q7daW7e4IJEGveg}/_ssgManifest.js +0 -0
package/dist/api.js
CHANGED
|
@@ -61,9 +61,9 @@ function discoveryTransfer(value) {
|
|
|
61
61
|
return v.map((s) => text(s));
|
|
62
62
|
};
|
|
63
63
|
const entrypoint = (v) => {
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
return
|
|
64
|
+
const path2 = text(v, 500);
|
|
65
|
+
if (path2.startsWith("/") || path2.includes("\\") || /(^|[\/])\.\.([\/#]|$)/.test(path2) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(path2)) throw new Error("Entrypoint must be relative to the project");
|
|
66
|
+
return path2;
|
|
67
67
|
};
|
|
68
68
|
const tools = input.tools.map((raw) => {
|
|
69
69
|
const t = raw;
|
|
@@ -91,9 +91,63 @@ var init_dashboard_transfer = __esm({
|
|
|
91
91
|
}
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
// src/runtime/contracts.ts
|
|
95
|
+
function operationMatrix(spec) {
|
|
96
|
+
return {
|
|
97
|
+
schema: "agenttwin/contracts-v1",
|
|
98
|
+
unsupported: { status: 501, marker: "X-AgentTwin-Unsupported", coverage: "incomplete", forwardedToProduction: false },
|
|
99
|
+
adapters: CONTRACTS,
|
|
100
|
+
services: spec?.services.map((s) => ({
|
|
101
|
+
name: s.name,
|
|
102
|
+
adapter: s.adapter ?? "json-collection",
|
|
103
|
+
version: CONTRACTS[s.adapter ?? "json-collection"].version,
|
|
104
|
+
operations: s.adapter === "slack" ? CONTRACTS.slack.operations : s.adapter === "stripe" ? CONTRACTS.stripe.operations : s.collections.flatMap((c) => CONTRACTS["json-collection"].operations.map((op) => ({ ...op, endpoint: c.path })))
|
|
105
|
+
})) ?? []
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
var CONTRACTS;
|
|
109
|
+
var init_contracts = __esm({
|
|
110
|
+
"src/runtime/contracts.ts"() {
|
|
111
|
+
"use strict";
|
|
112
|
+
CONTRACTS = {
|
|
113
|
+
"json-collection": {
|
|
114
|
+
version: "1.0.0",
|
|
115
|
+
authentication: "Bearer run-scoped test token",
|
|
116
|
+
operations: [
|
|
117
|
+
{ method: "GET", endpoint: "<collection path>", effect: "read", response: "{data: records}", errors: ["401 invalid_test_credential", "429 world_capacity"] },
|
|
118
|
+
{ method: "POST", endpoint: "<collection path>", effect: "append", response: "201 record; 200 idempotency replay", errors: ["400 expected_json_object", "409 idempotency_conflict", "413 body_too_large", "429 world_capacity"] }
|
|
119
|
+
],
|
|
120
|
+
limitations: ["Custom API contract, not a vendor replica", "No update/delete, pagination, OAuth, webhooks or schema validation", "Idempotency compares the exact parsed JSON serialization"]
|
|
121
|
+
},
|
|
122
|
+
stripe: {
|
|
123
|
+
version: "1.0.0",
|
|
124
|
+
authentication: "Bearer run-scoped test token; official Stripe SDK configured with test endpoint",
|
|
125
|
+
operations: [
|
|
126
|
+
{ method: "POST", endpoint: "/v1/customers", effect: "append customer", parameters: ["email", "name", "description"], errors: ["400 invalid parameters"] },
|
|
127
|
+
{ method: "GET", endpoint: "/v1/customers[/<id>]", effect: "retrieve or list customers", parameters: ["limit", "starting_after"], errors: ["404 resource_missing", "400 invalid pagination"] },
|
|
128
|
+
{ method: "POST", endpoint: "/v1/payment_intents", effect: "create test payment intent", parameters: ["amount", "currency", "customer", "payment_method", "confirm"], errors: ["400 invalid parameters", "400 resource_missing", "402 card_declined"] },
|
|
129
|
+
{ method: "GET", endpoint: "/v1/payment_intents/<id>", effect: "retrieve intent", parameters: [], errors: ["404 resource_missing"] },
|
|
130
|
+
{ method: "POST", endpoint: "/v1/payment_intents/<id>/confirm", effect: "test success or decline", parameters: ["payment_method"], errors: ["404 resource_missing", "400 unsupported test method", "402 card_declined"] }
|
|
131
|
+
],
|
|
132
|
+
limitations: ["Bounded subset, not full Stripe parity; minimal object fields only; no real money", "Only pm_card_visa and pm_card_chargeDeclined test methods", "No webhooks, refunds, subscriptions, payment-method APIs, metadata, updates or deletes", "Customers list supports limit/starting_after; payment intent listing unsupported", "POST idempotency supported; reset clears records and keys; unsupported operations/parameters return 501 and make coverage incomplete"]
|
|
133
|
+
},
|
|
134
|
+
slack: {
|
|
135
|
+
version: "1.0.0",
|
|
136
|
+
authentication: "Bearer run-scoped test token; no Slack OAuth",
|
|
137
|
+
operations: [
|
|
138
|
+
{ method: "POST", endpoint: "/api/chat.postMessage", effect: "append /messages", parameters: ["channel", "text"], errors: ["channel_not_found", "no_text"] },
|
|
139
|
+
{ method: "GET|POST", endpoint: "/api/conversations.history", effect: "read /messages", parameters: ["channel"], errors: ["channel_not_found"] },
|
|
140
|
+
{ method: "GET|POST", endpoint: "/api/conversations.list", effect: "read /channels", parameters: [], errors: [] }
|
|
141
|
+
],
|
|
142
|
+
limitations: ["JSON and form bodies only", "No OAuth, scopes, pagination, threads, edits, deletes, attachments or webhooks", "Errors channel_not_found/no_text use HTTP 200 with ok:false", "Unmodeled parameters and operations return HTTP 501 and make coverage incomplete"]
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
94
148
|
// src/lib/files.ts
|
|
95
|
-
import { readdir as
|
|
96
|
-
import { join as
|
|
149
|
+
import { readdir as readdir3, readFile as readFile6, stat } from "fs/promises";
|
|
150
|
+
import { join as join7, relative, sep } from "path";
|
|
97
151
|
function shouldIgnoreDir(name) {
|
|
98
152
|
return IGNORED_DIRS.has(name) || name === ".git";
|
|
99
153
|
}
|
|
@@ -106,14 +160,14 @@ async function walkDir(root, maxFiles, budgetMs) {
|
|
|
106
160
|
const dir = stack.pop();
|
|
107
161
|
let entries;
|
|
108
162
|
try {
|
|
109
|
-
entries = await
|
|
163
|
+
entries = await readdir3(dir, { withFileTypes: true });
|
|
110
164
|
} catch {
|
|
111
165
|
limits.push(`Directory could not be read: ${relative(root, dir) || "."}`);
|
|
112
166
|
continue;
|
|
113
167
|
}
|
|
114
168
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
115
169
|
for (const entry of entries) {
|
|
116
|
-
const full =
|
|
170
|
+
const full = join7(dir, entry.name);
|
|
117
171
|
if (entry.isDirectory()) {
|
|
118
172
|
if (!shouldIgnoreDir(entry.name)) stack.push(full);
|
|
119
173
|
} else if (entry.isFile()) {
|
|
@@ -190,27 +244,27 @@ var init_files = __esm({
|
|
|
190
244
|
scanLimits() {
|
|
191
245
|
return [...new Set(this.cache.limits)].sort();
|
|
192
246
|
}
|
|
193
|
-
async exists(
|
|
247
|
+
async exists(path2) {
|
|
194
248
|
try {
|
|
195
|
-
await stat(
|
|
249
|
+
await stat(join7(this.cwd, path2));
|
|
196
250
|
return true;
|
|
197
251
|
} catch {
|
|
198
252
|
return false;
|
|
199
253
|
}
|
|
200
254
|
}
|
|
201
|
-
async read(
|
|
202
|
-
const cached = this.cache.contents.get(
|
|
255
|
+
async read(path2, maxBytes = MAX_BYTES) {
|
|
256
|
+
const cached = this.cache.contents.get(path2);
|
|
203
257
|
if (cached !== void 0) return cached;
|
|
204
|
-
const full =
|
|
258
|
+
const full = join7(this.cwd, path2);
|
|
205
259
|
try {
|
|
206
260
|
const info = await stat(full);
|
|
207
261
|
if (!info.isFile()) return void 0;
|
|
208
262
|
if (info.size > maxBytes) {
|
|
209
|
-
this.cache.limits.push(`File excluded by size limit (${maxBytes} bytes): ${
|
|
263
|
+
this.cache.limits.push(`File excluded by size limit (${maxBytes} bytes): ${path2}`);
|
|
210
264
|
return void 0;
|
|
211
265
|
}
|
|
212
|
-
const text = await
|
|
213
|
-
this.cache.contents.set(
|
|
266
|
+
const text = await readFile6(full, "utf8");
|
|
267
|
+
this.cache.contents.set(path2, text);
|
|
214
268
|
return text;
|
|
215
269
|
} catch {
|
|
216
270
|
return void 0;
|
|
@@ -232,10 +286,10 @@ var init_files = __esm({
|
|
|
232
286
|
line(ref) {
|
|
233
287
|
const idx = ref.lastIndexOf(":");
|
|
234
288
|
if (idx < 0) return void 0;
|
|
235
|
-
const
|
|
289
|
+
const path2 = ref.slice(0, idx);
|
|
236
290
|
const num = Number(ref.slice(idx + 1));
|
|
237
291
|
if (!Number.isInteger(num)) return void 0;
|
|
238
|
-
const text = this.cache.contents.get(
|
|
292
|
+
const text = this.cache.contents.get(path2);
|
|
239
293
|
if (text === void 0) return void 0;
|
|
240
294
|
const lines = text.split(/\r?\n/);
|
|
241
295
|
return lines[num - 1];
|
|
@@ -258,12 +312,38 @@ function exportedEntrypoints(source) {
|
|
|
258
312
|
if (node.type !== "ExportNamedDeclaration" && node.type !== "ExportDefaultDeclaration") continue;
|
|
259
313
|
const declaration = node.declaration;
|
|
260
314
|
if (!declaration) continue;
|
|
261
|
-
if (declaration.type === "FunctionDeclaration" && declaration.
|
|
315
|
+
if (declaration.type === "FunctionDeclaration" && declaration.body.loc) result.push({ name: declaration.id?.name ?? "default", startLine: node.loc.start.line, endLine: declaration.body.loc.end.line });
|
|
262
316
|
if (declaration.type === "VariableDeclaration") for (const variable of declaration.declarations) {
|
|
263
317
|
const fn = variable.init;
|
|
264
318
|
if (variable.id.type === "Identifier" && fn && (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") && fn.body.type === "BlockStatement" && fn.body.loc) result.push({ name: variable.id.name, startLine: node.loc.start.line, endLine: fn.body.loc.end.line });
|
|
265
319
|
}
|
|
266
320
|
}
|
|
321
|
+
const visit = (node) => {
|
|
322
|
+
if (!node || typeof node !== "object") return;
|
|
323
|
+
const value = node;
|
|
324
|
+
if (value.type === "CallExpression" || value.type === "NewExpression") {
|
|
325
|
+
const callee = value.callee;
|
|
326
|
+
const operation = callee?.type === "MemberExpression" && !callee.computed ? callee.property?.name : void 0;
|
|
327
|
+
const worker = value.type === "NewExpression" && callee?.type === "Identifier" && callee.name === "Worker";
|
|
328
|
+
const route = ["get", "post", "put", "patch", "delete"].includes(operation);
|
|
329
|
+
const job = ["process", "subscribe", "consume"].includes(operation);
|
|
330
|
+
const trigger = value.arguments?.[0]?.type === "StringLiteral" ? value.arguments[0].value : void 0;
|
|
331
|
+
if (worker || job || route && typeof trigger === "string" && trigger.startsWith("/")) {
|
|
332
|
+
for (const argument of value.arguments ?? []) {
|
|
333
|
+
if ((argument.type === "ArrowFunctionExpression" || argument.type === "FunctionExpression") && argument.body?.type === "BlockStatement" && argument.body.loc) {
|
|
334
|
+
const prefix = worker || job ? "job" : "route";
|
|
335
|
+
result.push({ name: `${prefix}:${operation ?? "Worker"}:${trigger ?? "callback"}@${argument.loc.start.line}`, startLine: argument.loc.start.line, endLine: argument.body.loc.end.line });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const [key, child] of Object.entries(value)) {
|
|
341
|
+
if (["loc", "start", "end", "comments", "tokens"].includes(key)) continue;
|
|
342
|
+
if (Array.isArray(child)) child.forEach(visit);
|
|
343
|
+
else if (child && typeof child === "object") visit(child);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
visit(file.program);
|
|
267
347
|
return result;
|
|
268
348
|
}
|
|
269
349
|
var init_entrypoints = __esm({
|
|
@@ -332,14 +412,14 @@ var init_stripe = __esm({
|
|
|
332
412
|
evidence: []
|
|
333
413
|
};
|
|
334
414
|
}
|
|
335
|
-
const get = async (
|
|
336
|
-
const response = await ctx.fetch(`https://api.stripe.com${
|
|
415
|
+
const get = async (path2) => {
|
|
416
|
+
const response = await ctx.fetch(`https://api.stripe.com${path2}`, {
|
|
337
417
|
method: "GET",
|
|
338
418
|
headers: { Authorization: `Bearer ${key}` },
|
|
339
419
|
signal: ctx.signal
|
|
340
420
|
});
|
|
341
421
|
if (!response.ok) {
|
|
342
|
-
throw new Error(`Stripe ${
|
|
422
|
+
throw new Error(`Stripe ${path2} -> HTTP ${response.status}`);
|
|
343
423
|
}
|
|
344
424
|
return await response.json();
|
|
345
425
|
};
|
|
@@ -494,19 +574,48 @@ var init_github = __esm({
|
|
|
494
574
|
function planned(unknown) {
|
|
495
575
|
return { supported: [], verified: [], unknown, implemented: false };
|
|
496
576
|
}
|
|
577
|
+
function partial(adapter) {
|
|
578
|
+
return { implemented: true, supported: CONTRACTS[adapter].operations.map((operation) => `${operation.method} ${operation.endpoint}`), verified: [], unknown: [...CONTRACTS[adapter].limitations, "Customer workflow coverage requires preparation and execution"] };
|
|
579
|
+
}
|
|
497
580
|
var NO_TWIN, PROVIDERS;
|
|
498
581
|
var init_catalog = __esm({
|
|
499
582
|
"src/discovery/providers/catalog.ts"() {
|
|
500
583
|
"use strict";
|
|
584
|
+
init_contracts();
|
|
501
585
|
init_stripe();
|
|
502
586
|
init_github();
|
|
503
587
|
NO_TWIN = ["all behavior (no twin implemented)"];
|
|
504
588
|
PROVIDERS = [
|
|
589
|
+
{
|
|
590
|
+
id: "betterstack",
|
|
591
|
+
title: "Better Stack",
|
|
592
|
+
category: "observability",
|
|
593
|
+
twin: planned(NO_TWIN),
|
|
594
|
+
signatures: {
|
|
595
|
+
packages: { node: ["@logtail/node", "@logtail/browser"], python: ["logtail-python"], go: ["github.com/BetterStackHQ/slog-betterstack"] },
|
|
596
|
+
imports: ["@logtail/node", "@logtail/browser", "logtail"],
|
|
597
|
+
hosts: ["telemetry.betterstack.com", "in.logs.betterstack.com", "uptime.betterstack.com"],
|
|
598
|
+
envPrefixes: ["BETTERSTACK_", "LOGTAIL_"]
|
|
599
|
+
},
|
|
600
|
+
docs: "https://betterstack.com/docs/"
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
id: "opentelemetry",
|
|
604
|
+
title: "OpenTelemetry",
|
|
605
|
+
category: "observability",
|
|
606
|
+
twin: planned(NO_TWIN),
|
|
607
|
+
signatures: {
|
|
608
|
+
packages: { node: ["@opentelemetry/api", "@opentelemetry/sdk-node", "@opentelemetry/exporter-trace-otlp-http"], python: ["opentelemetry-api", "opentelemetry-sdk"], go: ["go.opentelemetry.io/otel"] },
|
|
609
|
+
imports: ["@opentelemetry/", "opentelemetry", "go\\.opentelemetry\\.io/otel"],
|
|
610
|
+
envPrefixes: ["OTEL_"]
|
|
611
|
+
},
|
|
612
|
+
docs: "https://opentelemetry.io/docs/"
|
|
613
|
+
},
|
|
505
614
|
{
|
|
506
615
|
id: "stripe",
|
|
507
616
|
title: "Stripe",
|
|
508
617
|
category: "payments",
|
|
509
|
-
twin:
|
|
618
|
+
twin: partial("stripe"),
|
|
510
619
|
probe: stripeProbe,
|
|
511
620
|
signatures: {
|
|
512
621
|
packages: {
|
|
@@ -529,7 +638,7 @@ var init_catalog = __esm({
|
|
|
529
638
|
id: "slack",
|
|
530
639
|
title: "Slack",
|
|
531
640
|
category: "comms",
|
|
532
|
-
twin:
|
|
641
|
+
twin: partial("slack"),
|
|
533
642
|
signatures: {
|
|
534
643
|
packages: {
|
|
535
644
|
node: ["@slack/web-api", "@slack/bolt", "@slack/events-api", "@slack/socket-mode"],
|
|
@@ -783,17 +892,17 @@ var init_catalog = __esm({
|
|
|
783
892
|
});
|
|
784
893
|
|
|
785
894
|
// src/inventory/store.ts
|
|
786
|
-
import { mkdir, readFile as
|
|
787
|
-
import { join as
|
|
895
|
+
import { mkdir as mkdir3, readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
|
|
896
|
+
import { join as join8 } from "path";
|
|
788
897
|
function storeDir(cwd) {
|
|
789
|
-
return
|
|
898
|
+
return join8(cwd, STORE_DIR);
|
|
790
899
|
}
|
|
791
900
|
function storePath(cwd, name) {
|
|
792
|
-
return
|
|
901
|
+
return join8(storeDir(cwd), name);
|
|
793
902
|
}
|
|
794
|
-
async function readJson(
|
|
903
|
+
async function readJson(path2) {
|
|
795
904
|
try {
|
|
796
|
-
return JSON.parse(await
|
|
905
|
+
return JSON.parse(await readFile7(path2, "utf8"));
|
|
797
906
|
} catch {
|
|
798
907
|
return void 0;
|
|
799
908
|
}
|
|
@@ -814,6 +923,10 @@ var init_store = __esm({
|
|
|
814
923
|
"cache/",
|
|
815
924
|
"logs/",
|
|
816
925
|
"connections/",
|
|
926
|
+
"observations.local.json",
|
|
927
|
+
"explorations/",
|
|
928
|
+
"control.json",
|
|
929
|
+
"runs/",
|
|
817
930
|
""
|
|
818
931
|
].join("\n");
|
|
819
932
|
readDecisions = (cwd) => readJson(storePath(cwd, "decisions.json"));
|
|
@@ -840,15 +953,15 @@ async function projectProducts(cwd, data, base) {
|
|
|
840
953
|
const files = await createFileIndex(cwd);
|
|
841
954
|
const paths = (await files.walk()).filter((p) => /\.(?:ts|tsx|js|mjs|py)$/.test(p) && !/(?:^|\/)(?:artifacts|tests?|testbed|docs?|fixtures?|examples?|public|assets)(?:\/|$)|\.(?:test|spec)\.|fixtures?\./i.test(p)).slice(0, 2500);
|
|
842
955
|
const found = /* @__PURE__ */ new Set();
|
|
843
|
-
for (const
|
|
844
|
-
const source = await files.read(
|
|
956
|
+
for (const path2 of paths) {
|
|
957
|
+
const source = await files.read(path2, 2e5);
|
|
845
958
|
if (!source) continue;
|
|
846
959
|
for (const p of products) if (p.pattern.test(source)) found.add(p.key);
|
|
847
960
|
}
|
|
848
961
|
const mapped = workflows.map((w) => {
|
|
849
962
|
if (!w.tools.includes(google.id)) return w;
|
|
850
|
-
const [
|
|
851
|
-
return { w, path, fn };
|
|
963
|
+
const [path2, fn] = (w.entrypoint ?? "").split("#");
|
|
964
|
+
return { w, path: path2, fn };
|
|
852
965
|
});
|
|
853
966
|
const finalWorkflows = [];
|
|
854
967
|
let unresolved = false;
|
|
@@ -857,17 +970,17 @@ async function projectProducts(cwd, data, base) {
|
|
|
857
970
|
finalWorkflows.push(value);
|
|
858
971
|
continue;
|
|
859
972
|
}
|
|
860
|
-
const { w, path, fn } = value;
|
|
861
|
-
const source =
|
|
973
|
+
const { w, path: path2, fn } = value;
|
|
974
|
+
const source = path2 ? await files.read(path2, 2e5) : void 0;
|
|
862
975
|
const entry = source && fn ? exportedEntrypoints(source).find((e) => e.name === fn) : void 0;
|
|
863
976
|
const body2 = source && entry ? source.split("\n").slice(entry.startLine - 1, entry.endLine).join("\n") : fn ? "" : source ?? "";
|
|
864
|
-
const
|
|
865
|
-
if (!
|
|
977
|
+
const matches2 = products.filter((p) => found.has(p.key) && p.pattern.test(body2)).map((p) => google.id + ":" + p.key);
|
|
978
|
+
if (!matches2.length) {
|
|
866
979
|
unresolved = true;
|
|
867
980
|
finalWorkflows.push(w);
|
|
868
981
|
continue;
|
|
869
982
|
}
|
|
870
|
-
finalWorkflows.push({ ...w, tools: [...new Set(w.tools.flatMap((id) => id === google.id ?
|
|
983
|
+
finalWorkflows.push({ ...w, tools: [...new Set(w.tools.flatMap((id) => id === google.id ? matches2 : [id]))], steps: w.steps.flatMap((s) => s.tool === google.id ? matches2.map((tool) => ({ ...s, tool })) : [s]) });
|
|
871
984
|
}
|
|
872
985
|
const productTools = products.filter((p) => found.has(p.key)).map((p) => ({ ...google, id: google.id + ":" + p.key, title: p.title }));
|
|
873
986
|
return discoveryTransfer({ ...data, tools: [...tools.filter((t) => t.id !== google.id || unresolved || !productTools.length).map((t) => t.id === google.id ? { ...t, title: "Google API (unspecified)" } : t), ...productTools], workflows: finalWorkflows });
|
|
@@ -901,8 +1014,8 @@ var init_sanitize = __esm({
|
|
|
901
1014
|
});
|
|
902
1015
|
|
|
903
1016
|
// src/commands/dashboard.ts
|
|
904
|
-
import { cp, mkdir as
|
|
905
|
-
import { basename, join as
|
|
1017
|
+
import { cp, mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "fs/promises";
|
|
1018
|
+
import { basename, join as join9 } from "path";
|
|
906
1019
|
import { fileURLToPath } from "url";
|
|
907
1020
|
function dashboardInventory(cwd, inventory) {
|
|
908
1021
|
return JSON.parse(sanitizeOutput(JSON.stringify({
|
|
@@ -938,8 +1051,8 @@ __export(session_exports, {
|
|
|
938
1051
|
writeSessionTask: () => writeSessionTask
|
|
939
1052
|
});
|
|
940
1053
|
import { spawn as spawn2, spawnSync } from "child_process";
|
|
941
|
-
import { writeFile as
|
|
942
|
-
import { join as
|
|
1054
|
+
import { writeFile as writeFile6, readFile as readFile9 } from "fs/promises";
|
|
1055
|
+
import { join as join10 } from "path";
|
|
943
1056
|
function sessionTask() {
|
|
944
1057
|
return `# Prepare this repository for AgentTwin
|
|
945
1058
|
|
|
@@ -964,30 +1077,30 @@ function installedOpenCode() {
|
|
|
964
1077
|
return spawnSync("opencode", ["--version"], { stdio: "ignore", timeout: 5e3 }).status === 0;
|
|
965
1078
|
}
|
|
966
1079
|
async function writeSessionTask(cwd) {
|
|
967
|
-
const
|
|
968
|
-
await
|
|
969
|
-
return
|
|
1080
|
+
const path2 = join10(cwd, ".agenttwin/AGENT-TASK.md");
|
|
1081
|
+
await writeFile6(path2, sessionTask(), "utf8");
|
|
1082
|
+
return path2;
|
|
970
1083
|
}
|
|
971
1084
|
async function launchSession(cwd) {
|
|
972
1085
|
if (!installedOpenCode()) throw new Error("OpenCode is not installed. Open .agenttwin/AGENT-TASK.md with your coding agent, or install OpenCode and retry init --agent opencode.");
|
|
973
1086
|
const prompt = "Read .agenttwin/AGENT-TASK.md and follow it to discover and prepare this project for AgentTwin. Start by analyzing the repository and presenting workflow findings and missing execution requirements.";
|
|
974
|
-
await new Promise((
|
|
1087
|
+
await new Promise((resolve4, reject) => {
|
|
975
1088
|
const child = spawn2("opencode", ["--prompt", prompt], { cwd, stdio: "inherit", shell: false });
|
|
976
1089
|
child.once("error", reject);
|
|
977
|
-
child.once("exit", (code, signal) => code === 0 ?
|
|
1090
|
+
child.once("exit", (code, signal) => code === 0 ? resolve4() : reject(new Error(`Coding session ended ${signal || `with exit code ${code}`}. Discovery files remain available.`)));
|
|
978
1091
|
});
|
|
979
1092
|
}
|
|
980
1093
|
async function loadDashboardDiscovery(cwd, inventory) {
|
|
981
1094
|
let previousHead;
|
|
982
1095
|
try {
|
|
983
|
-
previousHead = JSON.parse(await
|
|
1096
|
+
previousHead = JSON.parse(await readFile9(join10(cwd, ".agenttwin/git.json"), "utf8")).head;
|
|
984
1097
|
} catch {
|
|
985
1098
|
}
|
|
986
1099
|
const git = await captureGit(cwd, previousHead);
|
|
987
1100
|
const base = discoveryTransfer({ ...dashboardInventory(cwd, inventory), ...git ? { git } : {} });
|
|
988
1101
|
let raw;
|
|
989
1102
|
try {
|
|
990
|
-
raw = await
|
|
1103
|
+
raw = await readFile9(join10(cwd, ".agenttwin/agent-discovery.json"), "utf8");
|
|
991
1104
|
} catch (error) {
|
|
992
1105
|
if (error.code === "ENOENT") return projectProducts(cwd, base, base);
|
|
993
1106
|
throw error;
|
|
@@ -1013,13 +1126,206 @@ var init_session = __esm({
|
|
|
1013
1126
|
}
|
|
1014
1127
|
});
|
|
1015
1128
|
|
|
1129
|
+
// src/runtime/workspace-profile.ts
|
|
1130
|
+
function validateWorkspaceProfile(input) {
|
|
1131
|
+
const allowed = ["name", "workspace", "company", "website", "role", "teamSize", "useCase", "complete"];
|
|
1132
|
+
if (Object.keys(input).some((key) => !allowed.includes(key))) throw new Error("Unexpected profile field.");
|
|
1133
|
+
const text = (key, max, required = false) => {
|
|
1134
|
+
const value = input[key] === void 0 ? "" : input[key];
|
|
1135
|
+
if (typeof value !== "string" || value.trim().length > max || /[\u0000-\u001f\u007f]/.test(value) || required && !value.trim()) throw new Error(`Enter a valid ${key}.`);
|
|
1136
|
+
return value.trim();
|
|
1137
|
+
};
|
|
1138
|
+
const name = text("name", 100, true), workspace = text("workspace", 100, true);
|
|
1139
|
+
const website = text("website", 254);
|
|
1140
|
+
if (website) {
|
|
1141
|
+
let url;
|
|
1142
|
+
try {
|
|
1143
|
+
url = new URL(website);
|
|
1144
|
+
} catch {
|
|
1145
|
+
throw new Error("Enter a full company website URL.");
|
|
1146
|
+
}
|
|
1147
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new Error("Enter a public company website URL.");
|
|
1148
|
+
}
|
|
1149
|
+
const teamSize = text("teamSize", 20);
|
|
1150
|
+
if (!["", "1", "2\u201310", "11\u201350", "51\u2013200", "201\u20131000", "1000+"].includes(teamSize)) throw new Error("Choose a team size.");
|
|
1151
|
+
if (input.complete !== void 0 && typeof input.complete !== "boolean") throw new Error("Invalid completion state.");
|
|
1152
|
+
return { name, workspace, complete: input.complete === true, context: { company: text("company", 100), website, role: text("role", 100), teamSize, useCase: text("useCase", 500) } };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// src/runtime/email-verification.ts
|
|
1156
|
+
import { createHmac, randomBytes, randomInt, timingSafeEqual } from "crypto";
|
|
1157
|
+
function normalizedEmail(value) {
|
|
1158
|
+
const email = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
1159
|
+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error("Enter a valid email address.");
|
|
1160
|
+
return email;
|
|
1161
|
+
}
|
|
1162
|
+
function createEmailVerification(options, secret) {
|
|
1163
|
+
const hash3 = (value) => createHmac("sha256", secret).update(value).digest("hex");
|
|
1164
|
+
const matches2 = (value, expected) => {
|
|
1165
|
+
const a = Buffer.from(hash3(value), "hex"), b = Buffer.from(expected, "hex");
|
|
1166
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1167
|
+
};
|
|
1168
|
+
return {
|
|
1169
|
+
enabled: !!options,
|
|
1170
|
+
challenge(email, origin) {
|
|
1171
|
+
const id = randomBytes(24).toString("hex"), binding = randomBytes(32).toString("hex");
|
|
1172
|
+
const code = String(randomInt(0, 1e8)).padStart(8, "0");
|
|
1173
|
+
const challenge = { email, origin, binding: hash3(binding), codeHash: hash3(id + ":" + code), expires: Date.now() + 10 * 6e4, attempts: 0, sentAt: Date.now() };
|
|
1174
|
+
return { id, binding, code, challenge };
|
|
1175
|
+
},
|
|
1176
|
+
bound(challenge, binding, origin) {
|
|
1177
|
+
return !!challenge && challenge.expires > Date.now() && challenge.origin === origin && matches2(binding, challenge.binding);
|
|
1178
|
+
},
|
|
1179
|
+
verify(id, challenge, code) {
|
|
1180
|
+
if (challenge.verified || challenge.attempts >= 5 || challenge.expires <= Date.now()) return false;
|
|
1181
|
+
challenge.attempts++;
|
|
1182
|
+
if (typeof code !== "string" || !/^\d{8}$/.test(code) || !matches2(id + ":" + code, challenge.codeHash)) return false;
|
|
1183
|
+
challenge.verified = true;
|
|
1184
|
+
return true;
|
|
1185
|
+
},
|
|
1186
|
+
async deliver(email, code, id) {
|
|
1187
|
+
if (!options) throw new Error("email_not_configured");
|
|
1188
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
1189
|
+
method: "POST",
|
|
1190
|
+
headers: { authorization: "Bearer " + options.apiKey, "content-type": "application/json", "Idempotency-Key": "agenttwin-login-" + id },
|
|
1191
|
+
body: JSON.stringify({ from: options.from, to: [email], subject: "Your AgentTwin verification code", text: `Your AgentTwin verification code is ${code}.
|
|
1192
|
+
|
|
1193
|
+
It expires in 10 minutes. Do not share this code. If you did not request it, ignore this email.` }),
|
|
1194
|
+
signal: AbortSignal.timeout(15e3)
|
|
1195
|
+
});
|
|
1196
|
+
if (!response.ok) throw new Error("email_delivery_failed");
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// src/runtime/social-auth.ts
|
|
1202
|
+
import * as oidc from "openid-client";
|
|
1203
|
+
import { randomBytes as randomBytes2, createHash, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1204
|
+
var hash = (value) => createHash("sha256").update(value).digest();
|
|
1205
|
+
var validUrl = (value) => {
|
|
1206
|
+
const url = new URL(value);
|
|
1207
|
+
if (url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname))) throw new Error("Authentication URLs must use HTTPS (or local HTTP).");
|
|
1208
|
+
return url;
|
|
1209
|
+
};
|
|
1210
|
+
function createSocialAuth(options) {
|
|
1211
|
+
const base = options ? validUrl(options.publicUrl) : void 0;
|
|
1212
|
+
if (base && base.pathname !== "/") throw new Error("Authentication public URL must be an origin.");
|
|
1213
|
+
if (options?.sso) validUrl(options.sso.issuer);
|
|
1214
|
+
const transactions = /* @__PURE__ */ new Map();
|
|
1215
|
+
const configs = /* @__PURE__ */ new Map();
|
|
1216
|
+
const enabled = (provider) => !!options?.[provider];
|
|
1217
|
+
const callback = (provider) => new URL("/v1/auth/oauth/" + provider + "/callback", base).href;
|
|
1218
|
+
const config = (provider) => {
|
|
1219
|
+
let pending = configs.get(provider);
|
|
1220
|
+
if (!pending) {
|
|
1221
|
+
const credentials2 = options[provider];
|
|
1222
|
+
const issuer = provider === "google" ? "https://accounts.google.com" : options.sso.issuer;
|
|
1223
|
+
pending = oidc.discovery(new URL(issuer), credentials2.clientId, credentials2.clientSecret, void 0, { timeout: 10, execute: [oidc.enableNonRepudiationChecks] });
|
|
1224
|
+
configs.set(provider, pending);
|
|
1225
|
+
void pending.catch(() => configs.delete(provider));
|
|
1226
|
+
}
|
|
1227
|
+
return pending;
|
|
1228
|
+
};
|
|
1229
|
+
return {
|
|
1230
|
+
providers: () => ["google", "github", "sso"].map((id) => ({ id, enabled: enabled(id) })),
|
|
1231
|
+
async begin(provider, origin, signup, onboarding) {
|
|
1232
|
+
if (!enabled(provider)) throw new Error("provider_not_configured");
|
|
1233
|
+
for (const [key, value] of transactions) if (value.expires < Date.now()) transactions.delete(key);
|
|
1234
|
+
if (transactions.size >= 1e3) throw new Error("auth_busy");
|
|
1235
|
+
const state = oidc.randomState(), verifier = oidc.randomPKCECodeVerifier(), nonce = oidc.randomNonce(), binding = randomBytes2(32).toString("hex");
|
|
1236
|
+
const challenge = await oidc.calculatePKCECodeChallenge(verifier);
|
|
1237
|
+
let url;
|
|
1238
|
+
const parameters = { redirect_uri: callback(provider), state, code_challenge: challenge, code_challenge_method: "S256" };
|
|
1239
|
+
if (provider === "github") {
|
|
1240
|
+
url = new URL("https://github.com/login/oauth/authorize");
|
|
1241
|
+
Object.entries({ ...parameters, client_id: options.github.clientId, scope: "read:user user:email" }).forEach(([key, value]) => url.searchParams.set(key, value));
|
|
1242
|
+
} else url = oidc.buildAuthorizationUrl(await config(provider), { ...parameters, nonce, scope: "openid email profile" });
|
|
1243
|
+
if (transactions.size >= 1e3) throw new Error("auth_busy");
|
|
1244
|
+
transactions.set(state, { provider, origin, signup, onboarding, verifier, nonce, binding: hash(binding).toString("hex"), expires: Date.now() + 6e5 });
|
|
1245
|
+
return { url: url.href, binding, secure: base.protocol === "https:" };
|
|
1246
|
+
},
|
|
1247
|
+
// Bind the state to this browser before exchanging any provider code. Consume once.
|
|
1248
|
+
take(provider, query, cookie) {
|
|
1249
|
+
if (query.getAll("state").length !== 1) throw new Error("invalid_state");
|
|
1250
|
+
const state = query.get("state"), transaction = transactions.get(state);
|
|
1251
|
+
if (!transaction || transaction.provider !== provider || transaction.expires < Date.now() || !timingSafeEqual2(hash(cookie), Buffer.from(transaction.binding, "hex"))) throw new Error("invalid_state");
|
|
1252
|
+
transactions.delete(state);
|
|
1253
|
+
return transaction;
|
|
1254
|
+
},
|
|
1255
|
+
async finish(transaction, query) {
|
|
1256
|
+
const provider = transaction.provider;
|
|
1257
|
+
let subject, email, verified, name, issuer;
|
|
1258
|
+
if (provider === "github") {
|
|
1259
|
+
if (query.get("error") || query.getAll("code").length !== 1) throw new Error("authorization_failed");
|
|
1260
|
+
const response = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: options.github.clientId, client_secret: options.github.clientSecret, code: query.get("code"), redirect_uri: callback(provider), code_verifier: transaction.verifier }), signal: AbortSignal.timeout(1e4) });
|
|
1261
|
+
const token = await response.json();
|
|
1262
|
+
if (!response.ok || !token.access_token || token.token_type?.toLowerCase() !== "bearer") throw new Error("authorization_failed");
|
|
1263
|
+
const headers = { authorization: "Bearer " + token.access_token, accept: "application/vnd.github+json", "user-agent": "AgentTwin", "X-GitHub-Api-Version": "2022-11-28" };
|
|
1264
|
+
const [user, addresses] = await Promise.all([fetch("https://api.github.com/user", { headers, signal: AbortSignal.timeout(1e4) }), fetch("https://api.github.com/user/emails", { headers, signal: AbortSignal.timeout(1e4) })]);
|
|
1265
|
+
if (!user.ok || !addresses.ok) throw new Error("authorization_failed");
|
|
1266
|
+
const profile = await user.json();
|
|
1267
|
+
const emails = await addresses.json();
|
|
1268
|
+
const address = emails.find((item) => item.primary && item.verified);
|
|
1269
|
+
if (!Number.isSafeInteger(profile.id) || profile.id <= 0) throw new Error("authorization_failed");
|
|
1270
|
+
subject = String(profile.id);
|
|
1271
|
+
issuer = "https://github.com";
|
|
1272
|
+
email = address?.email;
|
|
1273
|
+
verified = address?.verified;
|
|
1274
|
+
name = profile.name || profile.login;
|
|
1275
|
+
} else {
|
|
1276
|
+
const configuration = await config(provider);
|
|
1277
|
+
const currentUrl = new URL(callback(provider));
|
|
1278
|
+
currentUrl.search = query.toString();
|
|
1279
|
+
const tokens = await oidc.authorizationCodeGrant(configuration, currentUrl, { pkceCodeVerifier: transaction.verifier, expectedState: query.get("state"), expectedNonce: transaction.nonce, idTokenExpected: true });
|
|
1280
|
+
const claims = tokens.claims();
|
|
1281
|
+
if (!claims) throw new Error("authorization_failed");
|
|
1282
|
+
subject = claims.sub;
|
|
1283
|
+
issuer = claims.iss;
|
|
1284
|
+
email = claims.email;
|
|
1285
|
+
verified = claims.email_verified;
|
|
1286
|
+
name = claims.name;
|
|
1287
|
+
}
|
|
1288
|
+
if (verified !== true || typeof email !== "string" || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error("verified_email_required");
|
|
1289
|
+
return { provider, issuer, subject, email: email.trim().toLowerCase(), name: (typeof name === "string" && name.trim() ? name.trim() : email.split("@")[0]).slice(0, 100) };
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// src/runtime/auth.ts
|
|
1295
|
+
import { randomBytes as randomBytes3, scrypt, timingSafeEqual as timingSafeEqual3, createHash as createHash2 } from "crypto";
|
|
1296
|
+
var digest = (value) => createHash2("sha256").update(value).digest("hex");
|
|
1297
|
+
var derive = (password, salt) => new Promise((resolve4, reject) => scrypt(password, salt, 64, { N: 131072, r: 8, p: 1, maxmem: 160 * 1024 * 1024 }, (error, key) => error ? reject(error) : resolve4(key)));
|
|
1298
|
+
async function passwordHash(password) {
|
|
1299
|
+
const salt = randomBytes3(16).toString("hex");
|
|
1300
|
+
return `${salt}:${(await derive(password, salt)).toString("hex")}`;
|
|
1301
|
+
}
|
|
1302
|
+
async function passwordMatches(password, stored) {
|
|
1303
|
+
const [salt, key] = stored.split(":");
|
|
1304
|
+
const actual = await derive(password, salt);
|
|
1305
|
+
const expected = Buffer.from(key, "hex");
|
|
1306
|
+
return expected.length === actual.length && timingSafeEqual3(expected, actual);
|
|
1307
|
+
}
|
|
1308
|
+
function credentials(input) {
|
|
1309
|
+
if (!input || typeof input !== "object") throw new Error("Enter your email and password.");
|
|
1310
|
+
const data = input;
|
|
1311
|
+
const email = typeof data.email === "string" ? data.email.trim().toLowerCase() : "";
|
|
1312
|
+
const password = typeof data.password === "string" ? data.password : "";
|
|
1313
|
+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || password.length < 12 || password.length > 128) throw new Error("Use a valid email and a password with 12\u2013128 characters.");
|
|
1314
|
+
return { email, password };
|
|
1315
|
+
}
|
|
1316
|
+
var challengeStore = (map, key, value, ttlMs) => {
|
|
1317
|
+
map[key] = { value, expires: Date.now() + ttlMs };
|
|
1318
|
+
for (const [k, v] of Object.entries(map)) if (v.expires < Date.now()) delete map[k];
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1016
1321
|
// src/runtime/gateway.ts
|
|
1017
1322
|
init_dashboard_transfer();
|
|
1323
|
+
import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
|
|
1018
1324
|
import { createServer as createServer2 } from "http";
|
|
1019
|
-
import { createHash as
|
|
1020
|
-
import { mkdir as
|
|
1021
|
-
import { resolve as
|
|
1022
|
-
import { tmpdir as
|
|
1325
|
+
import { createHash as createHash5, randomBytes as randomBytes6, randomUUID as randomUUID2, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
1326
|
+
import { mkdir as mkdir6, readFile as readFile11, writeFile as writeFile8, rename, mkdtemp as mkdtemp3, rm as rm4 } from "fs/promises";
|
|
1327
|
+
import { resolve as resolve3, join as join12, dirname as dirname2, extname } from "path";
|
|
1328
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
1023
1329
|
|
|
1024
1330
|
// src/runtime/transcription.ts
|
|
1025
1331
|
var transcriptionConfiguration = () => ({ available: Boolean(process.env.OPENAI_API_KEY), model: "gpt-4o-mini-transcribe" });
|
|
@@ -1095,7 +1401,7 @@ function parseWorld(value) {
|
|
|
1095
1401
|
if (!object(service) || typeof service.name !== "string" || !namePattern.test(service.name) || services.has(service.name)) invalid("service names must be unique lowercase identifiers");
|
|
1096
1402
|
const item = service;
|
|
1097
1403
|
if (!Array.isArray(item.collections) || !item.collections.length || item.collections.length > 20) invalid("each service needs 1\u201320 collections");
|
|
1098
|
-
if (item.adapter !== void 0 && !["json-collection", "slack"].includes(String(item.adapter))) invalid("unsupported service adapter");
|
|
1404
|
+
if (item.adapter !== void 0 && !["json-collection", "slack", "stripe"].includes(String(item.adapter))) invalid("unsupported service adapter");
|
|
1099
1405
|
const paths = /* @__PURE__ */ new Set();
|
|
1100
1406
|
for (const entry of item.collections) {
|
|
1101
1407
|
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");
|
|
@@ -1104,6 +1410,7 @@ function parseWorld(value) {
|
|
|
1104
1410
|
paths.add(collection.path);
|
|
1105
1411
|
}
|
|
1106
1412
|
if (item.adapter === "slack" && (!paths.has("/messages") || !paths.has("/channels") || paths.size !== 2)) invalid("slack adapter requires /messages and /channels collections");
|
|
1413
|
+
if (item.adapter === "stripe" && (!paths.has("/v1/customers") || !paths.has("/v1/payment_intents") || paths.size !== 2)) invalid("stripe adapter requires /v1/customers and /v1/payment_intents collections");
|
|
1107
1414
|
services.set(item.name, paths);
|
|
1108
1415
|
}
|
|
1109
1416
|
const known = (entry) => typeof entry.service === "string" && typeof entry.path === "string" && services.get(entry.service)?.has(entry.path);
|
|
@@ -1111,6 +1418,19 @@ function parseWorld(value) {
|
|
|
1111
1418
|
for (const assertion of v.assertions) {
|
|
1112
1419
|
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");
|
|
1113
1420
|
}
|
|
1421
|
+
const policyIds = /* @__PURE__ */ new Set();
|
|
1422
|
+
for (const field of ["forbiddenEffects", "preservedState"]) {
|
|
1423
|
+
if (v[field] === void 0) continue;
|
|
1424
|
+
if (!Array.isArray(v[field]) || v[field].length > 100) invalid(`${field} must contain at most 100 checks`);
|
|
1425
|
+
for (const entry of v[field]) {
|
|
1426
|
+
if (!object(entry) || typeof entry.id !== "string" || !namePattern.test(entry.id) || policyIds.has(entry.id) || !known(entry) || entry.where !== void 0 && !object(entry.where)) invalid(`${field} requires unique ids, known collections and an optional where object`);
|
|
1427
|
+
const item = entry;
|
|
1428
|
+
if (field === "forbiddenEffects" && !["write", "read", "replay"].includes(String(item.operation))) invalid("forbiddenEffects operation must be write, read or replay");
|
|
1429
|
+
if (Object.keys(item).some((k) => !["id", "service", "path", "where", ...field === "forbiddenEffects" ? ["operation"] : []].includes(k))) invalid(`unknown ${field} field`);
|
|
1430
|
+
policyIds.add(item.id);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (v.scenario !== void 0 && (!object(v.scenario) || typeof v.scenario.id !== "string" || !namePattern.test(v.scenario.id) || !Number.isSafeInteger(v.scenario.version) || Number(v.scenario.version) < 1 || typeof v.scenario.digest !== "string" || !/^[a-f0-9]{64}$/.test(v.scenario.digest) || Object.keys(v.scenario).some((k) => !["id", "version", "digest"].includes(k)))) invalid("scenario requires id, positive version and SHA-256 digest");
|
|
1114
1434
|
if (v.postgres !== void 0) {
|
|
1115
1435
|
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");
|
|
1116
1436
|
for (const a of v.postgres.assertions) {
|
|
@@ -1124,7 +1444,29 @@ function parseWorld(value) {
|
|
|
1124
1444
|
}
|
|
1125
1445
|
}
|
|
1126
1446
|
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");
|
|
1127
|
-
|
|
1447
|
+
if (v.browser !== void 0) {
|
|
1448
|
+
if (!object(v.browser) || typeof v.browser.baseUrl !== "string") invalid("browser requires baseUrl");
|
|
1449
|
+
const browser = v.browser;
|
|
1450
|
+
let u;
|
|
1451
|
+
try {
|
|
1452
|
+
u = new URL(String(v.browser.baseUrl));
|
|
1453
|
+
} catch {
|
|
1454
|
+
invalid("browser baseUrl must be a loopback HTTP URL");
|
|
1455
|
+
}
|
|
1456
|
+
if (u.protocol !== "http:" || u.hostname !== "127.0.0.1" || !u.port || u.username || u.password || u.pathname !== "/" || u.search || u.hash) invalid("browser baseUrl must be http://127.0.0.1:<port>");
|
|
1457
|
+
if (!Array.isArray(browser.steps) || !browser.steps.length || browser.steps.length > 100 || Object.keys(browser).some((k) => !["baseUrl", "steps"].includes(k))) invalid("browser needs 1\u2013100 steps");
|
|
1458
|
+
for (const raw of browser.steps) {
|
|
1459
|
+
const step = raw;
|
|
1460
|
+
if (!object(step) || !["goto", "click", "fill", "expectText"].includes(String(step.action))) invalid("unsupported browser action");
|
|
1461
|
+
if (step.action === "goto") {
|
|
1462
|
+
if (typeof step.path !== "string" || !step.path.startsWith("/") || step.path.startsWith("//") || step.path.includes("\\") || Object.keys(step).some((k) => !["action", "path"].includes(k))) invalid("goto requires a relative application path");
|
|
1463
|
+
} else {
|
|
1464
|
+
if (typeof step.selector !== "string" || !step.selector || step.action === "fill" && typeof step.value !== "string" || step.action === "expectText" && typeof step.text !== "string" || Object.keys(step).some((k) => !["action", "selector", ...step.action === "fill" ? ["value"] : step.action === "expectText" ? ["text"] : []].includes(k))) invalid("invalid browser selector/action parameters");
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
if (v.background !== void 0 && (!object(v.background) || !known(v.background) || !Number.isSafeInteger(v.background.count) || Number(v.background.count) < 0 || !Number.isInteger(v.background.timeoutMs) || Number(v.background.timeoutMs) < 1 || Number(v.background.timeoutMs) > Number(v.timeoutSeconds) * 1e3 || v.background.where !== void 0 && !object(v.background.where) || Object.keys(v.background).some((k) => !["service", "path", "count", "where", "timeoutMs"].includes(k)))) invalid("background requires a known collection, expected count, optional where, and bounded timeoutMs");
|
|
1469
|
+
const keys = ["schema", "name", "workflowId", "files", "command", "timeoutSeconds", "browser", "background", "services", "assertions", "forbiddenEffects", "preservedState", "scenario", "postgres", "redis", "fault"];
|
|
1128
1470
|
if (Object.keys(v).some((k) => !keys.includes(k))) invalid("unknown world field");
|
|
1129
1471
|
return v;
|
|
1130
1472
|
}
|
|
@@ -1141,9 +1483,18 @@ function portableLocalReceipt(value) {
|
|
|
1141
1483
|
const uuid = (v) => typeof v === "string" && /^[a-f0-9-]{36}$/.test(v);
|
|
1142
1484
|
if (!r || r.schema !== "agenttwin/run-v1" || r.runtime !== "local" || !uuid(r.runId) || typeof r.name !== "string" || !/^[a-z][a-z0-9_]{0,39}$/.test(r.name) || typeof r.project !== "string" || r.project.length > 240 || /[\x00-\x1f]/.test(r.project) || !["passed", "failed", "error", "incomplete"].includes(r.status) || typeof r.createdAt !== "string" || !Number.isFinite(Date.parse(r.createdAt)) || !Number.isFinite(r.durationMs) || r.durationMs < 0 || !r.application || !Array.isArray(r.application.command) || r.application.command[0] !== "node" || r.application.command.some((v) => typeof v !== "string") || !Array.isArray(r.assertions) || !r.assertions.length || r.assertions.length > 100 || r.assertions.some((a) => typeof a.service !== "string" || typeof a.path !== "string" || typeof a.passed !== "boolean" || !Number.isSafeInteger(a.count) || a.count < 0 || !Number.isSafeInteger(a.actual) || a.actual < 0 || a.passed !== (a.count === a.actual)) || !Array.isArray(r.events) || r.events.length > 5e4 || !r.finalState || typeof r.finalState !== "object" || !r.initialState || typeof r.initialState !== "object" || !r.sourceDigests || typeof r.sourceDigests !== "object" || !/^[a-f0-9]{64}$/.test(r.specDigest)) throw new Error("Invalid CLI receipt");
|
|
1143
1485
|
if (r.status === "passed" && (r.application.exitCode !== 0 || r.application.timedOut || r.assertions.some((a) => !a.passed) || r.unsupported?.length || r.fault && !r.fault.triggered)) throw new Error("Passed receipt contradicts recorded evidence");
|
|
1144
|
-
if (
|
|
1486
|
+
if (r.browser !== void 0) {
|
|
1487
|
+
const b = r.browser;
|
|
1488
|
+
if (!b || b.engine !== "playwright-chromium" || typeof b.passed !== "boolean" || !Array.isArray(b.steps) || b.steps.length > 100 || b.steps.some((step) => !step || !["goto", "click", "fill", "expectText"].includes(step.action) || typeof step.passed !== "boolean") || b.error !== void 0 && (typeof b.error !== "string" || b.error.length > 4096) || b.screenshot !== void 0 && (!b.screenshot || b.screenshot.mimeType !== "image/jpeg" || typeof b.screenshot.base64 !== "string" || b.screenshot.base64.length > 14e5 || !/^[A-Za-z0-9+/]*={0,2}$/.test(b.screenshot.base64) || Buffer.from(b.screenshot.base64, "base64").toString("base64") !== b.screenshot.base64) || r.status === "passed" && (!b.passed || b.steps.some((step) => !step.passed))) throw new Error("Invalid or contradictory browser evidence");
|
|
1489
|
+
}
|
|
1490
|
+
if (r.background !== void 0 && (!r.background || typeof r.background.service !== "string" || typeof r.background.path !== "string" || !Number.isSafeInteger(r.background.count) || !Number.isSafeInteger(r.background.actual) || r.background.actual < 0 || r.background.count < 0 || typeof r.background.passed !== "boolean" || r.background.passed !== (r.background.count === r.background.actual) || r.status === "passed" && !r.background.passed)) throw new Error("Invalid or contradictory background evidence");
|
|
1491
|
+
if (r.executionError !== void 0 && (typeof r.executionError !== "string" || r.executionError.length > 4096 || r.status === "passed")) throw new Error("Invalid execution error evidence");
|
|
1492
|
+
if (r.evaluation !== void 0 && (!r.evaluation || r.evaluation.schema !== "agenttwin/evaluation-v1" || !["passed", "failed", "unsafe"].includes(r.evaluation.outcome) || typeof r.evaluation.taskPassed !== "boolean" || typeof r.evaluation.safe !== "boolean" || !Array.isArray(r.evaluation.checks) || r.evaluation.checks.length > 300 || r.evaluation.checks.some((c) => !c || typeof c.id !== "string" || typeof c.passed !== "boolean" || !["required-outcome", "forbidden-effect", "preserved-state"].includes(c.kind)) || r.status === "passed" && (r.evaluation.outcome !== "passed" || !r.evaluation.safe || !r.evaluation.taskPassed || r.evaluation.checks.some((c) => !c.passed)))) throw new Error("Invalid or contradictory state evaluation");
|
|
1493
|
+
if (Array.isArray(r.sourceDigests) || Object.keys(r.sourceDigests).length > 100 || Object.entries(r.sourceDigests).some(([path2, digest2]) => path2.startsWith("/") || path2.split("/").includes("..") || typeof digest2 !== "string" || !/^[a-f0-9]{64}$/.test(digest2)) || Object.values(r.finalState).some((paths) => !paths || typeof paths !== "object" || Array.isArray(paths) || Object.values(paths).some((rows) => !Array.isArray(rows))) || typeof r.application.stdout !== "string" || typeof r.application.stderr !== "string" || r.application.stdout.length > 65536 || r.application.stderr.length > 65536 || r.events.some((e) => !e || !Number.isSafeInteger(e.sequence) || typeof e.service !== "string" || typeof e.operation !== "string" || typeof e.path !== "string" || typeof e.detail !== "string")) throw new Error("Invalid CLI evidence fields");
|
|
1494
|
+
if (r.events.some((e) => e.source !== void 0 && (!e.source || e.source.provenance !== "application-reported" || typeof e.source.file !== "string" || !Object.hasOwn(r.sourceDigests, e.source.file) || !Number.isSafeInteger(e.source.line) || e.source.line < 1 || e.source.line > 1e6 || !Number.isSafeInteger(e.source.column) || e.source.column < 1 || e.source.column > 1e6))) throw new Error("Invalid reported source evidence");
|
|
1495
|
+
if (r.sourceDirectory !== void 0 && (typeof r.sourceDirectory !== "string" || r.sourceDirectory.length > 1024 || r.sourceDirectory.startsWith("/") || r.sourceDirectory.includes("\\") || r.sourceDirectory.split("/").includes("..") || /[\x00-\x1f]/.test(r.sourceDirectory))) throw new Error("Invalid source directory");
|
|
1145
1496
|
const git = validateGit(r.git);
|
|
1146
|
-
const fields = ["schema", "runId", "name", "workflowId", "project", "createdAt", "durationMs", "status", "application", "assertions", "infrastructureAssertions", "fault", "initialState", "finalState", "events", "unsupported", "coverage", "sourceDigests", "specDigest"];
|
|
1497
|
+
const fields = ["schema", "runId", "name", "workflowId", "project", "createdAt", "durationMs", "status", "application", "assertions", "infrastructureAssertions", "fault", "initialState", "finalState", "events", "unsupported", "coverage", "sourceDigests", "sourceDirectory", "specDigest", "evaluation", "contracts", "scenario", "worldId", "browser", "background", "executionError"];
|
|
1147
1498
|
const result = Object.fromEntries(fields.filter((k) => r[k] !== void 0).map((k) => [k, r[k]]));
|
|
1148
1499
|
Object.assign(result, { runtime: "local", provenance: "CLI-reported execution; not independently attested", isolation: { kind: "process", network: "host network; not isolated", rootFilesystem: "host-accessible" }, ...git ? { git } : {} });
|
|
1149
1500
|
if (Buffer.byteLength(JSON.stringify(result)) > 2 * 1024 * 1024) throw new Error("CLI receipt exceeds 2 MiB");
|
|
@@ -1151,15 +1502,30 @@ function portableLocalReceipt(value) {
|
|
|
1151
1502
|
}
|
|
1152
1503
|
|
|
1153
1504
|
// src/commands/run.ts
|
|
1154
|
-
import { mkdtemp, mkdir as
|
|
1155
|
-
import { tmpdir } from "os";
|
|
1156
|
-
import { basename as basename2, dirname, isAbsolute, join as
|
|
1505
|
+
import { mkdtemp as mkdtemp2, mkdir as mkdir5, readFile as readFile10, writeFile as writeFile7, realpath as realpath2, lstat, rm as rm3, copyFile } from "fs/promises";
|
|
1506
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
1507
|
+
import { basename as basename2, dirname, isAbsolute, join as join11, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
|
|
1157
1508
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1158
|
-
import { createHash, randomUUID } from "crypto";
|
|
1509
|
+
import { createHash as createHash4, randomUUID } from "crypto";
|
|
1159
1510
|
|
|
1160
|
-
// src/runtime/
|
|
1161
|
-
import {
|
|
1511
|
+
// src/runtime/credentials.ts
|
|
1512
|
+
import { homedir } from "os";
|
|
1162
1513
|
import { join as join2 } from "path";
|
|
1514
|
+
import { readFile as readFile2, mkdir, writeFile, chmod, rm } from "fs/promises";
|
|
1515
|
+
var path = () => join2(homedir(), ".agenttwin", "credentials.json");
|
|
1516
|
+
async function savedConnection() {
|
|
1517
|
+
try {
|
|
1518
|
+
const data = JSON.parse(await readFile2(path(), "utf8"));
|
|
1519
|
+
return typeof data.base === "string" && /^at_user_[a-f0-9]{64}$/.test(data.token) ? data : null;
|
|
1520
|
+
} catch (e) {
|
|
1521
|
+
if (e.code === "ENOENT") return null;
|
|
1522
|
+
throw new Error("Cannot read saved login. Run npx agenttwin login again.");
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// src/runtime/remote.ts
|
|
1527
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1528
|
+
import { join as join3 } from "path";
|
|
1163
1529
|
|
|
1164
1530
|
// src/lib/exit.ts
|
|
1165
1531
|
var EXIT = {
|
|
@@ -1193,11 +1559,12 @@ function apiBase(value) {
|
|
|
1193
1559
|
return value.replace(/\/$/, "");
|
|
1194
1560
|
}
|
|
1195
1561
|
async function executeRemote(stage, spec, log, metadata = {}) {
|
|
1196
|
-
const
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1562
|
+
const saved = await savedConnection();
|
|
1563
|
+
const base = apiBase(process.env.AGENTTWIN_API_URL || saved?.base || "https://api.agenttwin.si");
|
|
1564
|
+
const token = process.env.AGENTTWIN_TOKEN || saved?.token;
|
|
1565
|
+
if (!token) throw new CliError("Sign in first with npx agenttwin login", { code: EXIT.BLOCKED });
|
|
1199
1566
|
const files = /* @__PURE__ */ Object.create(null);
|
|
1200
|
-
for (const file of spec.files) files[file] = (await
|
|
1567
|
+
for (const file of spec.files) files[file] = (await readFile3(join3(stage, "app", file))).toString("base64");
|
|
1201
1568
|
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
1202
1569
|
const response = await fetch(`${base}/v1/runs`, { method: "POST", headers, body: JSON.stringify({ spec, files, ...metadata }), redirect: "error", signal: AbortSignal.timeout(15e3) });
|
|
1203
1570
|
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 });
|
|
@@ -1214,17 +1581,184 @@ async function executeRemote(stage, spec, log, metadata = {}) {
|
|
|
1214
1581
|
return state.receipt;
|
|
1215
1582
|
}
|
|
1216
1583
|
if (["error", "cancelled"].includes(state.status)) throw new CliError(state.error ?? "Hosted runtime failed");
|
|
1217
|
-
await new Promise((
|
|
1584
|
+
await new Promise((resolve4) => setTimeout(resolve4, 1e3));
|
|
1218
1585
|
}
|
|
1219
1586
|
throw new CliError(`Timed out waiting for hosted run ${job.id}; inspect this run in the API before retrying`);
|
|
1220
1587
|
}
|
|
1221
1588
|
|
|
1589
|
+
// src/runtime/browser-execution.ts
|
|
1590
|
+
import { chromium } from "playwright";
|
|
1591
|
+
import { isDeepStrictEqual } from "util";
|
|
1592
|
+
async function browserExecution(spec, world, signal) {
|
|
1593
|
+
signal?.throwIfAborted();
|
|
1594
|
+
const recipe = spec.browser;
|
|
1595
|
+
const browser = await chromium.launch({ headless: true, timeout: spec.timeoutSeconds * 1e3, ...process.env.AGENTTWIN_CHROMIUM_EXECUTABLE_PATH ? { executablePath: process.env.AGENTTWIN_CHROMIUM_EXECUTABLE_PATH } : {} });
|
|
1596
|
+
const abort = () => {
|
|
1597
|
+
void browser.close().catch(() => {
|
|
1598
|
+
});
|
|
1599
|
+
};
|
|
1600
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1601
|
+
try {
|
|
1602
|
+
signal?.throwIfAborted();
|
|
1603
|
+
const context = await browser.newContext({ serviceWorkers: "block", viewport: { width: 1280, height: 720 } });
|
|
1604
|
+
const origins = /* @__PURE__ */ new Set([new URL(recipe.baseUrl).origin, ...Object.values(world.credentials).map((c) => new URL(c.endpoint).origin)]);
|
|
1605
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
1606
|
+
await context.route("**/*", (route) => {
|
|
1607
|
+
const origin = new URL(route.request().url()).origin;
|
|
1608
|
+
if (origins.has(origin)) return route.continue();
|
|
1609
|
+
if (blocked.size < 100) blocked.add(origin);
|
|
1610
|
+
return route.abort("blockedbyclient");
|
|
1611
|
+
});
|
|
1612
|
+
const page = await context.newPage();
|
|
1613
|
+
page.setDefaultTimeout(Math.min(spec.timeoutSeconds * 500, 1e4));
|
|
1614
|
+
const deadline = Date.now() + Math.min(spec.timeoutSeconds * 1e3, 1e4);
|
|
1615
|
+
while (true) {
|
|
1616
|
+
signal?.throwIfAborted();
|
|
1617
|
+
try {
|
|
1618
|
+
const r = await fetch(recipe.baseUrl, { signal: AbortSignal.timeout(300) });
|
|
1619
|
+
if (r.ok) break;
|
|
1620
|
+
} catch {
|
|
1621
|
+
}
|
|
1622
|
+
if (Date.now() > deadline) throw new Error("Application browser readiness timed out");
|
|
1623
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1624
|
+
}
|
|
1625
|
+
const steps = [];
|
|
1626
|
+
let error;
|
|
1627
|
+
for (const step of recipe.steps) {
|
|
1628
|
+
signal?.throwIfAborted();
|
|
1629
|
+
try {
|
|
1630
|
+
if (step.action === "goto") await page.goto(new URL(step.path, recipe.baseUrl).href);
|
|
1631
|
+
else if (step.action === "click") await page.locator(step.selector).click();
|
|
1632
|
+
else if (step.action === "fill") await page.locator(step.selector).fill(step.value);
|
|
1633
|
+
else await page.locator(step.selector).filter({ hasText: step.text }).waitFor({ state: "visible" });
|
|
1634
|
+
steps.push({ action: step.action, passed: true });
|
|
1635
|
+
} catch (cause) {
|
|
1636
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
1637
|
+
for (const s of recipe.steps) if (s.action === "fill" && s.value) error = error.split(s.value).join("[input]");
|
|
1638
|
+
steps.push({ action: step.action, passed: false });
|
|
1639
|
+
break;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
let screenshot;
|
|
1643
|
+
try {
|
|
1644
|
+
const bytes = await page.screenshot({ type: "jpeg", quality: 65 });
|
|
1645
|
+
if (bytes.length <= 768 * 1024) screenshot = { mimeType: "image/jpeg", base64: bytes.toString("base64") };
|
|
1646
|
+
} catch {
|
|
1647
|
+
}
|
|
1648
|
+
for (const origin of blocked) world.unsupported.push(`browser: blocked unmodeled request origin ${origin}`);
|
|
1649
|
+
return { engine: "playwright-chromium", steps, passed: !error, error, blockedOrigins: [...blocked], screenshot };
|
|
1650
|
+
} finally {
|
|
1651
|
+
signal?.removeEventListener("abort", abort);
|
|
1652
|
+
await browser.close();
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
async function waitBackground(spec, world, signal) {
|
|
1656
|
+
const check = spec.background, deadline = Date.now() + check.timeoutMs;
|
|
1657
|
+
while (true) {
|
|
1658
|
+
signal?.throwIfAborted();
|
|
1659
|
+
const actual = world.state[check.service][check.path].filter((row) => !check.where || Object.entries(check.where).every(([k, v]) => isDeepStrictEqual(row[k], v))).length;
|
|
1660
|
+
if (actual === check.count) return { ...check, actual, passed: true };
|
|
1661
|
+
if (Date.now() >= deadline) return { ...check, actual, passed: false };
|
|
1662
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/runtime/request-source.ts
|
|
1667
|
+
import { mkdtemp, writeFile as writeFile2, rm as rm2, realpath } from "fs/promises";
|
|
1668
|
+
import { tmpdir } from "os";
|
|
1669
|
+
import { join as join4, resolve } from "path";
|
|
1670
|
+
function parseRequestSource(raw, spec) {
|
|
1671
|
+
if (typeof raw !== "string" || raw.length > 1024) return;
|
|
1672
|
+
try {
|
|
1673
|
+
const source = JSON.parse(raw);
|
|
1674
|
+
if (!source || typeof source.file !== "string" || !spec.files.includes(source.file) || !Number.isSafeInteger(source.line) || source.line < 1 || source.line > 1e6 || !Number.isSafeInteger(source.column) || source.column < 1 || source.column > 1e6) return;
|
|
1675
|
+
return { file: source.file, line: source.line, column: source.column, provenance: "application-reported" };
|
|
1676
|
+
} catch {
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
async function createRequestSourceHook(app, spec, credentials2) {
|
|
1681
|
+
const dir = await mkdtemp(join4(tmpdir(), "agenttwin-source-hook-"));
|
|
1682
|
+
const path2 = join4(dir, "hook.cjs");
|
|
1683
|
+
const config = { root: await realpath(resolve(app)), files: spec.files, origins: Object.values(credentials2).map((c) => new URL(c.endpoint).origin) };
|
|
1684
|
+
const hook = `const config=${JSON.stringify(config)};
|
|
1685
|
+
` + String.raw`
|
|
1686
|
+
const http=require('node:http'),https=require('node:https'),path=require('node:path'),{fileURLToPath}=require('node:url');
|
|
1687
|
+
const origins=new Set(config.origins),files=new Set(config.files);
|
|
1688
|
+
function source(){for(const frame of new Error().stack.split('\n').slice(2)){const match=frame.match(/(?:\(|\s)(file:\/\/[^\s)]+|\/[^\s)]+):(\d+):(\d+)\)?$/);if(!match)continue;let file;try{file=match[1].startsWith('file:')?fileURLToPath(match[1]):match[1];}catch{continue;}file=path.relative(config.root,file);if(files.has(file))return JSON.stringify({file,line:Number(match[2]),column:Number(match[3])});}}
|
|
1689
|
+
const originalFetch=globalThis.fetch;
|
|
1690
|
+
if(originalFetch)globalThis.fetch=function(input,init){let url;try{url=new URL(typeof input==='string'||input instanceof URL?input:input.url);}catch{return originalFetch(input,init);}if(!origins.has(url.origin))return originalFetch(input,init);const location=source();if(!location)return originalFetch(input,init);const headers=new Headers(init?.headers??input?.headers);headers.set('x-agenttwin-source',location);return originalFetch(input,{...init,headers});};
|
|
1691
|
+
for(const mod of [http,https])for(const method of ['request','get']){const original=mod[method];mod[method]=function(...args){let options,url,index;try{if(typeof args[0]==='string'||args[0] instanceof URL){url=new URL(args[0]);index=1;options=args[1]&&typeof args[1]==='object'?args[1]:{};}else{index=0;options=args[0]||{};url=new URL((options.protocol||(mod===https?'https:':'http:'))+'//'+(options.hostname||options.host||'localhost')+(options.port?':'+options.port:''));}}catch{return original.apply(this,args);}if(!origins.has(url.origin))return original.apply(this,args);const location=source();if(!location)return original.apply(this,args);const headers=Array.isArray(options.headers)?[...options.headers,'x-agenttwin-source',location]:{...options.headers,'x-agenttwin-source':location};const updated={...options,headers};if(index===1&&typeof args[1]==='function')args.splice(1,0,updated);else args[index]=updated;return original.apply(this,args);};}
|
|
1692
|
+
require('node:module').syncBuiltinESMExports();
|
|
1693
|
+
`;
|
|
1694
|
+
await writeFile2(path2, hook, { mode: 384 });
|
|
1695
|
+
return { path: path2, close: () => rm2(dir, { recursive: true, force: true }) };
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1222
1698
|
// src/runtime/local.ts
|
|
1223
1699
|
import { spawn } from "child_process";
|
|
1224
1700
|
|
|
1225
1701
|
// src/runtime/http-world.ts
|
|
1226
1702
|
import { createServer } from "http";
|
|
1227
|
-
import { randomBytes } from "crypto";
|
|
1703
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
1704
|
+
import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
|
|
1705
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
1706
|
+
|
|
1707
|
+
// src/runtime/stripe-adapter.ts
|
|
1708
|
+
function stripeOperation(method, path2, params, data, sequence) {
|
|
1709
|
+
const error = (status, code, message, param) => ({ status, body: { error: { type: "invalid_request_error", code, message, ...param ? { param } : {} } } });
|
|
1710
|
+
const match = /^\/v1\/(customers|payment_intents)(?:\/([^/]+))?(?:\/(confirm))?$/.exec(path2);
|
|
1711
|
+
if (!match || !["GET", "POST"].includes(method)) return void 0;
|
|
1712
|
+
const [, resource, id, action] = match, collection = `/v1/${resource}`, rows = data[collection];
|
|
1713
|
+
const allowed = method === "GET" ? id ? [] : ["limit", "starting_after"] : resource === "customers" && !id ? ["email", "name", "description"] : resource === "payment_intents" && !id ? ["amount", "currency", "customer", "payment_method", "confirm"] : action === "confirm" ? ["payment_method"] : void 0;
|
|
1714
|
+
if (!allowed || resource === "customers" && action || resource === "payment_intents" && method === "GET" && !id) return void 0;
|
|
1715
|
+
if (Object.keys(params).some((k) => !allowed.includes(k))) return void 0;
|
|
1716
|
+
if (method === "GET" && !id) {
|
|
1717
|
+
const limit = params.limit === void 0 ? 10 : Number(params.limit);
|
|
1718
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) return { ...error(400, "parameter_invalid_integer", "limit must be 1\u2013100", "limit"), collection };
|
|
1719
|
+
const ordered = [...rows].reverse();
|
|
1720
|
+
const index = params.starting_after === void 0 ? -1 : ordered.findIndex((r) => r.id === params.starting_after);
|
|
1721
|
+
if (params.starting_after !== void 0 && index === -1) return { ...error(400, "resource_missing", "No such starting_after customer", "starting_after"), collection };
|
|
1722
|
+
const page = ordered.slice(index + 1, index + 1 + limit);
|
|
1723
|
+
return { status: 200, body: { object: "list", data: page, has_more: ordered.length > index + 1 + limit, url: collection }, collection, operation: "read" };
|
|
1724
|
+
}
|
|
1725
|
+
const row = id ? rows.find((r) => r.id === id) : void 0;
|
|
1726
|
+
if (id && !row) return { ...error(404, "resource_missing", `No such ${resource}: '${id}'`), collection };
|
|
1727
|
+
if (method === "GET") return { status: 200, body: row, collection, operation: "read" };
|
|
1728
|
+
if (action === "confirm") {
|
|
1729
|
+
if (row.status === "succeeded") return { status: 200, body: row, collection, operation: "replay" };
|
|
1730
|
+
const payment = params.payment_method ?? row.payment_method;
|
|
1731
|
+
if (payment !== "pm_card_visa" && payment !== "pm_card_chargeDeclined") return { ...error(400, "parameter_invalid", "Only pm_card_visa and pm_card_chargeDeclined are supported", "payment_method"), collection };
|
|
1732
|
+
row.payment_method = payment;
|
|
1733
|
+
row.status = payment === "pm_card_visa" ? "succeeded" : "requires_payment_method";
|
|
1734
|
+
row.last_payment_error = payment === "pm_card_chargeDeclined" ? { code: "card_declined", decline_code: "generic_decline", message: "Your card was declined." } : null;
|
|
1735
|
+
return { status: payment === "pm_card_chargeDeclined" ? 402 : 200, body: payment === "pm_card_chargeDeclined" ? { error: { type: "card_error", code: "card_declined", decline_code: "generic_decline", message: "Your card was declined.", payment_intent: structuredClone(row) } } : row, collection, record: structuredClone(row), operation: "write" };
|
|
1736
|
+
}
|
|
1737
|
+
const prefix = resource === "customers" ? "cus" : "pi";
|
|
1738
|
+
let suffix = sequence;
|
|
1739
|
+
while (rows.some((r) => r.id === `${prefix}_at_${suffix}`)) suffix++;
|
|
1740
|
+
const created = { id: `${prefix}_at_${suffix}`, object: resource === "customers" ? "customer" : "payment_intent", created: 17e8, livemode: false };
|
|
1741
|
+
if (resource === "customers") {
|
|
1742
|
+
if (params.email !== void 0 && typeof params.email !== "string") return { ...error(400, "parameter_invalid", "email must be a string", "email"), collection };
|
|
1743
|
+
Object.assign(created, { email: null, name: null, description: null, ...params });
|
|
1744
|
+
} else {
|
|
1745
|
+
const amount = Number(params.amount);
|
|
1746
|
+
if (!Number.isSafeInteger(amount) || amount < 1) return { ...error(400, "parameter_invalid_integer", "amount must be a positive integer", "amount"), collection };
|
|
1747
|
+
if (typeof params.currency !== "string" || !/^[a-z]{3}$/.test(params.currency)) return { ...error(400, "parameter_invalid", "currency must be a lowercase three-letter code", "currency"), collection };
|
|
1748
|
+
if (params.customer && !data["/v1/customers"].some((r) => r.id === params.customer)) return { ...error(400, "resource_missing", "No such customer", "customer"), collection };
|
|
1749
|
+
if (params.payment_method !== void 0 && !["pm_card_visa", "pm_card_chargeDeclined"].includes(String(params.payment_method))) return { ...error(400, "parameter_invalid", "Unsupported test payment method", "payment_method"), collection };
|
|
1750
|
+
if (params.confirm !== void 0 && !["true", "false", true, false].includes(params.confirm)) return { ...error(400, "parameter_invalid", "confirm must be boolean", "confirm"), collection };
|
|
1751
|
+
const confirm = params.confirm === "true" || params.confirm === true;
|
|
1752
|
+
if (confirm && !params.payment_method) return { ...error(400, "parameter_missing", "Missing payment_method", "payment_method"), collection };
|
|
1753
|
+
Object.assign(created, { amount, currency: params.currency, customer: params.customer ?? null, payment_method: params.payment_method ?? null, status: confirm && params.payment_method === "pm_card_visa" ? "succeeded" : !confirm && params.payment_method ? "requires_confirmation" : "requires_payment_method", last_payment_error: confirm && params.payment_method === "pm_card_chargeDeclined" ? { code: "card_declined", message: "Your card was declined." } : null });
|
|
1754
|
+
}
|
|
1755
|
+
if (rows.length >= 1e4) return { ...error(429, "world_capacity", "World record capacity reached"), collection };
|
|
1756
|
+
rows.push(created);
|
|
1757
|
+
const declined = resource === "payment_intents" && (params.confirm === "true" || params.confirm === true) && params.payment_method === "pm_card_chargeDeclined";
|
|
1758
|
+
return { status: declined ? 402 : 200, body: declined ? { error: { type: "card_error", code: "card_declined", decline_code: "generic_decline", message: "Your card was declined.", payment_intent: structuredClone(created) } } : created, collection, record: structuredClone(created), operation: "write" };
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// src/runtime/http-world.ts
|
|
1228
1762
|
var HttpWorld = class {
|
|
1229
1763
|
constructor(spec) {
|
|
1230
1764
|
this.spec = spec;
|
|
@@ -1237,21 +1771,32 @@ var HttpWorld = class {
|
|
|
1237
1771
|
servers = [];
|
|
1238
1772
|
timers = /* @__PURE__ */ new Set();
|
|
1239
1773
|
fired = false;
|
|
1240
|
-
|
|
1774
|
+
epoch = 0;
|
|
1775
|
+
replayKeys = /* @__PURE__ */ new Map();
|
|
1776
|
+
sourceContext = new AsyncLocalStorage();
|
|
1777
|
+
record(service, operation, path2, detail, record) {
|
|
1241
1778
|
if (this.events.length >= 5e4) return;
|
|
1242
|
-
|
|
1779
|
+
const source = this.sourceContext.getStore();
|
|
1780
|
+
this.events.push({ sequence: this.events.length + 1, service, operation, path: path2, detail, ...record ? { record: structuredClone(record) } : {}, ...source ? { source } : {} });
|
|
1781
|
+
}
|
|
1782
|
+
rejectUnsupported(service, method, path2, detail) {
|
|
1783
|
+
this.unsupported.push(`${service}: ${method} ${path2}: ${detail}`);
|
|
1784
|
+
this.record(service, "unsupported", path2, detail);
|
|
1243
1785
|
}
|
|
1244
1786
|
async start() {
|
|
1787
|
+
if (this.servers.length) throw new Error("World is already started");
|
|
1245
1788
|
for (const service of this.spec.services) {
|
|
1246
|
-
const token = `at_world_${
|
|
1789
|
+
const token = `at_world_${randomBytes4(24).toString("hex")}`;
|
|
1247
1790
|
const data = /* @__PURE__ */ Object.create(null);
|
|
1248
1791
|
this.state[service.name] = data;
|
|
1249
1792
|
for (const c of service.collections) data[c.path] = structuredClone(c.seed);
|
|
1250
1793
|
const keys = /* @__PURE__ */ new Map();
|
|
1251
|
-
|
|
1794
|
+
this.replayKeys.set(service.name, keys);
|
|
1795
|
+
const server = createServer((req, res) => this.sourceContext.run(parseRequestSource(req.headers["x-agenttwin-source"], this.spec), async () => {
|
|
1796
|
+
const epoch = this.epoch;
|
|
1252
1797
|
const send2 = (code, body2) => {
|
|
1253
1798
|
if (!res.destroyed) {
|
|
1254
|
-
res.writeHead(code, { "content-type": "application/json" });
|
|
1799
|
+
res.writeHead(code, { "content-type": "application/json", ...code === 501 ? { "X-AgentTwin-Unsupported": "true" } : {} });
|
|
1255
1800
|
res.end(JSON.stringify(body2));
|
|
1256
1801
|
}
|
|
1257
1802
|
};
|
|
@@ -1266,9 +1811,9 @@ var HttpWorld = class {
|
|
|
1266
1811
|
return;
|
|
1267
1812
|
}
|
|
1268
1813
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1269
|
-
let
|
|
1814
|
+
let path2 = url.pathname;
|
|
1270
1815
|
const slack = service.adapter === "slack";
|
|
1271
|
-
const slackMethod =
|
|
1816
|
+
const slackMethod = path2.replace(/^\/api\//, "");
|
|
1272
1817
|
let payload;
|
|
1273
1818
|
if (req.method === "POST") {
|
|
1274
1819
|
const chunks = [];
|
|
@@ -1284,7 +1829,7 @@ var HttpWorld = class {
|
|
|
1284
1829
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
1285
1830
|
let parsed;
|
|
1286
1831
|
try {
|
|
1287
|
-
parsed = slack && req.headers["content-type"]?.startsWith("application/x-www-form-urlencoded") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text);
|
|
1832
|
+
parsed = (slack || service.adapter === "stripe") && req.headers["content-type"]?.startsWith("application/x-www-form-urlencoded") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text);
|
|
1288
1833
|
} catch {
|
|
1289
1834
|
send2(400, { error: "expected_json_object" });
|
|
1290
1835
|
return;
|
|
@@ -1295,36 +1840,75 @@ var HttpWorld = class {
|
|
|
1295
1840
|
}
|
|
1296
1841
|
payload = parsed;
|
|
1297
1842
|
}
|
|
1843
|
+
if (epoch !== this.epoch) {
|
|
1844
|
+
send2(409, { error: "world_reset" });
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
if (service.adapter === "stripe") {
|
|
1848
|
+
const params = payload ?? Object.fromEntries(url.searchParams);
|
|
1849
|
+
const keyHeader2 = req.headers["idempotency-key"];
|
|
1850
|
+
const key2 = req.method === "POST" && typeof keyHeader2 === "string" ? `${path2}:${keyHeader2}` : void 0;
|
|
1851
|
+
const signature2 = JSON.stringify(params);
|
|
1852
|
+
const previous2 = key2 ? keys.get(key2) : void 0;
|
|
1853
|
+
if (previous2) {
|
|
1854
|
+
if (previous2.body !== signature2) {
|
|
1855
|
+
send2(400, { error: { type: "idempotency_error", message: "Keys for idempotent requests can only be used with the same parameters" } });
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
this.record(service.name, "replay", path2, "same-key response replay", previous2.record);
|
|
1859
|
+
send2(200, previous2.record);
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
const result = stripeOperation(req.method ?? "", path2, params, data, this.events.length + 1);
|
|
1863
|
+
if (!result) {
|
|
1864
|
+
this.rejectUnsupported(service.name, req.method ?? "", path2, "unsupported Stripe operation or parameters");
|
|
1865
|
+
send2(501, { error: { type: "invalid_request_error", code: "unsupported_operation", message: "Outside the verified AgentTwin Stripe subset" }, forwardedToProduction: false });
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
if (result.operation) this.record(service.name, result.operation, result.collection, path2, "record" in result ? result.record : result.body);
|
|
1869
|
+
if (key2 && result.status === 200) keys.set(key2, { body: signature2, record: structuredClone(result.body) });
|
|
1870
|
+
const fault2 = this.spec.fault;
|
|
1871
|
+
if (!this.fired && result.operation === "write" && fault2?.service === service.name && fault2.path === result.collection) {
|
|
1872
|
+
this.fired = true;
|
|
1873
|
+
this.record(service.name, "fault", result.collection, `write committed; response delayed ${fault2.delayMs}ms`);
|
|
1874
|
+
const timer = setTimeout(() => {
|
|
1875
|
+
this.timers.delete(timer);
|
|
1876
|
+
send2(result.status, result.body);
|
|
1877
|
+
}, fault2.delayMs);
|
|
1878
|
+
this.timers.add(timer);
|
|
1879
|
+
} else send2(result.status, result.body);
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1298
1882
|
if (slack) {
|
|
1299
1883
|
const params = payload ?? Object.fromEntries(url.searchParams);
|
|
1300
1884
|
const allowed = slackMethod === "chat.postMessage" ? ["channel", "text"] : slackMethod === "conversations.history" ? ["channel"] : [];
|
|
1301
1885
|
if (Object.keys(params).some((k) => !allowed.includes(k))) {
|
|
1302
|
-
this.
|
|
1886
|
+
this.rejectUnsupported(service.name, req.method ?? "", url.pathname, `unsupported parameters for ${slackMethod}`);
|
|
1303
1887
|
send2(501, { ok: false, error: "unsupported_parameters", forwardedToProduction: false });
|
|
1304
1888
|
return;
|
|
1305
1889
|
}
|
|
1306
|
-
if (slackMethod === "chat.postMessage" && req.method === "POST")
|
|
1307
|
-
else if (slackMethod === "conversations.history" && ["GET", "POST"].includes(req.method ?? ""))
|
|
1308
|
-
else if (slackMethod === "conversations.list" && ["GET", "POST"].includes(req.method ?? ""))
|
|
1890
|
+
if (slackMethod === "chat.postMessage" && req.method === "POST") path2 = "/messages";
|
|
1891
|
+
else if (slackMethod === "conversations.history" && ["GET", "POST"].includes(req.method ?? "")) path2 = "/messages";
|
|
1892
|
+
else if (slackMethod === "conversations.list" && ["GET", "POST"].includes(req.method ?? "")) path2 = "/channels";
|
|
1309
1893
|
else {
|
|
1310
|
-
this.
|
|
1311
|
-
send2(501, { ok: false, error: "unsupported_operation" });
|
|
1894
|
+
this.rejectUnsupported(service.name, req.method ?? "", url.pathname, "unsupported operation");
|
|
1895
|
+
send2(501, { ok: false, error: "unsupported_operation", forwardedToProduction: false });
|
|
1312
1896
|
return;
|
|
1313
1897
|
}
|
|
1314
1898
|
}
|
|
1315
|
-
if (!Object.hasOwn(data,
|
|
1316
|
-
this.
|
|
1899
|
+
if (!Object.hasOwn(data, path2) || !["GET", "POST"].includes(req.method ?? "")) {
|
|
1900
|
+
this.rejectUnsupported(service.name, req.method ?? "", path2, "unsupported operation");
|
|
1317
1901
|
send2(501, { error: "unsupported_operation", forwardedToProduction: false });
|
|
1318
1902
|
return;
|
|
1319
1903
|
}
|
|
1320
1904
|
if (req.method === "GET" || slack && slackMethod !== "chat.postMessage") {
|
|
1321
|
-
this.record(service.name, "read",
|
|
1322
|
-
if (slack &&
|
|
1905
|
+
this.record(service.name, "read", path2, `${data[path2].length} records`);
|
|
1906
|
+
if (slack && path2 === "/channels") send2(200, { ok: true, channels: data[path2], response_metadata: { next_cursor: "" } });
|
|
1323
1907
|
else if (slack) {
|
|
1324
1908
|
const channel = payload?.channel ?? url.searchParams.get("channel");
|
|
1325
1909
|
if (!data["/channels"].some((c) => c.id === channel)) send2(200, { ok: false, error: "channel_not_found" });
|
|
1326
|
-
else send2(200, { ok: true, messages: data[
|
|
1327
|
-
} else send2(200, { data: data[
|
|
1910
|
+
else send2(200, { ok: true, messages: data[path2].filter((m) => m.channel === channel), has_more: false });
|
|
1911
|
+
} else send2(200, { data: data[path2] });
|
|
1328
1912
|
return;
|
|
1329
1913
|
}
|
|
1330
1914
|
const body2 = payload;
|
|
@@ -1339,12 +1923,12 @@ var HttpWorld = class {
|
|
|
1339
1923
|
return;
|
|
1340
1924
|
}
|
|
1341
1925
|
}
|
|
1342
|
-
if (data[
|
|
1926
|
+
if (data[path2].length >= 1e4 || this.events.length >= 5e4) {
|
|
1343
1927
|
send2(429, { error: "world_capacity" });
|
|
1344
1928
|
return;
|
|
1345
1929
|
}
|
|
1346
1930
|
const keyHeader = slack ? void 0 : req.headers["idempotency-key"];
|
|
1347
|
-
const key = typeof keyHeader === "string" ? `${
|
|
1931
|
+
const key = typeof keyHeader === "string" ? `${path2}:${keyHeader}` : void 0;
|
|
1348
1932
|
const signature = JSON.stringify(body2);
|
|
1349
1933
|
const previous = key ? keys.get(key) : void 0;
|
|
1350
1934
|
if (previous) {
|
|
@@ -1352,22 +1936,22 @@ var HttpWorld = class {
|
|
|
1352
1936
|
send2(409, { error: "idempotency_conflict" });
|
|
1353
1937
|
return;
|
|
1354
1938
|
}
|
|
1355
|
-
this.record(service.name, "replay",
|
|
1939
|
+
this.record(service.name, "replay", path2, "same-key response replay", previous.record);
|
|
1356
1940
|
send2(200, previous.record);
|
|
1357
1941
|
return;
|
|
1358
1942
|
}
|
|
1359
|
-
let index = data[
|
|
1360
|
-
while (data[
|
|
1943
|
+
let index = data[path2].length + 1;
|
|
1944
|
+
while (data[path2].some((r) => r.id === `${service.name}_${index}`)) index++;
|
|
1361
1945
|
const row = { ...body2, id: `${service.name}_${index}` };
|
|
1362
1946
|
if (slack) row.ts = `${index}.000001`;
|
|
1363
1947
|
const reply = slack ? { ok: true, channel: row.channel, ts: row.ts, message: row } : row;
|
|
1364
|
-
data[
|
|
1948
|
+
data[path2].push(row);
|
|
1365
1949
|
if (key) keys.set(key, { body: signature, record: row });
|
|
1366
|
-
this.record(service.name, "write",
|
|
1950
|
+
this.record(service.name, "write", path2, String(row.id), row);
|
|
1367
1951
|
const fault = this.spec.fault;
|
|
1368
|
-
if (!this.fired && fault?.service === service.name && fault.path ===
|
|
1952
|
+
if (!this.fired && fault?.service === service.name && fault.path === path2) {
|
|
1369
1953
|
this.fired = true;
|
|
1370
|
-
this.record(service.name, "fault",
|
|
1954
|
+
this.record(service.name, "fault", path2, `write committed; response delayed ${fault.delayMs}ms`);
|
|
1371
1955
|
const timer = setTimeout(() => {
|
|
1372
1956
|
this.timers.delete(timer);
|
|
1373
1957
|
send2(slack ? 200 : 201, reply);
|
|
@@ -1377,11 +1961,11 @@ var HttpWorld = class {
|
|
|
1377
1961
|
} catch {
|
|
1378
1962
|
send2(500, { error: "world_request_error" });
|
|
1379
1963
|
}
|
|
1380
|
-
});
|
|
1964
|
+
}));
|
|
1381
1965
|
server.requestTimeout = 1e4;
|
|
1382
|
-
await new Promise((
|
|
1966
|
+
await new Promise((resolve4, reject) => {
|
|
1383
1967
|
server.once("error", reject);
|
|
1384
|
-
server.listen(0, "127.0.0.1",
|
|
1968
|
+
server.listen(0, "127.0.0.1", resolve4);
|
|
1385
1969
|
});
|
|
1386
1970
|
this.servers.push(server);
|
|
1387
1971
|
this.credentials[service.name] = { endpoint: `http://127.0.0.1:${server.address().port}`, token };
|
|
@@ -1390,37 +1974,121 @@ var HttpWorld = class {
|
|
|
1390
1974
|
}
|
|
1391
1975
|
assertions() {
|
|
1392
1976
|
return this.spec.assertions.map((a) => {
|
|
1393
|
-
const rows = this.state[a.service][a.path].filter((row) => !a.where || Object.entries(a.where).every(([k, v]) =>
|
|
1977
|
+
const rows = this.state[a.service][a.path].filter((row) => !a.where || Object.entries(a.where).every(([k, v]) => isDeepStrictEqual2(row[k], v)));
|
|
1394
1978
|
return { ...a, actual: rows.length, passed: rows.length === a.count };
|
|
1395
1979
|
});
|
|
1396
1980
|
}
|
|
1397
1981
|
get faultTriggered() {
|
|
1398
1982
|
return this.fired;
|
|
1399
1983
|
}
|
|
1984
|
+
snapshot() {
|
|
1985
|
+
return structuredClone(this.state);
|
|
1986
|
+
}
|
|
1987
|
+
quiesce() {
|
|
1988
|
+
this.epoch++;
|
|
1989
|
+
for (const server of this.servers) server.closeAllConnections();
|
|
1990
|
+
for (const timer of this.timers) clearTimeout(timer);
|
|
1991
|
+
this.timers.clear();
|
|
1992
|
+
}
|
|
1993
|
+
reset() {
|
|
1994
|
+
this.quiesce();
|
|
1995
|
+
for (const service of this.spec.services) {
|
|
1996
|
+
for (const collection of service.collections) this.state[service.name][collection.path] = structuredClone(collection.seed);
|
|
1997
|
+
this.replayKeys.get(service.name)?.clear();
|
|
1998
|
+
}
|
|
1999
|
+
this.events.length = 0;
|
|
2000
|
+
this.unsupported.length = 0;
|
|
2001
|
+
this.fired = false;
|
|
2002
|
+
return this.snapshot();
|
|
2003
|
+
}
|
|
1400
2004
|
async close() {
|
|
2005
|
+
this.epoch++;
|
|
1401
2006
|
for (const timer of this.timers) clearTimeout(timer);
|
|
1402
2007
|
this.timers.clear();
|
|
1403
|
-
await Promise.all(this.servers.map((server) => new Promise((
|
|
1404
|
-
server.close(() =>
|
|
2008
|
+
await Promise.all(this.servers.map((server) => new Promise((resolve4) => {
|
|
2009
|
+
server.close(() => resolve4());
|
|
1405
2010
|
server.closeAllConnections();
|
|
1406
2011
|
})));
|
|
2012
|
+
this.servers.length = 0;
|
|
1407
2013
|
}
|
|
1408
2014
|
};
|
|
1409
2015
|
|
|
2016
|
+
// src/runtime/evaluation.ts
|
|
2017
|
+
import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
|
|
2018
|
+
var matches = (row, where) => !where || Object.entries(where).every(([k, v]) => isDeepStrictEqual3(row[k], v));
|
|
2019
|
+
function evaluateState(spec, initial, final, events) {
|
|
2020
|
+
const checks = spec.assertions.map((a, index) => {
|
|
2021
|
+
const before = initial[a.service]?.[a.path];
|
|
2022
|
+
const after = final[a.service]?.[a.path];
|
|
2023
|
+
const actual = after?.filter((row) => matches(row, a.where)).length;
|
|
2024
|
+
return {
|
|
2025
|
+
id: `assertion_${index + 1}`,
|
|
2026
|
+
kind: "required-outcome",
|
|
2027
|
+
service: a.service,
|
|
2028
|
+
path: a.path,
|
|
2029
|
+
passed: actual === a.count,
|
|
2030
|
+
expected: a.count,
|
|
2031
|
+
actual: actual ?? null,
|
|
2032
|
+
evidence: {
|
|
2033
|
+
initial: `/initialState/${escape(a.service)}/${escape(a.path)}`,
|
|
2034
|
+
final: `/finalState/${escape(a.service)}/${escape(a.path)}`,
|
|
2035
|
+
beforeCount: before?.filter((row) => matches(row, a.where)).length ?? null,
|
|
2036
|
+
eventSequences: events.filter((e) => e.service === a.service && e.path === a.path).map((e) => e.sequence)
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
});
|
|
2040
|
+
const forbidden = (spec.forbiddenEffects ?? []).map((policy) => {
|
|
2041
|
+
const hits = events.filter((e) => e.service === policy.service && e.path === policy.path && e.operation === policy.operation && (!policy.where || e.record !== void 0 && matches(e.record, policy.where)));
|
|
2042
|
+
const unknown = events.some((e) => e.service === policy.service && e.path === policy.path && e.operation === policy.operation && policy.where && !e.record);
|
|
2043
|
+
return {
|
|
2044
|
+
id: policy.id,
|
|
2045
|
+
kind: "forbidden-effect",
|
|
2046
|
+
service: policy.service,
|
|
2047
|
+
path: policy.path,
|
|
2048
|
+
passed: hits.length === 0 && !unknown,
|
|
2049
|
+
expected: 0,
|
|
2050
|
+
actual: hits.length,
|
|
2051
|
+
evidence: { eventSequences: hits.map((e) => e.sequence), evidenceGap: unknown }
|
|
2052
|
+
};
|
|
2053
|
+
});
|
|
2054
|
+
const preserved = (spec.preservedState ?? []).map((policy) => {
|
|
2055
|
+
const before = initial[policy.service]?.[policy.path];
|
|
2056
|
+
const after = final[policy.service]?.[policy.path];
|
|
2057
|
+
const passed = !!before && !!after && isDeepStrictEqual3(before.filter((row) => matches(row, policy.where)), after.filter((row) => matches(row, policy.where)));
|
|
2058
|
+
return {
|
|
2059
|
+
id: policy.id,
|
|
2060
|
+
kind: "preserved-state",
|
|
2061
|
+
service: policy.service,
|
|
2062
|
+
path: policy.path,
|
|
2063
|
+
passed,
|
|
2064
|
+
expected: "unchanged",
|
|
2065
|
+
actual: passed ? "unchanged" : "changed or missing",
|
|
2066
|
+
evidence: { initial: `/initialState/${escape(policy.service)}/${escape(policy.path)}`, final: `/finalState/${escape(policy.service)}/${escape(policy.path)}` }
|
|
2067
|
+
};
|
|
2068
|
+
});
|
|
2069
|
+
const taskPassed = checks.every((c) => c.passed);
|
|
2070
|
+
const safe = [...forbidden, ...preserved].every((c) => c.passed);
|
|
2071
|
+
return { schema: "agenttwin/evaluation-v1", outcome: !safe ? "unsafe" : taskPassed ? "passed" : "failed", taskPassed, safe, checks: [...checks, ...forbidden, ...preserved] };
|
|
2072
|
+
}
|
|
2073
|
+
var escape = (value) => value.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
2074
|
+
|
|
1410
2075
|
// src/runtime/local.ts
|
|
1411
|
-
|
|
2076
|
+
init_contracts();
|
|
2077
|
+
async function executeLocal(app, spec, signal, existingWorld) {
|
|
1412
2078
|
if (spec.postgres || spec.redis) throw new Error("This world needs PostgreSQL or Redis. Use --runtime hosted or --runtime e2b, or prepare a workflow using HTTP twins only. No local containers are started.");
|
|
1413
2079
|
signal?.throwIfAborted();
|
|
1414
|
-
const world = new HttpWorld(spec), started = Date.now();
|
|
2080
|
+
const world = existingWorld ?? new HttpWorld(spec), started = Date.now();
|
|
1415
2081
|
try {
|
|
1416
|
-
const
|
|
2082
|
+
const credentials2 = existingWorld ? world.credentials : await world.start();
|
|
2083
|
+
const initialState = world.snapshot();
|
|
1417
2084
|
const env = { PATH: process.env.PATH ?? "", NODE_ENV: "test" };
|
|
1418
|
-
for (const [name, c] of Object.entries(
|
|
2085
|
+
for (const [name, c] of Object.entries(credentials2)) {
|
|
1419
2086
|
env[`AGENTTWIN_${name.toUpperCase()}_URL`] = c.endpoint;
|
|
1420
2087
|
env[`AGENTTWIN_${name.toUpperCase()}_TOKEN`] = c.token;
|
|
1421
2088
|
}
|
|
1422
2089
|
let stdout = "", stderr = "", timedOut = false;
|
|
1423
|
-
const
|
|
2090
|
+
const hook = await createRequestSourceHook(app, spec, credentials2);
|
|
2091
|
+
const child = spawn(process.execPath, ["--require", hook.path, ...spec.command.slice(1)], { cwd: app, env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
1424
2092
|
child.stdout.on("data", (b) => {
|
|
1425
2093
|
stdout = (stdout + b.toString()).slice(-65536);
|
|
1426
2094
|
});
|
|
@@ -1435,31 +2103,61 @@ async function executeLocal(app, spec, signal) {
|
|
|
1435
2103
|
}
|
|
1436
2104
|
}
|
|
1437
2105
|
};
|
|
1438
|
-
const
|
|
2106
|
+
const executionAbort = new AbortController();
|
|
2107
|
+
const abort = () => {
|
|
2108
|
+
executionAbort.abort();
|
|
2109
|
+
kill();
|
|
2110
|
+
};
|
|
1439
2111
|
signal?.addEventListener("abort", abort, { once: true });
|
|
1440
2112
|
const timer = setTimeout(() => {
|
|
1441
2113
|
timedOut = true;
|
|
2114
|
+
executionAbort.abort();
|
|
1442
2115
|
kill();
|
|
1443
2116
|
}, spec.timeoutSeconds * 1e3);
|
|
1444
|
-
const
|
|
2117
|
+
const completion = new Promise((resolve4, reject) => {
|
|
1445
2118
|
child.once("error", reject);
|
|
1446
|
-
child.once("close",
|
|
1447
|
-
})
|
|
2119
|
+
child.once("close", resolve4);
|
|
2120
|
+
});
|
|
2121
|
+
let browser, background, executionError;
|
|
2122
|
+
let exitCode;
|
|
2123
|
+
try {
|
|
2124
|
+
if (spec.browser || spec.background) {
|
|
2125
|
+
const work = (async () => {
|
|
2126
|
+
if (spec.browser) browser = await browserExecution(spec, world, executionAbort.signal);
|
|
2127
|
+
if (spec.background) background = await waitBackground(spec, world, executionAbort.signal);
|
|
2128
|
+
return 0;
|
|
2129
|
+
})();
|
|
2130
|
+
exitCode = await Promise.race([work, completion.then((code) => {
|
|
2131
|
+
if (!spec.browser && code === 0) return work;
|
|
2132
|
+
throw new Error(`Application exited before browser/background completion (${code})`);
|
|
2133
|
+
})]);
|
|
2134
|
+
} else exitCode = await completion;
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
executionError = error instanceof Error ? error.message : String(error);
|
|
2137
|
+
exitCode = 1;
|
|
2138
|
+
} finally {
|
|
2139
|
+
executionAbort.abort();
|
|
1448
2140
|
clearTimeout(timer);
|
|
1449
2141
|
signal?.removeEventListener("abort", abort);
|
|
1450
2142
|
kill();
|
|
1451
|
-
|
|
2143
|
+
await completion.catch(() => {
|
|
2144
|
+
});
|
|
2145
|
+
await hook.close();
|
|
2146
|
+
}
|
|
1452
2147
|
signal?.throwIfAborted();
|
|
1453
|
-
|
|
1454
|
-
|
|
2148
|
+
if (existingWorld) world.quiesce();
|
|
2149
|
+
else await world.close();
|
|
2150
|
+
for (const c of Object.values(credentials2)) {
|
|
1455
2151
|
stdout = stdout.split(c.token).join("[test-token]");
|
|
1456
2152
|
stderr = stderr.split(c.token).join("[test-token]");
|
|
1457
2153
|
}
|
|
1458
2154
|
const assertions = world.assertions();
|
|
1459
|
-
const
|
|
1460
|
-
|
|
2155
|
+
const evaluation = evaluateState(spec, initialState, world.state, world.events);
|
|
2156
|
+
const status = world.unsupported.length || spec.fault && !world.faultTriggered ? "incomplete" : timedOut || exitCode !== 0 ? "error" : background?.passed === false || browser?.passed === false ? "failed" : evaluation.outcome === "passed" ? "passed" : "failed";
|
|
2157
|
+
return { schema: "agenttwin/run-v1", name: spec.name, workflowId: spec.workflowId, status, browser, background, executionError, application: { command: spec.command, exitCode, timedOut, stdout, stderr }, assertions, evaluation, contracts: operationMatrix(spec), scenario: spec.scenario, infrastructureAssertions: [], fault: spec.fault ? { ...spec.fault, triggered: world.faultTriggered } : null, initialState, finalState: world.snapshot(), events: structuredClone(world.events), unsupported: [...world.unsupported], durationMs: Date.now() - started, coverage: "CLI Node execution with fresh in-process HTTP collections and advertised provider subsets. Optional Chromium browser steps and bounded collection-state background completion. Not a VM or network/filesystem isolation boundary. No production environment variables inherited; reviewed code can still access the host filesystem and network.", isolation: { kind: "process", productionCredentialsInherited: false, network: "host network; not isolated", rootFilesystem: "host-accessible; explicit files staged in temporary directory" } };
|
|
1461
2158
|
} finally {
|
|
1462
|
-
|
|
2159
|
+
if (existingWorld) world.quiesce();
|
|
2160
|
+
else await world.close();
|
|
1463
2161
|
}
|
|
1464
2162
|
}
|
|
1465
2163
|
|
|
@@ -1468,17 +2166,18 @@ init_git();
|
|
|
1468
2166
|
|
|
1469
2167
|
// src/runtime/e2b.ts
|
|
1470
2168
|
import { Sandbox } from "e2b";
|
|
1471
|
-
import { readFile as
|
|
1472
|
-
import { randomBytes as
|
|
1473
|
-
import { homedir } from "os";
|
|
1474
|
-
import { join as
|
|
2169
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
2170
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
2171
|
+
import { homedir as homedir2 } from "os";
|
|
2172
|
+
import { join as join5 } from "path";
|
|
1475
2173
|
async function executeE2B(stage, spec, log, signal) {
|
|
2174
|
+
if (spec.browser || spec.background) throw new Error("Browser and background execution currently require --runtime local; hosted browser dependencies are not provisioned.");
|
|
1476
2175
|
signal?.throwIfAborted();
|
|
1477
2176
|
const apiKey = process.env.E2B_API_KEY;
|
|
1478
2177
|
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." });
|
|
1479
2178
|
let saved = {};
|
|
1480
2179
|
try {
|
|
1481
|
-
saved = JSON.parse(await
|
|
2180
|
+
saved = JSON.parse(await readFile4(join5(homedir2(), ".agenttwin/runtime.json"), "utf8"));
|
|
1482
2181
|
} catch {
|
|
1483
2182
|
}
|
|
1484
2183
|
const template = process.env.E2B_TEMPLATE_ID ?? saved.templateId;
|
|
@@ -1497,12 +2196,12 @@ async function executeE2B(stage, spec, log, signal) {
|
|
|
1497
2196
|
signal?.throwIfAborted();
|
|
1498
2197
|
await sandbox.commands.run("mkdir -p /input/app && chmod 755 /input /input/app", { user: "root" });
|
|
1499
2198
|
for (const file of ["world.json", "worker.js", ...spec.files.map((f) => `app/${f}`)]) {
|
|
1500
|
-
await sandbox.files.write(`/input/${file}`, new Uint8Array(await
|
|
2199
|
+
await sandbox.files.write(`/input/${file}`, new Uint8Array(await readFile4(join5(stage, file))).buffer, { user: "root" });
|
|
1501
2200
|
}
|
|
1502
2201
|
await sandbox.commands.run("chmod -R a+rX /input", { user: "root" });
|
|
1503
2202
|
const runtimeEnv = {};
|
|
1504
2203
|
if (spec.postgres) {
|
|
1505
|
-
const password =
|
|
2204
|
+
const password = randomBytes5(24).toString("hex");
|
|
1506
2205
|
secrets.push(password);
|
|
1507
2206
|
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" });
|
|
1508
2207
|
await sandbox.files.write("/tmp/agenttwin-init.sql", `CREATE ROLE agenttwin LOGIN PASSWORD '${password}';
|
|
@@ -1516,7 +2215,7 @@ CREATE DATABASE agenttwin OWNER agenttwin;
|
|
|
1516
2215
|
}
|
|
1517
2216
|
}
|
|
1518
2217
|
if (spec.redis) {
|
|
1519
|
-
const password =
|
|
2218
|
+
const password = randomBytes5(24).toString("hex");
|
|
1520
2219
|
secrets.push(password);
|
|
1521
2220
|
await sandbox.files.write("/tmp/agenttwin-redis.conf", `bind 127.0.0.1
|
|
1522
2221
|
port 6379
|
|
@@ -1555,70 +2254,131 @@ logfile /tmp/agenttwin-redis.log
|
|
|
1555
2254
|
}
|
|
1556
2255
|
}
|
|
1557
2256
|
|
|
2257
|
+
// src/runtime/scenarios.ts
|
|
2258
|
+
import { createHash as createHash3 } from "crypto";
|
|
2259
|
+
import { mkdir as mkdir2, readFile as readFile5, readdir as readdir2, writeFile as writeFile3 } from "fs/promises";
|
|
2260
|
+
import { join as join6 } from "path";
|
|
2261
|
+
import { isDeepStrictEqual as isDeepStrictEqual4 } from "util";
|
|
2262
|
+
var identifier = /^[a-z][a-z0-9_]{0,39}$/;
|
|
2263
|
+
var object2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
2264
|
+
var sort = (v) => Array.isArray(v) ? v.map(sort) : object2(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort(v[k])])) : v;
|
|
2265
|
+
var scenarioDigest = (scenario) => createHash3("sha256").update(JSON.stringify(sort(scenario))).digest("hex");
|
|
2266
|
+
function parseScenario(value) {
|
|
2267
|
+
const fail = (detail) => {
|
|
2268
|
+
throw new Error(`Invalid scenario: ${detail}`);
|
|
2269
|
+
};
|
|
2270
|
+
if (!object2(value) || value.schema !== "agenttwin/scenario-v1" || typeof value.id !== "string" || !identifier.test(value.id) || !Number.isSafeInteger(value.version) || Number(value.version) < 1 || typeof value.description !== "string" || value.description.length > 2e3 || !Array.isArray(value.services) || !Array.isArray(value.entities) || value.entities.length > 100 || Object.keys(value).some((k) => !["schema", "id", "version", "description", "services", "entities"].includes(k))) fail("schema, id, version, description, services and entities are required");
|
|
2271
|
+
const scenario = value;
|
|
2272
|
+
parseWorld({
|
|
2273
|
+
schema: "agenttwin/world-v1",
|
|
2274
|
+
name: scenario.id,
|
|
2275
|
+
files: ["app.mjs"],
|
|
2276
|
+
command: ["node", "app.mjs"],
|
|
2277
|
+
timeoutSeconds: 1,
|
|
2278
|
+
services: scenario.services,
|
|
2279
|
+
assertions: [{ service: scenario.services[0]?.name, path: scenario.services[0]?.collections?.[0]?.path, count: 0 }]
|
|
2280
|
+
});
|
|
2281
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2282
|
+
for (const entity of scenario.entities) {
|
|
2283
|
+
if (!object2(entity) || typeof entity.id !== "string" || !identifier.test(entity.id) || ids.has(entity.id) || !Array.isArray(entity.refs) || entity.refs.length < 2 || entity.refs.length > 20 || Object.keys(entity).some((k) => !["id", "refs"].includes(k))) fail("entities need unique ids and 2\u201320 cross-service references");
|
|
2284
|
+
ids.add(entity.id);
|
|
2285
|
+
const services = /* @__PURE__ */ new Set();
|
|
2286
|
+
for (const ref of entity.refs) {
|
|
2287
|
+
if (!object2(ref) || typeof ref.service !== "string" || typeof ref.path !== "string" || !object2(ref.where) || !Object.keys(ref.where).length || Object.keys(ref).some((k) => !["service", "path", "where"].includes(k))) fail("entity reference needs service, path and a nonempty where selector");
|
|
2288
|
+
const rows = scenario.services.find((s) => s.name === ref.service)?.collections.find((c) => c.path === ref.path)?.seed;
|
|
2289
|
+
if (!rows || rows.filter((row) => Object.entries(ref.where).every(([k, v]) => isDeepStrictEqual4(row[k], v))).length !== 1) fail(`entity ${entity.id} reference must resolve to exactly one seeded record`);
|
|
2290
|
+
services.add(ref.service);
|
|
2291
|
+
}
|
|
2292
|
+
if (services.size < 2) fail(`entity ${entity.id} must link at least two services`);
|
|
2293
|
+
}
|
|
2294
|
+
return structuredClone(scenario);
|
|
2295
|
+
}
|
|
2296
|
+
function reference(ref) {
|
|
2297
|
+
const match = /^([a-z][a-z0-9_]{0,39})@([1-9][0-9]*)$/.exec(ref);
|
|
2298
|
+
if (!match || !Number.isSafeInteger(Number(match[2]))) throw new Error("Scenario reference must be id@version");
|
|
2299
|
+
return { id: match[1], version: Number(match[2]) };
|
|
2300
|
+
}
|
|
2301
|
+
async function loadScenario(cwd, ref) {
|
|
2302
|
+
const { id, version } = reference(ref);
|
|
2303
|
+
const path2 = join6(cwd, ".agenttwin/scenarios", id, `${version}.json`);
|
|
2304
|
+
const bytes = await readFile5(path2);
|
|
2305
|
+
if (bytes.length > 2 * 1024 * 1024) throw new Error("Scenario exceeds 2 MiB");
|
|
2306
|
+
const scenario = parseScenario(JSON.parse(bytes.toString()));
|
|
2307
|
+
if (scenario.id !== id || scenario.version !== version) throw new Error("Scenario identity does not match its stored version");
|
|
2308
|
+
return scenario;
|
|
2309
|
+
}
|
|
2310
|
+
function applyScenario(world, scenario) {
|
|
2311
|
+
const shape = (services) => services.map((s) => ({ name: s.name, adapter: s.adapter ?? "json-collection", paths: s.collections.map((c) => c.path).sort() })).sort((a, b) => a.name.localeCompare(b.name));
|
|
2312
|
+
if (!isDeepStrictEqual4(shape(world.services), shape(scenario.services))) throw new Error("Scenario services, adapters and collections must exactly match the world");
|
|
2313
|
+
return parseWorld({ ...world, services: structuredClone(scenario.services), scenario: { id: scenario.id, version: scenario.version, digest: scenarioDigest(scenario) } });
|
|
2314
|
+
}
|
|
2315
|
+
|
|
1558
2316
|
// src/commands/run.ts
|
|
1559
2317
|
async function stageApplication(root, files, destination) {
|
|
1560
|
-
const canonicalRoot = await
|
|
2318
|
+
const canonicalRoot = await realpath2(root);
|
|
1561
2319
|
const digests = {};
|
|
1562
2320
|
let bytes = 0;
|
|
1563
2321
|
for (const file of files) {
|
|
1564
2322
|
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 });
|
|
1565
|
-
const source = await
|
|
2323
|
+
const source = await realpath2(resolve2(canonicalRoot, file));
|
|
1566
2324
|
const rel = relative2(canonicalRoot, source);
|
|
1567
|
-
if (rel.startsWith(`..${sep2}`) || rel === ".." || isAbsolute(rel) || source !==
|
|
1568
|
-
const contents = await
|
|
2325
|
+
if (rel.startsWith(`..${sep2}`) || rel === ".." || isAbsolute(rel) || source !== resolve2(canonicalRoot, file) || !(await lstat(source)).isFile()) throw new CliError(`Files must be regular files within the project, without symlinks: ${file}`, { code: EXIT.USAGE });
|
|
2326
|
+
const contents = await readFile10(source);
|
|
1569
2327
|
bytes += contents.length;
|
|
1570
2328
|
if (bytes > 20 * 1024 * 1024) throw new CliError("Application files exceed 20 MiB; supply a smaller built bundle", { code: EXIT.USAGE });
|
|
1571
|
-
const target =
|
|
1572
|
-
await
|
|
1573
|
-
await
|
|
1574
|
-
digests[file] =
|
|
2329
|
+
const target = join11(destination, file);
|
|
2330
|
+
await mkdir5(dirname(target), { recursive: true });
|
|
2331
|
+
await writeFile7(target, contents, { mode: 420 });
|
|
2332
|
+
digests[file] = createHash4("sha256").update(contents).digest("hex");
|
|
1575
2333
|
}
|
|
1576
2334
|
return digests;
|
|
1577
2335
|
}
|
|
1578
2336
|
async function runWorld(cwd, opts) {
|
|
1579
|
-
const file =
|
|
2337
|
+
const file = resolve2(cwd, opts.file ?? "agenttwin.world.json");
|
|
1580
2338
|
let spec;
|
|
1581
2339
|
try {
|
|
1582
|
-
spec = parseWorld(JSON.parse(await
|
|
2340
|
+
spec = parseWorld(JSON.parse(await readFile10(file, "utf8")));
|
|
1583
2341
|
} catch (error) {
|
|
1584
2342
|
throw new CliError(String(error), { code: EXIT.USAGE, action: "Create agenttwin.world.json; run agenttwin example --out ./agenttwin-example for a working example." });
|
|
1585
2343
|
}
|
|
2344
|
+
if (opts.scenario) spec = applyScenario(spec, await loadScenario(cwd, opts.scenario));
|
|
1586
2345
|
if (spec.workflowId) {
|
|
1587
2346
|
let inventory;
|
|
1588
2347
|
try {
|
|
1589
|
-
inventory = JSON.parse(await
|
|
2348
|
+
inventory = JSON.parse(await readFile10(join11(cwd, ".agenttwin/inventory.json"), "utf8"));
|
|
1590
2349
|
} catch {
|
|
1591
2350
|
throw new CliError("Linked workflow needs discovery inventory; run init first", { code: EXIT.BLOCKED });
|
|
1592
2351
|
}
|
|
1593
2352
|
if (!inventory.workflows?.some((w) => w.id === spec.workflowId)) {
|
|
1594
2353
|
const { loadDashboardDiscovery: loadDashboardDiscovery2 } = await Promise.resolve().then(() => (init_session(), session_exports));
|
|
1595
|
-
const
|
|
1596
|
-
if (!
|
|
2354
|
+
const discovery2 = await loadDashboardDiscovery2(cwd, inventory);
|
|
2355
|
+
if (!discovery2.workflows.some((w) => w.id === spec.workflowId)) throw new CliError("workflowId is absent from the current inventory", { code: EXIT.BLOCKED });
|
|
1597
2356
|
}
|
|
1598
2357
|
}
|
|
1599
2358
|
const runtime = opts.runtime ?? "local";
|
|
1600
2359
|
if (!["local", "e2b", "hosted"].includes(runtime)) throw new CliError("runtime must be local, hosted or e2b", { code: EXIT.USAGE });
|
|
1601
2360
|
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." });
|
|
1602
|
-
const stage = await
|
|
2361
|
+
const stage = await mkdtemp2(join11(tmpdir2(), "agenttwin-world-"));
|
|
1603
2362
|
try {
|
|
1604
|
-
const sourceDigests = await stageApplication(dirname(file), spec.files,
|
|
1605
|
-
await
|
|
2363
|
+
const sourceDigests = await stageApplication(dirname(file), spec.files, join11(stage, "app"));
|
|
2364
|
+
await writeFile7(join11(stage, "world.json"), JSON.stringify(spec), { mode: 420 });
|
|
1606
2365
|
const git = await captureGit(cwd);
|
|
1607
2366
|
let project;
|
|
1608
2367
|
try {
|
|
1609
|
-
project = JSON.parse(await
|
|
2368
|
+
project = JSON.parse(await readFile10(join11(cwd, ".agenttwin/inventory.json"), "utf8")).name ?? basename2(cwd);
|
|
1610
2369
|
} catch {
|
|
1611
2370
|
}
|
|
1612
2371
|
if (runtime === "local") {
|
|
1613
2372
|
opts.log("Testing through the CLI with in-process service twins. No cloud VM or containers.");
|
|
1614
|
-
const report2 = await executeLocal(
|
|
2373
|
+
const report2 = await executeLocal(join11(stage, "app"), spec, opts.signal);
|
|
1615
2374
|
const runId2 = randomUUID();
|
|
1616
|
-
const receipt2 = { ...report2, runId: runId2, runtime, project, git, createdAt: (/* @__PURE__ */ new Date()).toISOString(), sourceDigests, specDigest:
|
|
1617
|
-
const out2 =
|
|
1618
|
-
await
|
|
1619
|
-
const evidencePath =
|
|
1620
|
-
await
|
|
1621
|
-
await
|
|
2375
|
+
const receipt2 = { ...report2, sourceDirectory: relative2(cwd, dirname(file)).split(sep2).join("/"), runId: runId2, runtime, project, git, createdAt: (/* @__PURE__ */ new Date()).toISOString(), sourceDigests, specDigest: createHash4("sha256").update(JSON.stringify(spec)).digest("hex"), provenance: "CLI-reported execution" };
|
|
2376
|
+
const out2 = resolve2(cwd, opts.out ?? `.agenttwin/runs/${runId2}`);
|
|
2377
|
+
await mkdir5(out2, { recursive: true });
|
|
2378
|
+
const evidencePath = join11(out2, "run.json"), reportPath = join11(out2, "report.md");
|
|
2379
|
+
await writeFile7(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
|
|
2380
|
+
await writeFile7(join11(out2, "world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
|
|
2381
|
+
await writeFile7(reportPath, `# ${spec.name}: ${receipt2.status}
|
|
1622
2382
|
|
|
1623
2383
|
Runner: CLI
|
|
1624
2384
|
Git: ${git?.head ?? "unavailable"}
|
|
@@ -1626,20 +2386,20 @@ Git: ${git?.head ?? "unavailable"}
|
|
|
1626
2386
|
` + receipt2.assertions.map((a) => `- ${a.passed ? "PASS" : "FAIL"} ${a.service}${a.path}: expected ${a.count}, observed ${a.actual}`).join("\n") + "\n\nSee run.json for activities, fault and initial/final state.\n");
|
|
1627
2387
|
return { ...receipt2, reportPath, evidencePath };
|
|
1628
2388
|
}
|
|
1629
|
-
const worker =
|
|
1630
|
-
await copyFile(worker,
|
|
2389
|
+
const worker = join11(dirname(fileURLToPath2(import.meta.url)), "worker.js");
|
|
2390
|
+
await copyFile(worker, join11(stage, "worker.js"));
|
|
1631
2391
|
if (runtime === "hosted") {
|
|
1632
2392
|
const receipt2 = await executeRemote(stage, spec, opts.log, { git, project });
|
|
1633
2393
|
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");
|
|
1634
|
-
const expectedSpec =
|
|
2394
|
+
const expectedSpec = createHash4("sha256").update(JSON.stringify(spec)).digest("hex");
|
|
1635
2395
|
const reportedSources = receipt2.sourceDigests;
|
|
1636
|
-
if (receipt2.specDigest !== expectedSpec || !reportedSources || Object.keys(reportedSources).length !== Object.keys(sourceDigests).length || Object.entries(sourceDigests).some(([
|
|
1637
|
-
const out2 =
|
|
1638
|
-
await
|
|
1639
|
-
const evidencePath =
|
|
1640
|
-
const reportPath =
|
|
1641
|
-
await
|
|
1642
|
-
await
|
|
2396
|
+
if (receipt2.specDigest !== expectedSpec || !reportedSources || Object.keys(reportedSources).length !== Object.keys(sourceDigests).length || Object.entries(sourceDigests).some(([path2, digest2]) => reportedSources[path2] !== digest2)) throw new CliError("Hosted receipt does not match the submitted source/specification");
|
|
2397
|
+
const out2 = resolve2(cwd, opts.out ?? `.agenttwin/runs/${receipt2.runId}`);
|
|
2398
|
+
await mkdir5(out2, { recursive: true });
|
|
2399
|
+
const evidencePath = join11(out2, "run.json");
|
|
2400
|
+
const reportPath = join11(out2, "report.md");
|
|
2401
|
+
await writeFile7(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
|
|
2402
|
+
await writeFile7(reportPath, `# ${spec.name}: ${receipt2.status}
|
|
1643
2403
|
|
|
1644
2404
|
Run: ${receipt2.runId}
|
|
1645
2405
|
|
|
@@ -1669,26 +2429,26 @@ See run.json for assertions, service state, application output and isolation met
|
|
|
1669
2429
|
image,
|
|
1670
2430
|
templateBuildId,
|
|
1671
2431
|
sourceDigests,
|
|
1672
|
-
specDigest:
|
|
2432
|
+
specDigest: createHash4("sha256").update(JSON.stringify(spec)).digest("hex"),
|
|
1673
2433
|
isolation
|
|
1674
2434
|
};
|
|
1675
|
-
const out =
|
|
1676
|
-
await
|
|
1677
|
-
await
|
|
1678
|
-
await
|
|
2435
|
+
const out = resolve2(cwd, opts.out ?? `.agenttwin/runs/${runId}`);
|
|
2436
|
+
await mkdir5(out, { recursive: true });
|
|
2437
|
+
await writeFile7(join11(out, "run.json"), JSON.stringify(receipt, null, 2) + "\n", { mode: 384 });
|
|
2438
|
+
await writeFile7(join11(out, "world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
|
|
1679
2439
|
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");
|
|
1680
|
-
await
|
|
1681
|
-
return { ...receipt, reportPath:
|
|
2440
|
+
await writeFile7(join11(out, "report.md"), summary);
|
|
2441
|
+
return { ...receipt, reportPath: join11(out, "report.md"), evidencePath: join11(out, "run.json") };
|
|
1682
2442
|
} finally {
|
|
1683
|
-
await
|
|
2443
|
+
await rm3(stage, { recursive: true, force: true });
|
|
1684
2444
|
}
|
|
1685
2445
|
}
|
|
1686
2446
|
|
|
1687
2447
|
// src/runtime/gateway.ts
|
|
1688
|
-
var
|
|
1689
|
-
function uploadPath(
|
|
1690
|
-
if (!
|
|
1691
|
-
return
|
|
2448
|
+
var hash2 = (value) => createHash5("sha256").update(value).digest("hex");
|
|
2449
|
+
function uploadPath(path2) {
|
|
2450
|
+
if (!path2 || path2.startsWith("/") || path2.includes("\\") || path2.includes("\0") || path2.split("/").some((p) => !p || p === "." || p === ".." || /^(\.env.*|\.git|\.ssh|\.aws|\.azure|\.config|\.npmrc|\.netrc|credentials.*)$/i.test(p)) || /\.(pem|key|p12|pfx)$/i.test(path2)) throw new Error("Unsafe uploaded file path");
|
|
2451
|
+
return path2;
|
|
1692
2452
|
}
|
|
1693
2453
|
async function body(req, limit = 28 * 1024 * 1024) {
|
|
1694
2454
|
const chunks = [];
|
|
@@ -1701,30 +2461,44 @@ async function body(req, limit = 28 * 1024 * 1024) {
|
|
|
1701
2461
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1702
2462
|
}
|
|
1703
2463
|
function send(res, code, value) {
|
|
1704
|
-
res.writeHead(code, {
|
|
2464
|
+
res.writeHead(code, {
|
|
2465
|
+
"content-type": "application/json",
|
|
2466
|
+
"cache-control": "no-store",
|
|
2467
|
+
"x-content-type-options": "nosniff",
|
|
2468
|
+
"referrer-policy": "no-referrer",
|
|
2469
|
+
"x-frame-options": "DENY",
|
|
2470
|
+
"content-security-policy": "default-src 'none'; frame-ancestors 'none'"
|
|
2471
|
+
});
|
|
1705
2472
|
res.end(JSON.stringify(value));
|
|
1706
2473
|
}
|
|
1707
2474
|
function equal(a, b) {
|
|
1708
|
-
const x = Buffer.from(
|
|
1709
|
-
return
|
|
2475
|
+
const x = Buffer.from(hash2(a)), y = Buffer.from(hash2(b));
|
|
2476
|
+
return timingSafeEqual4(x, y);
|
|
1710
2477
|
}
|
|
1711
2478
|
async function startGateway(opts) {
|
|
1712
2479
|
if (opts.adminToken.length < 40) throw new Error("AGENTTWIN_ADMIN_TOKEN must have at least 40 characters");
|
|
2480
|
+
const socialAuth = createSocialAuth(opts.socialAuth);
|
|
2481
|
+
const emailVerification = createEmailVerification(opts.emailVerification, opts.adminToken);
|
|
1713
2482
|
const allowedOrigins = new Set(opts.allowedOrigins ?? []);
|
|
1714
2483
|
for (const origin of allowedOrigins) {
|
|
1715
2484
|
const u = new URL(origin);
|
|
1716
2485
|
if (u.origin !== origin || u.protocol !== "https:" && !(u.protocol === "http:" && ["127.0.0.1", "localhost"].includes(u.hostname))) throw new Error("Invalid dashboard origin");
|
|
1717
2486
|
}
|
|
2487
|
+
const verificationUrl = opts.verificationUrl ?? "https://agenttwin.si/onboarding/";
|
|
2488
|
+
if (opts.verificationUrl) {
|
|
2489
|
+
const target = new URL(verificationUrl);
|
|
2490
|
+
if (!allowedOrigins.has(target.origin) || target.pathname !== "/onboarding/" || target.search || target.hash || target.username || target.password) throw new Error("Invalid device verification URL");
|
|
2491
|
+
}
|
|
1718
2492
|
const controllers = /* @__PURE__ */ new Map();
|
|
1719
2493
|
const chatTasks = /* @__PURE__ */ new Set();
|
|
1720
2494
|
const chatControllers = /* @__PURE__ */ new Map();
|
|
1721
2495
|
const voiceControllers = /* @__PURE__ */ new Map();
|
|
1722
|
-
const directory =
|
|
1723
|
-
await
|
|
1724
|
-
const storePath2 =
|
|
2496
|
+
const directory = resolve3(opts.directory);
|
|
2497
|
+
await mkdir6(directory, { recursive: true, mode: 448 });
|
|
2498
|
+
const storePath2 = join12(directory, "state.json");
|
|
1725
2499
|
let store;
|
|
1726
2500
|
try {
|
|
1727
|
-
store = JSON.parse(await
|
|
2501
|
+
store = JSON.parse(await readFile11(storePath2, "utf8"));
|
|
1728
2502
|
if (store.schema !== "agenttwin/gateway-v1") throw new Error("Invalid store");
|
|
1729
2503
|
} catch (error) {
|
|
1730
2504
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -1739,9 +2513,9 @@ async function startGateway(opts) {
|
|
|
1739
2513
|
const save = () => {
|
|
1740
2514
|
const snapshot = JSON.stringify(store);
|
|
1741
2515
|
writes = writes.then(async () => {
|
|
1742
|
-
const
|
|
1743
|
-
await
|
|
1744
|
-
await rename(
|
|
2516
|
+
const path2 = join12(directory, `state-${randomUUID2()}.tmp`);
|
|
2517
|
+
await writeFile8(path2, snapshot, { mode: 384 });
|
|
2518
|
+
await rename(path2, storePath2);
|
|
1745
2519
|
}).catch((error) => {
|
|
1746
2520
|
storageHealthy = false;
|
|
1747
2521
|
throw error;
|
|
@@ -1774,16 +2548,16 @@ async function startGateway(opts) {
|
|
|
1774
2548
|
if (!store.accounts.some((a) => a.id === item.job.accountId && a.active)) throw new Error("Account revoked before execution");
|
|
1775
2549
|
item.job.status = "running";
|
|
1776
2550
|
await save();
|
|
1777
|
-
workspace = await
|
|
2551
|
+
workspace = await mkdtemp3(join12(tmpdir3(), "agenttwin-api-run-"));
|
|
1778
2552
|
for (const [file, encoded] of Object.entries(item.files)) {
|
|
1779
|
-
const destination =
|
|
1780
|
-
await
|
|
1781
|
-
await
|
|
2553
|
+
const destination = join12(workspace, uploadPath(file));
|
|
2554
|
+
await mkdir6(dirname2(destination), { recursive: true });
|
|
2555
|
+
await writeFile8(destination, Buffer.from(encoded, "base64"), { mode: 384 });
|
|
1782
2556
|
}
|
|
1783
|
-
await
|
|
2557
|
+
await writeFile8(join12(workspace, "agenttwin.world.json"), JSON.stringify(item.spec));
|
|
1784
2558
|
if (item.spec.workflowId) {
|
|
1785
|
-
await
|
|
1786
|
-
await
|
|
2559
|
+
await mkdir6(join12(workspace, ".agenttwin"));
|
|
2560
|
+
await writeFile8(join12(workspace, ".agenttwin/inventory.json"), JSON.stringify({ workflows: [{ id: item.spec.workflowId }] }));
|
|
1787
2561
|
}
|
|
1788
2562
|
const receipt = await runWorld(workspace, { runtime: "e2b", signal: controllers.get(item.job.id)?.signal, log: () => {
|
|
1789
2563
|
} });
|
|
@@ -1797,7 +2571,7 @@ async function startGateway(opts) {
|
|
|
1797
2571
|
item.job.error = message.split(process.env.E2B_API_KEY ?? "\0").join("[redacted]").replace(/(?:postgresql|redis):\/\/[^\s]+/g, "[test-connection]").slice(0, 2e3);
|
|
1798
2572
|
} finally {
|
|
1799
2573
|
try {
|
|
1800
|
-
if (workspace) await
|
|
2574
|
+
if (workspace) await rm4(workspace, { recursive: true, force: true });
|
|
1801
2575
|
await save();
|
|
1802
2576
|
} finally {
|
|
1803
2577
|
controllers.delete(item.job.id);
|
|
@@ -1811,24 +2585,29 @@ async function startGateway(opts) {
|
|
|
1811
2585
|
});
|
|
1812
2586
|
}
|
|
1813
2587
|
}
|
|
2588
|
+
const authAttempts = /* @__PURE__ */ new Map();
|
|
2589
|
+
const passkeyChallenges = {};
|
|
2590
|
+
const devices = /* @__PURE__ */ new Map();
|
|
2591
|
+
let hashing = 0;
|
|
2592
|
+
const dummyPassword = randomBytes6(16).toString("hex") + ":" + randomBytes6(64).toString("hex");
|
|
1814
2593
|
const server = createServer2(async (req, res) => {
|
|
1815
2594
|
try {
|
|
1816
2595
|
const requestUrl = new URL(req.url ?? "/", "http://localhost");
|
|
1817
|
-
const
|
|
1818
|
-
if (opts.dashboardDirectory && req.method === "GET" && (
|
|
1819
|
-
if (
|
|
2596
|
+
const path2 = requestUrl.pathname;
|
|
2597
|
+
if (opts.dashboardDirectory && req.method === "GET" && (path2 === "/dashboard" || path2.startsWith("/dashboard/"))) {
|
|
2598
|
+
if (path2 === "/dashboard") {
|
|
1820
2599
|
res.writeHead(302, { location: "/dashboard/" });
|
|
1821
2600
|
res.end();
|
|
1822
2601
|
return;
|
|
1823
2602
|
}
|
|
1824
|
-
const relative3 = decodeURIComponent(
|
|
1825
|
-
const root =
|
|
2603
|
+
const relative3 = decodeURIComponent(path2.slice("/dashboard/".length)) || "index.html";
|
|
2604
|
+
const root = resolve3(opts.dashboardDirectory), file = resolve3(root, relative3);
|
|
1826
2605
|
if (!file.startsWith(root + "/") || relative3 !== "index.html" && !relative3.startsWith("assets/")) {
|
|
1827
2606
|
send(res, 404, { error: "not_found" });
|
|
1828
2607
|
return;
|
|
1829
2608
|
}
|
|
1830
2609
|
try {
|
|
1831
|
-
const bytes = await
|
|
2610
|
+
const bytes = await readFile11(file);
|
|
1832
2611
|
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" };
|
|
1833
2612
|
res.writeHead(200, { "content-type": types[extname(file)] ?? "application/octet-stream", "cache-control": "no-store", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer" });
|
|
1834
2613
|
res.end(bytes);
|
|
@@ -1845,6 +2624,7 @@ async function startGateway(opts) {
|
|
|
1845
2624
|
}
|
|
1846
2625
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
1847
2626
|
res.setHeader("Vary", "Origin");
|
|
2627
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
1848
2628
|
if (req.method === "OPTIONS") {
|
|
1849
2629
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
1850
2630
|
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
@@ -1854,7 +2634,7 @@ async function startGateway(opts) {
|
|
|
1854
2634
|
return;
|
|
1855
2635
|
}
|
|
1856
2636
|
}
|
|
1857
|
-
if (
|
|
2637
|
+
if (path2 === "/healthz" && req.method === "GET") {
|
|
1858
2638
|
send(res, 200, { status: stopping ? "stopping" : "ok" });
|
|
1859
2639
|
return;
|
|
1860
2640
|
}
|
|
@@ -1863,8 +2643,402 @@ async function startGateway(opts) {
|
|
|
1863
2643
|
return;
|
|
1864
2644
|
}
|
|
1865
2645
|
const token = req.headers.authorization?.replace(/^Bearer /, "") ?? "";
|
|
2646
|
+
const cookieToken = req.headers.cookie?.split(";").map((v) => v.trim()).find((v) => v.startsWith("at_session="))?.slice(11) ?? "";
|
|
2647
|
+
const session = store.sessions?.find((s) => s.hash === digest(cookieToken) && s.expires > Date.now());
|
|
2648
|
+
if (cookieToken && !token && !["GET", "HEAD", "OPTIONS"].includes(req.method ?? "") && (!origin || !allowedOrigins.has(origin))) {
|
|
2649
|
+
send(res, 403, { error: "origin_required" });
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
const setCookie = (value, age) => {
|
|
2653
|
+
const local = opts.host === "127.0.0.1" || opts.host === "localhost";
|
|
2654
|
+
res.setHeader("Set-Cookie", `at_session=${value}; Path=/; HttpOnly; Max-Age=${age}; SameSite=${local ? "Lax" : "None"}${local ? "" : "; Secure"}`);
|
|
2655
|
+
};
|
|
2656
|
+
const profile = (a) => ({ accountId: a.id, email: a.identity?.email, name: a.identity?.name ?? a.label, workspace: a.label, workspaceProfile: a.workspaceProfile, passwordEnabled: !!a.identity?.passwordHash, passwordSetupAllowed: !a.identity?.passwordHash && !!a.externalIdentities?.some((i) => i.provider === "google") });
|
|
2657
|
+
if (path2 === "/v1/dev/workspace" && req.method === "GET" && opts.localDevelopment && ["127.0.0.1", "localhost"].includes(opts.host ?? "") && verificationUrl === "http://localhost:3000/onboarding/") {
|
|
2658
|
+
send(res, 200, { schema: "agenttwin/local-workspace-v1", dashboard: verificationUrl });
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
if (path2 === "/v1/auth/providers" && req.method === "GET") {
|
|
2662
|
+
send(res, 200, { providers: socialAuth.providers(), emailVerification: emailVerification.enabled });
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
const emailCookie = req.headers.cookie?.split(";").map((value) => value.trim()).find((value) => value.startsWith("at_email="))?.slice(9) ?? "";
|
|
2666
|
+
const [emailChallengeId, emailBinding] = emailCookie.split(".");
|
|
2667
|
+
const emailGrant = store.emailChallenges?.[emailChallengeId ?? ""];
|
|
2668
|
+
const setEmailCookie = (value, age) => {
|
|
2669
|
+
const local = opts.host === "127.0.0.1" || opts.host === "localhost";
|
|
2670
|
+
return `at_email=${value}; Path=/v1/auth/; HttpOnly; Max-Age=${age}; SameSite=${local ? "Lax" : "None"}${local ? "" : "; Secure"}`;
|
|
2671
|
+
};
|
|
2672
|
+
if (path2 === "/v1/auth/email/start" && req.method === "POST") {
|
|
2673
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2674
|
+
send(res, 403, { error: "origin_required" });
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
if (!emailVerification.enabled) {
|
|
2678
|
+
send(res, 503, { error: "Email verification is not available yet. Please use Google sign-in." });
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
let email;
|
|
2682
|
+
try {
|
|
2683
|
+
email = normalizedEmail((await body(req, 8192)).email);
|
|
2684
|
+
} catch {
|
|
2685
|
+
send(res, 400, { error: "Enter a valid email address." });
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
store.emailChallenges ??= {};
|
|
2689
|
+
for (const [id, challenge] of Object.entries(store.emailChallenges)) if (challenge.expires <= Date.now()) delete store.emailChallenges[id];
|
|
2690
|
+
for (const [key, limit] of authAttempts) if (limit.until <= Date.now()) authAttempts.delete(key);
|
|
2691
|
+
const keys = ["email-send:" + digest(email), "email-ip:" + digest(req.socket.remoteAddress ?? "unknown")];
|
|
2692
|
+
if (Object.keys(store.emailChallenges).length >= 1e3 || authAttempts.size >= 1e4 || keys.some((key, index) => (authAttempts.get(key)?.count ?? 0) >= (index === 0 ? 5 : 20)) || Object.values(store.emailChallenges).some((challenge) => challenge.email === email && Date.now() - challenge.sentAt < 6e4)) {
|
|
2693
|
+
send(res, 429, { error: "Please wait before requesting another code." });
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
for (const key of keys) {
|
|
2697
|
+
const limit = authAttempts.get(key) ?? { count: 0, until: Date.now() + 15 * 6e4 };
|
|
2698
|
+
limit.count++;
|
|
2699
|
+
authAttempts.set(key, limit);
|
|
2700
|
+
}
|
|
2701
|
+
const flow = emailVerification.challenge(email, origin);
|
|
2702
|
+
for (const [id, challenge] of Object.entries(store.emailChallenges)) if (challenge.email === email) delete store.emailChallenges[id];
|
|
2703
|
+
store.emailChallenges[flow.id] = flow.challenge;
|
|
2704
|
+
await save();
|
|
2705
|
+
try {
|
|
2706
|
+
await emailVerification.deliver(email, flow.code, flow.id);
|
|
2707
|
+
} catch {
|
|
2708
|
+
delete store.emailChallenges[flow.id];
|
|
2709
|
+
await save();
|
|
2710
|
+
send(res, 503, { error: "Could not send your verification code. Please try again shortly." });
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
res.setHeader("Set-Cookie", setEmailCookie(flow.id + "." + flow.binding, 600));
|
|
2714
|
+
send(res, 200, { sent: true, expiresIn: 600, retryAfter: 60 });
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
if (path2 === "/v1/auth/email/verify" && req.method === "POST") {
|
|
2718
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2719
|
+
send(res, 403, { error: "origin_required" });
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
if (!emailVerification.enabled || !emailVerification.bound(emailGrant, emailBinding ?? "", origin)) {
|
|
2723
|
+
send(res, 400, { error: "Verification expired. Request a new code." });
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
const input = await body(req, 8192);
|
|
2727
|
+
const valid = emailVerification.verify(emailChallengeId, emailGrant, input.code);
|
|
2728
|
+
await save();
|
|
2729
|
+
if (!valid) {
|
|
2730
|
+
send(res, 400, { error: "The code is incorrect or expired. Request a new code after five unsuccessful attempts." });
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
send(res, 200, { verified: true });
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
const oauthStart = path2.match(/^\/v1\/auth\/oauth\/(google|github|sso)\/start$/);
|
|
2737
|
+
if (oauthStart && req.method === "POST") {
|
|
2738
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2739
|
+
send(res, 403, { error: "origin_required" });
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
const key = "oauth:" + (req.socket.remoteAddress ?? "unknown");
|
|
2743
|
+
for (const [k, value] of authAttempts) if (value.until < Date.now()) authAttempts.delete(k);
|
|
2744
|
+
const limit = authAttempts.get(key) ?? { count: 0, until: Date.now() + 6e5 };
|
|
2745
|
+
if (limit.count >= 20 || authAttempts.size >= 1e4) {
|
|
2746
|
+
send(res, 429, { error: "Too many attempts. Try again shortly." });
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
limit.count++;
|
|
2750
|
+
authAttempts.set(key, limit);
|
|
2751
|
+
const input = await body(req, 8192);
|
|
2752
|
+
try {
|
|
2753
|
+
const result = await socialAuth.begin(oauthStart[1], origin, input.signup === true, input.onboarding === true);
|
|
2754
|
+
res.setHeader("Set-Cookie", `at_oauth=${result.binding}; Path=/v1/auth/oauth/; HttpOnly; Max-Age=600; SameSite=Lax${result.secure ? "; Secure" : ""}`);
|
|
2755
|
+
send(res, 200, { url: result.url });
|
|
2756
|
+
} catch (error) {
|
|
2757
|
+
send(res, 503, { error: error instanceof Error && error.message === "provider_not_configured" ? "This sign-in provider has not been configured yet. Use email to continue." : "Could not start provider sign-in. Please try again." });
|
|
2758
|
+
}
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
const oauthCallback = path2.match(/^\/v1\/auth\/oauth\/(google|github|sso)\/callback$/);
|
|
2762
|
+
if (oauthCallback && req.method === "GET") {
|
|
2763
|
+
const binding = req.headers.cookie?.split(";").map((v) => v.trim()).find((v) => v.startsWith("at_oauth="))?.slice(9) ?? "";
|
|
2764
|
+
let transaction;
|
|
2765
|
+
try {
|
|
2766
|
+
transaction = socialAuth.take(oauthCallback[1], requestUrl.searchParams, binding);
|
|
2767
|
+
} catch {
|
|
2768
|
+
send(res, 400, { error: "Sign-in expired or belongs to another browser. Start again from the login page." });
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
const secure = opts.socialAuth?.publicUrl.startsWith("https:");
|
|
2772
|
+
const clearBinding = `at_oauth=; Path=/v1/auth/oauth/; HttpOnly; Max-Age=0; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
2773
|
+
const redirect = (path3) => {
|
|
2774
|
+
res.writeHead(303, { Location: transaction.origin + path3, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
|
|
2775
|
+
res.end();
|
|
2776
|
+
};
|
|
2777
|
+
res.setHeader("Set-Cookie", clearBinding);
|
|
2778
|
+
try {
|
|
2779
|
+
const identity = await socialAuth.finish(transaction, requestUrl.searchParams);
|
|
2780
|
+
let owner = store.accounts.find((a) => a.externalIdentities?.some((i) => i.provider === identity.provider && i.issuer === identity.issuer && i.subject === identity.subject));
|
|
2781
|
+
let created = false;
|
|
2782
|
+
if (!owner) {
|
|
2783
|
+
if (store.accounts.some((a) => a.identity?.email === identity.email)) {
|
|
2784
|
+
redirect((transaction.signup ? "/signup/" : "/login/") + "?auth_error=existing_account");
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
if (store.accounts.length >= 1e4) throw new Error("account_capacity");
|
|
2788
|
+
owner = { id: randomUUID2(), tokenHash: hash2(randomBytes6(32).toString("hex")), label: identity.name + "'s workspace", active: true, usageDay: "", runs: 0, identity: { email: identity.email, name: identity.name }, externalIdentities: [{ provider: identity.provider, issuer: identity.issuer, subject: identity.subject }] };
|
|
2789
|
+
store.accounts.push(owner);
|
|
2790
|
+
created = true;
|
|
2791
|
+
}
|
|
2792
|
+
if (!owner.active) throw new Error("account_inactive");
|
|
2793
|
+
const secret = randomBytes6(32).toString("hex");
|
|
2794
|
+
store.sessions = (store.sessions ?? []).filter((s) => s.expires > Date.now());
|
|
2795
|
+
const owned = store.sessions.filter((s) => s.accountId === owner.id);
|
|
2796
|
+
if (owned.length >= 10) store.sessions = store.sessions.filter((s) => s !== owned[0]);
|
|
2797
|
+
store.sessions.push({ accountId: owner.id, hash: digest(secret), expires: Date.now() + 7 * 864e5, authenticatedAt: Date.now(), method: identity.provider });
|
|
2798
|
+
await save();
|
|
2799
|
+
setCookie(secret, 7 * 86400);
|
|
2800
|
+
res.setHeader("Set-Cookie", [String(res.getHeader("Set-Cookie")), clearBinding]);
|
|
2801
|
+
redirect(created || transaction.signup || transaction.onboarding ? "/onboarding/" : "/dashboard/#home");
|
|
2802
|
+
} catch (error) {
|
|
2803
|
+
const reason = error instanceof Error && error.message === "verified_email_required" ? "verified_email" : "provider_failed";
|
|
2804
|
+
redirect((transaction.signup ? "/signup/" : "/login/") + "?auth_error=" + reason);
|
|
2805
|
+
}
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
if (path2 === "/v1/auth/device" && req.method === "POST") {
|
|
2809
|
+
for (const [k, v] of devices) if (v.expires < Date.now()) devices.delete(k);
|
|
2810
|
+
if (devices.size >= 100) {
|
|
2811
|
+
send(res, 429, { error: "Try again shortly." });
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
const secret = randomBytes6(32).toString("hex"), code = randomBytes6(5).toString("hex").toUpperCase();
|
|
2815
|
+
devices.set(digest(secret), { code, expires: Date.now() + 6e5, lastPoll: 0 });
|
|
2816
|
+
send(res, 200, { deviceCode: secret, userCode: code, verificationUrl, expiresIn: 600 });
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
if (path2 === "/v1/auth/passkey/options" && req.method === "POST") {
|
|
2820
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2821
|
+
send(res, 403, { error: "origin_required" });
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2824
|
+
if (!session) {
|
|
2825
|
+
send(res, 401, { error: "unauthorized" });
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
const owner = store.accounts.find((a) => a.id === session.accountId && a.active);
|
|
2829
|
+
if (!owner) {
|
|
2830
|
+
send(res, 401, { error: "unauthorized" });
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
const rpID = requestUrl.hostname === "localhost" || requestUrl.hostname === "127.0.0.1" ? "localhost" : new URL(origin).hostname;
|
|
2834
|
+
const options = await generateRegistrationOptions({ rpName: "AgentTwin", rpID, userName: owner.identity?.email ?? owner.label, userDisplayName: owner.identity?.name ?? owner.label, excludeCredentials: (owner.passkeys ?? []).map((k) => ({ id: k.id })) });
|
|
2835
|
+
const challengeId = randomBytes6(16).toString("hex");
|
|
2836
|
+
challengeStore(passkeyChallenges, challengeId, options.challenge, 12e4);
|
|
2837
|
+
send(res, 200, { options, challengeId });
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
if (path2 === "/v1/auth/passkey/register" && req.method === "POST") {
|
|
2841
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2842
|
+
send(res, 403, { error: "origin_required" });
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
if (!session) {
|
|
2846
|
+
send(res, 401, { error: "unauthorized" });
|
|
2847
|
+
return;
|
|
2848
|
+
}
|
|
2849
|
+
const owner = store.accounts.find((a) => a.id === session.accountId && a.active);
|
|
2850
|
+
if (!owner) {
|
|
2851
|
+
send(res, 401, { error: "unauthorized" });
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
const input = await body(req, 65536);
|
|
2855
|
+
const stored = passkeyChallenges[String(input.challengeId)];
|
|
2856
|
+
if (!stored) {
|
|
2857
|
+
send(res, 400, { error: "Registration expired. Try again." });
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
delete passkeyChallenges[String(input.challengeId)];
|
|
2861
|
+
const rpID = requestUrl.hostname === "localhost" || requestUrl.hostname === "127.0.0.1" ? "localhost" : new URL(origin).hostname;
|
|
2862
|
+
const verification = await verifyRegistrationResponse({ response: input.credential, expectedChallenge: stored.value, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false });
|
|
2863
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
2864
|
+
send(res, 400, { error: "Passkey could not be verified. Try again." });
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
const { credential } = verification.registrationInfo;
|
|
2868
|
+
owner.passkeys = [...(owner.passkeys ?? []).filter((k) => k.id !== credential.id), { id: credential.id, publicKey: Buffer.from(credential.publicKey).toString("base64url"), counter: credential.counter, deviceType: verification.registrationInfo.credentialDeviceType, backedUp: verification.registrationInfo.credentialBackedUp, createdAt: (/* @__PURE__ */ new Date()).toISOString() }].slice(-10);
|
|
2869
|
+
await save();
|
|
2870
|
+
send(res, 200, { registered: true });
|
|
2871
|
+
return;
|
|
2872
|
+
}
|
|
2873
|
+
if (path2 === "/v1/auth/passkey/auth/options" && req.method === "POST") {
|
|
2874
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2875
|
+
send(res, 403, { error: "origin_required" });
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
const input = await body(req, 8192);
|
|
2879
|
+
const credentialId = typeof input.credentialId === "string" ? input.credentialId.slice(0, 1024) : "";
|
|
2880
|
+
const owner = credentialId ? store.accounts.find((a) => a.active && (a.passkeys ?? []).some((k) => k.id === credentialId)) : void 0;
|
|
2881
|
+
const options = await generateAuthenticationOptions({ rpID: requestUrl.hostname === "localhost" || requestUrl.hostname === "127.0.0.1" ? "localhost" : new URL(origin).hostname, allowCredentials: credentialId && owner ? [{ id: credentialId }] : void 0, userVerification: "preferred" });
|
|
2882
|
+
const challengeId = randomBytes6(16).toString("hex");
|
|
2883
|
+
challengeStore(passkeyChallenges, challengeId, options.challenge, 12e4);
|
|
2884
|
+
send(res, 200, { options, challengeId });
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
if (path2 === "/v1/auth/passkey/auth" && req.method === "POST") {
|
|
2888
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2889
|
+
send(res, 403, { error: "origin_required" });
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
const input = await body(req, 65536);
|
|
2893
|
+
const stored = passkeyChallenges[String(input.challengeId)];
|
|
2894
|
+
if (!stored) {
|
|
2895
|
+
send(res, 400, { error: "Login expired. Try again." });
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
2898
|
+
delete passkeyChallenges[String(input.challengeId)];
|
|
2899
|
+
const credentialId = String(input.credential?.id ?? "");
|
|
2900
|
+
const owner = store.accounts.find((a) => a.active && (a.passkeys ?? []).some((k) => k.id === credentialId));
|
|
2901
|
+
if (!owner) {
|
|
2902
|
+
send(res, 401, { error: "No account matches this passkey. Sign in with email first." });
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
const key = owner.passkeys.find((k) => k.id === credentialId);
|
|
2906
|
+
const rpID = requestUrl.hostname === "localhost" || requestUrl.hostname === "127.0.0.1" ? "localhost" : new URL(origin).hostname;
|
|
2907
|
+
const verification = await verifyAuthenticationResponse({ response: input.credential, expectedChallenge: stored.value, expectedOrigin: origin, expectedRPID: rpID, credential: { id: key.id, publicKey: Buffer.from(key.publicKey, "base64url"), counter: key.counter, transports: void 0 }, requireUserVerification: false });
|
|
2908
|
+
if (!verification.verified) {
|
|
2909
|
+
send(res, 401, { error: "Passkey verification failed." });
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
key.counter = verification.authenticationInfo.newCounter;
|
|
2913
|
+
const secret = randomBytes6(32).toString("hex");
|
|
2914
|
+
store.sessions = (store.sessions ?? []).filter((s) => s.expires > Date.now());
|
|
2915
|
+
const owned = store.sessions.filter((s) => s.accountId === owner.id);
|
|
2916
|
+
if (owned.length >= 10) store.sessions = store.sessions.filter((s) => s !== owned[0]);
|
|
2917
|
+
store.sessions.push({ accountId: owner.id, hash: digest(secret), expires: Date.now() + 7 * 864e5 });
|
|
2918
|
+
await save();
|
|
2919
|
+
setCookie(secret, 7 * 86400);
|
|
2920
|
+
send(res, 200, { user: profile(owner) });
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
if (path2 === "/v1/auth/device/poll" && req.method === "POST") {
|
|
2924
|
+
const input = await body(req, 8192);
|
|
2925
|
+
const device = typeof input.deviceCode === "string" ? devices.get(digest(input.deviceCode)) : void 0;
|
|
2926
|
+
if (!device || device.expires < Date.now()) {
|
|
2927
|
+
send(res, 410, { error: "Pairing expired. Run agenttwin login again." });
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
if (Date.now() - device.lastPoll < 4e3) {
|
|
2931
|
+
send(res, 429, { error: "slow_down" });
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
device.lastPoll = Date.now();
|
|
2935
|
+
if (!storageHealthy) {
|
|
2936
|
+
send(res, 503, { error: "storage_unavailable" });
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
if (!device.token) {
|
|
2940
|
+
send(res, 202, { pending: true });
|
|
2941
|
+
return;
|
|
2942
|
+
}
|
|
2943
|
+
const owner = store.accounts.find((a) => a.id === device.accountId && a.active);
|
|
2944
|
+
if (!owner) {
|
|
2945
|
+
send(res, 401, { error: "unauthorized" });
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
devices.delete(digest(input.deviceCode));
|
|
2949
|
+
send(res, 200, { token: device.token, workspace: owner.label });
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
if (["/v1/auth/signup", "/v1/auth/login"].includes(path2) && req.method === "POST") {
|
|
2953
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2954
|
+
send(res, 403, { error: "origin_required" });
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
const key = req.socket.remoteAddress ?? "unknown";
|
|
2958
|
+
for (const [k, v] of authAttempts) if (v.until < Date.now()) authAttempts.delete(k);
|
|
2959
|
+
const limit = authAttempts.get(key) ?? { count: 0, until: Date.now() + 15 * 6e4 };
|
|
2960
|
+
if (limit.count >= 120 || hashing >= 2 || authAttempts.size >= 1e4) {
|
|
2961
|
+
send(res, 429, { error: "Too many attempts. Try again in 15 minutes." });
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
limit.count++;
|
|
2965
|
+
authAttempts.set(key, limit);
|
|
2966
|
+
let input, parsed;
|
|
2967
|
+
try {
|
|
2968
|
+
input = await body(req, 8192);
|
|
2969
|
+
parsed = credentials(input);
|
|
2970
|
+
} catch {
|
|
2971
|
+
send(res, 400, { error: "Use a valid email and a password with 12\u2013128 characters." });
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
if (emailVerification.enabled && (!emailGrant?.verified || emailGrant.email !== parsed.email || !emailVerification.bound(emailGrant, emailBinding ?? "", origin))) {
|
|
2975
|
+
send(res, 403, { error: "Verify your email with a one-time code before continuing.", code: "email_verification_required" });
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
const emailKey = "email:" + digest(parsed.email);
|
|
2979
|
+
const emailLimit = authAttempts.get(emailKey) ?? { count: 0, until: Date.now() + 15 * 6e4 };
|
|
2980
|
+
if (emailLimit.count >= 10) {
|
|
2981
|
+
send(res, 429, { error: "Too many attempts. Try again in 15 minutes." });
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
emailLimit.count++;
|
|
2985
|
+
authAttempts.set(emailKey, emailLimit);
|
|
2986
|
+
hashing++;
|
|
2987
|
+
try {
|
|
2988
|
+
let a = store.accounts.find((a2) => a2.identity?.email === parsed.email);
|
|
2989
|
+
if (path2.endsWith("/signup")) {
|
|
2990
|
+
const name = typeof input.name === "string" ? input.name.trim() : "";
|
|
2991
|
+
const workspace = typeof input.workspace === "string" ? input.workspace.trim() : "";
|
|
2992
|
+
if (!name || name.length > 100 || !workspace || workspace.length > 100) {
|
|
2993
|
+
send(res, 400, { error: "Enter your name and workspace name." });
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
const encoded = await passwordHash(parsed.password);
|
|
2997
|
+
if (emailVerification.enabled && (store.emailChallenges?.[emailChallengeId] !== emailGrant || !emailVerification.bound(emailGrant, emailBinding ?? "", origin))) {
|
|
2998
|
+
send(res, 403, { error: "Email verification expired. Request a new code." });
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
if (store.accounts.some((a2) => a2.identity?.email === parsed.email)) {
|
|
3002
|
+
send(res, 409, { error: "Unable to create account. Try signing in instead." });
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
if (store.accounts.length >= 1e4) {
|
|
3006
|
+
send(res, 429, { error: "account_capacity" });
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
a = { id: randomUUID2(), tokenHash: hash2(randomBytes6(32).toString("hex")), label: workspace, active: true, usageDay: "", runs: 0, identity: { email: parsed.email, name, passwordHash: encoded } };
|
|
3010
|
+
store.accounts.push(a);
|
|
3011
|
+
} else {
|
|
3012
|
+
const matches2 = await passwordMatches(parsed.password, a?.identity?.passwordHash ?? dummyPassword);
|
|
3013
|
+
if (!a?.active || !a.identity?.passwordHash || !matches2) {
|
|
3014
|
+
send(res, 401, { error: "Email or password is incorrect." });
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
const secret = randomBytes6(32).toString("hex");
|
|
3019
|
+
store.sessions = (store.sessions ?? []).filter((s) => s.expires > Date.now());
|
|
3020
|
+
const owned = store.sessions.filter((s) => s.accountId === a.id);
|
|
3021
|
+
if (owned.length >= 10) store.sessions = store.sessions.filter((s) => s !== owned[0]);
|
|
3022
|
+
if (emailVerification.enabled) {
|
|
3023
|
+
if (store.emailChallenges?.[emailChallengeId] !== emailGrant || !emailVerification.bound(emailGrant, emailBinding ?? "", origin)) {
|
|
3024
|
+
send(res, 403, { error: "Email verification expired. Request a new code." });
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
delete store.emailChallenges[emailChallengeId];
|
|
3028
|
+
a.identity.emailVerified = true;
|
|
3029
|
+
}
|
|
3030
|
+
store.sessions.push({ accountId: a.id, hash: digest(secret), expires: Date.now() + 7 * 864e5 });
|
|
3031
|
+
await save();
|
|
3032
|
+
setCookie(secret, 7 * 86400);
|
|
3033
|
+
if (emailVerification.enabled) res.setHeader("Set-Cookie", [String(res.getHeader("Set-Cookie")), setEmailCookie("", 0)]);
|
|
3034
|
+
send(res, 200, { user: profile(a) });
|
|
3035
|
+
return;
|
|
3036
|
+
} finally {
|
|
3037
|
+
hashing--;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
1866
3040
|
const admin = equal(token, opts.adminToken);
|
|
1867
|
-
if (
|
|
3041
|
+
if (path2 === "/v1/accounts" && req.method === "POST") {
|
|
1868
3042
|
if (!admin) {
|
|
1869
3043
|
send(res, 401, { error: "unauthorized" });
|
|
1870
3044
|
return;
|
|
@@ -1874,8 +3048,8 @@ async function startGateway(opts) {
|
|
|
1874
3048
|
send(res, 400, { error: "label_required" });
|
|
1875
3049
|
return;
|
|
1876
3050
|
}
|
|
1877
|
-
const accessToken = `at_user_${
|
|
1878
|
-
const account2 = { id: randomUUID2(), tokenHash:
|
|
3051
|
+
const accessToken = `at_user_${randomBytes6(32).toString("hex")}`;
|
|
3052
|
+
const account2 = { id: randomUUID2(), tokenHash: hash2(accessToken), label: data.label, active: true, usageDay: "", runs: 0 };
|
|
1879
3053
|
if (store.accounts.length >= 1e4) {
|
|
1880
3054
|
send(res, 429, { error: "account_capacity" });
|
|
1881
3055
|
return;
|
|
@@ -1885,7 +3059,7 @@ async function startGateway(opts) {
|
|
|
1885
3059
|
send(res, 201, { accountId: account2.id, token: accessToken });
|
|
1886
3060
|
return;
|
|
1887
3061
|
}
|
|
1888
|
-
const revoke =
|
|
3062
|
+
const revoke = path2.match(/^\/v1\/accounts\/([a-f0-9-]{36})$/);
|
|
1889
3063
|
if (revoke && req.method === "DELETE") {
|
|
1890
3064
|
if (!admin) {
|
|
1891
3065
|
send(res, 401, { error: "unauthorized" });
|
|
@@ -1901,21 +3075,140 @@ async function startGateway(opts) {
|
|
|
1901
3075
|
send(res, 200, { revoked: true });
|
|
1902
3076
|
return;
|
|
1903
3077
|
}
|
|
1904
|
-
const account = store.accounts.find((a) => a.active && a.tokenHash ===
|
|
3078
|
+
const account = store.accounts.find((a) => a.active && (token ? a.tokenHash === hash2(token) || a.cliTokens?.includes(hash2(token)) : a.id === session?.accountId));
|
|
1905
3079
|
if (!account) {
|
|
1906
3080
|
send(res, 401, { error: "unauthorized" });
|
|
1907
3081
|
return;
|
|
1908
3082
|
}
|
|
3083
|
+
if (path2 === "/v1/auth/password" && req.method === "POST") {
|
|
3084
|
+
if (token || !session || !account.identity || !origin || !allowedOrigins.has(origin)) {
|
|
3085
|
+
send(res, 403, { error: "browser_session_required" });
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
if (account.identity.passwordHash) {
|
|
3089
|
+
send(res, 409, { error: "A password is already set. Use email and password to sign in." });
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
const recentlyAuthenticated = () => session.method === "google" && typeof session.authenticatedAt === "number" && Date.now() - session.authenticatedAt >= 0 && Date.now() - session.authenticatedAt < 10 * 6e4;
|
|
3093
|
+
if (!recentlyAuthenticated() || !account.externalIdentities?.some((i) => i.provider === "google")) {
|
|
3094
|
+
send(res, 403, { error: "Sign in with Google again, then create your password within 10 minutes.", code: "reauthentication_required" });
|
|
3095
|
+
return;
|
|
3096
|
+
}
|
|
3097
|
+
let parsed;
|
|
3098
|
+
try {
|
|
3099
|
+
const input = await body(req, 8192);
|
|
3100
|
+
parsed = credentials({ email: account.identity.email, password: input.password });
|
|
3101
|
+
} catch {
|
|
3102
|
+
send(res, 400, { error: "Use a password with 12\u2013128 characters." });
|
|
3103
|
+
return;
|
|
3104
|
+
}
|
|
3105
|
+
if (hashing >= 2) {
|
|
3106
|
+
send(res, 429, { error: "Please try again shortly." });
|
|
3107
|
+
return;
|
|
3108
|
+
}
|
|
3109
|
+
hashing++;
|
|
3110
|
+
try {
|
|
3111
|
+
const encoded = await passwordHash(parsed.password);
|
|
3112
|
+
if (account.identity.passwordHash) {
|
|
3113
|
+
send(res, 409, { error: "A password is already set." });
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
if (!account.active || !store.sessions?.includes(session) || session.expires <= Date.now() || !recentlyAuthenticated()) {
|
|
3117
|
+
send(res, 403, { error: "Sign in with Google again before creating a password.", code: "reauthentication_required" });
|
|
3118
|
+
return;
|
|
3119
|
+
}
|
|
3120
|
+
const previousIdentity = account.identity, previousSessions = store.sessions;
|
|
3121
|
+
const secret = randomBytes6(32).toString("hex");
|
|
3122
|
+
account.identity = { ...account.identity, passwordHash: encoded };
|
|
3123
|
+
store.sessions = [...store.sessions.filter((s) => s.accountId !== account.id), { accountId: account.id, hash: digest(secret), expires: Date.now() + 7 * 864e5 }];
|
|
3124
|
+
try {
|
|
3125
|
+
await save();
|
|
3126
|
+
} catch (error) {
|
|
3127
|
+
account.identity = previousIdentity;
|
|
3128
|
+
store.sessions = previousSessions;
|
|
3129
|
+
throw error;
|
|
3130
|
+
}
|
|
3131
|
+
setCookie(secret, 7 * 86400);
|
|
3132
|
+
send(res, 200, { user: profile(account) });
|
|
3133
|
+
return;
|
|
3134
|
+
} finally {
|
|
3135
|
+
hashing--;
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
if (path2 === "/v1/auth/token/revoke" && req.method === "POST") {
|
|
3139
|
+
if (!token) {
|
|
3140
|
+
send(res, 400, { error: "terminal_token_required" });
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3143
|
+
account.cliTokens = (account.cliTokens ?? []).filter((t) => t !== hash2(token));
|
|
3144
|
+
if (account.tokenHash === hash2(token)) account.tokenHash = hash2(randomBytes6(32).toString("hex"));
|
|
3145
|
+
await save();
|
|
3146
|
+
send(res, 200, { signedOut: true });
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
if (path2 === "/v1/auth/device/approve" && req.method === "POST") {
|
|
3150
|
+
const input = await body(req, 8192);
|
|
3151
|
+
const code = typeof input.userCode === "string" ? input.userCode.trim().toUpperCase() : "";
|
|
3152
|
+
const device = [...devices.values()].find((d) => d.code === code && d.expires > Date.now() && !d.accountId);
|
|
3153
|
+
if (!device) {
|
|
3154
|
+
send(res, 400, { error: "Code not found or expired. Run agenttwin login again." });
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
const accessToken = `at_user_${randomBytes6(32).toString("hex")}`;
|
|
3158
|
+
account.cliTokens = [...(account.cliTokens ?? []).slice(-9), hash2(accessToken)];
|
|
3159
|
+
device.accountId = account.id;
|
|
3160
|
+
await save();
|
|
3161
|
+
device.token = accessToken;
|
|
3162
|
+
send(res, 200, { connected: true, workspace: account.label });
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
3165
|
+
if (path2 === "/v1/auth/profile" && req.method === "POST") {
|
|
3166
|
+
if (token || !session || !account.identity) {
|
|
3167
|
+
send(res, 403, { error: "browser_session_required" });
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
const input = await body(req, 8192);
|
|
3171
|
+
let validated;
|
|
3172
|
+
try {
|
|
3173
|
+
validated = validateWorkspaceProfile(input);
|
|
3174
|
+
} catch (error) {
|
|
3175
|
+
send(res, 400, { error: error instanceof Error ? error.message : "Invalid profile." });
|
|
3176
|
+
return;
|
|
3177
|
+
}
|
|
3178
|
+
const previous = { label: account.label, identity: account.identity, workspaceProfile: account.workspaceProfile };
|
|
3179
|
+
account.label = validated.workspace;
|
|
3180
|
+
account.identity = { ...account.identity, name: validated.name };
|
|
3181
|
+
account.workspaceProfile = { ...validated.context, ...validated.complete ? { completedAt: (/* @__PURE__ */ new Date()).toISOString() } : account.workspaceProfile?.completedAt ? { completedAt: account.workspaceProfile.completedAt } : {} };
|
|
3182
|
+
try {
|
|
3183
|
+
await save();
|
|
3184
|
+
} catch (error) {
|
|
3185
|
+
Object.assign(account, previous);
|
|
3186
|
+
throw error;
|
|
3187
|
+
}
|
|
3188
|
+
send(res, 200, { user: profile(account) });
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
if (path2 === "/v1/auth/me" && req.method === "GET") {
|
|
3192
|
+
send(res, 200, { user: profile(account) });
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
if (path2 === "/v1/auth/logout" && req.method === "POST") {
|
|
3196
|
+
store.sessions = (store.sessions ?? []).filter((s) => s !== session);
|
|
3197
|
+
await save();
|
|
3198
|
+
setCookie("", 0);
|
|
3199
|
+
send(res, 200, { signedOut: true });
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
1909
3202
|
const projectInventories = [...store.projectDiscoveries?.[account.id] ?? []];
|
|
1910
3203
|
const latestInventory = store.discoveries?.[account.id];
|
|
1911
3204
|
if (latestInventory && !projectInventories.some((p) => p.name === latestInventory.name)) projectInventories.push(latestInventory);
|
|
1912
3205
|
const requestedProject = requestUrl.searchParams.get("project");
|
|
1913
3206
|
const selectedInventory = requestedProject === null ? latestInventory : projectInventories.find((p) => p.name === requestedProject);
|
|
1914
|
-
if (
|
|
3207
|
+
if (path2 === "/v1/projects" && req.method === "GET") {
|
|
1915
3208
|
send(res, 200, { projects: projectInventories.map((p) => ({ name: p.name, tools: p.tools.length, workflows: p.workflows.length })), latest: latestInventory?.name ?? null });
|
|
1916
3209
|
return;
|
|
1917
3210
|
}
|
|
1918
|
-
if (
|
|
3211
|
+
if (path2 === "/v1/discovery" && req.method === "GET") {
|
|
1919
3212
|
if (requestedProject !== null && !selectedInventory) {
|
|
1920
3213
|
send(res, 404, { error: "project_not_found" });
|
|
1921
3214
|
return;
|
|
@@ -1923,7 +3216,7 @@ async function startGateway(opts) {
|
|
|
1923
3216
|
send(res, 200, { inventory: selectedInventory ?? null });
|
|
1924
3217
|
return;
|
|
1925
3218
|
}
|
|
1926
|
-
if (
|
|
3219
|
+
if (path2 === "/v1/discovery" && req.method === "POST") {
|
|
1927
3220
|
if (stopping) {
|
|
1928
3221
|
send(res, 503, { error: "stopping" });
|
|
1929
3222
|
return;
|
|
@@ -1942,7 +3235,7 @@ async function startGateway(opts) {
|
|
|
1942
3235
|
send(res, 200, { synced: true, tools: inventory.tools.length, workflows: inventory.workflows.length });
|
|
1943
3236
|
return;
|
|
1944
3237
|
}
|
|
1945
|
-
if (
|
|
3238
|
+
if (path2 === "/v1/receipts" && req.method === "POST") {
|
|
1946
3239
|
const receipt = portableLocalReceipt(await body(req));
|
|
1947
3240
|
if (!projectInventories.some((p) => p.name === receipt.project)) {
|
|
1948
3241
|
send(res, 409, { error: "sync_project_before_receipt" });
|
|
@@ -1969,11 +3262,11 @@ async function startGateway(opts) {
|
|
|
1969
3262
|
send(res, 201, { id, synced: true });
|
|
1970
3263
|
return;
|
|
1971
3264
|
}
|
|
1972
|
-
if (
|
|
3265
|
+
if (path2 === "/v1/runs" && req.method === "GET") {
|
|
1973
3266
|
send(res, 200, { jobs: store.jobs.filter((j) => j.accountId === account.id).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, 100).map((job) => ({ id: job.id, status: job.status, createdAt: job.createdAt, name: job.name ?? (typeof job.receipt?.name === "string" ? job.receipt.name : void 0), workflowId: job.workflowId ?? (typeof job.receipt?.workflowId === "string" ? job.receipt.workflowId : void 0), project: job.project ?? (typeof job.receipt?.project === "string" ? job.receipt.project : void 0) })) });
|
|
1974
3267
|
return;
|
|
1975
3268
|
}
|
|
1976
|
-
if (
|
|
3269
|
+
if (path2 === "/v1/runs" && req.method === "POST") {
|
|
1977
3270
|
if (stopping) {
|
|
1978
3271
|
send(res, 503, { error: "stopping" });
|
|
1979
3272
|
return;
|
|
@@ -1997,6 +3290,10 @@ async function startGateway(opts) {
|
|
|
1997
3290
|
}
|
|
1998
3291
|
const input = await body(req);
|
|
1999
3292
|
const spec = parseWorld(input.spec);
|
|
3293
|
+
if (spec.browser || spec.background) {
|
|
3294
|
+
send(res, 422, { error: "unsupported_runtime", message: "Browser recipes and background completion checks currently require the local runtime. No cloud job was queued." });
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
2000
3297
|
const git = validateGit(input.git);
|
|
2001
3298
|
const project = input.project;
|
|
2002
3299
|
if (project !== void 0 && (typeof project !== "string" || !projectInventories.some((p) => p.name === project))) {
|
|
@@ -2041,16 +3338,16 @@ async function startGateway(opts) {
|
|
|
2041
3338
|
send(res, 202, { id: job.id, status: job.status });
|
|
2042
3339
|
return;
|
|
2043
3340
|
}
|
|
2044
|
-
if ((
|
|
3341
|
+
if ((path2 === "/v1/chat" || path2 === "/v1/workflows") && requestedProject !== null && !selectedInventory) {
|
|
2045
3342
|
send(res, 404, { error: "project_not_found" });
|
|
2046
3343
|
return;
|
|
2047
3344
|
}
|
|
2048
|
-
if (
|
|
3345
|
+
if (path2 === "/v1/workflows" && req.method === "GET") {
|
|
2049
3346
|
const project = selectedInventory?.name ?? null;
|
|
2050
3347
|
send(res, 200, { project, workflows: (store.workflows ?? []).filter((w) => w.accountId === account.id && w.project === project) });
|
|
2051
3348
|
return;
|
|
2052
3349
|
}
|
|
2053
|
-
if (
|
|
3350
|
+
if (path2 === "/v1/workflows" && req.method === "POST") {
|
|
2054
3351
|
if (stopping) {
|
|
2055
3352
|
send(res, 503, { error: "stopping" });
|
|
2056
3353
|
return;
|
|
@@ -2094,13 +3391,13 @@ async function startGateway(opts) {
|
|
|
2094
3391
|
send(res, 200, { workflow });
|
|
2095
3392
|
return;
|
|
2096
3393
|
}
|
|
2097
|
-
if (
|
|
3394
|
+
if (path2 === "/v1/chat" && req.method === "GET") {
|
|
2098
3395
|
const project = selectedInventory?.name ?? null;
|
|
2099
3396
|
const thread = store.chats?.find((t) => t.accountId === account.id && t.project === project);
|
|
2100
3397
|
send(res, 200, { ...chatConfiguration(), transcription: transcriptionConfiguration(), project, thread: thread ?? null, busy: chatControllers.has(account.id) });
|
|
2101
3398
|
return;
|
|
2102
3399
|
}
|
|
2103
|
-
if (
|
|
3400
|
+
if (path2 === "/v1/transcriptions" && req.method === "POST") {
|
|
2104
3401
|
if (stopping) {
|
|
2105
3402
|
send(res, 503, { error: "stopping" });
|
|
2106
3403
|
return;
|
|
@@ -2156,7 +3453,7 @@ async function startGateway(opts) {
|
|
|
2156
3453
|
}
|
|
2157
3454
|
return;
|
|
2158
3455
|
}
|
|
2159
|
-
if (
|
|
3456
|
+
if (path2 === "/v1/chat" && req.method === "POST") {
|
|
2160
3457
|
if (stopping) {
|
|
2161
3458
|
send(res, 503, { error: "stopping" });
|
|
2162
3459
|
return;
|
|
@@ -2221,7 +3518,7 @@ async function startGateway(opts) {
|
|
|
2221
3518
|
const controller = new AbortController();
|
|
2222
3519
|
chatControllers.set(account.id, controller);
|
|
2223
3520
|
const timeout = setTimeout(() => controller.abort(), 45e3);
|
|
2224
|
-
const context = { project, discoveryDigest: inventory ?
|
|
3521
|
+
const context = { project, discoveryDigest: inventory ? hash2(JSON.stringify(inventory)) : null, jobId: job?.id ?? null };
|
|
2225
3522
|
thread.messages.push({ id: randomUUID2(), role: "user", content: input.content.trim(), requestId: input.requestId, createdAt: (/* @__PURE__ */ new Date()).toISOString(), context });
|
|
2226
3523
|
try {
|
|
2227
3524
|
await save();
|
|
@@ -2256,7 +3553,7 @@ async function startGateway(opts) {
|
|
|
2256
3553
|
}
|
|
2257
3554
|
return;
|
|
2258
3555
|
}
|
|
2259
|
-
const match =
|
|
3556
|
+
const match = path2.match(/^\/v1\/runs\/([a-f0-9-]{36})$/);
|
|
2260
3557
|
if (match && req.method === "DELETE") {
|
|
2261
3558
|
const job = store.jobs.find((j) => j.id === match[1] && j.accountId === account.id);
|
|
2262
3559
|
if (!job) {
|
|
@@ -2300,6 +3597,7 @@ async function startGateway(opts) {
|
|
|
2300
3597
|
});
|
|
2301
3598
|
server.requestTimeout = 3e4;
|
|
2302
3599
|
server.headersTimeout = 1e4;
|
|
3600
|
+
server.maxHeadersCount = 64;
|
|
2303
3601
|
await new Promise((done, reject) => {
|
|
2304
3602
|
server.once("error", reject);
|
|
2305
3603
|
server.listen(opts.port, opts.host ?? "127.0.0.1", done);
|