agenttwin 0.3.5 → 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 +25 -6
- package/dist/api.js +1552 -214
- package/dist/cli.js +2857 -712
- package/dist/dashboard/assets/dashboard/agent-home.css +156 -1
- package/dist/dashboard/assets/dashboard/bay-morning.png +0 -0
- package/dist/dashboard/assets/dashboard/golden-gate-afternoon.png +0 -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/bay-morning.1a9f358c.png +0 -0
- package/dist/dashboard/assets/next/static/media/golden-gate-afternoon.3e7cea58.png +0 -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 +1571 -215
- package/dist/worker.js +316 -22
- package/dist/world-host.js +1090 -0
- package/package.json +6 -1
- package/dist/dashboard/assets/next/static/It81cabE0Vc-SUEO_z8CQ/_buildManifest.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/dashboard/layout-fa1f6ba56d7af38a.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/dashboard/page-9b4372b1e47bc3bc.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-330c9b9dd99c0d55.js +0 -1
- package/dist/dashboard/assets/next/static/css/1323d7da89b3fa5e.css +0 -1
- package/dist/dashboard/assets/next/static/css/62e3209163fa5e55.css +0 -1
- package/dist/dashboard/assets/next/static/css/9282330a2fd1ab73.css +0 -1
- /package/dist/dashboard/assets/next/static/{It81cabE0Vc-SUEO_z8CQ → P_sIw5q7daW7e4IJEGveg}/_ssgManifest.js +0 -0
package/dist/gateway.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"],
|
|
@@ -782,11 +891,54 @@ var init_catalog = __esm({
|
|
|
782
891
|
}
|
|
783
892
|
});
|
|
784
893
|
|
|
894
|
+
// src/inventory/store.ts
|
|
895
|
+
import { mkdir as mkdir3, readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
|
|
896
|
+
import { join as join8 } from "path";
|
|
897
|
+
function storeDir(cwd) {
|
|
898
|
+
return join8(cwd, STORE_DIR);
|
|
899
|
+
}
|
|
900
|
+
function storePath(cwd, name) {
|
|
901
|
+
return join8(storeDir(cwd), name);
|
|
902
|
+
}
|
|
903
|
+
async function readJson(path2) {
|
|
904
|
+
try {
|
|
905
|
+
return JSON.parse(await readFile7(path2, "utf8"));
|
|
906
|
+
} catch {
|
|
907
|
+
return void 0;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
var STORE_DIR, STORE_GITIGNORE, readDecisions;
|
|
911
|
+
var init_store = __esm({
|
|
912
|
+
"src/inventory/store.ts"() {
|
|
913
|
+
"use strict";
|
|
914
|
+
STORE_DIR = ".agenttwin";
|
|
915
|
+
STORE_GITIGNORE = [
|
|
916
|
+
"inventory.json",
|
|
917
|
+
"plan.json",
|
|
918
|
+
"permissions.local.json",
|
|
919
|
+
"report.html",
|
|
920
|
+
"demo/",
|
|
921
|
+
"trial/",
|
|
922
|
+
"feedback.json",
|
|
923
|
+
"cache/",
|
|
924
|
+
"logs/",
|
|
925
|
+
"connections/",
|
|
926
|
+
"observations.local.json",
|
|
927
|
+
"explorations/",
|
|
928
|
+
"control.json",
|
|
929
|
+
"runs/",
|
|
930
|
+
""
|
|
931
|
+
].join("\n");
|
|
932
|
+
readDecisions = (cwd) => readJson(storePath(cwd, "decisions.json"));
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
|
|
785
936
|
// src/inventory/project-products.ts
|
|
786
937
|
async function projectProducts(cwd, data, base) {
|
|
787
938
|
const current = new Set(base.tools.map((t) => t.id));
|
|
788
939
|
const known = new Set(PROVIDERS.map((p) => p.title));
|
|
789
|
-
const
|
|
940
|
+
const excluded = new Set(((await readDecisions(cwd))?.items ?? []).filter((d) => d.kind === "exclude_tool").map((d) => d.target));
|
|
941
|
+
const tools = data.tools.filter((t) => !excluded.has(t.id) && (current.has(t.id) || !known.has(t.title) && t.category !== "access" && t.category !== "tooling"));
|
|
790
942
|
const ids = new Set(tools.map((t) => t.id));
|
|
791
943
|
const workflows = data.workflows.map((w) => ({ ...w, tools: w.tools.filter((id) => ids.has(id)), steps: w.steps.filter((s) => ids.has(s.tool)) }));
|
|
792
944
|
const google = tools.find((t) => /Google \(Gmail/.test(t.title));
|
|
@@ -801,15 +953,15 @@ async function projectProducts(cwd, data, base) {
|
|
|
801
953
|
const files = await createFileIndex(cwd);
|
|
802
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);
|
|
803
955
|
const found = /* @__PURE__ */ new Set();
|
|
804
|
-
for (const
|
|
805
|
-
const source = await files.read(
|
|
956
|
+
for (const path2 of paths) {
|
|
957
|
+
const source = await files.read(path2, 2e5);
|
|
806
958
|
if (!source) continue;
|
|
807
959
|
for (const p of products) if (p.pattern.test(source)) found.add(p.key);
|
|
808
960
|
}
|
|
809
961
|
const mapped = workflows.map((w) => {
|
|
810
962
|
if (!w.tools.includes(google.id)) return w;
|
|
811
|
-
const [
|
|
812
|
-
return { w, path, fn };
|
|
963
|
+
const [path2, fn] = (w.entrypoint ?? "").split("#");
|
|
964
|
+
return { w, path: path2, fn };
|
|
813
965
|
});
|
|
814
966
|
const finalWorkflows = [];
|
|
815
967
|
let unresolved = false;
|
|
@@ -818,17 +970,17 @@ async function projectProducts(cwd, data, base) {
|
|
|
818
970
|
finalWorkflows.push(value);
|
|
819
971
|
continue;
|
|
820
972
|
}
|
|
821
|
-
const { w, path, fn } = value;
|
|
822
|
-
const source =
|
|
973
|
+
const { w, path: path2, fn } = value;
|
|
974
|
+
const source = path2 ? await files.read(path2, 2e5) : void 0;
|
|
823
975
|
const entry = source && fn ? exportedEntrypoints(source).find((e) => e.name === fn) : void 0;
|
|
824
976
|
const body2 = source && entry ? source.split("\n").slice(entry.startLine - 1, entry.endLine).join("\n") : fn ? "" : source ?? "";
|
|
825
|
-
const
|
|
826
|
-
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) {
|
|
827
979
|
unresolved = true;
|
|
828
980
|
finalWorkflows.push(w);
|
|
829
981
|
continue;
|
|
830
982
|
}
|
|
831
|
-
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]) });
|
|
832
984
|
}
|
|
833
985
|
const productTools = products.filter((p) => found.has(p.key)).map((p) => ({ ...google, id: google.id + ":" + p.key, title: p.title }));
|
|
834
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 });
|
|
@@ -840,6 +992,7 @@ var init_project_products = __esm({
|
|
|
840
992
|
init_entrypoints();
|
|
841
993
|
init_catalog();
|
|
842
994
|
init_dashboard_transfer();
|
|
995
|
+
init_store();
|
|
843
996
|
}
|
|
844
997
|
});
|
|
845
998
|
|
|
@@ -861,8 +1014,8 @@ var init_sanitize = __esm({
|
|
|
861
1014
|
});
|
|
862
1015
|
|
|
863
1016
|
// src/commands/dashboard.ts
|
|
864
|
-
import { cp, mkdir, readFile as
|
|
865
|
-
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";
|
|
866
1019
|
import { fileURLToPath } from "url";
|
|
867
1020
|
function dashboardInventory(cwd, inventory) {
|
|
868
1021
|
return JSON.parse(sanitizeOutput(JSON.stringify({
|
|
@@ -898,8 +1051,8 @@ __export(session_exports, {
|
|
|
898
1051
|
writeSessionTask: () => writeSessionTask
|
|
899
1052
|
});
|
|
900
1053
|
import { spawn as spawn2, spawnSync } from "child_process";
|
|
901
|
-
import { writeFile as
|
|
902
|
-
import { join as
|
|
1054
|
+
import { writeFile as writeFile6, readFile as readFile9 } from "fs/promises";
|
|
1055
|
+
import { join as join10 } from "path";
|
|
903
1056
|
function sessionTask() {
|
|
904
1057
|
return `# Prepare this repository for AgentTwin
|
|
905
1058
|
|
|
@@ -924,30 +1077,30 @@ function installedOpenCode() {
|
|
|
924
1077
|
return spawnSync("opencode", ["--version"], { stdio: "ignore", timeout: 5e3 }).status === 0;
|
|
925
1078
|
}
|
|
926
1079
|
async function writeSessionTask(cwd) {
|
|
927
|
-
const
|
|
928
|
-
await
|
|
929
|
-
return
|
|
1080
|
+
const path2 = join10(cwd, ".agenttwin/AGENT-TASK.md");
|
|
1081
|
+
await writeFile6(path2, sessionTask(), "utf8");
|
|
1082
|
+
return path2;
|
|
930
1083
|
}
|
|
931
1084
|
async function launchSession(cwd) {
|
|
932
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.");
|
|
933
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.";
|
|
934
|
-
await new Promise((
|
|
1087
|
+
await new Promise((resolve4, reject) => {
|
|
935
1088
|
const child = spawn2("opencode", ["--prompt", prompt], { cwd, stdio: "inherit", shell: false });
|
|
936
1089
|
child.once("error", reject);
|
|
937
|
-
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.`)));
|
|
938
1091
|
});
|
|
939
1092
|
}
|
|
940
1093
|
async function loadDashboardDiscovery(cwd, inventory) {
|
|
941
1094
|
let previousHead;
|
|
942
1095
|
try {
|
|
943
|
-
previousHead = JSON.parse(await
|
|
1096
|
+
previousHead = JSON.parse(await readFile9(join10(cwd, ".agenttwin/git.json"), "utf8")).head;
|
|
944
1097
|
} catch {
|
|
945
1098
|
}
|
|
946
1099
|
const git = await captureGit(cwd, previousHead);
|
|
947
1100
|
const base = discoveryTransfer({ ...dashboardInventory(cwd, inventory), ...git ? { git } : {} });
|
|
948
1101
|
let raw;
|
|
949
1102
|
try {
|
|
950
|
-
raw = await
|
|
1103
|
+
raw = await readFile9(join10(cwd, ".agenttwin/agent-discovery.json"), "utf8");
|
|
951
1104
|
} catch (error) {
|
|
952
1105
|
if (error.code === "ENOENT") return projectProducts(cwd, base, base);
|
|
953
1106
|
throw error;
|
|
@@ -973,13 +1126,224 @@ var init_session = __esm({
|
|
|
973
1126
|
}
|
|
974
1127
|
});
|
|
975
1128
|
|
|
1129
|
+
// src/runtime/social-auth.ts
|
|
1130
|
+
import * as oidc from "openid-client";
|
|
1131
|
+
import { randomBytes, createHash, timingSafeEqual } from "crypto";
|
|
1132
|
+
var hash = (value) => createHash("sha256").update(value).digest();
|
|
1133
|
+
var validUrl = (value) => {
|
|
1134
|
+
const url = new URL(value);
|
|
1135
|
+
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).");
|
|
1136
|
+
return url;
|
|
1137
|
+
};
|
|
1138
|
+
function socialAuthFromEnv(env) {
|
|
1139
|
+
const read = (prefix) => {
|
|
1140
|
+
const clientId = env[prefix + "_CLIENT_ID"], clientSecret = env[prefix + "_CLIENT_SECRET"];
|
|
1141
|
+
if (!clientId && !clientSecret) return void 0;
|
|
1142
|
+
if (!clientId || !clientSecret) throw new Error(prefix + " requires both client ID and secret.");
|
|
1143
|
+
return { clientId, clientSecret };
|
|
1144
|
+
};
|
|
1145
|
+
const google = read("AGENTTWIN_GOOGLE"), github = read("AGENTTWIN_GITHUB"), company = read("AGENTTWIN_SSO");
|
|
1146
|
+
if (!google && !github && !company) return void 0;
|
|
1147
|
+
if (!env.AGENTTWIN_AUTH_PUBLIC_URL) throw new Error("AGENTTWIN_AUTH_PUBLIC_URL is required for social authentication.");
|
|
1148
|
+
if (company && !env.AGENTTWIN_SSO_ISSUER) throw new Error("AGENTTWIN_SSO_ISSUER is required.");
|
|
1149
|
+
return { publicUrl: env.AGENTTWIN_AUTH_PUBLIC_URL, google, github, sso: company ? { ...company, issuer: env.AGENTTWIN_SSO_ISSUER } : void 0 };
|
|
1150
|
+
}
|
|
1151
|
+
function createSocialAuth(options) {
|
|
1152
|
+
const base = options ? validUrl(options.publicUrl) : void 0;
|
|
1153
|
+
if (base && base.pathname !== "/") throw new Error("Authentication public URL must be an origin.");
|
|
1154
|
+
if (options?.sso) validUrl(options.sso.issuer);
|
|
1155
|
+
const transactions = /* @__PURE__ */ new Map();
|
|
1156
|
+
const configs = /* @__PURE__ */ new Map();
|
|
1157
|
+
const enabled = (provider) => !!options?.[provider];
|
|
1158
|
+
const callback = (provider) => new URL("/v1/auth/oauth/" + provider + "/callback", base).href;
|
|
1159
|
+
const config = (provider) => {
|
|
1160
|
+
let pending = configs.get(provider);
|
|
1161
|
+
if (!pending) {
|
|
1162
|
+
const credentials2 = options[provider];
|
|
1163
|
+
const issuer = provider === "google" ? "https://accounts.google.com" : options.sso.issuer;
|
|
1164
|
+
pending = oidc.discovery(new URL(issuer), credentials2.clientId, credentials2.clientSecret, void 0, { timeout: 10, execute: [oidc.enableNonRepudiationChecks] });
|
|
1165
|
+
configs.set(provider, pending);
|
|
1166
|
+
void pending.catch(() => configs.delete(provider));
|
|
1167
|
+
}
|
|
1168
|
+
return pending;
|
|
1169
|
+
};
|
|
1170
|
+
return {
|
|
1171
|
+
providers: () => ["google", "github", "sso"].map((id) => ({ id, enabled: enabled(id) })),
|
|
1172
|
+
async begin(provider, origin, signup, onboarding) {
|
|
1173
|
+
if (!enabled(provider)) throw new Error("provider_not_configured");
|
|
1174
|
+
for (const [key, value] of transactions) if (value.expires < Date.now()) transactions.delete(key);
|
|
1175
|
+
if (transactions.size >= 1e3) throw new Error("auth_busy");
|
|
1176
|
+
const state = oidc.randomState(), verifier = oidc.randomPKCECodeVerifier(), nonce = oidc.randomNonce(), binding = randomBytes(32).toString("hex");
|
|
1177
|
+
const challenge = await oidc.calculatePKCECodeChallenge(verifier);
|
|
1178
|
+
let url;
|
|
1179
|
+
const parameters = { redirect_uri: callback(provider), state, code_challenge: challenge, code_challenge_method: "S256" };
|
|
1180
|
+
if (provider === "github") {
|
|
1181
|
+
url = new URL("https://github.com/login/oauth/authorize");
|
|
1182
|
+
Object.entries({ ...parameters, client_id: options.github.clientId, scope: "read:user user:email" }).forEach(([key, value]) => url.searchParams.set(key, value));
|
|
1183
|
+
} else url = oidc.buildAuthorizationUrl(await config(provider), { ...parameters, nonce, scope: "openid email profile" });
|
|
1184
|
+
if (transactions.size >= 1e3) throw new Error("auth_busy");
|
|
1185
|
+
transactions.set(state, { provider, origin, signup, onboarding, verifier, nonce, binding: hash(binding).toString("hex"), expires: Date.now() + 6e5 });
|
|
1186
|
+
return { url: url.href, binding, secure: base.protocol === "https:" };
|
|
1187
|
+
},
|
|
1188
|
+
// Bind the state to this browser before exchanging any provider code. Consume once.
|
|
1189
|
+
take(provider, query, cookie) {
|
|
1190
|
+
if (query.getAll("state").length !== 1) throw new Error("invalid_state");
|
|
1191
|
+
const state = query.get("state"), transaction = transactions.get(state);
|
|
1192
|
+
if (!transaction || transaction.provider !== provider || transaction.expires < Date.now() || !timingSafeEqual(hash(cookie), Buffer.from(transaction.binding, "hex"))) throw new Error("invalid_state");
|
|
1193
|
+
transactions.delete(state);
|
|
1194
|
+
return transaction;
|
|
1195
|
+
},
|
|
1196
|
+
async finish(transaction, query) {
|
|
1197
|
+
const provider = transaction.provider;
|
|
1198
|
+
let subject, email, verified, name, issuer;
|
|
1199
|
+
if (provider === "github") {
|
|
1200
|
+
if (query.get("error") || query.getAll("code").length !== 1) throw new Error("authorization_failed");
|
|
1201
|
+
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) });
|
|
1202
|
+
const token = await response.json();
|
|
1203
|
+
if (!response.ok || !token.access_token || token.token_type?.toLowerCase() !== "bearer") throw new Error("authorization_failed");
|
|
1204
|
+
const headers = { authorization: "Bearer " + token.access_token, accept: "application/vnd.github+json", "user-agent": "AgentTwin", "X-GitHub-Api-Version": "2022-11-28" };
|
|
1205
|
+
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) })]);
|
|
1206
|
+
if (!user.ok || !addresses.ok) throw new Error("authorization_failed");
|
|
1207
|
+
const profile = await user.json();
|
|
1208
|
+
const emails = await addresses.json();
|
|
1209
|
+
const address = emails.find((item) => item.primary && item.verified);
|
|
1210
|
+
if (!Number.isSafeInteger(profile.id) || profile.id <= 0) throw new Error("authorization_failed");
|
|
1211
|
+
subject = String(profile.id);
|
|
1212
|
+
issuer = "https://github.com";
|
|
1213
|
+
email = address?.email;
|
|
1214
|
+
verified = address?.verified;
|
|
1215
|
+
name = profile.name || profile.login;
|
|
1216
|
+
} else {
|
|
1217
|
+
const configuration = await config(provider);
|
|
1218
|
+
const currentUrl = new URL(callback(provider));
|
|
1219
|
+
currentUrl.search = query.toString();
|
|
1220
|
+
const tokens = await oidc.authorizationCodeGrant(configuration, currentUrl, { pkceCodeVerifier: transaction.verifier, expectedState: query.get("state"), expectedNonce: transaction.nonce, idTokenExpected: true });
|
|
1221
|
+
const claims = tokens.claims();
|
|
1222
|
+
if (!claims) throw new Error("authorization_failed");
|
|
1223
|
+
subject = claims.sub;
|
|
1224
|
+
issuer = claims.iss;
|
|
1225
|
+
email = claims.email;
|
|
1226
|
+
verified = claims.email_verified;
|
|
1227
|
+
name = claims.name;
|
|
1228
|
+
}
|
|
1229
|
+
if (verified !== true || typeof email !== "string" || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error("verified_email_required");
|
|
1230
|
+
return { provider, issuer, subject, email: email.trim().toLowerCase(), name: (typeof name === "string" && name.trim() ? name.trim() : email.split("@")[0]).slice(0, 100) };
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// src/runtime/email-verification.ts
|
|
1236
|
+
import { createHmac, randomBytes as randomBytes2, randomInt, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1237
|
+
function emailVerificationFromEnv(env) {
|
|
1238
|
+
if (!env.RESEND_API_KEY) return void 0;
|
|
1239
|
+
if (!env.AGENTTWIN_EMAIL_FROM || /[\r\n]/.test(env.AGENTTWIN_EMAIL_FROM)) throw new Error("AGENTTWIN_EMAIL_FROM is required with RESEND_API_KEY.");
|
|
1240
|
+
return { apiKey: env.RESEND_API_KEY, from: env.AGENTTWIN_EMAIL_FROM };
|
|
1241
|
+
}
|
|
1242
|
+
function normalizedEmail(value) {
|
|
1243
|
+
const email = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
1244
|
+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error("Enter a valid email address.");
|
|
1245
|
+
return email;
|
|
1246
|
+
}
|
|
1247
|
+
function createEmailVerification(options, secret) {
|
|
1248
|
+
const hash3 = (value) => createHmac("sha256", secret).update(value).digest("hex");
|
|
1249
|
+
const matches2 = (value, expected) => {
|
|
1250
|
+
const a = Buffer.from(hash3(value), "hex"), b = Buffer.from(expected, "hex");
|
|
1251
|
+
return a.length === b.length && timingSafeEqual2(a, b);
|
|
1252
|
+
};
|
|
1253
|
+
return {
|
|
1254
|
+
enabled: !!options,
|
|
1255
|
+
challenge(email, origin) {
|
|
1256
|
+
const id = randomBytes2(24).toString("hex"), binding = randomBytes2(32).toString("hex");
|
|
1257
|
+
const code = String(randomInt(0, 1e8)).padStart(8, "0");
|
|
1258
|
+
const challenge = { email, origin, binding: hash3(binding), codeHash: hash3(id + ":" + code), expires: Date.now() + 10 * 6e4, attempts: 0, sentAt: Date.now() };
|
|
1259
|
+
return { id, binding, code, challenge };
|
|
1260
|
+
},
|
|
1261
|
+
bound(challenge, binding, origin) {
|
|
1262
|
+
return !!challenge && challenge.expires > Date.now() && challenge.origin === origin && matches2(binding, challenge.binding);
|
|
1263
|
+
},
|
|
1264
|
+
verify(id, challenge, code) {
|
|
1265
|
+
if (challenge.verified || challenge.attempts >= 5 || challenge.expires <= Date.now()) return false;
|
|
1266
|
+
challenge.attempts++;
|
|
1267
|
+
if (typeof code !== "string" || !/^\d{8}$/.test(code) || !matches2(id + ":" + code, challenge.codeHash)) return false;
|
|
1268
|
+
challenge.verified = true;
|
|
1269
|
+
return true;
|
|
1270
|
+
},
|
|
1271
|
+
async deliver(email, code, id) {
|
|
1272
|
+
if (!options) throw new Error("email_not_configured");
|
|
1273
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
1274
|
+
method: "POST",
|
|
1275
|
+
headers: { authorization: "Bearer " + options.apiKey, "content-type": "application/json", "Idempotency-Key": "agenttwin-login-" + id },
|
|
1276
|
+
body: JSON.stringify({ from: options.from, to: [email], subject: "Your AgentTwin verification code", text: `Your AgentTwin verification code is ${code}.
|
|
1277
|
+
|
|
1278
|
+
It expires in 10 minutes. Do not share this code. If you did not request it, ignore this email.` }),
|
|
1279
|
+
signal: AbortSignal.timeout(15e3)
|
|
1280
|
+
});
|
|
1281
|
+
if (!response.ok) throw new Error("email_delivery_failed");
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// src/runtime/workspace-profile.ts
|
|
1287
|
+
function validateWorkspaceProfile(input) {
|
|
1288
|
+
const allowed = ["name", "workspace", "company", "website", "role", "teamSize", "useCase", "complete"];
|
|
1289
|
+
if (Object.keys(input).some((key) => !allowed.includes(key))) throw new Error("Unexpected profile field.");
|
|
1290
|
+
const text = (key, max, required = false) => {
|
|
1291
|
+
const value = input[key] === void 0 ? "" : input[key];
|
|
1292
|
+
if (typeof value !== "string" || value.trim().length > max || /[\u0000-\u001f\u007f]/.test(value) || required && !value.trim()) throw new Error(`Enter a valid ${key}.`);
|
|
1293
|
+
return value.trim();
|
|
1294
|
+
};
|
|
1295
|
+
const name = text("name", 100, true), workspace = text("workspace", 100, true);
|
|
1296
|
+
const website = text("website", 254);
|
|
1297
|
+
if (website) {
|
|
1298
|
+
let url;
|
|
1299
|
+
try {
|
|
1300
|
+
url = new URL(website);
|
|
1301
|
+
} catch {
|
|
1302
|
+
throw new Error("Enter a full company website URL.");
|
|
1303
|
+
}
|
|
1304
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new Error("Enter a public company website URL.");
|
|
1305
|
+
}
|
|
1306
|
+
const teamSize = text("teamSize", 20);
|
|
1307
|
+
if (!["", "1", "2\u201310", "11\u201350", "51\u2013200", "201\u20131000", "1000+"].includes(teamSize)) throw new Error("Choose a team size.");
|
|
1308
|
+
if (input.complete !== void 0 && typeof input.complete !== "boolean") throw new Error("Invalid completion state.");
|
|
1309
|
+
return { name, workspace, complete: input.complete === true, context: { company: text("company", 100), website, role: text("role", 100), teamSize, useCase: text("useCase", 500) } };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/runtime/auth.ts
|
|
1313
|
+
import { randomBytes as randomBytes3, scrypt, timingSafeEqual as timingSafeEqual3, createHash as createHash2 } from "crypto";
|
|
1314
|
+
var digest = (value) => createHash2("sha256").update(value).digest("hex");
|
|
1315
|
+
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)));
|
|
1316
|
+
async function passwordHash(password) {
|
|
1317
|
+
const salt = randomBytes3(16).toString("hex");
|
|
1318
|
+
return `${salt}:${(await derive(password, salt)).toString("hex")}`;
|
|
1319
|
+
}
|
|
1320
|
+
async function passwordMatches(password, stored) {
|
|
1321
|
+
const [salt, key] = stored.split(":");
|
|
1322
|
+
const actual = await derive(password, salt);
|
|
1323
|
+
const expected = Buffer.from(key, "hex");
|
|
1324
|
+
return expected.length === actual.length && timingSafeEqual3(expected, actual);
|
|
1325
|
+
}
|
|
1326
|
+
function credentials(input) {
|
|
1327
|
+
if (!input || typeof input !== "object") throw new Error("Enter your email and password.");
|
|
1328
|
+
const data = input;
|
|
1329
|
+
const email = typeof data.email === "string" ? data.email.trim().toLowerCase() : "";
|
|
1330
|
+
const password = typeof data.password === "string" ? data.password : "";
|
|
1331
|
+
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.");
|
|
1332
|
+
return { email, password };
|
|
1333
|
+
}
|
|
1334
|
+
var challengeStore = (map, key, value, ttlMs) => {
|
|
1335
|
+
map[key] = { value, expires: Date.now() + ttlMs };
|
|
1336
|
+
for (const [k, v] of Object.entries(map)) if (v.expires < Date.now()) delete map[k];
|
|
1337
|
+
};
|
|
1338
|
+
|
|
976
1339
|
// src/runtime/gateway.ts
|
|
977
1340
|
init_dashboard_transfer();
|
|
1341
|
+
import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
|
|
978
1342
|
import { createServer as createServer2 } from "http";
|
|
979
|
-
import { createHash as
|
|
980
|
-
import { mkdir as
|
|
981
|
-
import { resolve as
|
|
982
|
-
import { tmpdir as
|
|
1343
|
+
import { createHash as createHash5, randomBytes as randomBytes6, randomUUID as randomUUID2, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
1344
|
+
import { mkdir as mkdir6, readFile as readFile11, writeFile as writeFile8, rename, mkdtemp as mkdtemp3, rm as rm4 } from "fs/promises";
|
|
1345
|
+
import { resolve as resolve3, join as join12, dirname as dirname2, extname } from "path";
|
|
1346
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
983
1347
|
|
|
984
1348
|
// src/runtime/transcription.ts
|
|
985
1349
|
var transcriptionConfiguration = () => ({ available: Boolean(process.env.OPENAI_API_KEY), model: "gpt-4o-mini-transcribe" });
|
|
@@ -1055,7 +1419,7 @@ function parseWorld(value) {
|
|
|
1055
1419
|
if (!object(service) || typeof service.name !== "string" || !namePattern.test(service.name) || services.has(service.name)) invalid("service names must be unique lowercase identifiers");
|
|
1056
1420
|
const item = service;
|
|
1057
1421
|
if (!Array.isArray(item.collections) || !item.collections.length || item.collections.length > 20) invalid("each service needs 1\u201320 collections");
|
|
1058
|
-
if (item.adapter !== void 0 && !["json-collection", "slack"].includes(String(item.adapter))) invalid("unsupported service adapter");
|
|
1422
|
+
if (item.adapter !== void 0 && !["json-collection", "slack", "stripe"].includes(String(item.adapter))) invalid("unsupported service adapter");
|
|
1059
1423
|
const paths = /* @__PURE__ */ new Set();
|
|
1060
1424
|
for (const entry of item.collections) {
|
|
1061
1425
|
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");
|
|
@@ -1064,6 +1428,7 @@ function parseWorld(value) {
|
|
|
1064
1428
|
paths.add(collection.path);
|
|
1065
1429
|
}
|
|
1066
1430
|
if (item.adapter === "slack" && (!paths.has("/messages") || !paths.has("/channels") || paths.size !== 2)) invalid("slack adapter requires /messages and /channels collections");
|
|
1431
|
+
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");
|
|
1067
1432
|
services.set(item.name, paths);
|
|
1068
1433
|
}
|
|
1069
1434
|
const known = (entry) => typeof entry.service === "string" && typeof entry.path === "string" && services.get(entry.service)?.has(entry.path);
|
|
@@ -1071,6 +1436,19 @@ function parseWorld(value) {
|
|
|
1071
1436
|
for (const assertion of v.assertions) {
|
|
1072
1437
|
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");
|
|
1073
1438
|
}
|
|
1439
|
+
const policyIds = /* @__PURE__ */ new Set();
|
|
1440
|
+
for (const field of ["forbiddenEffects", "preservedState"]) {
|
|
1441
|
+
if (v[field] === void 0) continue;
|
|
1442
|
+
if (!Array.isArray(v[field]) || v[field].length > 100) invalid(`${field} must contain at most 100 checks`);
|
|
1443
|
+
for (const entry of v[field]) {
|
|
1444
|
+
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`);
|
|
1445
|
+
const item = entry;
|
|
1446
|
+
if (field === "forbiddenEffects" && !["write", "read", "replay"].includes(String(item.operation))) invalid("forbiddenEffects operation must be write, read or replay");
|
|
1447
|
+
if (Object.keys(item).some((k) => !["id", "service", "path", "where", ...field === "forbiddenEffects" ? ["operation"] : []].includes(k))) invalid(`unknown ${field} field`);
|
|
1448
|
+
policyIds.add(item.id);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
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");
|
|
1074
1452
|
if (v.postgres !== void 0) {
|
|
1075
1453
|
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");
|
|
1076
1454
|
for (const a of v.postgres.assertions) {
|
|
@@ -1084,7 +1462,29 @@ function parseWorld(value) {
|
|
|
1084
1462
|
}
|
|
1085
1463
|
}
|
|
1086
1464
|
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");
|
|
1087
|
-
|
|
1465
|
+
if (v.browser !== void 0) {
|
|
1466
|
+
if (!object(v.browser) || typeof v.browser.baseUrl !== "string") invalid("browser requires baseUrl");
|
|
1467
|
+
const browser = v.browser;
|
|
1468
|
+
let u;
|
|
1469
|
+
try {
|
|
1470
|
+
u = new URL(String(v.browser.baseUrl));
|
|
1471
|
+
} catch {
|
|
1472
|
+
invalid("browser baseUrl must be a loopback HTTP URL");
|
|
1473
|
+
}
|
|
1474
|
+
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>");
|
|
1475
|
+
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");
|
|
1476
|
+
for (const raw of browser.steps) {
|
|
1477
|
+
const step = raw;
|
|
1478
|
+
if (!object(step) || !["goto", "click", "fill", "expectText"].includes(String(step.action))) invalid("unsupported browser action");
|
|
1479
|
+
if (step.action === "goto") {
|
|
1480
|
+
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");
|
|
1481
|
+
} else {
|
|
1482
|
+
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");
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
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");
|
|
1487
|
+
const keys = ["schema", "name", "workflowId", "files", "command", "timeoutSeconds", "browser", "background", "services", "assertions", "forbiddenEffects", "preservedState", "scenario", "postgres", "redis", "fault"];
|
|
1088
1488
|
if (Object.keys(v).some((k) => !keys.includes(k))) invalid("unknown world field");
|
|
1089
1489
|
return v;
|
|
1090
1490
|
}
|
|
@@ -1101,9 +1501,18 @@ function portableLocalReceipt(value) {
|
|
|
1101
1501
|
const uuid = (v) => typeof v === "string" && /^[a-f0-9-]{36}$/.test(v);
|
|
1102
1502
|
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");
|
|
1103
1503
|
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");
|
|
1104
|
-
if (
|
|
1504
|
+
if (r.browser !== void 0) {
|
|
1505
|
+
const b = r.browser;
|
|
1506
|
+
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");
|
|
1507
|
+
}
|
|
1508
|
+
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");
|
|
1509
|
+
if (r.executionError !== void 0 && (typeof r.executionError !== "string" || r.executionError.length > 4096 || r.status === "passed")) throw new Error("Invalid execution error evidence");
|
|
1510
|
+
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");
|
|
1511
|
+
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");
|
|
1512
|
+
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");
|
|
1513
|
+
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");
|
|
1105
1514
|
const git = validateGit(r.git);
|
|
1106
|
-
const fields = ["schema", "runId", "name", "workflowId", "project", "createdAt", "durationMs", "status", "application", "assertions", "infrastructureAssertions", "fault", "initialState", "finalState", "events", "unsupported", "coverage", "sourceDigests", "specDigest"];
|
|
1515
|
+
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"];
|
|
1107
1516
|
const result = Object.fromEntries(fields.filter((k) => r[k] !== void 0).map((k) => [k, r[k]]));
|
|
1108
1517
|
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 } : {} });
|
|
1109
1518
|
if (Buffer.byteLength(JSON.stringify(result)) > 2 * 1024 * 1024) throw new Error("CLI receipt exceeds 2 MiB");
|
|
@@ -1111,15 +1520,30 @@ function portableLocalReceipt(value) {
|
|
|
1111
1520
|
}
|
|
1112
1521
|
|
|
1113
1522
|
// src/commands/run.ts
|
|
1114
|
-
import { mkdtemp, mkdir as
|
|
1115
|
-
import { tmpdir } from "os";
|
|
1116
|
-
import { basename as basename2, dirname, isAbsolute, join as
|
|
1523
|
+
import { mkdtemp as mkdtemp2, mkdir as mkdir5, readFile as readFile10, writeFile as writeFile7, realpath as realpath2, lstat, rm as rm3, copyFile } from "fs/promises";
|
|
1524
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
1525
|
+
import { basename as basename2, dirname, isAbsolute, join as join11, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
|
|
1117
1526
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1118
|
-
import { createHash, randomUUID } from "crypto";
|
|
1527
|
+
import { createHash as createHash4, randomUUID } from "crypto";
|
|
1119
1528
|
|
|
1120
|
-
// src/runtime/
|
|
1121
|
-
import {
|
|
1529
|
+
// src/runtime/credentials.ts
|
|
1530
|
+
import { homedir } from "os";
|
|
1122
1531
|
import { join as join2 } from "path";
|
|
1532
|
+
import { readFile as readFile2, mkdir, writeFile, chmod, rm } from "fs/promises";
|
|
1533
|
+
var path = () => join2(homedir(), ".agenttwin", "credentials.json");
|
|
1534
|
+
async function savedConnection() {
|
|
1535
|
+
try {
|
|
1536
|
+
const data = JSON.parse(await readFile2(path(), "utf8"));
|
|
1537
|
+
return typeof data.base === "string" && /^at_user_[a-f0-9]{64}$/.test(data.token) ? data : null;
|
|
1538
|
+
} catch (e) {
|
|
1539
|
+
if (e.code === "ENOENT") return null;
|
|
1540
|
+
throw new Error("Cannot read saved login. Run npx agenttwin login again.");
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// src/runtime/remote.ts
|
|
1545
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1546
|
+
import { join as join3 } from "path";
|
|
1123
1547
|
|
|
1124
1548
|
// src/lib/exit.ts
|
|
1125
1549
|
var EXIT = {
|
|
@@ -1153,11 +1577,12 @@ function apiBase(value) {
|
|
|
1153
1577
|
return value.replace(/\/$/, "");
|
|
1154
1578
|
}
|
|
1155
1579
|
async function executeRemote(stage, spec, log, metadata = {}) {
|
|
1156
|
-
const
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1580
|
+
const saved = await savedConnection();
|
|
1581
|
+
const base = apiBase(process.env.AGENTTWIN_API_URL || saved?.base || "https://api.agenttwin.si");
|
|
1582
|
+
const token = process.env.AGENTTWIN_TOKEN || saved?.token;
|
|
1583
|
+
if (!token) throw new CliError("Sign in first with npx agenttwin login", { code: EXIT.BLOCKED });
|
|
1159
1584
|
const files = /* @__PURE__ */ Object.create(null);
|
|
1160
|
-
for (const file of spec.files) files[file] = (await
|
|
1585
|
+
for (const file of spec.files) files[file] = (await readFile3(join3(stage, "app", file))).toString("base64");
|
|
1161
1586
|
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
1162
1587
|
const response = await fetch(`${base}/v1/runs`, { method: "POST", headers, body: JSON.stringify({ spec, files, ...metadata }), redirect: "error", signal: AbortSignal.timeout(15e3) });
|
|
1163
1588
|
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 });
|
|
@@ -1174,17 +1599,184 @@ async function executeRemote(stage, spec, log, metadata = {}) {
|
|
|
1174
1599
|
return state.receipt;
|
|
1175
1600
|
}
|
|
1176
1601
|
if (["error", "cancelled"].includes(state.status)) throw new CliError(state.error ?? "Hosted runtime failed");
|
|
1177
|
-
await new Promise((
|
|
1602
|
+
await new Promise((resolve4) => setTimeout(resolve4, 1e3));
|
|
1178
1603
|
}
|
|
1179
1604
|
throw new CliError(`Timed out waiting for hosted run ${job.id}; inspect this run in the API before retrying`);
|
|
1180
1605
|
}
|
|
1181
1606
|
|
|
1607
|
+
// src/runtime/browser-execution.ts
|
|
1608
|
+
import { chromium } from "playwright";
|
|
1609
|
+
import { isDeepStrictEqual } from "util";
|
|
1610
|
+
async function browserExecution(spec, world, signal) {
|
|
1611
|
+
signal?.throwIfAborted();
|
|
1612
|
+
const recipe = spec.browser;
|
|
1613
|
+
const browser = await chromium.launch({ headless: true, timeout: spec.timeoutSeconds * 1e3, ...process.env.AGENTTWIN_CHROMIUM_EXECUTABLE_PATH ? { executablePath: process.env.AGENTTWIN_CHROMIUM_EXECUTABLE_PATH } : {} });
|
|
1614
|
+
const abort = () => {
|
|
1615
|
+
void browser.close().catch(() => {
|
|
1616
|
+
});
|
|
1617
|
+
};
|
|
1618
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1619
|
+
try {
|
|
1620
|
+
signal?.throwIfAborted();
|
|
1621
|
+
const context = await browser.newContext({ serviceWorkers: "block", viewport: { width: 1280, height: 720 } });
|
|
1622
|
+
const origins = /* @__PURE__ */ new Set([new URL(recipe.baseUrl).origin, ...Object.values(world.credentials).map((c) => new URL(c.endpoint).origin)]);
|
|
1623
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
1624
|
+
await context.route("**/*", (route) => {
|
|
1625
|
+
const origin = new URL(route.request().url()).origin;
|
|
1626
|
+
if (origins.has(origin)) return route.continue();
|
|
1627
|
+
if (blocked.size < 100) blocked.add(origin);
|
|
1628
|
+
return route.abort("blockedbyclient");
|
|
1629
|
+
});
|
|
1630
|
+
const page = await context.newPage();
|
|
1631
|
+
page.setDefaultTimeout(Math.min(spec.timeoutSeconds * 500, 1e4));
|
|
1632
|
+
const deadline = Date.now() + Math.min(spec.timeoutSeconds * 1e3, 1e4);
|
|
1633
|
+
while (true) {
|
|
1634
|
+
signal?.throwIfAborted();
|
|
1635
|
+
try {
|
|
1636
|
+
const r = await fetch(recipe.baseUrl, { signal: AbortSignal.timeout(300) });
|
|
1637
|
+
if (r.ok) break;
|
|
1638
|
+
} catch {
|
|
1639
|
+
}
|
|
1640
|
+
if (Date.now() > deadline) throw new Error("Application browser readiness timed out");
|
|
1641
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1642
|
+
}
|
|
1643
|
+
const steps = [];
|
|
1644
|
+
let error;
|
|
1645
|
+
for (const step of recipe.steps) {
|
|
1646
|
+
signal?.throwIfAborted();
|
|
1647
|
+
try {
|
|
1648
|
+
if (step.action === "goto") await page.goto(new URL(step.path, recipe.baseUrl).href);
|
|
1649
|
+
else if (step.action === "click") await page.locator(step.selector).click();
|
|
1650
|
+
else if (step.action === "fill") await page.locator(step.selector).fill(step.value);
|
|
1651
|
+
else await page.locator(step.selector).filter({ hasText: step.text }).waitFor({ state: "visible" });
|
|
1652
|
+
steps.push({ action: step.action, passed: true });
|
|
1653
|
+
} catch (cause) {
|
|
1654
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
1655
|
+
for (const s of recipe.steps) if (s.action === "fill" && s.value) error = error.split(s.value).join("[input]");
|
|
1656
|
+
steps.push({ action: step.action, passed: false });
|
|
1657
|
+
break;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
let screenshot;
|
|
1661
|
+
try {
|
|
1662
|
+
const bytes = await page.screenshot({ type: "jpeg", quality: 65 });
|
|
1663
|
+
if (bytes.length <= 768 * 1024) screenshot = { mimeType: "image/jpeg", base64: bytes.toString("base64") };
|
|
1664
|
+
} catch {
|
|
1665
|
+
}
|
|
1666
|
+
for (const origin of blocked) world.unsupported.push(`browser: blocked unmodeled request origin ${origin}`);
|
|
1667
|
+
return { engine: "playwright-chromium", steps, passed: !error, error, blockedOrigins: [...blocked], screenshot };
|
|
1668
|
+
} finally {
|
|
1669
|
+
signal?.removeEventListener("abort", abort);
|
|
1670
|
+
await browser.close();
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
async function waitBackground(spec, world, signal) {
|
|
1674
|
+
const check = spec.background, deadline = Date.now() + check.timeoutMs;
|
|
1675
|
+
while (true) {
|
|
1676
|
+
signal?.throwIfAborted();
|
|
1677
|
+
const actual = world.state[check.service][check.path].filter((row) => !check.where || Object.entries(check.where).every(([k, v]) => isDeepStrictEqual(row[k], v))).length;
|
|
1678
|
+
if (actual === check.count) return { ...check, actual, passed: true };
|
|
1679
|
+
if (Date.now() >= deadline) return { ...check, actual, passed: false };
|
|
1680
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// src/runtime/request-source.ts
|
|
1685
|
+
import { mkdtemp, writeFile as writeFile2, rm as rm2, realpath } from "fs/promises";
|
|
1686
|
+
import { tmpdir } from "os";
|
|
1687
|
+
import { join as join4, resolve } from "path";
|
|
1688
|
+
function parseRequestSource(raw, spec) {
|
|
1689
|
+
if (typeof raw !== "string" || raw.length > 1024) return;
|
|
1690
|
+
try {
|
|
1691
|
+
const source = JSON.parse(raw);
|
|
1692
|
+
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;
|
|
1693
|
+
return { file: source.file, line: source.line, column: source.column, provenance: "application-reported" };
|
|
1694
|
+
} catch {
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
async function createRequestSourceHook(app, spec, credentials2) {
|
|
1699
|
+
const dir = await mkdtemp(join4(tmpdir(), "agenttwin-source-hook-"));
|
|
1700
|
+
const path2 = join4(dir, "hook.cjs");
|
|
1701
|
+
const config = { root: await realpath(resolve(app)), files: spec.files, origins: Object.values(credentials2).map((c) => new URL(c.endpoint).origin) };
|
|
1702
|
+
const hook = `const config=${JSON.stringify(config)};
|
|
1703
|
+
` + String.raw`
|
|
1704
|
+
const http=require('node:http'),https=require('node:https'),path=require('node:path'),{fileURLToPath}=require('node:url');
|
|
1705
|
+
const origins=new Set(config.origins),files=new Set(config.files);
|
|
1706
|
+
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])});}}
|
|
1707
|
+
const originalFetch=globalThis.fetch;
|
|
1708
|
+
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});};
|
|
1709
|
+
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);};}
|
|
1710
|
+
require('node:module').syncBuiltinESMExports();
|
|
1711
|
+
`;
|
|
1712
|
+
await writeFile2(path2, hook, { mode: 384 });
|
|
1713
|
+
return { path: path2, close: () => rm2(dir, { recursive: true, force: true }) };
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1182
1716
|
// src/runtime/local.ts
|
|
1183
1717
|
import { spawn } from "child_process";
|
|
1184
1718
|
|
|
1185
1719
|
// src/runtime/http-world.ts
|
|
1186
1720
|
import { createServer } from "http";
|
|
1187
|
-
import { randomBytes } from "crypto";
|
|
1721
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
1722
|
+
import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
|
|
1723
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
1724
|
+
|
|
1725
|
+
// src/runtime/stripe-adapter.ts
|
|
1726
|
+
function stripeOperation(method, path2, params, data, sequence) {
|
|
1727
|
+
const error = (status, code, message, param) => ({ status, body: { error: { type: "invalid_request_error", code, message, ...param ? { param } : {} } } });
|
|
1728
|
+
const match = /^\/v1\/(customers|payment_intents)(?:\/([^/]+))?(?:\/(confirm))?$/.exec(path2);
|
|
1729
|
+
if (!match || !["GET", "POST"].includes(method)) return void 0;
|
|
1730
|
+
const [, resource, id, action] = match, collection = `/v1/${resource}`, rows = data[collection];
|
|
1731
|
+
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;
|
|
1732
|
+
if (!allowed || resource === "customers" && action || resource === "payment_intents" && method === "GET" && !id) return void 0;
|
|
1733
|
+
if (Object.keys(params).some((k) => !allowed.includes(k))) return void 0;
|
|
1734
|
+
if (method === "GET" && !id) {
|
|
1735
|
+
const limit = params.limit === void 0 ? 10 : Number(params.limit);
|
|
1736
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) return { ...error(400, "parameter_invalid_integer", "limit must be 1\u2013100", "limit"), collection };
|
|
1737
|
+
const ordered = [...rows].reverse();
|
|
1738
|
+
const index = params.starting_after === void 0 ? -1 : ordered.findIndex((r) => r.id === params.starting_after);
|
|
1739
|
+
if (params.starting_after !== void 0 && index === -1) return { ...error(400, "resource_missing", "No such starting_after customer", "starting_after"), collection };
|
|
1740
|
+
const page = ordered.slice(index + 1, index + 1 + limit);
|
|
1741
|
+
return { status: 200, body: { object: "list", data: page, has_more: ordered.length > index + 1 + limit, url: collection }, collection, operation: "read" };
|
|
1742
|
+
}
|
|
1743
|
+
const row = id ? rows.find((r) => r.id === id) : void 0;
|
|
1744
|
+
if (id && !row) return { ...error(404, "resource_missing", `No such ${resource}: '${id}'`), collection };
|
|
1745
|
+
if (method === "GET") return { status: 200, body: row, collection, operation: "read" };
|
|
1746
|
+
if (action === "confirm") {
|
|
1747
|
+
if (row.status === "succeeded") return { status: 200, body: row, collection, operation: "replay" };
|
|
1748
|
+
const payment = params.payment_method ?? row.payment_method;
|
|
1749
|
+
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 };
|
|
1750
|
+
row.payment_method = payment;
|
|
1751
|
+
row.status = payment === "pm_card_visa" ? "succeeded" : "requires_payment_method";
|
|
1752
|
+
row.last_payment_error = payment === "pm_card_chargeDeclined" ? { code: "card_declined", decline_code: "generic_decline", message: "Your card was declined." } : null;
|
|
1753
|
+
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" };
|
|
1754
|
+
}
|
|
1755
|
+
const prefix = resource === "customers" ? "cus" : "pi";
|
|
1756
|
+
let suffix = sequence;
|
|
1757
|
+
while (rows.some((r) => r.id === `${prefix}_at_${suffix}`)) suffix++;
|
|
1758
|
+
const created = { id: `${prefix}_at_${suffix}`, object: resource === "customers" ? "customer" : "payment_intent", created: 17e8, livemode: false };
|
|
1759
|
+
if (resource === "customers") {
|
|
1760
|
+
if (params.email !== void 0 && typeof params.email !== "string") return { ...error(400, "parameter_invalid", "email must be a string", "email"), collection };
|
|
1761
|
+
Object.assign(created, { email: null, name: null, description: null, ...params });
|
|
1762
|
+
} else {
|
|
1763
|
+
const amount = Number(params.amount);
|
|
1764
|
+
if (!Number.isSafeInteger(amount) || amount < 1) return { ...error(400, "parameter_invalid_integer", "amount must be a positive integer", "amount"), collection };
|
|
1765
|
+
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 };
|
|
1766
|
+
if (params.customer && !data["/v1/customers"].some((r) => r.id === params.customer)) return { ...error(400, "resource_missing", "No such customer", "customer"), collection };
|
|
1767
|
+
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 };
|
|
1768
|
+
if (params.confirm !== void 0 && !["true", "false", true, false].includes(params.confirm)) return { ...error(400, "parameter_invalid", "confirm must be boolean", "confirm"), collection };
|
|
1769
|
+
const confirm = params.confirm === "true" || params.confirm === true;
|
|
1770
|
+
if (confirm && !params.payment_method) return { ...error(400, "parameter_missing", "Missing payment_method", "payment_method"), collection };
|
|
1771
|
+
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 });
|
|
1772
|
+
}
|
|
1773
|
+
if (rows.length >= 1e4) return { ...error(429, "world_capacity", "World record capacity reached"), collection };
|
|
1774
|
+
rows.push(created);
|
|
1775
|
+
const declined = resource === "payment_intents" && (params.confirm === "true" || params.confirm === true) && params.payment_method === "pm_card_chargeDeclined";
|
|
1776
|
+
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" };
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// src/runtime/http-world.ts
|
|
1188
1780
|
var HttpWorld = class {
|
|
1189
1781
|
constructor(spec) {
|
|
1190
1782
|
this.spec = spec;
|
|
@@ -1197,21 +1789,32 @@ var HttpWorld = class {
|
|
|
1197
1789
|
servers = [];
|
|
1198
1790
|
timers = /* @__PURE__ */ new Set();
|
|
1199
1791
|
fired = false;
|
|
1200
|
-
|
|
1792
|
+
epoch = 0;
|
|
1793
|
+
replayKeys = /* @__PURE__ */ new Map();
|
|
1794
|
+
sourceContext = new AsyncLocalStorage();
|
|
1795
|
+
record(service, operation, path2, detail, record) {
|
|
1201
1796
|
if (this.events.length >= 5e4) return;
|
|
1202
|
-
|
|
1797
|
+
const source = this.sourceContext.getStore();
|
|
1798
|
+
this.events.push({ sequence: this.events.length + 1, service, operation, path: path2, detail, ...record ? { record: structuredClone(record) } : {}, ...source ? { source } : {} });
|
|
1799
|
+
}
|
|
1800
|
+
rejectUnsupported(service, method, path2, detail) {
|
|
1801
|
+
this.unsupported.push(`${service}: ${method} ${path2}: ${detail}`);
|
|
1802
|
+
this.record(service, "unsupported", path2, detail);
|
|
1203
1803
|
}
|
|
1204
1804
|
async start() {
|
|
1805
|
+
if (this.servers.length) throw new Error("World is already started");
|
|
1205
1806
|
for (const service of this.spec.services) {
|
|
1206
|
-
const token = `at_world_${
|
|
1807
|
+
const token = `at_world_${randomBytes4(24).toString("hex")}`;
|
|
1207
1808
|
const data = /* @__PURE__ */ Object.create(null);
|
|
1208
1809
|
this.state[service.name] = data;
|
|
1209
1810
|
for (const c of service.collections) data[c.path] = structuredClone(c.seed);
|
|
1210
1811
|
const keys = /* @__PURE__ */ new Map();
|
|
1211
|
-
|
|
1812
|
+
this.replayKeys.set(service.name, keys);
|
|
1813
|
+
const server = createServer((req, res) => this.sourceContext.run(parseRequestSource(req.headers["x-agenttwin-source"], this.spec), async () => {
|
|
1814
|
+
const epoch = this.epoch;
|
|
1212
1815
|
const send2 = (code, body2) => {
|
|
1213
1816
|
if (!res.destroyed) {
|
|
1214
|
-
res.writeHead(code, { "content-type": "application/json" });
|
|
1817
|
+
res.writeHead(code, { "content-type": "application/json", ...code === 501 ? { "X-AgentTwin-Unsupported": "true" } : {} });
|
|
1215
1818
|
res.end(JSON.stringify(body2));
|
|
1216
1819
|
}
|
|
1217
1820
|
};
|
|
@@ -1226,9 +1829,9 @@ var HttpWorld = class {
|
|
|
1226
1829
|
return;
|
|
1227
1830
|
}
|
|
1228
1831
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1229
|
-
let
|
|
1832
|
+
let path2 = url.pathname;
|
|
1230
1833
|
const slack = service.adapter === "slack";
|
|
1231
|
-
const slackMethod =
|
|
1834
|
+
const slackMethod = path2.replace(/^\/api\//, "");
|
|
1232
1835
|
let payload;
|
|
1233
1836
|
if (req.method === "POST") {
|
|
1234
1837
|
const chunks = [];
|
|
@@ -1244,7 +1847,7 @@ var HttpWorld = class {
|
|
|
1244
1847
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
1245
1848
|
let parsed;
|
|
1246
1849
|
try {
|
|
1247
|
-
parsed = slack && req.headers["content-type"]?.startsWith("application/x-www-form-urlencoded") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text);
|
|
1850
|
+
parsed = (slack || service.adapter === "stripe") && req.headers["content-type"]?.startsWith("application/x-www-form-urlencoded") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text);
|
|
1248
1851
|
} catch {
|
|
1249
1852
|
send2(400, { error: "expected_json_object" });
|
|
1250
1853
|
return;
|
|
@@ -1255,36 +1858,75 @@ var HttpWorld = class {
|
|
|
1255
1858
|
}
|
|
1256
1859
|
payload = parsed;
|
|
1257
1860
|
}
|
|
1861
|
+
if (epoch !== this.epoch) {
|
|
1862
|
+
send2(409, { error: "world_reset" });
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
if (service.adapter === "stripe") {
|
|
1866
|
+
const params = payload ?? Object.fromEntries(url.searchParams);
|
|
1867
|
+
const keyHeader2 = req.headers["idempotency-key"];
|
|
1868
|
+
const key2 = req.method === "POST" && typeof keyHeader2 === "string" ? `${path2}:${keyHeader2}` : void 0;
|
|
1869
|
+
const signature2 = JSON.stringify(params);
|
|
1870
|
+
const previous2 = key2 ? keys.get(key2) : void 0;
|
|
1871
|
+
if (previous2) {
|
|
1872
|
+
if (previous2.body !== signature2) {
|
|
1873
|
+
send2(400, { error: { type: "idempotency_error", message: "Keys for idempotent requests can only be used with the same parameters" } });
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
this.record(service.name, "replay", path2, "same-key response replay", previous2.record);
|
|
1877
|
+
send2(200, previous2.record);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
const result = stripeOperation(req.method ?? "", path2, params, data, this.events.length + 1);
|
|
1881
|
+
if (!result) {
|
|
1882
|
+
this.rejectUnsupported(service.name, req.method ?? "", path2, "unsupported Stripe operation or parameters");
|
|
1883
|
+
send2(501, { error: { type: "invalid_request_error", code: "unsupported_operation", message: "Outside the verified AgentTwin Stripe subset" }, forwardedToProduction: false });
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
if (result.operation) this.record(service.name, result.operation, result.collection, path2, "record" in result ? result.record : result.body);
|
|
1887
|
+
if (key2 && result.status === 200) keys.set(key2, { body: signature2, record: structuredClone(result.body) });
|
|
1888
|
+
const fault2 = this.spec.fault;
|
|
1889
|
+
if (!this.fired && result.operation === "write" && fault2?.service === service.name && fault2.path === result.collection) {
|
|
1890
|
+
this.fired = true;
|
|
1891
|
+
this.record(service.name, "fault", result.collection, `write committed; response delayed ${fault2.delayMs}ms`);
|
|
1892
|
+
const timer = setTimeout(() => {
|
|
1893
|
+
this.timers.delete(timer);
|
|
1894
|
+
send2(result.status, result.body);
|
|
1895
|
+
}, fault2.delayMs);
|
|
1896
|
+
this.timers.add(timer);
|
|
1897
|
+
} else send2(result.status, result.body);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1258
1900
|
if (slack) {
|
|
1259
1901
|
const params = payload ?? Object.fromEntries(url.searchParams);
|
|
1260
1902
|
const allowed = slackMethod === "chat.postMessage" ? ["channel", "text"] : slackMethod === "conversations.history" ? ["channel"] : [];
|
|
1261
1903
|
if (Object.keys(params).some((k) => !allowed.includes(k))) {
|
|
1262
|
-
this.
|
|
1904
|
+
this.rejectUnsupported(service.name, req.method ?? "", url.pathname, `unsupported parameters for ${slackMethod}`);
|
|
1263
1905
|
send2(501, { ok: false, error: "unsupported_parameters", forwardedToProduction: false });
|
|
1264
1906
|
return;
|
|
1265
1907
|
}
|
|
1266
|
-
if (slackMethod === "chat.postMessage" && req.method === "POST")
|
|
1267
|
-
else if (slackMethod === "conversations.history" && ["GET", "POST"].includes(req.method ?? ""))
|
|
1268
|
-
else if (slackMethod === "conversations.list" && ["GET", "POST"].includes(req.method ?? ""))
|
|
1908
|
+
if (slackMethod === "chat.postMessage" && req.method === "POST") path2 = "/messages";
|
|
1909
|
+
else if (slackMethod === "conversations.history" && ["GET", "POST"].includes(req.method ?? "")) path2 = "/messages";
|
|
1910
|
+
else if (slackMethod === "conversations.list" && ["GET", "POST"].includes(req.method ?? "")) path2 = "/channels";
|
|
1269
1911
|
else {
|
|
1270
|
-
this.
|
|
1271
|
-
send2(501, { ok: false, error: "unsupported_operation" });
|
|
1912
|
+
this.rejectUnsupported(service.name, req.method ?? "", url.pathname, "unsupported operation");
|
|
1913
|
+
send2(501, { ok: false, error: "unsupported_operation", forwardedToProduction: false });
|
|
1272
1914
|
return;
|
|
1273
1915
|
}
|
|
1274
1916
|
}
|
|
1275
|
-
if (!Object.hasOwn(data,
|
|
1276
|
-
this.
|
|
1917
|
+
if (!Object.hasOwn(data, path2) || !["GET", "POST"].includes(req.method ?? "")) {
|
|
1918
|
+
this.rejectUnsupported(service.name, req.method ?? "", path2, "unsupported operation");
|
|
1277
1919
|
send2(501, { error: "unsupported_operation", forwardedToProduction: false });
|
|
1278
1920
|
return;
|
|
1279
1921
|
}
|
|
1280
1922
|
if (req.method === "GET" || slack && slackMethod !== "chat.postMessage") {
|
|
1281
|
-
this.record(service.name, "read",
|
|
1282
|
-
if (slack &&
|
|
1923
|
+
this.record(service.name, "read", path2, `${data[path2].length} records`);
|
|
1924
|
+
if (slack && path2 === "/channels") send2(200, { ok: true, channels: data[path2], response_metadata: { next_cursor: "" } });
|
|
1283
1925
|
else if (slack) {
|
|
1284
1926
|
const channel = payload?.channel ?? url.searchParams.get("channel");
|
|
1285
1927
|
if (!data["/channels"].some((c) => c.id === channel)) send2(200, { ok: false, error: "channel_not_found" });
|
|
1286
|
-
else send2(200, { ok: true, messages: data[
|
|
1287
|
-
} else send2(200, { data: data[
|
|
1928
|
+
else send2(200, { ok: true, messages: data[path2].filter((m) => m.channel === channel), has_more: false });
|
|
1929
|
+
} else send2(200, { data: data[path2] });
|
|
1288
1930
|
return;
|
|
1289
1931
|
}
|
|
1290
1932
|
const body2 = payload;
|
|
@@ -1299,12 +1941,12 @@ var HttpWorld = class {
|
|
|
1299
1941
|
return;
|
|
1300
1942
|
}
|
|
1301
1943
|
}
|
|
1302
|
-
if (data[
|
|
1944
|
+
if (data[path2].length >= 1e4 || this.events.length >= 5e4) {
|
|
1303
1945
|
send2(429, { error: "world_capacity" });
|
|
1304
1946
|
return;
|
|
1305
1947
|
}
|
|
1306
1948
|
const keyHeader = slack ? void 0 : req.headers["idempotency-key"];
|
|
1307
|
-
const key = typeof keyHeader === "string" ? `${
|
|
1949
|
+
const key = typeof keyHeader === "string" ? `${path2}:${keyHeader}` : void 0;
|
|
1308
1950
|
const signature = JSON.stringify(body2);
|
|
1309
1951
|
const previous = key ? keys.get(key) : void 0;
|
|
1310
1952
|
if (previous) {
|
|
@@ -1312,22 +1954,22 @@ var HttpWorld = class {
|
|
|
1312
1954
|
send2(409, { error: "idempotency_conflict" });
|
|
1313
1955
|
return;
|
|
1314
1956
|
}
|
|
1315
|
-
this.record(service.name, "replay",
|
|
1957
|
+
this.record(service.name, "replay", path2, "same-key response replay", previous.record);
|
|
1316
1958
|
send2(200, previous.record);
|
|
1317
1959
|
return;
|
|
1318
1960
|
}
|
|
1319
|
-
let index = data[
|
|
1320
|
-
while (data[
|
|
1961
|
+
let index = data[path2].length + 1;
|
|
1962
|
+
while (data[path2].some((r) => r.id === `${service.name}_${index}`)) index++;
|
|
1321
1963
|
const row = { ...body2, id: `${service.name}_${index}` };
|
|
1322
1964
|
if (slack) row.ts = `${index}.000001`;
|
|
1323
1965
|
const reply = slack ? { ok: true, channel: row.channel, ts: row.ts, message: row } : row;
|
|
1324
|
-
data[
|
|
1966
|
+
data[path2].push(row);
|
|
1325
1967
|
if (key) keys.set(key, { body: signature, record: row });
|
|
1326
|
-
this.record(service.name, "write",
|
|
1968
|
+
this.record(service.name, "write", path2, String(row.id), row);
|
|
1327
1969
|
const fault = this.spec.fault;
|
|
1328
|
-
if (!this.fired && fault?.service === service.name && fault.path ===
|
|
1970
|
+
if (!this.fired && fault?.service === service.name && fault.path === path2) {
|
|
1329
1971
|
this.fired = true;
|
|
1330
|
-
this.record(service.name, "fault",
|
|
1972
|
+
this.record(service.name, "fault", path2, `write committed; response delayed ${fault.delayMs}ms`);
|
|
1331
1973
|
const timer = setTimeout(() => {
|
|
1332
1974
|
this.timers.delete(timer);
|
|
1333
1975
|
send2(slack ? 200 : 201, reply);
|
|
@@ -1337,11 +1979,11 @@ var HttpWorld = class {
|
|
|
1337
1979
|
} catch {
|
|
1338
1980
|
send2(500, { error: "world_request_error" });
|
|
1339
1981
|
}
|
|
1340
|
-
});
|
|
1982
|
+
}));
|
|
1341
1983
|
server.requestTimeout = 1e4;
|
|
1342
|
-
await new Promise((
|
|
1984
|
+
await new Promise((resolve4, reject) => {
|
|
1343
1985
|
server.once("error", reject);
|
|
1344
|
-
server.listen(0, "127.0.0.1",
|
|
1986
|
+
server.listen(0, "127.0.0.1", resolve4);
|
|
1345
1987
|
});
|
|
1346
1988
|
this.servers.push(server);
|
|
1347
1989
|
this.credentials[service.name] = { endpoint: `http://127.0.0.1:${server.address().port}`, token };
|
|
@@ -1350,37 +1992,121 @@ var HttpWorld = class {
|
|
|
1350
1992
|
}
|
|
1351
1993
|
assertions() {
|
|
1352
1994
|
return this.spec.assertions.map((a) => {
|
|
1353
|
-
const rows = this.state[a.service][a.path].filter((row) => !a.where || Object.entries(a.where).every(([k, v]) =>
|
|
1995
|
+
const rows = this.state[a.service][a.path].filter((row) => !a.where || Object.entries(a.where).every(([k, v]) => isDeepStrictEqual2(row[k], v)));
|
|
1354
1996
|
return { ...a, actual: rows.length, passed: rows.length === a.count };
|
|
1355
1997
|
});
|
|
1356
1998
|
}
|
|
1357
1999
|
get faultTriggered() {
|
|
1358
2000
|
return this.fired;
|
|
1359
2001
|
}
|
|
2002
|
+
snapshot() {
|
|
2003
|
+
return structuredClone(this.state);
|
|
2004
|
+
}
|
|
2005
|
+
quiesce() {
|
|
2006
|
+
this.epoch++;
|
|
2007
|
+
for (const server of this.servers) server.closeAllConnections();
|
|
2008
|
+
for (const timer of this.timers) clearTimeout(timer);
|
|
2009
|
+
this.timers.clear();
|
|
2010
|
+
}
|
|
2011
|
+
reset() {
|
|
2012
|
+
this.quiesce();
|
|
2013
|
+
for (const service of this.spec.services) {
|
|
2014
|
+
for (const collection of service.collections) this.state[service.name][collection.path] = structuredClone(collection.seed);
|
|
2015
|
+
this.replayKeys.get(service.name)?.clear();
|
|
2016
|
+
}
|
|
2017
|
+
this.events.length = 0;
|
|
2018
|
+
this.unsupported.length = 0;
|
|
2019
|
+
this.fired = false;
|
|
2020
|
+
return this.snapshot();
|
|
2021
|
+
}
|
|
1360
2022
|
async close() {
|
|
2023
|
+
this.epoch++;
|
|
1361
2024
|
for (const timer of this.timers) clearTimeout(timer);
|
|
1362
2025
|
this.timers.clear();
|
|
1363
|
-
await Promise.all(this.servers.map((server) => new Promise((
|
|
1364
|
-
server.close(() =>
|
|
2026
|
+
await Promise.all(this.servers.map((server) => new Promise((resolve4) => {
|
|
2027
|
+
server.close(() => resolve4());
|
|
1365
2028
|
server.closeAllConnections();
|
|
1366
2029
|
})));
|
|
2030
|
+
this.servers.length = 0;
|
|
1367
2031
|
}
|
|
1368
2032
|
};
|
|
1369
2033
|
|
|
2034
|
+
// src/runtime/evaluation.ts
|
|
2035
|
+
import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
|
|
2036
|
+
var matches = (row, where) => !where || Object.entries(where).every(([k, v]) => isDeepStrictEqual3(row[k], v));
|
|
2037
|
+
function evaluateState(spec, initial, final, events) {
|
|
2038
|
+
const checks = spec.assertions.map((a, index) => {
|
|
2039
|
+
const before = initial[a.service]?.[a.path];
|
|
2040
|
+
const after = final[a.service]?.[a.path];
|
|
2041
|
+
const actual = after?.filter((row) => matches(row, a.where)).length;
|
|
2042
|
+
return {
|
|
2043
|
+
id: `assertion_${index + 1}`,
|
|
2044
|
+
kind: "required-outcome",
|
|
2045
|
+
service: a.service,
|
|
2046
|
+
path: a.path,
|
|
2047
|
+
passed: actual === a.count,
|
|
2048
|
+
expected: a.count,
|
|
2049
|
+
actual: actual ?? null,
|
|
2050
|
+
evidence: {
|
|
2051
|
+
initial: `/initialState/${escape(a.service)}/${escape(a.path)}`,
|
|
2052
|
+
final: `/finalState/${escape(a.service)}/${escape(a.path)}`,
|
|
2053
|
+
beforeCount: before?.filter((row) => matches(row, a.where)).length ?? null,
|
|
2054
|
+
eventSequences: events.filter((e) => e.service === a.service && e.path === a.path).map((e) => e.sequence)
|
|
2055
|
+
}
|
|
2056
|
+
};
|
|
2057
|
+
});
|
|
2058
|
+
const forbidden = (spec.forbiddenEffects ?? []).map((policy) => {
|
|
2059
|
+
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)));
|
|
2060
|
+
const unknown = events.some((e) => e.service === policy.service && e.path === policy.path && e.operation === policy.operation && policy.where && !e.record);
|
|
2061
|
+
return {
|
|
2062
|
+
id: policy.id,
|
|
2063
|
+
kind: "forbidden-effect",
|
|
2064
|
+
service: policy.service,
|
|
2065
|
+
path: policy.path,
|
|
2066
|
+
passed: hits.length === 0 && !unknown,
|
|
2067
|
+
expected: 0,
|
|
2068
|
+
actual: hits.length,
|
|
2069
|
+
evidence: { eventSequences: hits.map((e) => e.sequence), evidenceGap: unknown }
|
|
2070
|
+
};
|
|
2071
|
+
});
|
|
2072
|
+
const preserved = (spec.preservedState ?? []).map((policy) => {
|
|
2073
|
+
const before = initial[policy.service]?.[policy.path];
|
|
2074
|
+
const after = final[policy.service]?.[policy.path];
|
|
2075
|
+
const passed = !!before && !!after && isDeepStrictEqual3(before.filter((row) => matches(row, policy.where)), after.filter((row) => matches(row, policy.where)));
|
|
2076
|
+
return {
|
|
2077
|
+
id: policy.id,
|
|
2078
|
+
kind: "preserved-state",
|
|
2079
|
+
service: policy.service,
|
|
2080
|
+
path: policy.path,
|
|
2081
|
+
passed,
|
|
2082
|
+
expected: "unchanged",
|
|
2083
|
+
actual: passed ? "unchanged" : "changed or missing",
|
|
2084
|
+
evidence: { initial: `/initialState/${escape(policy.service)}/${escape(policy.path)}`, final: `/finalState/${escape(policy.service)}/${escape(policy.path)}` }
|
|
2085
|
+
};
|
|
2086
|
+
});
|
|
2087
|
+
const taskPassed = checks.every((c) => c.passed);
|
|
2088
|
+
const safe = [...forbidden, ...preserved].every((c) => c.passed);
|
|
2089
|
+
return { schema: "agenttwin/evaluation-v1", outcome: !safe ? "unsafe" : taskPassed ? "passed" : "failed", taskPassed, safe, checks: [...checks, ...forbidden, ...preserved] };
|
|
2090
|
+
}
|
|
2091
|
+
var escape = (value) => value.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
2092
|
+
|
|
1370
2093
|
// src/runtime/local.ts
|
|
1371
|
-
|
|
2094
|
+
init_contracts();
|
|
2095
|
+
async function executeLocal(app, spec, signal, existingWorld) {
|
|
1372
2096
|
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.");
|
|
1373
2097
|
signal?.throwIfAborted();
|
|
1374
|
-
const world = new HttpWorld(spec), started = Date.now();
|
|
2098
|
+
const world = existingWorld ?? new HttpWorld(spec), started = Date.now();
|
|
1375
2099
|
try {
|
|
1376
|
-
const
|
|
2100
|
+
const credentials2 = existingWorld ? world.credentials : await world.start();
|
|
2101
|
+
const initialState = world.snapshot();
|
|
1377
2102
|
const env = { PATH: process.env.PATH ?? "", NODE_ENV: "test" };
|
|
1378
|
-
for (const [name, c] of Object.entries(
|
|
2103
|
+
for (const [name, c] of Object.entries(credentials2)) {
|
|
1379
2104
|
env[`AGENTTWIN_${name.toUpperCase()}_URL`] = c.endpoint;
|
|
1380
2105
|
env[`AGENTTWIN_${name.toUpperCase()}_TOKEN`] = c.token;
|
|
1381
2106
|
}
|
|
1382
2107
|
let stdout = "", stderr = "", timedOut = false;
|
|
1383
|
-
const
|
|
2108
|
+
const hook = await createRequestSourceHook(app, spec, credentials2);
|
|
2109
|
+
const child = spawn(process.execPath, ["--require", hook.path, ...spec.command.slice(1)], { cwd: app, env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
1384
2110
|
child.stdout.on("data", (b) => {
|
|
1385
2111
|
stdout = (stdout + b.toString()).slice(-65536);
|
|
1386
2112
|
});
|
|
@@ -1395,31 +2121,61 @@ async function executeLocal(app, spec, signal) {
|
|
|
1395
2121
|
}
|
|
1396
2122
|
}
|
|
1397
2123
|
};
|
|
1398
|
-
const
|
|
2124
|
+
const executionAbort = new AbortController();
|
|
2125
|
+
const abort = () => {
|
|
2126
|
+
executionAbort.abort();
|
|
2127
|
+
kill();
|
|
2128
|
+
};
|
|
1399
2129
|
signal?.addEventListener("abort", abort, { once: true });
|
|
1400
2130
|
const timer = setTimeout(() => {
|
|
1401
2131
|
timedOut = true;
|
|
2132
|
+
executionAbort.abort();
|
|
1402
2133
|
kill();
|
|
1403
2134
|
}, spec.timeoutSeconds * 1e3);
|
|
1404
|
-
const
|
|
2135
|
+
const completion = new Promise((resolve4, reject) => {
|
|
1405
2136
|
child.once("error", reject);
|
|
1406
|
-
child.once("close",
|
|
1407
|
-
})
|
|
2137
|
+
child.once("close", resolve4);
|
|
2138
|
+
});
|
|
2139
|
+
let browser, background, executionError;
|
|
2140
|
+
let exitCode;
|
|
2141
|
+
try {
|
|
2142
|
+
if (spec.browser || spec.background) {
|
|
2143
|
+
const work = (async () => {
|
|
2144
|
+
if (spec.browser) browser = await browserExecution(spec, world, executionAbort.signal);
|
|
2145
|
+
if (spec.background) background = await waitBackground(spec, world, executionAbort.signal);
|
|
2146
|
+
return 0;
|
|
2147
|
+
})();
|
|
2148
|
+
exitCode = await Promise.race([work, completion.then((code) => {
|
|
2149
|
+
if (!spec.browser && code === 0) return work;
|
|
2150
|
+
throw new Error(`Application exited before browser/background completion (${code})`);
|
|
2151
|
+
})]);
|
|
2152
|
+
} else exitCode = await completion;
|
|
2153
|
+
} catch (error) {
|
|
2154
|
+
executionError = error instanceof Error ? error.message : String(error);
|
|
2155
|
+
exitCode = 1;
|
|
2156
|
+
} finally {
|
|
2157
|
+
executionAbort.abort();
|
|
1408
2158
|
clearTimeout(timer);
|
|
1409
2159
|
signal?.removeEventListener("abort", abort);
|
|
1410
2160
|
kill();
|
|
1411
|
-
|
|
2161
|
+
await completion.catch(() => {
|
|
2162
|
+
});
|
|
2163
|
+
await hook.close();
|
|
2164
|
+
}
|
|
1412
2165
|
signal?.throwIfAborted();
|
|
1413
|
-
|
|
1414
|
-
|
|
2166
|
+
if (existingWorld) world.quiesce();
|
|
2167
|
+
else await world.close();
|
|
2168
|
+
for (const c of Object.values(credentials2)) {
|
|
1415
2169
|
stdout = stdout.split(c.token).join("[test-token]");
|
|
1416
2170
|
stderr = stderr.split(c.token).join("[test-token]");
|
|
1417
2171
|
}
|
|
1418
2172
|
const assertions = world.assertions();
|
|
1419
|
-
const
|
|
1420
|
-
|
|
2173
|
+
const evaluation = evaluateState(spec, initialState, world.state, world.events);
|
|
2174
|
+
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";
|
|
2175
|
+
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" } };
|
|
1421
2176
|
} finally {
|
|
1422
|
-
|
|
2177
|
+
if (existingWorld) world.quiesce();
|
|
2178
|
+
else await world.close();
|
|
1423
2179
|
}
|
|
1424
2180
|
}
|
|
1425
2181
|
|
|
@@ -1428,17 +2184,18 @@ init_git();
|
|
|
1428
2184
|
|
|
1429
2185
|
// src/runtime/e2b.ts
|
|
1430
2186
|
import { Sandbox } from "e2b";
|
|
1431
|
-
import { readFile as
|
|
1432
|
-
import { randomBytes as
|
|
1433
|
-
import { homedir } from "os";
|
|
1434
|
-
import { join as
|
|
2187
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
2188
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
2189
|
+
import { homedir as homedir2 } from "os";
|
|
2190
|
+
import { join as join5 } from "path";
|
|
1435
2191
|
async function executeE2B(stage, spec, log, signal) {
|
|
2192
|
+
if (spec.browser || spec.background) throw new Error("Browser and background execution currently require --runtime local; hosted browser dependencies are not provisioned.");
|
|
1436
2193
|
signal?.throwIfAborted();
|
|
1437
2194
|
const apiKey = process.env.E2B_API_KEY;
|
|
1438
2195
|
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." });
|
|
1439
2196
|
let saved = {};
|
|
1440
2197
|
try {
|
|
1441
|
-
saved = JSON.parse(await
|
|
2198
|
+
saved = JSON.parse(await readFile4(join5(homedir2(), ".agenttwin/runtime.json"), "utf8"));
|
|
1442
2199
|
} catch {
|
|
1443
2200
|
}
|
|
1444
2201
|
const template = process.env.E2B_TEMPLATE_ID ?? saved.templateId;
|
|
@@ -1457,12 +2214,12 @@ async function executeE2B(stage, spec, log, signal) {
|
|
|
1457
2214
|
signal?.throwIfAborted();
|
|
1458
2215
|
await sandbox.commands.run("mkdir -p /input/app && chmod 755 /input /input/app", { user: "root" });
|
|
1459
2216
|
for (const file of ["world.json", "worker.js", ...spec.files.map((f) => `app/${f}`)]) {
|
|
1460
|
-
await sandbox.files.write(`/input/${file}`, new Uint8Array(await
|
|
2217
|
+
await sandbox.files.write(`/input/${file}`, new Uint8Array(await readFile4(join5(stage, file))).buffer, { user: "root" });
|
|
1461
2218
|
}
|
|
1462
2219
|
await sandbox.commands.run("chmod -R a+rX /input", { user: "root" });
|
|
1463
2220
|
const runtimeEnv = {};
|
|
1464
2221
|
if (spec.postgres) {
|
|
1465
|
-
const password =
|
|
2222
|
+
const password = randomBytes5(24).toString("hex");
|
|
1466
2223
|
secrets.push(password);
|
|
1467
2224
|
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" });
|
|
1468
2225
|
await sandbox.files.write("/tmp/agenttwin-init.sql", `CREATE ROLE agenttwin LOGIN PASSWORD '${password}';
|
|
@@ -1476,7 +2233,7 @@ CREATE DATABASE agenttwin OWNER agenttwin;
|
|
|
1476
2233
|
}
|
|
1477
2234
|
}
|
|
1478
2235
|
if (spec.redis) {
|
|
1479
|
-
const password =
|
|
2236
|
+
const password = randomBytes5(24).toString("hex");
|
|
1480
2237
|
secrets.push(password);
|
|
1481
2238
|
await sandbox.files.write("/tmp/agenttwin-redis.conf", `bind 127.0.0.1
|
|
1482
2239
|
port 6379
|
|
@@ -1515,70 +2272,131 @@ logfile /tmp/agenttwin-redis.log
|
|
|
1515
2272
|
}
|
|
1516
2273
|
}
|
|
1517
2274
|
|
|
2275
|
+
// src/runtime/scenarios.ts
|
|
2276
|
+
import { createHash as createHash3 } from "crypto";
|
|
2277
|
+
import { mkdir as mkdir2, readFile as readFile5, readdir as readdir2, writeFile as writeFile3 } from "fs/promises";
|
|
2278
|
+
import { join as join6 } from "path";
|
|
2279
|
+
import { isDeepStrictEqual as isDeepStrictEqual4 } from "util";
|
|
2280
|
+
var identifier = /^[a-z][a-z0-9_]{0,39}$/;
|
|
2281
|
+
var object2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
2282
|
+
var sort = (v) => Array.isArray(v) ? v.map(sort) : object2(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort(v[k])])) : v;
|
|
2283
|
+
var scenarioDigest = (scenario) => createHash3("sha256").update(JSON.stringify(sort(scenario))).digest("hex");
|
|
2284
|
+
function parseScenario(value) {
|
|
2285
|
+
const fail = (detail) => {
|
|
2286
|
+
throw new Error(`Invalid scenario: ${detail}`);
|
|
2287
|
+
};
|
|
2288
|
+
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");
|
|
2289
|
+
const scenario = value;
|
|
2290
|
+
parseWorld({
|
|
2291
|
+
schema: "agenttwin/world-v1",
|
|
2292
|
+
name: scenario.id,
|
|
2293
|
+
files: ["app.mjs"],
|
|
2294
|
+
command: ["node", "app.mjs"],
|
|
2295
|
+
timeoutSeconds: 1,
|
|
2296
|
+
services: scenario.services,
|
|
2297
|
+
assertions: [{ service: scenario.services[0]?.name, path: scenario.services[0]?.collections?.[0]?.path, count: 0 }]
|
|
2298
|
+
});
|
|
2299
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2300
|
+
for (const entity of scenario.entities) {
|
|
2301
|
+
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");
|
|
2302
|
+
ids.add(entity.id);
|
|
2303
|
+
const services = /* @__PURE__ */ new Set();
|
|
2304
|
+
for (const ref of entity.refs) {
|
|
2305
|
+
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");
|
|
2306
|
+
const rows = scenario.services.find((s) => s.name === ref.service)?.collections.find((c) => c.path === ref.path)?.seed;
|
|
2307
|
+
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`);
|
|
2308
|
+
services.add(ref.service);
|
|
2309
|
+
}
|
|
2310
|
+
if (services.size < 2) fail(`entity ${entity.id} must link at least two services`);
|
|
2311
|
+
}
|
|
2312
|
+
return structuredClone(scenario);
|
|
2313
|
+
}
|
|
2314
|
+
function reference(ref) {
|
|
2315
|
+
const match = /^([a-z][a-z0-9_]{0,39})@([1-9][0-9]*)$/.exec(ref);
|
|
2316
|
+
if (!match || !Number.isSafeInteger(Number(match[2]))) throw new Error("Scenario reference must be id@version");
|
|
2317
|
+
return { id: match[1], version: Number(match[2]) };
|
|
2318
|
+
}
|
|
2319
|
+
async function loadScenario(cwd, ref) {
|
|
2320
|
+
const { id, version } = reference(ref);
|
|
2321
|
+
const path2 = join6(cwd, ".agenttwin/scenarios", id, `${version}.json`);
|
|
2322
|
+
const bytes = await readFile5(path2);
|
|
2323
|
+
if (bytes.length > 2 * 1024 * 1024) throw new Error("Scenario exceeds 2 MiB");
|
|
2324
|
+
const scenario = parseScenario(JSON.parse(bytes.toString()));
|
|
2325
|
+
if (scenario.id !== id || scenario.version !== version) throw new Error("Scenario identity does not match its stored version");
|
|
2326
|
+
return scenario;
|
|
2327
|
+
}
|
|
2328
|
+
function applyScenario(world, scenario) {
|
|
2329
|
+
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));
|
|
2330
|
+
if (!isDeepStrictEqual4(shape(world.services), shape(scenario.services))) throw new Error("Scenario services, adapters and collections must exactly match the world");
|
|
2331
|
+
return parseWorld({ ...world, services: structuredClone(scenario.services), scenario: { id: scenario.id, version: scenario.version, digest: scenarioDigest(scenario) } });
|
|
2332
|
+
}
|
|
2333
|
+
|
|
1518
2334
|
// src/commands/run.ts
|
|
1519
2335
|
async function stageApplication(root, files, destination) {
|
|
1520
|
-
const canonicalRoot = await
|
|
2336
|
+
const canonicalRoot = await realpath2(root);
|
|
1521
2337
|
const digests = {};
|
|
1522
2338
|
let bytes = 0;
|
|
1523
2339
|
for (const file of files) {
|
|
1524
2340
|
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 });
|
|
1525
|
-
const source = await
|
|
2341
|
+
const source = await realpath2(resolve2(canonicalRoot, file));
|
|
1526
2342
|
const rel = relative2(canonicalRoot, source);
|
|
1527
|
-
if (rel.startsWith(`..${sep2}`) || rel === ".." || isAbsolute(rel) || source !==
|
|
1528
|
-
const contents = await
|
|
2343
|
+
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 });
|
|
2344
|
+
const contents = await readFile10(source);
|
|
1529
2345
|
bytes += contents.length;
|
|
1530
2346
|
if (bytes > 20 * 1024 * 1024) throw new CliError("Application files exceed 20 MiB; supply a smaller built bundle", { code: EXIT.USAGE });
|
|
1531
|
-
const target =
|
|
1532
|
-
await
|
|
1533
|
-
await
|
|
1534
|
-
digests[file] =
|
|
2347
|
+
const target = join11(destination, file);
|
|
2348
|
+
await mkdir5(dirname(target), { recursive: true });
|
|
2349
|
+
await writeFile7(target, contents, { mode: 420 });
|
|
2350
|
+
digests[file] = createHash4("sha256").update(contents).digest("hex");
|
|
1535
2351
|
}
|
|
1536
2352
|
return digests;
|
|
1537
2353
|
}
|
|
1538
2354
|
async function runWorld(cwd, opts) {
|
|
1539
|
-
const file =
|
|
2355
|
+
const file = resolve2(cwd, opts.file ?? "agenttwin.world.json");
|
|
1540
2356
|
let spec;
|
|
1541
2357
|
try {
|
|
1542
|
-
spec = parseWorld(JSON.parse(await
|
|
2358
|
+
spec = parseWorld(JSON.parse(await readFile10(file, "utf8")));
|
|
1543
2359
|
} catch (error) {
|
|
1544
2360
|
throw new CliError(String(error), { code: EXIT.USAGE, action: "Create agenttwin.world.json; run agenttwin example --out ./agenttwin-example for a working example." });
|
|
1545
2361
|
}
|
|
2362
|
+
if (opts.scenario) spec = applyScenario(spec, await loadScenario(cwd, opts.scenario));
|
|
1546
2363
|
if (spec.workflowId) {
|
|
1547
2364
|
let inventory;
|
|
1548
2365
|
try {
|
|
1549
|
-
inventory = JSON.parse(await
|
|
2366
|
+
inventory = JSON.parse(await readFile10(join11(cwd, ".agenttwin/inventory.json"), "utf8"));
|
|
1550
2367
|
} catch {
|
|
1551
2368
|
throw new CliError("Linked workflow needs discovery inventory; run init first", { code: EXIT.BLOCKED });
|
|
1552
2369
|
}
|
|
1553
2370
|
if (!inventory.workflows?.some((w) => w.id === spec.workflowId)) {
|
|
1554
2371
|
const { loadDashboardDiscovery: loadDashboardDiscovery2 } = await Promise.resolve().then(() => (init_session(), session_exports));
|
|
1555
|
-
const
|
|
1556
|
-
if (!
|
|
2372
|
+
const discovery2 = await loadDashboardDiscovery2(cwd, inventory);
|
|
2373
|
+
if (!discovery2.workflows.some((w) => w.id === spec.workflowId)) throw new CliError("workflowId is absent from the current inventory", { code: EXIT.BLOCKED });
|
|
1557
2374
|
}
|
|
1558
2375
|
}
|
|
1559
2376
|
const runtime = opts.runtime ?? "local";
|
|
1560
2377
|
if (!["local", "e2b", "hosted"].includes(runtime)) throw new CliError("runtime must be local, hosted or e2b", { code: EXIT.USAGE });
|
|
1561
2378
|
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." });
|
|
1562
|
-
const stage = await
|
|
2379
|
+
const stage = await mkdtemp2(join11(tmpdir2(), "agenttwin-world-"));
|
|
1563
2380
|
try {
|
|
1564
|
-
const sourceDigests = await stageApplication(dirname(file), spec.files,
|
|
1565
|
-
await
|
|
2381
|
+
const sourceDigests = await stageApplication(dirname(file), spec.files, join11(stage, "app"));
|
|
2382
|
+
await writeFile7(join11(stage, "world.json"), JSON.stringify(spec), { mode: 420 });
|
|
1566
2383
|
const git = await captureGit(cwd);
|
|
1567
2384
|
let project;
|
|
1568
2385
|
try {
|
|
1569
|
-
project = JSON.parse(await
|
|
2386
|
+
project = JSON.parse(await readFile10(join11(cwd, ".agenttwin/inventory.json"), "utf8")).name ?? basename2(cwd);
|
|
1570
2387
|
} catch {
|
|
1571
2388
|
}
|
|
1572
2389
|
if (runtime === "local") {
|
|
1573
2390
|
opts.log("Testing through the CLI with in-process service twins. No cloud VM or containers.");
|
|
1574
|
-
const report2 = await executeLocal(
|
|
2391
|
+
const report2 = await executeLocal(join11(stage, "app"), spec, opts.signal);
|
|
1575
2392
|
const runId2 = randomUUID();
|
|
1576
|
-
const receipt2 = { ...report2, runId: runId2, runtime, project, git, createdAt: (/* @__PURE__ */ new Date()).toISOString(), sourceDigests, specDigest:
|
|
1577
|
-
const out2 =
|
|
1578
|
-
await
|
|
1579
|
-
const evidencePath =
|
|
1580
|
-
await
|
|
1581
|
-
await
|
|
2393
|
+
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" };
|
|
2394
|
+
const out2 = resolve2(cwd, opts.out ?? `.agenttwin/runs/${runId2}`);
|
|
2395
|
+
await mkdir5(out2, { recursive: true });
|
|
2396
|
+
const evidencePath = join11(out2, "run.json"), reportPath = join11(out2, "report.md");
|
|
2397
|
+
await writeFile7(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
|
|
2398
|
+
await writeFile7(join11(out2, "world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
|
|
2399
|
+
await writeFile7(reportPath, `# ${spec.name}: ${receipt2.status}
|
|
1582
2400
|
|
|
1583
2401
|
Runner: CLI
|
|
1584
2402
|
Git: ${git?.head ?? "unavailable"}
|
|
@@ -1586,20 +2404,20 @@ Git: ${git?.head ?? "unavailable"}
|
|
|
1586
2404
|
` + 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");
|
|
1587
2405
|
return { ...receipt2, reportPath, evidencePath };
|
|
1588
2406
|
}
|
|
1589
|
-
const worker =
|
|
1590
|
-
await copyFile(worker,
|
|
2407
|
+
const worker = join11(dirname(fileURLToPath2(import.meta.url)), "worker.js");
|
|
2408
|
+
await copyFile(worker, join11(stage, "worker.js"));
|
|
1591
2409
|
if (runtime === "hosted") {
|
|
1592
2410
|
const receipt2 = await executeRemote(stage, spec, opts.log, { git, project });
|
|
1593
2411
|
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");
|
|
1594
|
-
const expectedSpec =
|
|
2412
|
+
const expectedSpec = createHash4("sha256").update(JSON.stringify(spec)).digest("hex");
|
|
1595
2413
|
const reportedSources = receipt2.sourceDigests;
|
|
1596
|
-
if (receipt2.specDigest !== expectedSpec || !reportedSources || Object.keys(reportedSources).length !== Object.keys(sourceDigests).length || Object.entries(sourceDigests).some(([
|
|
1597
|
-
const out2 =
|
|
1598
|
-
await
|
|
1599
|
-
const evidencePath =
|
|
1600
|
-
const reportPath =
|
|
1601
|
-
await
|
|
1602
|
-
await
|
|
2414
|
+
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");
|
|
2415
|
+
const out2 = resolve2(cwd, opts.out ?? `.agenttwin/runs/${receipt2.runId}`);
|
|
2416
|
+
await mkdir5(out2, { recursive: true });
|
|
2417
|
+
const evidencePath = join11(out2, "run.json");
|
|
2418
|
+
const reportPath = join11(out2, "report.md");
|
|
2419
|
+
await writeFile7(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
|
|
2420
|
+
await writeFile7(reportPath, `# ${spec.name}: ${receipt2.status}
|
|
1603
2421
|
|
|
1604
2422
|
Run: ${receipt2.runId}
|
|
1605
2423
|
|
|
@@ -1629,26 +2447,26 @@ See run.json for assertions, service state, application output and isolation met
|
|
|
1629
2447
|
image,
|
|
1630
2448
|
templateBuildId,
|
|
1631
2449
|
sourceDigests,
|
|
1632
|
-
specDigest:
|
|
2450
|
+
specDigest: createHash4("sha256").update(JSON.stringify(spec)).digest("hex"),
|
|
1633
2451
|
isolation
|
|
1634
2452
|
};
|
|
1635
|
-
const out =
|
|
1636
|
-
await
|
|
1637
|
-
await
|
|
1638
|
-
await
|
|
2453
|
+
const out = resolve2(cwd, opts.out ?? `.agenttwin/runs/${runId}`);
|
|
2454
|
+
await mkdir5(out, { recursive: true });
|
|
2455
|
+
await writeFile7(join11(out, "run.json"), JSON.stringify(receipt, null, 2) + "\n", { mode: 384 });
|
|
2456
|
+
await writeFile7(join11(out, "world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
|
|
1639
2457
|
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");
|
|
1640
|
-
await
|
|
1641
|
-
return { ...receipt, reportPath:
|
|
2458
|
+
await writeFile7(join11(out, "report.md"), summary);
|
|
2459
|
+
return { ...receipt, reportPath: join11(out, "report.md"), evidencePath: join11(out, "run.json") };
|
|
1642
2460
|
} finally {
|
|
1643
|
-
await
|
|
2461
|
+
await rm3(stage, { recursive: true, force: true });
|
|
1644
2462
|
}
|
|
1645
2463
|
}
|
|
1646
2464
|
|
|
1647
2465
|
// src/runtime/gateway.ts
|
|
1648
|
-
var
|
|
1649
|
-
function uploadPath(
|
|
1650
|
-
if (!
|
|
1651
|
-
return
|
|
2466
|
+
var hash2 = (value) => createHash5("sha256").update(value).digest("hex");
|
|
2467
|
+
function uploadPath(path2) {
|
|
2468
|
+
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");
|
|
2469
|
+
return path2;
|
|
1652
2470
|
}
|
|
1653
2471
|
async function body(req, limit = 28 * 1024 * 1024) {
|
|
1654
2472
|
const chunks = [];
|
|
@@ -1661,30 +2479,44 @@ async function body(req, limit = 28 * 1024 * 1024) {
|
|
|
1661
2479
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1662
2480
|
}
|
|
1663
2481
|
function send(res, code, value) {
|
|
1664
|
-
res.writeHead(code, {
|
|
2482
|
+
res.writeHead(code, {
|
|
2483
|
+
"content-type": "application/json",
|
|
2484
|
+
"cache-control": "no-store",
|
|
2485
|
+
"x-content-type-options": "nosniff",
|
|
2486
|
+
"referrer-policy": "no-referrer",
|
|
2487
|
+
"x-frame-options": "DENY",
|
|
2488
|
+
"content-security-policy": "default-src 'none'; frame-ancestors 'none'"
|
|
2489
|
+
});
|
|
1665
2490
|
res.end(JSON.stringify(value));
|
|
1666
2491
|
}
|
|
1667
2492
|
function equal(a, b) {
|
|
1668
|
-
const x = Buffer.from(
|
|
1669
|
-
return
|
|
2493
|
+
const x = Buffer.from(hash2(a)), y = Buffer.from(hash2(b));
|
|
2494
|
+
return timingSafeEqual4(x, y);
|
|
1670
2495
|
}
|
|
1671
2496
|
async function startGateway(opts) {
|
|
1672
2497
|
if (opts.adminToken.length < 40) throw new Error("AGENTTWIN_ADMIN_TOKEN must have at least 40 characters");
|
|
2498
|
+
const socialAuth = createSocialAuth(opts.socialAuth);
|
|
2499
|
+
const emailVerification = createEmailVerification(opts.emailVerification, opts.adminToken);
|
|
1673
2500
|
const allowedOrigins = new Set(opts.allowedOrigins ?? []);
|
|
1674
2501
|
for (const origin of allowedOrigins) {
|
|
1675
2502
|
const u = new URL(origin);
|
|
1676
2503
|
if (u.origin !== origin || u.protocol !== "https:" && !(u.protocol === "http:" && ["127.0.0.1", "localhost"].includes(u.hostname))) throw new Error("Invalid dashboard origin");
|
|
1677
2504
|
}
|
|
2505
|
+
const verificationUrl = opts.verificationUrl ?? "https://agenttwin.si/onboarding/";
|
|
2506
|
+
if (opts.verificationUrl) {
|
|
2507
|
+
const target = new URL(verificationUrl);
|
|
2508
|
+
if (!allowedOrigins.has(target.origin) || target.pathname !== "/onboarding/" || target.search || target.hash || target.username || target.password) throw new Error("Invalid device verification URL");
|
|
2509
|
+
}
|
|
1678
2510
|
const controllers = /* @__PURE__ */ new Map();
|
|
1679
2511
|
const chatTasks = /* @__PURE__ */ new Set();
|
|
1680
2512
|
const chatControllers = /* @__PURE__ */ new Map();
|
|
1681
2513
|
const voiceControllers = /* @__PURE__ */ new Map();
|
|
1682
|
-
const directory =
|
|
1683
|
-
await
|
|
1684
|
-
const
|
|
2514
|
+
const directory = resolve3(opts.directory);
|
|
2515
|
+
await mkdir6(directory, { recursive: true, mode: 448 });
|
|
2516
|
+
const storePath2 = join12(directory, "state.json");
|
|
1685
2517
|
let store;
|
|
1686
2518
|
try {
|
|
1687
|
-
store = JSON.parse(await
|
|
2519
|
+
store = JSON.parse(await readFile11(storePath2, "utf8"));
|
|
1688
2520
|
if (store.schema !== "agenttwin/gateway-v1") throw new Error("Invalid store");
|
|
1689
2521
|
} catch (error) {
|
|
1690
2522
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -1699,9 +2531,9 @@ async function startGateway(opts) {
|
|
|
1699
2531
|
const save = () => {
|
|
1700
2532
|
const snapshot = JSON.stringify(store);
|
|
1701
2533
|
writes = writes.then(async () => {
|
|
1702
|
-
const
|
|
1703
|
-
await
|
|
1704
|
-
await rename(
|
|
2534
|
+
const path2 = join12(directory, `state-${randomUUID2()}.tmp`);
|
|
2535
|
+
await writeFile8(path2, snapshot, { mode: 384 });
|
|
2536
|
+
await rename(path2, storePath2);
|
|
1705
2537
|
}).catch((error) => {
|
|
1706
2538
|
storageHealthy = false;
|
|
1707
2539
|
throw error;
|
|
@@ -1734,16 +2566,16 @@ async function startGateway(opts) {
|
|
|
1734
2566
|
if (!store.accounts.some((a) => a.id === item.job.accountId && a.active)) throw new Error("Account revoked before execution");
|
|
1735
2567
|
item.job.status = "running";
|
|
1736
2568
|
await save();
|
|
1737
|
-
workspace = await
|
|
2569
|
+
workspace = await mkdtemp3(join12(tmpdir3(), "agenttwin-api-run-"));
|
|
1738
2570
|
for (const [file, encoded] of Object.entries(item.files)) {
|
|
1739
|
-
const destination =
|
|
1740
|
-
await
|
|
1741
|
-
await
|
|
2571
|
+
const destination = join12(workspace, uploadPath(file));
|
|
2572
|
+
await mkdir6(dirname2(destination), { recursive: true });
|
|
2573
|
+
await writeFile8(destination, Buffer.from(encoded, "base64"), { mode: 384 });
|
|
1742
2574
|
}
|
|
1743
|
-
await
|
|
2575
|
+
await writeFile8(join12(workspace, "agenttwin.world.json"), JSON.stringify(item.spec));
|
|
1744
2576
|
if (item.spec.workflowId) {
|
|
1745
|
-
await
|
|
1746
|
-
await
|
|
2577
|
+
await mkdir6(join12(workspace, ".agenttwin"));
|
|
2578
|
+
await writeFile8(join12(workspace, ".agenttwin/inventory.json"), JSON.stringify({ workflows: [{ id: item.spec.workflowId }] }));
|
|
1747
2579
|
}
|
|
1748
2580
|
const receipt = await runWorld(workspace, { runtime: "e2b", signal: controllers.get(item.job.id)?.signal, log: () => {
|
|
1749
2581
|
} });
|
|
@@ -1757,7 +2589,7 @@ async function startGateway(opts) {
|
|
|
1757
2589
|
item.job.error = message.split(process.env.E2B_API_KEY ?? "\0").join("[redacted]").replace(/(?:postgresql|redis):\/\/[^\s]+/g, "[test-connection]").slice(0, 2e3);
|
|
1758
2590
|
} finally {
|
|
1759
2591
|
try {
|
|
1760
|
-
if (workspace) await
|
|
2592
|
+
if (workspace) await rm4(workspace, { recursive: true, force: true });
|
|
1761
2593
|
await save();
|
|
1762
2594
|
} finally {
|
|
1763
2595
|
controllers.delete(item.job.id);
|
|
@@ -1771,24 +2603,29 @@ async function startGateway(opts) {
|
|
|
1771
2603
|
});
|
|
1772
2604
|
}
|
|
1773
2605
|
}
|
|
2606
|
+
const authAttempts = /* @__PURE__ */ new Map();
|
|
2607
|
+
const passkeyChallenges = {};
|
|
2608
|
+
const devices = /* @__PURE__ */ new Map();
|
|
2609
|
+
let hashing = 0;
|
|
2610
|
+
const dummyPassword = randomBytes6(16).toString("hex") + ":" + randomBytes6(64).toString("hex");
|
|
1774
2611
|
const server = createServer2(async (req, res) => {
|
|
1775
2612
|
try {
|
|
1776
2613
|
const requestUrl = new URL(req.url ?? "/", "http://localhost");
|
|
1777
|
-
const
|
|
1778
|
-
if (opts.dashboardDirectory && req.method === "GET" && (
|
|
1779
|
-
if (
|
|
2614
|
+
const path2 = requestUrl.pathname;
|
|
2615
|
+
if (opts.dashboardDirectory && req.method === "GET" && (path2 === "/dashboard" || path2.startsWith("/dashboard/"))) {
|
|
2616
|
+
if (path2 === "/dashboard") {
|
|
1780
2617
|
res.writeHead(302, { location: "/dashboard/" });
|
|
1781
2618
|
res.end();
|
|
1782
2619
|
return;
|
|
1783
2620
|
}
|
|
1784
|
-
const relative3 = decodeURIComponent(
|
|
1785
|
-
const root =
|
|
2621
|
+
const relative3 = decodeURIComponent(path2.slice("/dashboard/".length)) || "index.html";
|
|
2622
|
+
const root = resolve3(opts.dashboardDirectory), file = resolve3(root, relative3);
|
|
1786
2623
|
if (!file.startsWith(root + "/") || relative3 !== "index.html" && !relative3.startsWith("assets/")) {
|
|
1787
2624
|
send(res, 404, { error: "not_found" });
|
|
1788
2625
|
return;
|
|
1789
2626
|
}
|
|
1790
2627
|
try {
|
|
1791
|
-
const bytes = await
|
|
2628
|
+
const bytes = await readFile11(file);
|
|
1792
2629
|
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" };
|
|
1793
2630
|
res.writeHead(200, { "content-type": types[extname(file)] ?? "application/octet-stream", "cache-control": "no-store", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer" });
|
|
1794
2631
|
res.end(bytes);
|
|
@@ -1805,6 +2642,7 @@ async function startGateway(opts) {
|
|
|
1805
2642
|
}
|
|
1806
2643
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
1807
2644
|
res.setHeader("Vary", "Origin");
|
|
2645
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
1808
2646
|
if (req.method === "OPTIONS") {
|
|
1809
2647
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
1810
2648
|
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
@@ -1814,7 +2652,7 @@ async function startGateway(opts) {
|
|
|
1814
2652
|
return;
|
|
1815
2653
|
}
|
|
1816
2654
|
}
|
|
1817
|
-
if (
|
|
2655
|
+
if (path2 === "/healthz" && req.method === "GET") {
|
|
1818
2656
|
send(res, 200, { status: stopping ? "stopping" : "ok" });
|
|
1819
2657
|
return;
|
|
1820
2658
|
}
|
|
@@ -1823,8 +2661,402 @@ async function startGateway(opts) {
|
|
|
1823
2661
|
return;
|
|
1824
2662
|
}
|
|
1825
2663
|
const token = req.headers.authorization?.replace(/^Bearer /, "") ?? "";
|
|
2664
|
+
const cookieToken = req.headers.cookie?.split(";").map((v) => v.trim()).find((v) => v.startsWith("at_session="))?.slice(11) ?? "";
|
|
2665
|
+
const session = store.sessions?.find((s) => s.hash === digest(cookieToken) && s.expires > Date.now());
|
|
2666
|
+
if (cookieToken && !token && !["GET", "HEAD", "OPTIONS"].includes(req.method ?? "") && (!origin || !allowedOrigins.has(origin))) {
|
|
2667
|
+
send(res, 403, { error: "origin_required" });
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
const setCookie = (value, age) => {
|
|
2671
|
+
const local = opts.host === "127.0.0.1" || opts.host === "localhost";
|
|
2672
|
+
res.setHeader("Set-Cookie", `at_session=${value}; Path=/; HttpOnly; Max-Age=${age}; SameSite=${local ? "Lax" : "None"}${local ? "" : "; Secure"}`);
|
|
2673
|
+
};
|
|
2674
|
+
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") });
|
|
2675
|
+
if (path2 === "/v1/dev/workspace" && req.method === "GET" && opts.localDevelopment && ["127.0.0.1", "localhost"].includes(opts.host ?? "") && verificationUrl === "http://localhost:3000/onboarding/") {
|
|
2676
|
+
send(res, 200, { schema: "agenttwin/local-workspace-v1", dashboard: verificationUrl });
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
if (path2 === "/v1/auth/providers" && req.method === "GET") {
|
|
2680
|
+
send(res, 200, { providers: socialAuth.providers(), emailVerification: emailVerification.enabled });
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
const emailCookie = req.headers.cookie?.split(";").map((value) => value.trim()).find((value) => value.startsWith("at_email="))?.slice(9) ?? "";
|
|
2684
|
+
const [emailChallengeId, emailBinding] = emailCookie.split(".");
|
|
2685
|
+
const emailGrant = store.emailChallenges?.[emailChallengeId ?? ""];
|
|
2686
|
+
const setEmailCookie = (value, age) => {
|
|
2687
|
+
const local = opts.host === "127.0.0.1" || opts.host === "localhost";
|
|
2688
|
+
return `at_email=${value}; Path=/v1/auth/; HttpOnly; Max-Age=${age}; SameSite=${local ? "Lax" : "None"}${local ? "" : "; Secure"}`;
|
|
2689
|
+
};
|
|
2690
|
+
if (path2 === "/v1/auth/email/start" && req.method === "POST") {
|
|
2691
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2692
|
+
send(res, 403, { error: "origin_required" });
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2695
|
+
if (!emailVerification.enabled) {
|
|
2696
|
+
send(res, 503, { error: "Email verification is not available yet. Please use Google sign-in." });
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
let email;
|
|
2700
|
+
try {
|
|
2701
|
+
email = normalizedEmail((await body(req, 8192)).email);
|
|
2702
|
+
} catch {
|
|
2703
|
+
send(res, 400, { error: "Enter a valid email address." });
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
store.emailChallenges ??= {};
|
|
2707
|
+
for (const [id, challenge] of Object.entries(store.emailChallenges)) if (challenge.expires <= Date.now()) delete store.emailChallenges[id];
|
|
2708
|
+
for (const [key, limit] of authAttempts) if (limit.until <= Date.now()) authAttempts.delete(key);
|
|
2709
|
+
const keys = ["email-send:" + digest(email), "email-ip:" + digest(req.socket.remoteAddress ?? "unknown")];
|
|
2710
|
+
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)) {
|
|
2711
|
+
send(res, 429, { error: "Please wait before requesting another code." });
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
for (const key of keys) {
|
|
2715
|
+
const limit = authAttempts.get(key) ?? { count: 0, until: Date.now() + 15 * 6e4 };
|
|
2716
|
+
limit.count++;
|
|
2717
|
+
authAttempts.set(key, limit);
|
|
2718
|
+
}
|
|
2719
|
+
const flow = emailVerification.challenge(email, origin);
|
|
2720
|
+
for (const [id, challenge] of Object.entries(store.emailChallenges)) if (challenge.email === email) delete store.emailChallenges[id];
|
|
2721
|
+
store.emailChallenges[flow.id] = flow.challenge;
|
|
2722
|
+
await save();
|
|
2723
|
+
try {
|
|
2724
|
+
await emailVerification.deliver(email, flow.code, flow.id);
|
|
2725
|
+
} catch {
|
|
2726
|
+
delete store.emailChallenges[flow.id];
|
|
2727
|
+
await save();
|
|
2728
|
+
send(res, 503, { error: "Could not send your verification code. Please try again shortly." });
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
res.setHeader("Set-Cookie", setEmailCookie(flow.id + "." + flow.binding, 600));
|
|
2732
|
+
send(res, 200, { sent: true, expiresIn: 600, retryAfter: 60 });
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
if (path2 === "/v1/auth/email/verify" && req.method === "POST") {
|
|
2736
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2737
|
+
send(res, 403, { error: "origin_required" });
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
if (!emailVerification.enabled || !emailVerification.bound(emailGrant, emailBinding ?? "", origin)) {
|
|
2741
|
+
send(res, 400, { error: "Verification expired. Request a new code." });
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
const input = await body(req, 8192);
|
|
2745
|
+
const valid = emailVerification.verify(emailChallengeId, emailGrant, input.code);
|
|
2746
|
+
await save();
|
|
2747
|
+
if (!valid) {
|
|
2748
|
+
send(res, 400, { error: "The code is incorrect or expired. Request a new code after five unsuccessful attempts." });
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
send(res, 200, { verified: true });
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
const oauthStart = path2.match(/^\/v1\/auth\/oauth\/(google|github|sso)\/start$/);
|
|
2755
|
+
if (oauthStart && req.method === "POST") {
|
|
2756
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2757
|
+
send(res, 403, { error: "origin_required" });
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2760
|
+
const key = "oauth:" + (req.socket.remoteAddress ?? "unknown");
|
|
2761
|
+
for (const [k, value] of authAttempts) if (value.until < Date.now()) authAttempts.delete(k);
|
|
2762
|
+
const limit = authAttempts.get(key) ?? { count: 0, until: Date.now() + 6e5 };
|
|
2763
|
+
if (limit.count >= 20 || authAttempts.size >= 1e4) {
|
|
2764
|
+
send(res, 429, { error: "Too many attempts. Try again shortly." });
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
limit.count++;
|
|
2768
|
+
authAttempts.set(key, limit);
|
|
2769
|
+
const input = await body(req, 8192);
|
|
2770
|
+
try {
|
|
2771
|
+
const result = await socialAuth.begin(oauthStart[1], origin, input.signup === true, input.onboarding === true);
|
|
2772
|
+
res.setHeader("Set-Cookie", `at_oauth=${result.binding}; Path=/v1/auth/oauth/; HttpOnly; Max-Age=600; SameSite=Lax${result.secure ? "; Secure" : ""}`);
|
|
2773
|
+
send(res, 200, { url: result.url });
|
|
2774
|
+
} catch (error) {
|
|
2775
|
+
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." });
|
|
2776
|
+
}
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2779
|
+
const oauthCallback = path2.match(/^\/v1\/auth\/oauth\/(google|github|sso)\/callback$/);
|
|
2780
|
+
if (oauthCallback && req.method === "GET") {
|
|
2781
|
+
const binding = req.headers.cookie?.split(";").map((v) => v.trim()).find((v) => v.startsWith("at_oauth="))?.slice(9) ?? "";
|
|
2782
|
+
let transaction;
|
|
2783
|
+
try {
|
|
2784
|
+
transaction = socialAuth.take(oauthCallback[1], requestUrl.searchParams, binding);
|
|
2785
|
+
} catch {
|
|
2786
|
+
send(res, 400, { error: "Sign-in expired or belongs to another browser. Start again from the login page." });
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
const secure = opts.socialAuth?.publicUrl.startsWith("https:");
|
|
2790
|
+
const clearBinding = `at_oauth=; Path=/v1/auth/oauth/; HttpOnly; Max-Age=0; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
2791
|
+
const redirect = (path3) => {
|
|
2792
|
+
res.writeHead(303, { Location: transaction.origin + path3, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
|
|
2793
|
+
res.end();
|
|
2794
|
+
};
|
|
2795
|
+
res.setHeader("Set-Cookie", clearBinding);
|
|
2796
|
+
try {
|
|
2797
|
+
const identity = await socialAuth.finish(transaction, requestUrl.searchParams);
|
|
2798
|
+
let owner = store.accounts.find((a) => a.externalIdentities?.some((i) => i.provider === identity.provider && i.issuer === identity.issuer && i.subject === identity.subject));
|
|
2799
|
+
let created = false;
|
|
2800
|
+
if (!owner) {
|
|
2801
|
+
if (store.accounts.some((a) => a.identity?.email === identity.email)) {
|
|
2802
|
+
redirect((transaction.signup ? "/signup/" : "/login/") + "?auth_error=existing_account");
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
if (store.accounts.length >= 1e4) throw new Error("account_capacity");
|
|
2806
|
+
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 }] };
|
|
2807
|
+
store.accounts.push(owner);
|
|
2808
|
+
created = true;
|
|
2809
|
+
}
|
|
2810
|
+
if (!owner.active) throw new Error("account_inactive");
|
|
2811
|
+
const secret = randomBytes6(32).toString("hex");
|
|
2812
|
+
store.sessions = (store.sessions ?? []).filter((s) => s.expires > Date.now());
|
|
2813
|
+
const owned = store.sessions.filter((s) => s.accountId === owner.id);
|
|
2814
|
+
if (owned.length >= 10) store.sessions = store.sessions.filter((s) => s !== owned[0]);
|
|
2815
|
+
store.sessions.push({ accountId: owner.id, hash: digest(secret), expires: Date.now() + 7 * 864e5, authenticatedAt: Date.now(), method: identity.provider });
|
|
2816
|
+
await save();
|
|
2817
|
+
setCookie(secret, 7 * 86400);
|
|
2818
|
+
res.setHeader("Set-Cookie", [String(res.getHeader("Set-Cookie")), clearBinding]);
|
|
2819
|
+
redirect(created || transaction.signup || transaction.onboarding ? "/onboarding/" : "/dashboard/#home");
|
|
2820
|
+
} catch (error) {
|
|
2821
|
+
const reason = error instanceof Error && error.message === "verified_email_required" ? "verified_email" : "provider_failed";
|
|
2822
|
+
redirect((transaction.signup ? "/signup/" : "/login/") + "?auth_error=" + reason);
|
|
2823
|
+
}
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
if (path2 === "/v1/auth/device" && req.method === "POST") {
|
|
2827
|
+
for (const [k, v] of devices) if (v.expires < Date.now()) devices.delete(k);
|
|
2828
|
+
if (devices.size >= 100) {
|
|
2829
|
+
send(res, 429, { error: "Try again shortly." });
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
const secret = randomBytes6(32).toString("hex"), code = randomBytes6(5).toString("hex").toUpperCase();
|
|
2833
|
+
devices.set(digest(secret), { code, expires: Date.now() + 6e5, lastPoll: 0 });
|
|
2834
|
+
send(res, 200, { deviceCode: secret, userCode: code, verificationUrl, expiresIn: 600 });
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
if (path2 === "/v1/auth/passkey/options" && req.method === "POST") {
|
|
2838
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2839
|
+
send(res, 403, { error: "origin_required" });
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
if (!session) {
|
|
2843
|
+
send(res, 401, { error: "unauthorized" });
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
const owner = store.accounts.find((a) => a.id === session.accountId && a.active);
|
|
2847
|
+
if (!owner) {
|
|
2848
|
+
send(res, 401, { error: "unauthorized" });
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
const rpID = requestUrl.hostname === "localhost" || requestUrl.hostname === "127.0.0.1" ? "localhost" : new URL(origin).hostname;
|
|
2852
|
+
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 })) });
|
|
2853
|
+
const challengeId = randomBytes6(16).toString("hex");
|
|
2854
|
+
challengeStore(passkeyChallenges, challengeId, options.challenge, 12e4);
|
|
2855
|
+
send(res, 200, { options, challengeId });
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
if (path2 === "/v1/auth/passkey/register" && req.method === "POST") {
|
|
2859
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2860
|
+
send(res, 403, { error: "origin_required" });
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
if (!session) {
|
|
2864
|
+
send(res, 401, { error: "unauthorized" });
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
const owner = store.accounts.find((a) => a.id === session.accountId && a.active);
|
|
2868
|
+
if (!owner) {
|
|
2869
|
+
send(res, 401, { error: "unauthorized" });
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
const input = await body(req, 65536);
|
|
2873
|
+
const stored = passkeyChallenges[String(input.challengeId)];
|
|
2874
|
+
if (!stored) {
|
|
2875
|
+
send(res, 400, { error: "Registration expired. Try again." });
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
delete passkeyChallenges[String(input.challengeId)];
|
|
2879
|
+
const rpID = requestUrl.hostname === "localhost" || requestUrl.hostname === "127.0.0.1" ? "localhost" : new URL(origin).hostname;
|
|
2880
|
+
const verification = await verifyRegistrationResponse({ response: input.credential, expectedChallenge: stored.value, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false });
|
|
2881
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
2882
|
+
send(res, 400, { error: "Passkey could not be verified. Try again." });
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
const { credential } = verification.registrationInfo;
|
|
2886
|
+
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);
|
|
2887
|
+
await save();
|
|
2888
|
+
send(res, 200, { registered: true });
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2891
|
+
if (path2 === "/v1/auth/passkey/auth/options" && req.method === "POST") {
|
|
2892
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2893
|
+
send(res, 403, { error: "origin_required" });
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
const input = await body(req, 8192);
|
|
2897
|
+
const credentialId = typeof input.credentialId === "string" ? input.credentialId.slice(0, 1024) : "";
|
|
2898
|
+
const owner = credentialId ? store.accounts.find((a) => a.active && (a.passkeys ?? []).some((k) => k.id === credentialId)) : void 0;
|
|
2899
|
+
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" });
|
|
2900
|
+
const challengeId = randomBytes6(16).toString("hex");
|
|
2901
|
+
challengeStore(passkeyChallenges, challengeId, options.challenge, 12e4);
|
|
2902
|
+
send(res, 200, { options, challengeId });
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
if (path2 === "/v1/auth/passkey/auth" && req.method === "POST") {
|
|
2906
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2907
|
+
send(res, 403, { error: "origin_required" });
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
const input = await body(req, 65536);
|
|
2911
|
+
const stored = passkeyChallenges[String(input.challengeId)];
|
|
2912
|
+
if (!stored) {
|
|
2913
|
+
send(res, 400, { error: "Login expired. Try again." });
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
delete passkeyChallenges[String(input.challengeId)];
|
|
2917
|
+
const credentialId = String(input.credential?.id ?? "");
|
|
2918
|
+
const owner = store.accounts.find((a) => a.active && (a.passkeys ?? []).some((k) => k.id === credentialId));
|
|
2919
|
+
if (!owner) {
|
|
2920
|
+
send(res, 401, { error: "No account matches this passkey. Sign in with email first." });
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
const key = owner.passkeys.find((k) => k.id === credentialId);
|
|
2924
|
+
const rpID = requestUrl.hostname === "localhost" || requestUrl.hostname === "127.0.0.1" ? "localhost" : new URL(origin).hostname;
|
|
2925
|
+
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 });
|
|
2926
|
+
if (!verification.verified) {
|
|
2927
|
+
send(res, 401, { error: "Passkey verification failed." });
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
key.counter = verification.authenticationInfo.newCounter;
|
|
2931
|
+
const secret = randomBytes6(32).toString("hex");
|
|
2932
|
+
store.sessions = (store.sessions ?? []).filter((s) => s.expires > Date.now());
|
|
2933
|
+
const owned = store.sessions.filter((s) => s.accountId === owner.id);
|
|
2934
|
+
if (owned.length >= 10) store.sessions = store.sessions.filter((s) => s !== owned[0]);
|
|
2935
|
+
store.sessions.push({ accountId: owner.id, hash: digest(secret), expires: Date.now() + 7 * 864e5 });
|
|
2936
|
+
await save();
|
|
2937
|
+
setCookie(secret, 7 * 86400);
|
|
2938
|
+
send(res, 200, { user: profile(owner) });
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
if (path2 === "/v1/auth/device/poll" && req.method === "POST") {
|
|
2942
|
+
const input = await body(req, 8192);
|
|
2943
|
+
const device = typeof input.deviceCode === "string" ? devices.get(digest(input.deviceCode)) : void 0;
|
|
2944
|
+
if (!device || device.expires < Date.now()) {
|
|
2945
|
+
send(res, 410, { error: "Pairing expired. Run agenttwin login again." });
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
if (Date.now() - device.lastPoll < 4e3) {
|
|
2949
|
+
send(res, 429, { error: "slow_down" });
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
device.lastPoll = Date.now();
|
|
2953
|
+
if (!storageHealthy) {
|
|
2954
|
+
send(res, 503, { error: "storage_unavailable" });
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
if (!device.token) {
|
|
2958
|
+
send(res, 202, { pending: true });
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
const owner = store.accounts.find((a) => a.id === device.accountId && a.active);
|
|
2962
|
+
if (!owner) {
|
|
2963
|
+
send(res, 401, { error: "unauthorized" });
|
|
2964
|
+
return;
|
|
2965
|
+
}
|
|
2966
|
+
devices.delete(digest(input.deviceCode));
|
|
2967
|
+
send(res, 200, { token: device.token, workspace: owner.label });
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
if (["/v1/auth/signup", "/v1/auth/login"].includes(path2) && req.method === "POST") {
|
|
2971
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
2972
|
+
send(res, 403, { error: "origin_required" });
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
const key = req.socket.remoteAddress ?? "unknown";
|
|
2976
|
+
for (const [k, v] of authAttempts) if (v.until < Date.now()) authAttempts.delete(k);
|
|
2977
|
+
const limit = authAttempts.get(key) ?? { count: 0, until: Date.now() + 15 * 6e4 };
|
|
2978
|
+
if (limit.count >= 120 || hashing >= 2 || authAttempts.size >= 1e4) {
|
|
2979
|
+
send(res, 429, { error: "Too many attempts. Try again in 15 minutes." });
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2982
|
+
limit.count++;
|
|
2983
|
+
authAttempts.set(key, limit);
|
|
2984
|
+
let input, parsed;
|
|
2985
|
+
try {
|
|
2986
|
+
input = await body(req, 8192);
|
|
2987
|
+
parsed = credentials(input);
|
|
2988
|
+
} catch {
|
|
2989
|
+
send(res, 400, { error: "Use a valid email and a password with 12\u2013128 characters." });
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2992
|
+
if (emailVerification.enabled && (!emailGrant?.verified || emailGrant.email !== parsed.email || !emailVerification.bound(emailGrant, emailBinding ?? "", origin))) {
|
|
2993
|
+
send(res, 403, { error: "Verify your email with a one-time code before continuing.", code: "email_verification_required" });
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
const emailKey = "email:" + digest(parsed.email);
|
|
2997
|
+
const emailLimit = authAttempts.get(emailKey) ?? { count: 0, until: Date.now() + 15 * 6e4 };
|
|
2998
|
+
if (emailLimit.count >= 10) {
|
|
2999
|
+
send(res, 429, { error: "Too many attempts. Try again in 15 minutes." });
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
emailLimit.count++;
|
|
3003
|
+
authAttempts.set(emailKey, emailLimit);
|
|
3004
|
+
hashing++;
|
|
3005
|
+
try {
|
|
3006
|
+
let a = store.accounts.find((a2) => a2.identity?.email === parsed.email);
|
|
3007
|
+
if (path2.endsWith("/signup")) {
|
|
3008
|
+
const name = typeof input.name === "string" ? input.name.trim() : "";
|
|
3009
|
+
const workspace = typeof input.workspace === "string" ? input.workspace.trim() : "";
|
|
3010
|
+
if (!name || name.length > 100 || !workspace || workspace.length > 100) {
|
|
3011
|
+
send(res, 400, { error: "Enter your name and workspace name." });
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
const encoded = await passwordHash(parsed.password);
|
|
3015
|
+
if (emailVerification.enabled && (store.emailChallenges?.[emailChallengeId] !== emailGrant || !emailVerification.bound(emailGrant, emailBinding ?? "", origin))) {
|
|
3016
|
+
send(res, 403, { error: "Email verification expired. Request a new code." });
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
if (store.accounts.some((a2) => a2.identity?.email === parsed.email)) {
|
|
3020
|
+
send(res, 409, { error: "Unable to create account. Try signing in instead." });
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
if (store.accounts.length >= 1e4) {
|
|
3024
|
+
send(res, 429, { error: "account_capacity" });
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
a = { id: randomUUID2(), tokenHash: hash2(randomBytes6(32).toString("hex")), label: workspace, active: true, usageDay: "", runs: 0, identity: { email: parsed.email, name, passwordHash: encoded } };
|
|
3028
|
+
store.accounts.push(a);
|
|
3029
|
+
} else {
|
|
3030
|
+
const matches2 = await passwordMatches(parsed.password, a?.identity?.passwordHash ?? dummyPassword);
|
|
3031
|
+
if (!a?.active || !a.identity?.passwordHash || !matches2) {
|
|
3032
|
+
send(res, 401, { error: "Email or password is incorrect." });
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
const secret = randomBytes6(32).toString("hex");
|
|
3037
|
+
store.sessions = (store.sessions ?? []).filter((s) => s.expires > Date.now());
|
|
3038
|
+
const owned = store.sessions.filter((s) => s.accountId === a.id);
|
|
3039
|
+
if (owned.length >= 10) store.sessions = store.sessions.filter((s) => s !== owned[0]);
|
|
3040
|
+
if (emailVerification.enabled) {
|
|
3041
|
+
if (store.emailChallenges?.[emailChallengeId] !== emailGrant || !emailVerification.bound(emailGrant, emailBinding ?? "", origin)) {
|
|
3042
|
+
send(res, 403, { error: "Email verification expired. Request a new code." });
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
delete store.emailChallenges[emailChallengeId];
|
|
3046
|
+
a.identity.emailVerified = true;
|
|
3047
|
+
}
|
|
3048
|
+
store.sessions.push({ accountId: a.id, hash: digest(secret), expires: Date.now() + 7 * 864e5 });
|
|
3049
|
+
await save();
|
|
3050
|
+
setCookie(secret, 7 * 86400);
|
|
3051
|
+
if (emailVerification.enabled) res.setHeader("Set-Cookie", [String(res.getHeader("Set-Cookie")), setEmailCookie("", 0)]);
|
|
3052
|
+
send(res, 200, { user: profile(a) });
|
|
3053
|
+
return;
|
|
3054
|
+
} finally {
|
|
3055
|
+
hashing--;
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
1826
3058
|
const admin = equal(token, opts.adminToken);
|
|
1827
|
-
if (
|
|
3059
|
+
if (path2 === "/v1/accounts" && req.method === "POST") {
|
|
1828
3060
|
if (!admin) {
|
|
1829
3061
|
send(res, 401, { error: "unauthorized" });
|
|
1830
3062
|
return;
|
|
@@ -1834,8 +3066,8 @@ async function startGateway(opts) {
|
|
|
1834
3066
|
send(res, 400, { error: "label_required" });
|
|
1835
3067
|
return;
|
|
1836
3068
|
}
|
|
1837
|
-
const accessToken = `at_user_${
|
|
1838
|
-
const account2 = { id: randomUUID2(), tokenHash:
|
|
3069
|
+
const accessToken = `at_user_${randomBytes6(32).toString("hex")}`;
|
|
3070
|
+
const account2 = { id: randomUUID2(), tokenHash: hash2(accessToken), label: data.label, active: true, usageDay: "", runs: 0 };
|
|
1839
3071
|
if (store.accounts.length >= 1e4) {
|
|
1840
3072
|
send(res, 429, { error: "account_capacity" });
|
|
1841
3073
|
return;
|
|
@@ -1845,7 +3077,7 @@ async function startGateway(opts) {
|
|
|
1845
3077
|
send(res, 201, { accountId: account2.id, token: accessToken });
|
|
1846
3078
|
return;
|
|
1847
3079
|
}
|
|
1848
|
-
const revoke =
|
|
3080
|
+
const revoke = path2.match(/^\/v1\/accounts\/([a-f0-9-]{36})$/);
|
|
1849
3081
|
if (revoke && req.method === "DELETE") {
|
|
1850
3082
|
if (!admin) {
|
|
1851
3083
|
send(res, 401, { error: "unauthorized" });
|
|
@@ -1861,21 +3093,140 @@ async function startGateway(opts) {
|
|
|
1861
3093
|
send(res, 200, { revoked: true });
|
|
1862
3094
|
return;
|
|
1863
3095
|
}
|
|
1864
|
-
const account = store.accounts.find((a) => a.active && a.tokenHash ===
|
|
3096
|
+
const account = store.accounts.find((a) => a.active && (token ? a.tokenHash === hash2(token) || a.cliTokens?.includes(hash2(token)) : a.id === session?.accountId));
|
|
1865
3097
|
if (!account) {
|
|
1866
3098
|
send(res, 401, { error: "unauthorized" });
|
|
1867
3099
|
return;
|
|
1868
3100
|
}
|
|
3101
|
+
if (path2 === "/v1/auth/password" && req.method === "POST") {
|
|
3102
|
+
if (token || !session || !account.identity || !origin || !allowedOrigins.has(origin)) {
|
|
3103
|
+
send(res, 403, { error: "browser_session_required" });
|
|
3104
|
+
return;
|
|
3105
|
+
}
|
|
3106
|
+
if (account.identity.passwordHash) {
|
|
3107
|
+
send(res, 409, { error: "A password is already set. Use email and password to sign in." });
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
const recentlyAuthenticated = () => session.method === "google" && typeof session.authenticatedAt === "number" && Date.now() - session.authenticatedAt >= 0 && Date.now() - session.authenticatedAt < 10 * 6e4;
|
|
3111
|
+
if (!recentlyAuthenticated() || !account.externalIdentities?.some((i) => i.provider === "google")) {
|
|
3112
|
+
send(res, 403, { error: "Sign in with Google again, then create your password within 10 minutes.", code: "reauthentication_required" });
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
let parsed;
|
|
3116
|
+
try {
|
|
3117
|
+
const input = await body(req, 8192);
|
|
3118
|
+
parsed = credentials({ email: account.identity.email, password: input.password });
|
|
3119
|
+
} catch {
|
|
3120
|
+
send(res, 400, { error: "Use a password with 12\u2013128 characters." });
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
if (hashing >= 2) {
|
|
3124
|
+
send(res, 429, { error: "Please try again shortly." });
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
hashing++;
|
|
3128
|
+
try {
|
|
3129
|
+
const encoded = await passwordHash(parsed.password);
|
|
3130
|
+
if (account.identity.passwordHash) {
|
|
3131
|
+
send(res, 409, { error: "A password is already set." });
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
3134
|
+
if (!account.active || !store.sessions?.includes(session) || session.expires <= Date.now() || !recentlyAuthenticated()) {
|
|
3135
|
+
send(res, 403, { error: "Sign in with Google again before creating a password.", code: "reauthentication_required" });
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
const previousIdentity = account.identity, previousSessions = store.sessions;
|
|
3139
|
+
const secret = randomBytes6(32).toString("hex");
|
|
3140
|
+
account.identity = { ...account.identity, passwordHash: encoded };
|
|
3141
|
+
store.sessions = [...store.sessions.filter((s) => s.accountId !== account.id), { accountId: account.id, hash: digest(secret), expires: Date.now() + 7 * 864e5 }];
|
|
3142
|
+
try {
|
|
3143
|
+
await save();
|
|
3144
|
+
} catch (error) {
|
|
3145
|
+
account.identity = previousIdentity;
|
|
3146
|
+
store.sessions = previousSessions;
|
|
3147
|
+
throw error;
|
|
3148
|
+
}
|
|
3149
|
+
setCookie(secret, 7 * 86400);
|
|
3150
|
+
send(res, 200, { user: profile(account) });
|
|
3151
|
+
return;
|
|
3152
|
+
} finally {
|
|
3153
|
+
hashing--;
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
if (path2 === "/v1/auth/token/revoke" && req.method === "POST") {
|
|
3157
|
+
if (!token) {
|
|
3158
|
+
send(res, 400, { error: "terminal_token_required" });
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
account.cliTokens = (account.cliTokens ?? []).filter((t) => t !== hash2(token));
|
|
3162
|
+
if (account.tokenHash === hash2(token)) account.tokenHash = hash2(randomBytes6(32).toString("hex"));
|
|
3163
|
+
await save();
|
|
3164
|
+
send(res, 200, { signedOut: true });
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
if (path2 === "/v1/auth/device/approve" && req.method === "POST") {
|
|
3168
|
+
const input = await body(req, 8192);
|
|
3169
|
+
const code = typeof input.userCode === "string" ? input.userCode.trim().toUpperCase() : "";
|
|
3170
|
+
const device = [...devices.values()].find((d) => d.code === code && d.expires > Date.now() && !d.accountId);
|
|
3171
|
+
if (!device) {
|
|
3172
|
+
send(res, 400, { error: "Code not found or expired. Run agenttwin login again." });
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
const accessToken = `at_user_${randomBytes6(32).toString("hex")}`;
|
|
3176
|
+
account.cliTokens = [...(account.cliTokens ?? []).slice(-9), hash2(accessToken)];
|
|
3177
|
+
device.accountId = account.id;
|
|
3178
|
+
await save();
|
|
3179
|
+
device.token = accessToken;
|
|
3180
|
+
send(res, 200, { connected: true, workspace: account.label });
|
|
3181
|
+
return;
|
|
3182
|
+
}
|
|
3183
|
+
if (path2 === "/v1/auth/profile" && req.method === "POST") {
|
|
3184
|
+
if (token || !session || !account.identity) {
|
|
3185
|
+
send(res, 403, { error: "browser_session_required" });
|
|
3186
|
+
return;
|
|
3187
|
+
}
|
|
3188
|
+
const input = await body(req, 8192);
|
|
3189
|
+
let validated;
|
|
3190
|
+
try {
|
|
3191
|
+
validated = validateWorkspaceProfile(input);
|
|
3192
|
+
} catch (error) {
|
|
3193
|
+
send(res, 400, { error: error instanceof Error ? error.message : "Invalid profile." });
|
|
3194
|
+
return;
|
|
3195
|
+
}
|
|
3196
|
+
const previous = { label: account.label, identity: account.identity, workspaceProfile: account.workspaceProfile };
|
|
3197
|
+
account.label = validated.workspace;
|
|
3198
|
+
account.identity = { ...account.identity, name: validated.name };
|
|
3199
|
+
account.workspaceProfile = { ...validated.context, ...validated.complete ? { completedAt: (/* @__PURE__ */ new Date()).toISOString() } : account.workspaceProfile?.completedAt ? { completedAt: account.workspaceProfile.completedAt } : {} };
|
|
3200
|
+
try {
|
|
3201
|
+
await save();
|
|
3202
|
+
} catch (error) {
|
|
3203
|
+
Object.assign(account, previous);
|
|
3204
|
+
throw error;
|
|
3205
|
+
}
|
|
3206
|
+
send(res, 200, { user: profile(account) });
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
if (path2 === "/v1/auth/me" && req.method === "GET") {
|
|
3210
|
+
send(res, 200, { user: profile(account) });
|
|
3211
|
+
return;
|
|
3212
|
+
}
|
|
3213
|
+
if (path2 === "/v1/auth/logout" && req.method === "POST") {
|
|
3214
|
+
store.sessions = (store.sessions ?? []).filter((s) => s !== session);
|
|
3215
|
+
await save();
|
|
3216
|
+
setCookie("", 0);
|
|
3217
|
+
send(res, 200, { signedOut: true });
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
1869
3220
|
const projectInventories = [...store.projectDiscoveries?.[account.id] ?? []];
|
|
1870
3221
|
const latestInventory = store.discoveries?.[account.id];
|
|
1871
3222
|
if (latestInventory && !projectInventories.some((p) => p.name === latestInventory.name)) projectInventories.push(latestInventory);
|
|
1872
3223
|
const requestedProject = requestUrl.searchParams.get("project");
|
|
1873
3224
|
const selectedInventory = requestedProject === null ? latestInventory : projectInventories.find((p) => p.name === requestedProject);
|
|
1874
|
-
if (
|
|
3225
|
+
if (path2 === "/v1/projects" && req.method === "GET") {
|
|
1875
3226
|
send(res, 200, { projects: projectInventories.map((p) => ({ name: p.name, tools: p.tools.length, workflows: p.workflows.length })), latest: latestInventory?.name ?? null });
|
|
1876
3227
|
return;
|
|
1877
3228
|
}
|
|
1878
|
-
if (
|
|
3229
|
+
if (path2 === "/v1/discovery" && req.method === "GET") {
|
|
1879
3230
|
if (requestedProject !== null && !selectedInventory) {
|
|
1880
3231
|
send(res, 404, { error: "project_not_found" });
|
|
1881
3232
|
return;
|
|
@@ -1883,7 +3234,7 @@ async function startGateway(opts) {
|
|
|
1883
3234
|
send(res, 200, { inventory: selectedInventory ?? null });
|
|
1884
3235
|
return;
|
|
1885
3236
|
}
|
|
1886
|
-
if (
|
|
3237
|
+
if (path2 === "/v1/discovery" && req.method === "POST") {
|
|
1887
3238
|
if (stopping) {
|
|
1888
3239
|
send(res, 503, { error: "stopping" });
|
|
1889
3240
|
return;
|
|
@@ -1902,7 +3253,7 @@ async function startGateway(opts) {
|
|
|
1902
3253
|
send(res, 200, { synced: true, tools: inventory.tools.length, workflows: inventory.workflows.length });
|
|
1903
3254
|
return;
|
|
1904
3255
|
}
|
|
1905
|
-
if (
|
|
3256
|
+
if (path2 === "/v1/receipts" && req.method === "POST") {
|
|
1906
3257
|
const receipt = portableLocalReceipt(await body(req));
|
|
1907
3258
|
if (!projectInventories.some((p) => p.name === receipt.project)) {
|
|
1908
3259
|
send(res, 409, { error: "sync_project_before_receipt" });
|
|
@@ -1929,11 +3280,11 @@ async function startGateway(opts) {
|
|
|
1929
3280
|
send(res, 201, { id, synced: true });
|
|
1930
3281
|
return;
|
|
1931
3282
|
}
|
|
1932
|
-
if (
|
|
1933
|
-
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(({ id, status, createdAt
|
|
3283
|
+
if (path2 === "/v1/runs" && req.method === "GET") {
|
|
3284
|
+
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) })) });
|
|
1934
3285
|
return;
|
|
1935
3286
|
}
|
|
1936
|
-
if (
|
|
3287
|
+
if (path2 === "/v1/runs" && req.method === "POST") {
|
|
1937
3288
|
if (stopping) {
|
|
1938
3289
|
send(res, 503, { error: "stopping" });
|
|
1939
3290
|
return;
|
|
@@ -1957,6 +3308,10 @@ async function startGateway(opts) {
|
|
|
1957
3308
|
}
|
|
1958
3309
|
const input = await body(req);
|
|
1959
3310
|
const spec = parseWorld(input.spec);
|
|
3311
|
+
if (spec.browser || spec.background) {
|
|
3312
|
+
send(res, 422, { error: "unsupported_runtime", message: "Browser recipes and background completion checks currently require the local runtime. No cloud job was queued." });
|
|
3313
|
+
return;
|
|
3314
|
+
}
|
|
1960
3315
|
const git = validateGit(input.git);
|
|
1961
3316
|
const project = input.project;
|
|
1962
3317
|
if (project !== void 0 && (typeof project !== "string" || !projectInventories.some((p) => p.name === project))) {
|
|
@@ -1991,7 +3346,7 @@ async function startGateway(opts) {
|
|
|
1991
3346
|
return;
|
|
1992
3347
|
}
|
|
1993
3348
|
store.jobs = store.jobs.filter((j) => Date.parse(j.createdAt) > Date.now() - 30 * 864e5);
|
|
1994
|
-
const job = { id: randomUUID2(), accountId: account.id, status: "queued", createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3349
|
+
const job = { id: randomUUID2(), accountId: account.id, status: "queued", createdAt: (/* @__PURE__ */ new Date()).toISOString(), name: spec.name, workflowId: spec.workflowId, project };
|
|
1995
3350
|
account.runs++;
|
|
1996
3351
|
store.jobs.push(job);
|
|
1997
3352
|
await save();
|
|
@@ -2001,16 +3356,16 @@ async function startGateway(opts) {
|
|
|
2001
3356
|
send(res, 202, { id: job.id, status: job.status });
|
|
2002
3357
|
return;
|
|
2003
3358
|
}
|
|
2004
|
-
if ((
|
|
3359
|
+
if ((path2 === "/v1/chat" || path2 === "/v1/workflows") && requestedProject !== null && !selectedInventory) {
|
|
2005
3360
|
send(res, 404, { error: "project_not_found" });
|
|
2006
3361
|
return;
|
|
2007
3362
|
}
|
|
2008
|
-
if (
|
|
3363
|
+
if (path2 === "/v1/workflows" && req.method === "GET") {
|
|
2009
3364
|
const project = selectedInventory?.name ?? null;
|
|
2010
3365
|
send(res, 200, { project, workflows: (store.workflows ?? []).filter((w) => w.accountId === account.id && w.project === project) });
|
|
2011
3366
|
return;
|
|
2012
3367
|
}
|
|
2013
|
-
if (
|
|
3368
|
+
if (path2 === "/v1/workflows" && req.method === "POST") {
|
|
2014
3369
|
if (stopping) {
|
|
2015
3370
|
send(res, 503, { error: "stopping" });
|
|
2016
3371
|
return;
|
|
@@ -2054,13 +3409,13 @@ async function startGateway(opts) {
|
|
|
2054
3409
|
send(res, 200, { workflow });
|
|
2055
3410
|
return;
|
|
2056
3411
|
}
|
|
2057
|
-
if (
|
|
3412
|
+
if (path2 === "/v1/chat" && req.method === "GET") {
|
|
2058
3413
|
const project = selectedInventory?.name ?? null;
|
|
2059
3414
|
const thread = store.chats?.find((t) => t.accountId === account.id && t.project === project);
|
|
2060
3415
|
send(res, 200, { ...chatConfiguration(), transcription: transcriptionConfiguration(), project, thread: thread ?? null, busy: chatControllers.has(account.id) });
|
|
2061
3416
|
return;
|
|
2062
3417
|
}
|
|
2063
|
-
if (
|
|
3418
|
+
if (path2 === "/v1/transcriptions" && req.method === "POST") {
|
|
2064
3419
|
if (stopping) {
|
|
2065
3420
|
send(res, 503, { error: "stopping" });
|
|
2066
3421
|
return;
|
|
@@ -2116,7 +3471,7 @@ async function startGateway(opts) {
|
|
|
2116
3471
|
}
|
|
2117
3472
|
return;
|
|
2118
3473
|
}
|
|
2119
|
-
if (
|
|
3474
|
+
if (path2 === "/v1/chat" && req.method === "POST") {
|
|
2120
3475
|
if (stopping) {
|
|
2121
3476
|
send(res, 503, { error: "stopping" });
|
|
2122
3477
|
return;
|
|
@@ -2181,7 +3536,7 @@ async function startGateway(opts) {
|
|
|
2181
3536
|
const controller = new AbortController();
|
|
2182
3537
|
chatControllers.set(account.id, controller);
|
|
2183
3538
|
const timeout = setTimeout(() => controller.abort(), 45e3);
|
|
2184
|
-
const context = { project, discoveryDigest: inventory ?
|
|
3539
|
+
const context = { project, discoveryDigest: inventory ? hash2(JSON.stringify(inventory)) : null, jobId: job?.id ?? null };
|
|
2185
3540
|
thread.messages.push({ id: randomUUID2(), role: "user", content: input.content.trim(), requestId: input.requestId, createdAt: (/* @__PURE__ */ new Date()).toISOString(), context });
|
|
2186
3541
|
try {
|
|
2187
3542
|
await save();
|
|
@@ -2216,7 +3571,7 @@ async function startGateway(opts) {
|
|
|
2216
3571
|
}
|
|
2217
3572
|
return;
|
|
2218
3573
|
}
|
|
2219
|
-
const match =
|
|
3574
|
+
const match = path2.match(/^\/v1\/runs\/([a-f0-9-]{36})$/);
|
|
2220
3575
|
if (match && req.method === "DELETE") {
|
|
2221
3576
|
const job = store.jobs.find((j) => j.id === match[1] && j.accountId === account.id);
|
|
2222
3577
|
if (!job) {
|
|
@@ -2260,6 +3615,7 @@ async function startGateway(opts) {
|
|
|
2260
3615
|
});
|
|
2261
3616
|
server.requestTimeout = 3e4;
|
|
2262
3617
|
server.headersTimeout = 1e4;
|
|
3618
|
+
server.maxHeadersCount = 64;
|
|
2263
3619
|
await new Promise((done, reject) => {
|
|
2264
3620
|
server.once("error", reject);
|
|
2265
3621
|
server.listen(opts.port, opts.host ?? "127.0.0.1", done);
|
|
@@ -2278,7 +3634,7 @@ async function startGateway(opts) {
|
|
|
2278
3634
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
2279
3635
|
var port = Number(process.env.PORT ?? 8080);
|
|
2280
3636
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid PORT");
|
|
2281
|
-
var gateway = await startGateway({ directory: process.env.AGENTTWIN_STATE_DIR ?? "/data/agenttwin", adminToken: process.env.AGENTTWIN_ADMIN_TOKEN ?? "", host: "0.0.0.0", dashboardDirectory: fileURLToPath3(new URL("./dashboard/", import.meta.url)), allowedOrigins: (process.env.AGENTTWIN_DASHBOARD_ORIGINS ?? "").split(",").map((origin) => origin.trim()).filter(Boolean), port });
|
|
3637
|
+
var gateway = await startGateway({ emailVerification: emailVerificationFromEnv(process.env), socialAuth: socialAuthFromEnv(process.env), directory: process.env.AGENTTWIN_STATE_DIR ?? "/data/agenttwin", adminToken: process.env.AGENTTWIN_ADMIN_TOKEN ?? "", host: "0.0.0.0", dashboardDirectory: fileURLToPath3(new URL("./dashboard/", import.meta.url)), allowedOrigins: (process.env.AGENTTWIN_DASHBOARD_ORIGINS ?? "").split(",").map((origin) => origin.trim()).filter(Boolean), port });
|
|
2282
3638
|
process.stdout.write(`AgentTwin API listening on port ${port}
|
|
2283
3639
|
`);
|
|
2284
3640
|
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
|