agenttwin 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +17 -6
  2. package/dist/api.js +1515 -217
  3. package/dist/cli.js +2757 -642
  4. package/dist/dashboard/assets/dashboard/agent-home.css +130 -0
  5. package/dist/dashboard/assets/dashboard/golden-gate-portrait.png +0 -0
  6. package/dist/dashboard/assets/next/static/P_sIw5q7daW7e4IJEGveg/_buildManifest.js +1 -0
  7. package/dist/dashboard/assets/next/static/chunks/549-f8859485ba130d9c.js +1 -0
  8. package/dist/dashboard/assets/next/static/chunks/787-e53be69d23ff02f5.js +1 -0
  9. package/dist/dashboard/assets/next/static/chunks/app/_not-found/{page-5dc6a60918e8652d.js → page-95856eb81db7799b.js} +1 -1
  10. package/dist/dashboard/assets/next/static/chunks/app/account/password/page-ff06be670b822cfe.js +1 -0
  11. package/dist/dashboard/assets/next/static/chunks/app/dashboard/layout-64afdd8a8798e3b6.js +1 -0
  12. package/dist/dashboard/assets/next/static/chunks/app/dashboard/page-a11f5eeef34fdff9.js +1 -0
  13. package/dist/dashboard/assets/next/static/chunks/app/demo/{page-760a47a5e4dee43f.js → page-8a71f24127eb1e63.js} +1 -1
  14. package/dist/dashboard/assets/next/static/chunks/app/docs/[[...slug]]/page-df1468fc904db4f7.js +1 -0
  15. package/dist/dashboard/assets/next/static/chunks/app/layout-9da6df58ef40be60.js +1 -0
  16. package/dist/dashboard/assets/next/static/chunks/app/login/page-caa5c579f14ec911.js +1 -0
  17. package/dist/dashboard/assets/next/static/chunks/app/onboarding/page-e58bc195fd4bc31c.js +1 -0
  18. package/dist/dashboard/assets/next/static/chunks/app/page-df1d4cae513d5eed.js +1 -0
  19. package/dist/dashboard/assets/next/static/chunks/app/pricing/page-21130c3524f7e45e.js +1 -0
  20. package/dist/dashboard/assets/next/static/chunks/app/signup/page-caa5c579f14ec911.js +1 -0
  21. package/dist/dashboard/assets/next/static/chunks/app/v2/dashboard/layout-2936df21408f38fd.js +1 -0
  22. package/dist/dashboard/assets/next/static/chunks/app/v2/dashboard/page-04e82fd8f3044022.js +1 -0
  23. package/dist/dashboard/assets/next/static/chunks/app/v2/layout-dc60420b4d88dd63.js +1 -0
  24. package/dist/dashboard/assets/next/static/chunks/app/v2/login/page-d3a27a2684498e13.js +1 -0
  25. package/dist/dashboard/assets/next/static/chunks/app/v2/onboarding/page-b48a6cab88bb7f78.js +1 -0
  26. package/dist/dashboard/assets/next/static/chunks/app/v2/page-ca672c49c2235ce2.js +1 -0
  27. package/dist/dashboard/assets/next/static/chunks/app/v2/signup/page-d3a27a2684498e13.js +1 -0
  28. package/dist/dashboard/assets/next/static/chunks/main-app-def27ec5d496405f.js +1 -0
  29. package/dist/dashboard/assets/next/static/chunks/webpack-29cc60ad58d8aefe.js +1 -0
  30. package/dist/dashboard/assets/next/static/css/0071f19b19d3d0c1.css +1 -0
  31. package/dist/dashboard/assets/next/static/css/138b575a572b2958.css +1 -0
  32. package/dist/dashboard/assets/next/static/css/1f0bd0855bbd1a14.css +1 -0
  33. package/dist/dashboard/assets/next/static/css/311920319a51fc90.css +1 -0
  34. package/dist/dashboard/assets/next/static/css/32d869ddee6d2dc1.css +1 -0
  35. package/dist/dashboard/assets/next/static/css/3f003235629d65a0.css +1 -0
  36. package/dist/dashboard/assets/next/static/css/4af3c8ca0da0f48b.css +1 -0
  37. package/dist/dashboard/assets/next/static/css/4fa51ab33fbc7e1b.css +1 -0
  38. package/dist/dashboard/assets/next/static/css/807e07644631d8e2.css +1 -0
  39. package/dist/dashboard/assets/next/static/css/97d686ac80c4018f.css +1 -0
  40. package/dist/dashboard/assets/next/static/css/98cc7bbbb43b84df.css +1 -0
  41. package/dist/dashboard/assets/next/static/css/c1117da59daa6ba9.css +1 -0
  42. package/dist/dashboard/assets/next/static/css/d99eecb7633d1200.css +1 -0
  43. package/dist/dashboard/assets/next/static/media/golden-gate-portrait.7941528f.png +0 -0
  44. package/dist/dashboard/index.html +1 -1
  45. package/dist/gateway.js +1534 -218
  46. package/dist/worker.js +316 -22
  47. package/dist/world-host.js +1090 -0
  48. package/package.json +6 -1
  49. package/dist/dashboard/assets/next/static/HZwGKKvvx_oO6caYggjFh/_buildManifest.js +0 -1
  50. package/dist/dashboard/assets/next/static/chunks/app/dashboard/layout-74603c2c8eaf0760.js +0 -1
  51. package/dist/dashboard/assets/next/static/chunks/app/dashboard/page-b79ed135f6bac5f7.js +0 -1
  52. package/dist/dashboard/assets/next/static/chunks/app/docs/[[...slug]]/page-80a0c2c82d5b4efd.js +0 -1
  53. package/dist/dashboard/assets/next/static/chunks/app/layout-1a40c59e535513d4.js +0 -1
  54. package/dist/dashboard/assets/next/static/chunks/app/page-de4735052adab906.js +0 -1
  55. package/dist/dashboard/assets/next/static/chunks/app/pricing/page-7703fc31d6dff0f7.js +0 -1
  56. package/dist/dashboard/assets/next/static/chunks/main-app-99295a8cc37e8a47.js +0 -1
  57. package/dist/dashboard/assets/next/static/chunks/webpack-f3b6dd27998e84ce.js +0 -1
  58. package/dist/dashboard/assets/next/static/css/1323d7da89b3fa5e.css +0 -1
  59. package/dist/dashboard/assets/next/static/css/9282330a2fd1ab73.css +0 -1
  60. package/dist/dashboard/assets/next/static/css/96e4f356a1159071.css +0 -1
  61. /package/dist/dashboard/assets/next/static/{HZwGKKvvx_oO6caYggjFh → 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 path = text(v, 500);
65
- if (path.startsWith("/") || path.includes("\\") || /(^|[\/])\.\.([\/#]|$)/.test(path) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(path)) throw new Error("Entrypoint must be relative to the project");
66
- return path;
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 readdir2, readFile as readFile4, stat } from "fs/promises";
96
- import { join as join4, relative, sep } from "path";
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 readdir2(dir, { withFileTypes: true });
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 = join4(dir, entry.name);
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(path) {
247
+ async exists(path2) {
194
248
  try {
195
- await stat(join4(this.cwd, path));
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(path, maxBytes = MAX_BYTES) {
202
- const cached = this.cache.contents.get(path);
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 = join4(this.cwd, path);
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): ${path}`);
263
+ this.cache.limits.push(`File excluded by size limit (${maxBytes} bytes): ${path2}`);
210
264
  return void 0;
211
265
  }
212
- const text = await readFile4(full, "utf8");
213
- this.cache.contents.set(path, text);
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 path = ref.slice(0, idx);
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(path);
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.id && declaration.body.loc) result.push({ name: declaration.id.name, startLine: node.loc.start.line, endLine: declaration.body.loc.end.line });
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 (path) => {
336
- const response = await ctx.fetch(`https://api.stripe.com${path}`, {
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 ${path} -> HTTP ${response.status}`);
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: planned(["refunds", "payouts", "disputes", "webhook ordering"]),
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: planned(NO_TWIN),
641
+ twin: partial("slack"),
533
642
  signatures: {
534
643
  packages: {
535
644
  node: ["@slack/web-api", "@slack/bolt", "@slack/events-api", "@slack/socket-mode"],
@@ -783,17 +892,17 @@ var init_catalog = __esm({
783
892
  });
784
893
 
785
894
  // src/inventory/store.ts
786
- import { mkdir, readFile as readFile5, writeFile } from "fs/promises";
787
- import { join as join5 } from "path";
895
+ import { mkdir as mkdir3, readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
896
+ import { join as join8 } from "path";
788
897
  function storeDir(cwd) {
789
- return join5(cwd, STORE_DIR);
898
+ return join8(cwd, STORE_DIR);
790
899
  }
791
900
  function storePath(cwd, name) {
792
- return join5(storeDir(cwd), name);
901
+ return join8(storeDir(cwd), name);
793
902
  }
794
- async function readJson(path) {
903
+ async function readJson(path2) {
795
904
  try {
796
- return JSON.parse(await readFile5(path, "utf8"));
905
+ return JSON.parse(await readFile7(path2, "utf8"));
797
906
  } catch {
798
907
  return void 0;
799
908
  }
@@ -814,6 +923,10 @@ var init_store = __esm({
814
923
  "cache/",
815
924
  "logs/",
816
925
  "connections/",
926
+ "observations.local.json",
927
+ "explorations/",
928
+ "control.json",
929
+ "runs/",
817
930
  ""
818
931
  ].join("\n");
819
932
  readDecisions = (cwd) => readJson(storePath(cwd, "decisions.json"));
@@ -840,15 +953,15 @@ async function projectProducts(cwd, data, base) {
840
953
  const files = await createFileIndex(cwd);
841
954
  const paths = (await files.walk()).filter((p) => /\.(?:ts|tsx|js|mjs|py)$/.test(p) && !/(?:^|\/)(?:artifacts|tests?|testbed|docs?|fixtures?|examples?|public|assets)(?:\/|$)|\.(?:test|spec)\.|fixtures?\./i.test(p)).slice(0, 2500);
842
955
  const found = /* @__PURE__ */ new Set();
843
- for (const path of paths) {
844
- const source = await files.read(path, 2e5);
956
+ for (const path2 of paths) {
957
+ const source = await files.read(path2, 2e5);
845
958
  if (!source) continue;
846
959
  for (const p of products) if (p.pattern.test(source)) found.add(p.key);
847
960
  }
848
961
  const mapped = workflows.map((w) => {
849
962
  if (!w.tools.includes(google.id)) return w;
850
- const [path, fn] = (w.entrypoint ?? "").split("#");
851
- return { w, path, fn };
963
+ const [path2, fn] = (w.entrypoint ?? "").split("#");
964
+ return { w, path: path2, fn };
852
965
  });
853
966
  const finalWorkflows = [];
854
967
  let unresolved = false;
@@ -857,17 +970,17 @@ async function projectProducts(cwd, data, base) {
857
970
  finalWorkflows.push(value);
858
971
  continue;
859
972
  }
860
- const { w, path, fn } = value;
861
- const source = path ? await files.read(path, 2e5) : void 0;
973
+ const { w, path: path2, fn } = value;
974
+ const source = path2 ? await files.read(path2, 2e5) : void 0;
862
975
  const entry = source && fn ? exportedEntrypoints(source).find((e) => e.name === fn) : void 0;
863
976
  const body2 = source && entry ? source.split("\n").slice(entry.startLine - 1, entry.endLine).join("\n") : fn ? "" : source ?? "";
864
- const matches = products.filter((p) => found.has(p.key) && p.pattern.test(body2)).map((p) => google.id + ":" + p.key);
865
- if (!matches.length) {
977
+ const matches2 = products.filter((p) => found.has(p.key) && p.pattern.test(body2)).map((p) => google.id + ":" + p.key);
978
+ if (!matches2.length) {
866
979
  unresolved = true;
867
980
  finalWorkflows.push(w);
868
981
  continue;
869
982
  }
870
- finalWorkflows.push({ ...w, tools: [...new Set(w.tools.flatMap((id) => id === google.id ? matches : [id]))], steps: w.steps.flatMap((s) => s.tool === google.id ? matches.map((tool) => ({ ...s, tool })) : [s]) });
983
+ finalWorkflows.push({ ...w, tools: [...new Set(w.tools.flatMap((id) => id === google.id ? matches2 : [id]))], steps: w.steps.flatMap((s) => s.tool === google.id ? matches2.map((tool) => ({ ...s, tool })) : [s]) });
871
984
  }
872
985
  const productTools = products.filter((p) => found.has(p.key)).map((p) => ({ ...google, id: google.id + ":" + p.key, title: p.title }));
873
986
  return discoveryTransfer({ ...data, tools: [...tools.filter((t) => t.id !== google.id || unresolved || !productTools.length).map((t) => t.id === google.id ? { ...t, title: "Google API (unspecified)" } : t), ...productTools], workflows: finalWorkflows });
@@ -901,8 +1014,8 @@ var init_sanitize = __esm({
901
1014
  });
902
1015
 
903
1016
  // src/commands/dashboard.ts
904
- import { cp, mkdir as mkdir2, readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
905
- import { basename, join as join6 } from "path";
1017
+ import { cp, mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "fs/promises";
1018
+ import { basename, join as join9 } from "path";
906
1019
  import { fileURLToPath } from "url";
907
1020
  function dashboardInventory(cwd, inventory) {
908
1021
  return JSON.parse(sanitizeOutput(JSON.stringify({
@@ -938,8 +1051,8 @@ __export(session_exports, {
938
1051
  writeSessionTask: () => writeSessionTask
939
1052
  });
940
1053
  import { spawn as spawn2, spawnSync } from "child_process";
941
- import { writeFile as writeFile3, readFile as readFile7 } from "fs/promises";
942
- import { join as join7 } from "path";
1054
+ import { writeFile as writeFile6, readFile as readFile9 } from "fs/promises";
1055
+ import { join as join10 } from "path";
943
1056
  function sessionTask() {
944
1057
  return `# Prepare this repository for AgentTwin
945
1058
 
@@ -964,30 +1077,30 @@ function installedOpenCode() {
964
1077
  return spawnSync("opencode", ["--version"], { stdio: "ignore", timeout: 5e3 }).status === 0;
965
1078
  }
966
1079
  async function writeSessionTask(cwd) {
967
- const path = join7(cwd, ".agenttwin/AGENT-TASK.md");
968
- await writeFile3(path, sessionTask(), "utf8");
969
- return path;
1080
+ const path2 = join10(cwd, ".agenttwin/AGENT-TASK.md");
1081
+ await writeFile6(path2, sessionTask(), "utf8");
1082
+ return path2;
970
1083
  }
971
1084
  async function launchSession(cwd) {
972
1085
  if (!installedOpenCode()) throw new Error("OpenCode is not installed. Open .agenttwin/AGENT-TASK.md with your coding agent, or install OpenCode and retry init --agent opencode.");
973
1086
  const prompt = "Read .agenttwin/AGENT-TASK.md and follow it to discover and prepare this project for AgentTwin. Start by analyzing the repository and presenting workflow findings and missing execution requirements.";
974
- await new Promise((resolve3, reject) => {
1087
+ await new Promise((resolve4, reject) => {
975
1088
  const child = spawn2("opencode", ["--prompt", prompt], { cwd, stdio: "inherit", shell: false });
976
1089
  child.once("error", reject);
977
- child.once("exit", (code, signal) => code === 0 ? resolve3() : reject(new Error(`Coding session ended ${signal || `with exit code ${code}`}. Discovery files remain available.`)));
1090
+ child.once("exit", (code, signal) => code === 0 ? resolve4() : reject(new Error(`Coding session ended ${signal || `with exit code ${code}`}. Discovery files remain available.`)));
978
1091
  });
979
1092
  }
980
1093
  async function loadDashboardDiscovery(cwd, inventory) {
981
1094
  let previousHead;
982
1095
  try {
983
- previousHead = JSON.parse(await readFile7(join7(cwd, ".agenttwin/git.json"), "utf8")).head;
1096
+ previousHead = JSON.parse(await readFile9(join10(cwd, ".agenttwin/git.json"), "utf8")).head;
984
1097
  } catch {
985
1098
  }
986
1099
  const git = await captureGit(cwd, previousHead);
987
1100
  const base = discoveryTransfer({ ...dashboardInventory(cwd, inventory), ...git ? { git } : {} });
988
1101
  let raw;
989
1102
  try {
990
- raw = await readFile7(join7(cwd, ".agenttwin/agent-discovery.json"), "utf8");
1103
+ raw = await readFile9(join10(cwd, ".agenttwin/agent-discovery.json"), "utf8");
991
1104
  } catch (error) {
992
1105
  if (error.code === "ENOENT") return projectProducts(cwd, base, base);
993
1106
  throw error;
@@ -1013,13 +1126,224 @@ var init_session = __esm({
1013
1126
  }
1014
1127
  });
1015
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
+
1016
1339
  // src/runtime/gateway.ts
1017
1340
  init_dashboard_transfer();
1341
+ import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
1018
1342
  import { createServer as createServer2 } from "http";
1019
- import { createHash as createHash2, randomBytes as randomBytes3, randomUUID as randomUUID2, timingSafeEqual } from "crypto";
1020
- import { mkdir as mkdir4, readFile as readFile9, writeFile as writeFile5, rename, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
1021
- import { resolve as resolve2, join as join9, dirname as dirname2, extname } from "path";
1022
- import { tmpdir as tmpdir2 } from "os";
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";
1023
1347
 
1024
1348
  // src/runtime/transcription.ts
1025
1349
  var transcriptionConfiguration = () => ({ available: Boolean(process.env.OPENAI_API_KEY), model: "gpt-4o-mini-transcribe" });
@@ -1095,7 +1419,7 @@ function parseWorld(value) {
1095
1419
  if (!object(service) || typeof service.name !== "string" || !namePattern.test(service.name) || services.has(service.name)) invalid("service names must be unique lowercase identifiers");
1096
1420
  const item = service;
1097
1421
  if (!Array.isArray(item.collections) || !item.collections.length || item.collections.length > 20) invalid("each service needs 1\u201320 collections");
1098
- if (item.adapter !== void 0 && !["json-collection", "slack"].includes(String(item.adapter))) invalid("unsupported service adapter");
1422
+ if (item.adapter !== void 0 && !["json-collection", "slack", "stripe"].includes(String(item.adapter))) invalid("unsupported service adapter");
1099
1423
  const paths = /* @__PURE__ */ new Set();
1100
1424
  for (const entry of item.collections) {
1101
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");
@@ -1104,6 +1428,7 @@ function parseWorld(value) {
1104
1428
  paths.add(collection.path);
1105
1429
  }
1106
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");
1107
1432
  services.set(item.name, paths);
1108
1433
  }
1109
1434
  const known = (entry) => typeof entry.service === "string" && typeof entry.path === "string" && services.get(entry.service)?.has(entry.path);
@@ -1111,6 +1436,19 @@ function parseWorld(value) {
1111
1436
  for (const assertion of v.assertions) {
1112
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");
1113
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");
1114
1452
  if (v.postgres !== void 0) {
1115
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");
1116
1454
  for (const a of v.postgres.assertions) {
@@ -1124,7 +1462,29 @@ function parseWorld(value) {
1124
1462
  }
1125
1463
  }
1126
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");
1127
- const keys = ["schema", "name", "workflowId", "files", "command", "timeoutSeconds", "services", "assertions", "postgres", "redis", "fault"];
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"];
1128
1488
  if (Object.keys(v).some((k) => !keys.includes(k))) invalid("unknown world field");
1129
1489
  return v;
1130
1490
  }
@@ -1141,9 +1501,18 @@ function portableLocalReceipt(value) {
1141
1501
  const uuid = (v) => typeof v === "string" && /^[a-f0-9-]{36}$/.test(v);
1142
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");
1143
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");
1144
- if (Array.isArray(r.sourceDigests) || Object.keys(r.sourceDigests).length > 100 || Object.entries(r.sourceDigests).some(([path, digest]) => path.startsWith("/") || path.split("/").includes("..") || typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)) || 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");
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");
1145
1514
  const git = validateGit(r.git);
1146
- const fields = ["schema", "runId", "name", "workflowId", "project", "createdAt", "durationMs", "status", "application", "assertions", "infrastructureAssertions", "fault", "initialState", "finalState", "events", "unsupported", "coverage", "sourceDigests", "specDigest"];
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"];
1147
1516
  const result = Object.fromEntries(fields.filter((k) => r[k] !== void 0).map((k) => [k, r[k]]));
1148
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 } : {} });
1149
1518
  if (Buffer.byteLength(JSON.stringify(result)) > 2 * 1024 * 1024) throw new Error("CLI receipt exceeds 2 MiB");
@@ -1151,15 +1520,30 @@ function portableLocalReceipt(value) {
1151
1520
  }
1152
1521
 
1153
1522
  // src/commands/run.ts
1154
- import { mkdtemp, mkdir as mkdir3, readFile as readFile8, writeFile as writeFile4, realpath, lstat, rm, copyFile } from "fs/promises";
1155
- import { tmpdir } from "os";
1156
- import { basename as basename2, dirname, isAbsolute, join as join8, relative as relative2, resolve, sep as sep2 } from "path";
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";
1157
1526
  import { fileURLToPath as fileURLToPath2 } from "url";
1158
- import { createHash, randomUUID } from "crypto";
1527
+ import { createHash as createHash4, randomUUID } from "crypto";
1159
1528
 
1160
- // src/runtime/remote.ts
1161
- import { readFile as readFile2 } from "fs/promises";
1529
+ // src/runtime/credentials.ts
1530
+ import { homedir } from "os";
1162
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";
1163
1547
 
1164
1548
  // src/lib/exit.ts
1165
1549
  var EXIT = {
@@ -1193,11 +1577,12 @@ function apiBase(value) {
1193
1577
  return value.replace(/\/$/, "");
1194
1578
  }
1195
1579
  async function executeRemote(stage, spec, log, metadata = {}) {
1196
- const base = apiBase(process.env.AGENTTWIN_API_URL ?? "");
1197
- const token = process.env.AGENTTWIN_TOKEN;
1198
- if (!token) throw new CliError("AGENTTWIN_TOKEN is not configured", { code: EXIT.BLOCKED });
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 });
1199
1584
  const files = /* @__PURE__ */ Object.create(null);
1200
- for (const file of spec.files) files[file] = (await readFile2(join2(stage, "app", file))).toString("base64");
1585
+ for (const file of spec.files) files[file] = (await readFile3(join3(stage, "app", file))).toString("base64");
1201
1586
  const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
1202
1587
  const response = await fetch(`${base}/v1/runs`, { method: "POST", headers, body: JSON.stringify({ spec, files, ...metadata }), redirect: "error", signal: AbortSignal.timeout(15e3) });
1203
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 });
@@ -1214,17 +1599,184 @@ async function executeRemote(stage, spec, log, metadata = {}) {
1214
1599
  return state.receipt;
1215
1600
  }
1216
1601
  if (["error", "cancelled"].includes(state.status)) throw new CliError(state.error ?? "Hosted runtime failed");
1217
- await new Promise((resolve3) => setTimeout(resolve3, 1e3));
1602
+ await new Promise((resolve4) => setTimeout(resolve4, 1e3));
1218
1603
  }
1219
1604
  throw new CliError(`Timed out waiting for hosted run ${job.id}; inspect this run in the API before retrying`);
1220
1605
  }
1221
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
+
1222
1716
  // src/runtime/local.ts
1223
1717
  import { spawn } from "child_process";
1224
1718
 
1225
1719
  // src/runtime/http-world.ts
1226
1720
  import { createServer } from "http";
1227
- 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
1228
1780
  var HttpWorld = class {
1229
1781
  constructor(spec) {
1230
1782
  this.spec = spec;
@@ -1237,21 +1789,32 @@ var HttpWorld = class {
1237
1789
  servers = [];
1238
1790
  timers = /* @__PURE__ */ new Set();
1239
1791
  fired = false;
1240
- record(service, operation, path, detail) {
1792
+ epoch = 0;
1793
+ replayKeys = /* @__PURE__ */ new Map();
1794
+ sourceContext = new AsyncLocalStorage();
1795
+ record(service, operation, path2, detail, record) {
1241
1796
  if (this.events.length >= 5e4) return;
1242
- this.events.push({ sequence: this.events.length + 1, service, operation, path, detail });
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);
1243
1803
  }
1244
1804
  async start() {
1805
+ if (this.servers.length) throw new Error("World is already started");
1245
1806
  for (const service of this.spec.services) {
1246
- const token = `at_world_${randomBytes(24).toString("hex")}`;
1807
+ const token = `at_world_${randomBytes4(24).toString("hex")}`;
1247
1808
  const data = /* @__PURE__ */ Object.create(null);
1248
1809
  this.state[service.name] = data;
1249
1810
  for (const c of service.collections) data[c.path] = structuredClone(c.seed);
1250
1811
  const keys = /* @__PURE__ */ new Map();
1251
- const server = createServer(async (req, res) => {
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;
1252
1815
  const send2 = (code, body2) => {
1253
1816
  if (!res.destroyed) {
1254
- res.writeHead(code, { "content-type": "application/json" });
1817
+ res.writeHead(code, { "content-type": "application/json", ...code === 501 ? { "X-AgentTwin-Unsupported": "true" } : {} });
1255
1818
  res.end(JSON.stringify(body2));
1256
1819
  }
1257
1820
  };
@@ -1266,9 +1829,9 @@ var HttpWorld = class {
1266
1829
  return;
1267
1830
  }
1268
1831
  const url = new URL(req.url ?? "/", "http://localhost");
1269
- let path = url.pathname;
1832
+ let path2 = url.pathname;
1270
1833
  const slack = service.adapter === "slack";
1271
- const slackMethod = path.replace(/^\/api\//, "");
1834
+ const slackMethod = path2.replace(/^\/api\//, "");
1272
1835
  let payload;
1273
1836
  if (req.method === "POST") {
1274
1837
  const chunks = [];
@@ -1284,7 +1847,7 @@ var HttpWorld = class {
1284
1847
  const text = Buffer.concat(chunks).toString("utf8");
1285
1848
  let parsed;
1286
1849
  try {
1287
- 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);
1288
1851
  } catch {
1289
1852
  send2(400, { error: "expected_json_object" });
1290
1853
  return;
@@ -1295,36 +1858,75 @@ var HttpWorld = class {
1295
1858
  }
1296
1859
  payload = parsed;
1297
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
+ }
1298
1900
  if (slack) {
1299
1901
  const params = payload ?? Object.fromEntries(url.searchParams);
1300
1902
  const allowed = slackMethod === "chat.postMessage" ? ["channel", "text"] : slackMethod === "conversations.history" ? ["channel"] : [];
1301
1903
  if (Object.keys(params).some((k) => !allowed.includes(k))) {
1302
- this.unsupported.push(`${service.name}: unsupported parameters for ${slackMethod}`);
1904
+ this.rejectUnsupported(service.name, req.method ?? "", url.pathname, `unsupported parameters for ${slackMethod}`);
1303
1905
  send2(501, { ok: false, error: "unsupported_parameters", forwardedToProduction: false });
1304
1906
  return;
1305
1907
  }
1306
- if (slackMethod === "chat.postMessage" && req.method === "POST") path = "/messages";
1307
- else if (slackMethod === "conversations.history" && ["GET", "POST"].includes(req.method ?? "")) path = "/messages";
1308
- else if (slackMethod === "conversations.list" && ["GET", "POST"].includes(req.method ?? "")) path = "/channels";
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";
1309
1911
  else {
1310
- this.unsupported.push(`${service.name}: ${req.method} ${url.pathname}`);
1311
- 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 });
1312
1914
  return;
1313
1915
  }
1314
1916
  }
1315
- if (!Object.hasOwn(data, path) || !["GET", "POST"].includes(req.method ?? "")) {
1316
- this.unsupported.push(`${service.name}: ${req.method} ${path}`);
1917
+ if (!Object.hasOwn(data, path2) || !["GET", "POST"].includes(req.method ?? "")) {
1918
+ this.rejectUnsupported(service.name, req.method ?? "", path2, "unsupported operation");
1317
1919
  send2(501, { error: "unsupported_operation", forwardedToProduction: false });
1318
1920
  return;
1319
1921
  }
1320
1922
  if (req.method === "GET" || slack && slackMethod !== "chat.postMessage") {
1321
- this.record(service.name, "read", path, `${data[path].length} records`);
1322
- if (slack && path === "/channels") send2(200, { ok: true, channels: data[path], response_metadata: { next_cursor: "" } });
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: "" } });
1323
1925
  else if (slack) {
1324
1926
  const channel = payload?.channel ?? url.searchParams.get("channel");
1325
1927
  if (!data["/channels"].some((c) => c.id === channel)) send2(200, { ok: false, error: "channel_not_found" });
1326
- else send2(200, { ok: true, messages: data[path].filter((m) => m.channel === channel), has_more: false });
1327
- } else send2(200, { data: data[path] });
1928
+ else send2(200, { ok: true, messages: data[path2].filter((m) => m.channel === channel), has_more: false });
1929
+ } else send2(200, { data: data[path2] });
1328
1930
  return;
1329
1931
  }
1330
1932
  const body2 = payload;
@@ -1339,12 +1941,12 @@ var HttpWorld = class {
1339
1941
  return;
1340
1942
  }
1341
1943
  }
1342
- if (data[path].length >= 1e4 || this.events.length >= 5e4) {
1944
+ if (data[path2].length >= 1e4 || this.events.length >= 5e4) {
1343
1945
  send2(429, { error: "world_capacity" });
1344
1946
  return;
1345
1947
  }
1346
1948
  const keyHeader = slack ? void 0 : req.headers["idempotency-key"];
1347
- const key = typeof keyHeader === "string" ? `${path}:${keyHeader}` : void 0;
1949
+ const key = typeof keyHeader === "string" ? `${path2}:${keyHeader}` : void 0;
1348
1950
  const signature = JSON.stringify(body2);
1349
1951
  const previous = key ? keys.get(key) : void 0;
1350
1952
  if (previous) {
@@ -1352,22 +1954,22 @@ var HttpWorld = class {
1352
1954
  send2(409, { error: "idempotency_conflict" });
1353
1955
  return;
1354
1956
  }
1355
- this.record(service.name, "replay", path, "same-key response replay");
1957
+ this.record(service.name, "replay", path2, "same-key response replay", previous.record);
1356
1958
  send2(200, previous.record);
1357
1959
  return;
1358
1960
  }
1359
- let index = data[path].length + 1;
1360
- while (data[path].some((r) => r.id === `${service.name}_${index}`)) index++;
1961
+ let index = data[path2].length + 1;
1962
+ while (data[path2].some((r) => r.id === `${service.name}_${index}`)) index++;
1361
1963
  const row = { ...body2, id: `${service.name}_${index}` };
1362
1964
  if (slack) row.ts = `${index}.000001`;
1363
1965
  const reply = slack ? { ok: true, channel: row.channel, ts: row.ts, message: row } : row;
1364
- data[path].push(row);
1966
+ data[path2].push(row);
1365
1967
  if (key) keys.set(key, { body: signature, record: row });
1366
- this.record(service.name, "write", path, String(row.id));
1968
+ this.record(service.name, "write", path2, String(row.id), row);
1367
1969
  const fault = this.spec.fault;
1368
- if (!this.fired && fault?.service === service.name && fault.path === path) {
1970
+ if (!this.fired && fault?.service === service.name && fault.path === path2) {
1369
1971
  this.fired = true;
1370
- this.record(service.name, "fault", path, `write committed; response delayed ${fault.delayMs}ms`);
1972
+ this.record(service.name, "fault", path2, `write committed; response delayed ${fault.delayMs}ms`);
1371
1973
  const timer = setTimeout(() => {
1372
1974
  this.timers.delete(timer);
1373
1975
  send2(slack ? 200 : 201, reply);
@@ -1377,11 +1979,11 @@ var HttpWorld = class {
1377
1979
  } catch {
1378
1980
  send2(500, { error: "world_request_error" });
1379
1981
  }
1380
- });
1982
+ }));
1381
1983
  server.requestTimeout = 1e4;
1382
- await new Promise((resolve3, reject) => {
1984
+ await new Promise((resolve4, reject) => {
1383
1985
  server.once("error", reject);
1384
- server.listen(0, "127.0.0.1", resolve3);
1986
+ server.listen(0, "127.0.0.1", resolve4);
1385
1987
  });
1386
1988
  this.servers.push(server);
1387
1989
  this.credentials[service.name] = { endpoint: `http://127.0.0.1:${server.address().port}`, token };
@@ -1390,37 +1992,121 @@ var HttpWorld = class {
1390
1992
  }
1391
1993
  assertions() {
1392
1994
  return this.spec.assertions.map((a) => {
1393
- const rows = this.state[a.service][a.path].filter((row) => !a.where || Object.entries(a.where).every(([k, v]) => JSON.stringify(row[k]) === JSON.stringify(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)));
1394
1996
  return { ...a, actual: rows.length, passed: rows.length === a.count };
1395
1997
  });
1396
1998
  }
1397
1999
  get faultTriggered() {
1398
2000
  return this.fired;
1399
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
+ }
1400
2022
  async close() {
2023
+ this.epoch++;
1401
2024
  for (const timer of this.timers) clearTimeout(timer);
1402
2025
  this.timers.clear();
1403
- await Promise.all(this.servers.map((server) => new Promise((resolve3) => {
1404
- server.close(() => resolve3());
2026
+ await Promise.all(this.servers.map((server) => new Promise((resolve4) => {
2027
+ server.close(() => resolve4());
1405
2028
  server.closeAllConnections();
1406
2029
  })));
2030
+ this.servers.length = 0;
1407
2031
  }
1408
2032
  };
1409
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
+
1410
2093
  // src/runtime/local.ts
1411
- async function executeLocal(app, spec, signal) {
2094
+ init_contracts();
2095
+ async function executeLocal(app, spec, signal, existingWorld) {
1412
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.");
1413
2097
  signal?.throwIfAborted();
1414
- const world = new HttpWorld(spec), started = Date.now();
2098
+ const world = existingWorld ?? new HttpWorld(spec), started = Date.now();
1415
2099
  try {
1416
- const credentials = await world.start();
2100
+ const credentials2 = existingWorld ? world.credentials : await world.start();
2101
+ const initialState = world.snapshot();
1417
2102
  const env = { PATH: process.env.PATH ?? "", NODE_ENV: "test" };
1418
- for (const [name, c] of Object.entries(credentials)) {
2103
+ for (const [name, c] of Object.entries(credentials2)) {
1419
2104
  env[`AGENTTWIN_${name.toUpperCase()}_URL`] = c.endpoint;
1420
2105
  env[`AGENTTWIN_${name.toUpperCase()}_TOKEN`] = c.token;
1421
2106
  }
1422
2107
  let stdout = "", stderr = "", timedOut = false;
1423
- const child = spawn(process.execPath, spec.command.slice(1), { cwd: app, env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
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" });
1424
2110
  child.stdout.on("data", (b) => {
1425
2111
  stdout = (stdout + b.toString()).slice(-65536);
1426
2112
  });
@@ -1435,31 +2121,61 @@ async function executeLocal(app, spec, signal) {
1435
2121
  }
1436
2122
  }
1437
2123
  };
1438
- const abort = () => kill();
2124
+ const executionAbort = new AbortController();
2125
+ const abort = () => {
2126
+ executionAbort.abort();
2127
+ kill();
2128
+ };
1439
2129
  signal?.addEventListener("abort", abort, { once: true });
1440
2130
  const timer = setTimeout(() => {
1441
2131
  timedOut = true;
2132
+ executionAbort.abort();
1442
2133
  kill();
1443
2134
  }, spec.timeoutSeconds * 1e3);
1444
- const exitCode = await new Promise((resolve3, reject) => {
2135
+ const completion = new Promise((resolve4, reject) => {
1445
2136
  child.once("error", reject);
1446
- child.once("close", resolve3);
1447
- }).finally(() => {
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();
1448
2158
  clearTimeout(timer);
1449
2159
  signal?.removeEventListener("abort", abort);
1450
2160
  kill();
1451
- });
2161
+ await completion.catch(() => {
2162
+ });
2163
+ await hook.close();
2164
+ }
1452
2165
  signal?.throwIfAborted();
1453
- await world.close();
1454
- for (const c of Object.values(credentials)) {
2166
+ if (existingWorld) world.quiesce();
2167
+ else await world.close();
2168
+ for (const c of Object.values(credentials2)) {
1455
2169
  stdout = stdout.split(c.token).join("[test-token]");
1456
2170
  stderr = stderr.split(c.token).join("[test-token]");
1457
2171
  }
1458
2172
  const assertions = world.assertions();
1459
- const status = world.unsupported.length || spec.fault && !world.faultTriggered ? "incomplete" : timedOut || exitCode !== 0 ? "error" : assertions.every((a) => a.passed) ? "passed" : "failed";
1460
- return { schema: "agenttwin/run-v1", name: spec.name, workflowId: spec.workflowId, status, application: { command: spec.command, exitCode, timedOut, stdout, stderr }, assertions, infrastructureAssertions: [], fault: spec.fault ? { ...spec.fault, triggered: world.faultTriggered } : null, initialState: Object.fromEntries(spec.services.map((s) => [s.name, Object.fromEntries(s.collections.map((c) => [c.path, c.seed]))])), finalState: world.state, events: world.events, unsupported: world.unsupported, durationMs: Date.now() - started, coverage: "CLI Node execution with fresh in-process HTTP collections and bounded Slack operations. 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" } };
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" } };
1461
2176
  } finally {
1462
- await world.close();
2177
+ if (existingWorld) world.quiesce();
2178
+ else await world.close();
1463
2179
  }
1464
2180
  }
1465
2181
 
@@ -1468,17 +2184,18 @@ init_git();
1468
2184
 
1469
2185
  // src/runtime/e2b.ts
1470
2186
  import { Sandbox } from "e2b";
1471
- import { readFile as readFile3 } from "fs/promises";
1472
- import { randomBytes as randomBytes2 } from "crypto";
1473
- import { homedir } from "os";
1474
- import { join as join3 } from "path";
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";
1475
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.");
1476
2193
  signal?.throwIfAborted();
1477
2194
  const apiKey = process.env.E2B_API_KEY;
1478
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." });
1479
2196
  let saved = {};
1480
2197
  try {
1481
- saved = JSON.parse(await readFile3(join3(homedir(), ".agenttwin/runtime.json"), "utf8"));
2198
+ saved = JSON.parse(await readFile4(join5(homedir2(), ".agenttwin/runtime.json"), "utf8"));
1482
2199
  } catch {
1483
2200
  }
1484
2201
  const template = process.env.E2B_TEMPLATE_ID ?? saved.templateId;
@@ -1497,12 +2214,12 @@ async function executeE2B(stage, spec, log, signal) {
1497
2214
  signal?.throwIfAborted();
1498
2215
  await sandbox.commands.run("mkdir -p /input/app && chmod 755 /input /input/app", { user: "root" });
1499
2216
  for (const file of ["world.json", "worker.js", ...spec.files.map((f) => `app/${f}`)]) {
1500
- await sandbox.files.write(`/input/${file}`, new Uint8Array(await readFile3(join3(stage, file))).buffer, { user: "root" });
2217
+ await sandbox.files.write(`/input/${file}`, new Uint8Array(await readFile4(join5(stage, file))).buffer, { user: "root" });
1501
2218
  }
1502
2219
  await sandbox.commands.run("chmod -R a+rX /input", { user: "root" });
1503
2220
  const runtimeEnv = {};
1504
2221
  if (spec.postgres) {
1505
- const password = randomBytes2(24).toString("hex");
2222
+ const password = randomBytes5(24).toString("hex");
1506
2223
  secrets.push(password);
1507
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" });
1508
2225
  await sandbox.files.write("/tmp/agenttwin-init.sql", `CREATE ROLE agenttwin LOGIN PASSWORD '${password}';
@@ -1516,7 +2233,7 @@ CREATE DATABASE agenttwin OWNER agenttwin;
1516
2233
  }
1517
2234
  }
1518
2235
  if (spec.redis) {
1519
- const password = randomBytes2(24).toString("hex");
2236
+ const password = randomBytes5(24).toString("hex");
1520
2237
  secrets.push(password);
1521
2238
  await sandbox.files.write("/tmp/agenttwin-redis.conf", `bind 127.0.0.1
1522
2239
  port 6379
@@ -1555,70 +2272,131 @@ logfile /tmp/agenttwin-redis.log
1555
2272
  }
1556
2273
  }
1557
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
+
1558
2334
  // src/commands/run.ts
1559
2335
  async function stageApplication(root, files, destination) {
1560
- const canonicalRoot = await realpath(root);
2336
+ const canonicalRoot = await realpath2(root);
1561
2337
  const digests = {};
1562
2338
  let bytes = 0;
1563
2339
  for (const file of files) {
1564
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 });
1565
- const source = await realpath(resolve(canonicalRoot, file));
2341
+ const source = await realpath2(resolve2(canonicalRoot, file));
1566
2342
  const rel = relative2(canonicalRoot, source);
1567
- if (rel.startsWith(`..${sep2}`) || rel === ".." || isAbsolute(rel) || source !== resolve(canonicalRoot, file) || !(await lstat(source)).isFile()) throw new CliError(`Files must be regular files within the project, without symlinks: ${file}`, { code: EXIT.USAGE });
1568
- const contents = await readFile8(source);
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);
1569
2345
  bytes += contents.length;
1570
2346
  if (bytes > 20 * 1024 * 1024) throw new CliError("Application files exceed 20 MiB; supply a smaller built bundle", { code: EXIT.USAGE });
1571
- const target = join8(destination, file);
1572
- await mkdir3(dirname(target), { recursive: true });
1573
- await writeFile4(target, contents, { mode: 420 });
1574
- digests[file] = createHash("sha256").update(contents).digest("hex");
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");
1575
2351
  }
1576
2352
  return digests;
1577
2353
  }
1578
2354
  async function runWorld(cwd, opts) {
1579
- const file = resolve(cwd, opts.file ?? "agenttwin.world.json");
2355
+ const file = resolve2(cwd, opts.file ?? "agenttwin.world.json");
1580
2356
  let spec;
1581
2357
  try {
1582
- spec = parseWorld(JSON.parse(await readFile8(file, "utf8")));
2358
+ spec = parseWorld(JSON.parse(await readFile10(file, "utf8")));
1583
2359
  } catch (error) {
1584
2360
  throw new CliError(String(error), { code: EXIT.USAGE, action: "Create agenttwin.world.json; run agenttwin example --out ./agenttwin-example for a working example." });
1585
2361
  }
2362
+ if (opts.scenario) spec = applyScenario(spec, await loadScenario(cwd, opts.scenario));
1586
2363
  if (spec.workflowId) {
1587
2364
  let inventory;
1588
2365
  try {
1589
- inventory = JSON.parse(await readFile8(join8(cwd, ".agenttwin/inventory.json"), "utf8"));
2366
+ inventory = JSON.parse(await readFile10(join11(cwd, ".agenttwin/inventory.json"), "utf8"));
1590
2367
  } catch {
1591
2368
  throw new CliError("Linked workflow needs discovery inventory; run init first", { code: EXIT.BLOCKED });
1592
2369
  }
1593
2370
  if (!inventory.workflows?.some((w) => w.id === spec.workflowId)) {
1594
2371
  const { loadDashboardDiscovery: loadDashboardDiscovery2 } = await Promise.resolve().then(() => (init_session(), session_exports));
1595
- const discovery = await loadDashboardDiscovery2(cwd, inventory);
1596
- if (!discovery.workflows.some((w) => w.id === spec.workflowId)) throw new CliError("workflowId is absent from the current inventory", { code: EXIT.BLOCKED });
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 });
1597
2374
  }
1598
2375
  }
1599
2376
  const runtime = opts.runtime ?? "local";
1600
2377
  if (!["local", "e2b", "hosted"].includes(runtime)) throw new CliError("runtime must be local, hosted or e2b", { code: EXIT.USAGE });
1601
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." });
1602
- const stage = await mkdtemp(join8(tmpdir(), "agenttwin-world-"));
2379
+ const stage = await mkdtemp2(join11(tmpdir2(), "agenttwin-world-"));
1603
2380
  try {
1604
- const sourceDigests = await stageApplication(dirname(file), spec.files, join8(stage, "app"));
1605
- await writeFile4(join8(stage, "world.json"), JSON.stringify(spec), { mode: 420 });
2381
+ const sourceDigests = await stageApplication(dirname(file), spec.files, join11(stage, "app"));
2382
+ await writeFile7(join11(stage, "world.json"), JSON.stringify(spec), { mode: 420 });
1606
2383
  const git = await captureGit(cwd);
1607
2384
  let project;
1608
2385
  try {
1609
- project = JSON.parse(await readFile8(join8(cwd, ".agenttwin/inventory.json"), "utf8")).name ?? basename2(cwd);
2386
+ project = JSON.parse(await readFile10(join11(cwd, ".agenttwin/inventory.json"), "utf8")).name ?? basename2(cwd);
1610
2387
  } catch {
1611
2388
  }
1612
2389
  if (runtime === "local") {
1613
2390
  opts.log("Testing through the CLI with in-process service twins. No cloud VM or containers.");
1614
- const report2 = await executeLocal(join8(stage, "app"), spec, opts.signal);
2391
+ const report2 = await executeLocal(join11(stage, "app"), spec, opts.signal);
1615
2392
  const runId2 = randomUUID();
1616
- const receipt2 = { ...report2, runId: runId2, runtime, project, git, createdAt: (/* @__PURE__ */ new Date()).toISOString(), sourceDigests, specDigest: createHash("sha256").update(JSON.stringify(spec)).digest("hex"), provenance: "CLI-reported execution" };
1617
- const out2 = resolve(cwd, opts.out ?? `.agenttwin/runs/${runId2}`);
1618
- await mkdir3(out2, { recursive: true });
1619
- const evidencePath = join8(out2, "run.json"), reportPath = join8(out2, "report.md");
1620
- await writeFile4(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
1621
- await writeFile4(reportPath, `# ${spec.name}: ${receipt2.status}
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}
1622
2400
 
1623
2401
  Runner: CLI
1624
2402
  Git: ${git?.head ?? "unavailable"}
@@ -1626,20 +2404,20 @@ Git: ${git?.head ?? "unavailable"}
1626
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");
1627
2405
  return { ...receipt2, reportPath, evidencePath };
1628
2406
  }
1629
- const worker = join8(dirname(fileURLToPath2(import.meta.url)), "worker.js");
1630
- await copyFile(worker, join8(stage, "worker.js"));
2407
+ const worker = join11(dirname(fileURLToPath2(import.meta.url)), "worker.js");
2408
+ await copyFile(worker, join11(stage, "worker.js"));
1631
2409
  if (runtime === "hosted") {
1632
2410
  const receipt2 = await executeRemote(stage, spec, opts.log, { git, project });
1633
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");
1634
- const expectedSpec = createHash("sha256").update(JSON.stringify(spec)).digest("hex");
2412
+ const expectedSpec = createHash4("sha256").update(JSON.stringify(spec)).digest("hex");
1635
2413
  const reportedSources = receipt2.sourceDigests;
1636
- if (receipt2.specDigest !== expectedSpec || !reportedSources || Object.keys(reportedSources).length !== Object.keys(sourceDigests).length || Object.entries(sourceDigests).some(([path, digest]) => reportedSources[path] !== digest)) throw new CliError("Hosted receipt does not match the submitted source/specification");
1637
- const out2 = resolve(cwd, opts.out ?? `.agenttwin/runs/${receipt2.runId}`);
1638
- await mkdir3(out2, { recursive: true });
1639
- const evidencePath = join8(out2, "run.json");
1640
- const reportPath = join8(out2, "report.md");
1641
- await writeFile4(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
1642
- await writeFile4(reportPath, `# ${spec.name}: ${receipt2.status}
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}
1643
2421
 
1644
2422
  Run: ${receipt2.runId}
1645
2423
 
@@ -1669,26 +2447,26 @@ See run.json for assertions, service state, application output and isolation met
1669
2447
  image,
1670
2448
  templateBuildId,
1671
2449
  sourceDigests,
1672
- specDigest: createHash("sha256").update(JSON.stringify(spec)).digest("hex"),
2450
+ specDigest: createHash4("sha256").update(JSON.stringify(spec)).digest("hex"),
1673
2451
  isolation
1674
2452
  };
1675
- const out = resolve(cwd, opts.out ?? `.agenttwin/runs/${runId}`);
1676
- await mkdir3(out, { recursive: true });
1677
- await writeFile4(join8(out, "run.json"), JSON.stringify(receipt, null, 2) + "\n", { mode: 384 });
1678
- await writeFile4(join8(out, "world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
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 });
1679
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");
1680
- await writeFile4(join8(out, "report.md"), summary);
1681
- return { ...receipt, reportPath: join8(out, "report.md"), evidencePath: join8(out, "run.json") };
2458
+ await writeFile7(join11(out, "report.md"), summary);
2459
+ return { ...receipt, reportPath: join11(out, "report.md"), evidencePath: join11(out, "run.json") };
1682
2460
  } finally {
1683
- await rm(stage, { recursive: true, force: true });
2461
+ await rm3(stage, { recursive: true, force: true });
1684
2462
  }
1685
2463
  }
1686
2464
 
1687
2465
  // src/runtime/gateway.ts
1688
- var hash = (value) => createHash2("sha256").update(value).digest("hex");
1689
- function uploadPath(path) {
1690
- if (!path || path.startsWith("/") || path.includes("\\") || path.includes("\0") || path.split("/").some((p) => !p || p === "." || p === ".." || /^(\.env.*|\.git|\.ssh|\.aws|\.azure|\.config|\.npmrc|\.netrc|credentials.*)$/i.test(p)) || /\.(pem|key|p12|pfx)$/i.test(path)) throw new Error("Unsafe uploaded file path");
1691
- return path;
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;
1692
2470
  }
1693
2471
  async function body(req, limit = 28 * 1024 * 1024) {
1694
2472
  const chunks = [];
@@ -1701,30 +2479,44 @@ async function body(req, limit = 28 * 1024 * 1024) {
1701
2479
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
1702
2480
  }
1703
2481
  function send(res, code, value) {
1704
- res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" });
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
+ });
1705
2490
  res.end(JSON.stringify(value));
1706
2491
  }
1707
2492
  function equal(a, b) {
1708
- const x = Buffer.from(hash(a)), y = Buffer.from(hash(b));
1709
- return timingSafeEqual(x, y);
2493
+ const x = Buffer.from(hash2(a)), y = Buffer.from(hash2(b));
2494
+ return timingSafeEqual4(x, y);
1710
2495
  }
1711
2496
  async function startGateway(opts) {
1712
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);
1713
2500
  const allowedOrigins = new Set(opts.allowedOrigins ?? []);
1714
2501
  for (const origin of allowedOrigins) {
1715
2502
  const u = new URL(origin);
1716
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");
1717
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
+ }
1718
2510
  const controllers = /* @__PURE__ */ new Map();
1719
2511
  const chatTasks = /* @__PURE__ */ new Set();
1720
2512
  const chatControllers = /* @__PURE__ */ new Map();
1721
2513
  const voiceControllers = /* @__PURE__ */ new Map();
1722
- const directory = resolve2(opts.directory);
1723
- await mkdir4(directory, { recursive: true, mode: 448 });
1724
- const storePath2 = join9(directory, "state.json");
2514
+ const directory = resolve3(opts.directory);
2515
+ await mkdir6(directory, { recursive: true, mode: 448 });
2516
+ const storePath2 = join12(directory, "state.json");
1725
2517
  let store;
1726
2518
  try {
1727
- store = JSON.parse(await readFile9(storePath2, "utf8"));
2519
+ store = JSON.parse(await readFile11(storePath2, "utf8"));
1728
2520
  if (store.schema !== "agenttwin/gateway-v1") throw new Error("Invalid store");
1729
2521
  } catch (error) {
1730
2522
  if (error.code !== "ENOENT") throw error;
@@ -1739,9 +2531,9 @@ async function startGateway(opts) {
1739
2531
  const save = () => {
1740
2532
  const snapshot = JSON.stringify(store);
1741
2533
  writes = writes.then(async () => {
1742
- const path = join9(directory, `state-${randomUUID2()}.tmp`);
1743
- await writeFile5(path, snapshot, { mode: 384 });
1744
- await rename(path, storePath2);
2534
+ const path2 = join12(directory, `state-${randomUUID2()}.tmp`);
2535
+ await writeFile8(path2, snapshot, { mode: 384 });
2536
+ await rename(path2, storePath2);
1745
2537
  }).catch((error) => {
1746
2538
  storageHealthy = false;
1747
2539
  throw error;
@@ -1774,16 +2566,16 @@ async function startGateway(opts) {
1774
2566
  if (!store.accounts.some((a) => a.id === item.job.accountId && a.active)) throw new Error("Account revoked before execution");
1775
2567
  item.job.status = "running";
1776
2568
  await save();
1777
- workspace = await mkdtemp2(join9(tmpdir2(), "agenttwin-api-run-"));
2569
+ workspace = await mkdtemp3(join12(tmpdir3(), "agenttwin-api-run-"));
1778
2570
  for (const [file, encoded] of Object.entries(item.files)) {
1779
- const destination = join9(workspace, uploadPath(file));
1780
- await mkdir4(dirname2(destination), { recursive: true });
1781
- await writeFile5(destination, Buffer.from(encoded, "base64"), { mode: 384 });
2571
+ const destination = join12(workspace, uploadPath(file));
2572
+ await mkdir6(dirname2(destination), { recursive: true });
2573
+ await writeFile8(destination, Buffer.from(encoded, "base64"), { mode: 384 });
1782
2574
  }
1783
- await writeFile5(join9(workspace, "agenttwin.world.json"), JSON.stringify(item.spec));
2575
+ await writeFile8(join12(workspace, "agenttwin.world.json"), JSON.stringify(item.spec));
1784
2576
  if (item.spec.workflowId) {
1785
- await mkdir4(join9(workspace, ".agenttwin"));
1786
- await writeFile5(join9(workspace, ".agenttwin/inventory.json"), JSON.stringify({ workflows: [{ id: item.spec.workflowId }] }));
2577
+ await mkdir6(join12(workspace, ".agenttwin"));
2578
+ await writeFile8(join12(workspace, ".agenttwin/inventory.json"), JSON.stringify({ workflows: [{ id: item.spec.workflowId }] }));
1787
2579
  }
1788
2580
  const receipt = await runWorld(workspace, { runtime: "e2b", signal: controllers.get(item.job.id)?.signal, log: () => {
1789
2581
  } });
@@ -1797,7 +2589,7 @@ async function startGateway(opts) {
1797
2589
  item.job.error = message.split(process.env.E2B_API_KEY ?? "\0").join("[redacted]").replace(/(?:postgresql|redis):\/\/[^\s]+/g, "[test-connection]").slice(0, 2e3);
1798
2590
  } finally {
1799
2591
  try {
1800
- if (workspace) await rm2(workspace, { recursive: true, force: true });
2592
+ if (workspace) await rm4(workspace, { recursive: true, force: true });
1801
2593
  await save();
1802
2594
  } finally {
1803
2595
  controllers.delete(item.job.id);
@@ -1811,24 +2603,29 @@ async function startGateway(opts) {
1811
2603
  });
1812
2604
  }
1813
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");
1814
2611
  const server = createServer2(async (req, res) => {
1815
2612
  try {
1816
2613
  const requestUrl = new URL(req.url ?? "/", "http://localhost");
1817
- const path = requestUrl.pathname;
1818
- if (opts.dashboardDirectory && req.method === "GET" && (path === "/dashboard" || path.startsWith("/dashboard/"))) {
1819
- if (path === "/dashboard") {
2614
+ const path2 = requestUrl.pathname;
2615
+ if (opts.dashboardDirectory && req.method === "GET" && (path2 === "/dashboard" || path2.startsWith("/dashboard/"))) {
2616
+ if (path2 === "/dashboard") {
1820
2617
  res.writeHead(302, { location: "/dashboard/" });
1821
2618
  res.end();
1822
2619
  return;
1823
2620
  }
1824
- const relative3 = decodeURIComponent(path.slice("/dashboard/".length)) || "index.html";
1825
- const root = resolve2(opts.dashboardDirectory), file = resolve2(root, relative3);
2621
+ const relative3 = decodeURIComponent(path2.slice("/dashboard/".length)) || "index.html";
2622
+ const root = resolve3(opts.dashboardDirectory), file = resolve3(root, relative3);
1826
2623
  if (!file.startsWith(root + "/") || relative3 !== "index.html" && !relative3.startsWith("assets/")) {
1827
2624
  send(res, 404, { error: "not_found" });
1828
2625
  return;
1829
2626
  }
1830
2627
  try {
1831
- const bytes = await readFile9(file);
2628
+ const bytes = await readFile11(file);
1832
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" };
1833
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" });
1834
2631
  res.end(bytes);
@@ -1845,6 +2642,7 @@ async function startGateway(opts) {
1845
2642
  }
1846
2643
  res.setHeader("Access-Control-Allow-Origin", origin);
1847
2644
  res.setHeader("Vary", "Origin");
2645
+ res.setHeader("Access-Control-Allow-Credentials", "true");
1848
2646
  if (req.method === "OPTIONS") {
1849
2647
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
1850
2648
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
@@ -1854,7 +2652,7 @@ async function startGateway(opts) {
1854
2652
  return;
1855
2653
  }
1856
2654
  }
1857
- if (path === "/healthz" && req.method === "GET") {
2655
+ if (path2 === "/healthz" && req.method === "GET") {
1858
2656
  send(res, 200, { status: stopping ? "stopping" : "ok" });
1859
2657
  return;
1860
2658
  }
@@ -1863,8 +2661,402 @@ async function startGateway(opts) {
1863
2661
  return;
1864
2662
  }
1865
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
+ }
1866
3058
  const admin = equal(token, opts.adminToken);
1867
- if (path === "/v1/accounts" && req.method === "POST") {
3059
+ if (path2 === "/v1/accounts" && req.method === "POST") {
1868
3060
  if (!admin) {
1869
3061
  send(res, 401, { error: "unauthorized" });
1870
3062
  return;
@@ -1874,8 +3066,8 @@ async function startGateway(opts) {
1874
3066
  send(res, 400, { error: "label_required" });
1875
3067
  return;
1876
3068
  }
1877
- const accessToken = `at_user_${randomBytes3(32).toString("hex")}`;
1878
- const account2 = { id: randomUUID2(), tokenHash: hash(accessToken), label: data.label, active: true, usageDay: "", runs: 0 };
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 };
1879
3071
  if (store.accounts.length >= 1e4) {
1880
3072
  send(res, 429, { error: "account_capacity" });
1881
3073
  return;
@@ -1885,7 +3077,7 @@ async function startGateway(opts) {
1885
3077
  send(res, 201, { accountId: account2.id, token: accessToken });
1886
3078
  return;
1887
3079
  }
1888
- const revoke = path.match(/^\/v1\/accounts\/([a-f0-9-]{36})$/);
3080
+ const revoke = path2.match(/^\/v1\/accounts\/([a-f0-9-]{36})$/);
1889
3081
  if (revoke && req.method === "DELETE") {
1890
3082
  if (!admin) {
1891
3083
  send(res, 401, { error: "unauthorized" });
@@ -1901,21 +3093,140 @@ async function startGateway(opts) {
1901
3093
  send(res, 200, { revoked: true });
1902
3094
  return;
1903
3095
  }
1904
- const account = store.accounts.find((a) => a.active && a.tokenHash === hash(token));
3096
+ const account = store.accounts.find((a) => a.active && (token ? a.tokenHash === hash2(token) || a.cliTokens?.includes(hash2(token)) : a.id === session?.accountId));
1905
3097
  if (!account) {
1906
3098
  send(res, 401, { error: "unauthorized" });
1907
3099
  return;
1908
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
+ }
1909
3220
  const projectInventories = [...store.projectDiscoveries?.[account.id] ?? []];
1910
3221
  const latestInventory = store.discoveries?.[account.id];
1911
3222
  if (latestInventory && !projectInventories.some((p) => p.name === latestInventory.name)) projectInventories.push(latestInventory);
1912
3223
  const requestedProject = requestUrl.searchParams.get("project");
1913
3224
  const selectedInventory = requestedProject === null ? latestInventory : projectInventories.find((p) => p.name === requestedProject);
1914
- if (path === "/v1/projects" && req.method === "GET") {
3225
+ if (path2 === "/v1/projects" && req.method === "GET") {
1915
3226
  send(res, 200, { projects: projectInventories.map((p) => ({ name: p.name, tools: p.tools.length, workflows: p.workflows.length })), latest: latestInventory?.name ?? null });
1916
3227
  return;
1917
3228
  }
1918
- if (path === "/v1/discovery" && req.method === "GET") {
3229
+ if (path2 === "/v1/discovery" && req.method === "GET") {
1919
3230
  if (requestedProject !== null && !selectedInventory) {
1920
3231
  send(res, 404, { error: "project_not_found" });
1921
3232
  return;
@@ -1923,7 +3234,7 @@ async function startGateway(opts) {
1923
3234
  send(res, 200, { inventory: selectedInventory ?? null });
1924
3235
  return;
1925
3236
  }
1926
- if (path === "/v1/discovery" && req.method === "POST") {
3237
+ if (path2 === "/v1/discovery" && req.method === "POST") {
1927
3238
  if (stopping) {
1928
3239
  send(res, 503, { error: "stopping" });
1929
3240
  return;
@@ -1942,7 +3253,7 @@ async function startGateway(opts) {
1942
3253
  send(res, 200, { synced: true, tools: inventory.tools.length, workflows: inventory.workflows.length });
1943
3254
  return;
1944
3255
  }
1945
- if (path === "/v1/receipts" && req.method === "POST") {
3256
+ if (path2 === "/v1/receipts" && req.method === "POST") {
1946
3257
  const receipt = portableLocalReceipt(await body(req));
1947
3258
  if (!projectInventories.some((p) => p.name === receipt.project)) {
1948
3259
  send(res, 409, { error: "sync_project_before_receipt" });
@@ -1969,11 +3280,11 @@ async function startGateway(opts) {
1969
3280
  send(res, 201, { id, synced: true });
1970
3281
  return;
1971
3282
  }
1972
- if (path === "/v1/runs" && req.method === "GET") {
3283
+ if (path2 === "/v1/runs" && req.method === "GET") {
1973
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) })) });
1974
3285
  return;
1975
3286
  }
1976
- if (path === "/v1/runs" && req.method === "POST") {
3287
+ if (path2 === "/v1/runs" && req.method === "POST") {
1977
3288
  if (stopping) {
1978
3289
  send(res, 503, { error: "stopping" });
1979
3290
  return;
@@ -1997,6 +3308,10 @@ async function startGateway(opts) {
1997
3308
  }
1998
3309
  const input = await body(req);
1999
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
+ }
2000
3315
  const git = validateGit(input.git);
2001
3316
  const project = input.project;
2002
3317
  if (project !== void 0 && (typeof project !== "string" || !projectInventories.some((p) => p.name === project))) {
@@ -2041,16 +3356,16 @@ async function startGateway(opts) {
2041
3356
  send(res, 202, { id: job.id, status: job.status });
2042
3357
  return;
2043
3358
  }
2044
- if ((path === "/v1/chat" || path === "/v1/workflows") && requestedProject !== null && !selectedInventory) {
3359
+ if ((path2 === "/v1/chat" || path2 === "/v1/workflows") && requestedProject !== null && !selectedInventory) {
2045
3360
  send(res, 404, { error: "project_not_found" });
2046
3361
  return;
2047
3362
  }
2048
- if (path === "/v1/workflows" && req.method === "GET") {
3363
+ if (path2 === "/v1/workflows" && req.method === "GET") {
2049
3364
  const project = selectedInventory?.name ?? null;
2050
3365
  send(res, 200, { project, workflows: (store.workflows ?? []).filter((w) => w.accountId === account.id && w.project === project) });
2051
3366
  return;
2052
3367
  }
2053
- if (path === "/v1/workflows" && req.method === "POST") {
3368
+ if (path2 === "/v1/workflows" && req.method === "POST") {
2054
3369
  if (stopping) {
2055
3370
  send(res, 503, { error: "stopping" });
2056
3371
  return;
@@ -2094,13 +3409,13 @@ async function startGateway(opts) {
2094
3409
  send(res, 200, { workflow });
2095
3410
  return;
2096
3411
  }
2097
- if (path === "/v1/chat" && req.method === "GET") {
3412
+ if (path2 === "/v1/chat" && req.method === "GET") {
2098
3413
  const project = selectedInventory?.name ?? null;
2099
3414
  const thread = store.chats?.find((t) => t.accountId === account.id && t.project === project);
2100
3415
  send(res, 200, { ...chatConfiguration(), transcription: transcriptionConfiguration(), project, thread: thread ?? null, busy: chatControllers.has(account.id) });
2101
3416
  return;
2102
3417
  }
2103
- if (path === "/v1/transcriptions" && req.method === "POST") {
3418
+ if (path2 === "/v1/transcriptions" && req.method === "POST") {
2104
3419
  if (stopping) {
2105
3420
  send(res, 503, { error: "stopping" });
2106
3421
  return;
@@ -2156,7 +3471,7 @@ async function startGateway(opts) {
2156
3471
  }
2157
3472
  return;
2158
3473
  }
2159
- if (path === "/v1/chat" && req.method === "POST") {
3474
+ if (path2 === "/v1/chat" && req.method === "POST") {
2160
3475
  if (stopping) {
2161
3476
  send(res, 503, { error: "stopping" });
2162
3477
  return;
@@ -2221,7 +3536,7 @@ async function startGateway(opts) {
2221
3536
  const controller = new AbortController();
2222
3537
  chatControllers.set(account.id, controller);
2223
3538
  const timeout = setTimeout(() => controller.abort(), 45e3);
2224
- const context = { project, discoveryDigest: inventory ? hash(JSON.stringify(inventory)) : null, jobId: job?.id ?? null };
3539
+ const context = { project, discoveryDigest: inventory ? hash2(JSON.stringify(inventory)) : null, jobId: job?.id ?? null };
2225
3540
  thread.messages.push({ id: randomUUID2(), role: "user", content: input.content.trim(), requestId: input.requestId, createdAt: (/* @__PURE__ */ new Date()).toISOString(), context });
2226
3541
  try {
2227
3542
  await save();
@@ -2256,7 +3571,7 @@ async function startGateway(opts) {
2256
3571
  }
2257
3572
  return;
2258
3573
  }
2259
- const match = path.match(/^\/v1\/runs\/([a-f0-9-]{36})$/);
3574
+ const match = path2.match(/^\/v1\/runs\/([a-f0-9-]{36})$/);
2260
3575
  if (match && req.method === "DELETE") {
2261
3576
  const job = store.jobs.find((j) => j.id === match[1] && j.accountId === account.id);
2262
3577
  if (!job) {
@@ -2300,6 +3615,7 @@ async function startGateway(opts) {
2300
3615
  });
2301
3616
  server.requestTimeout = 3e4;
2302
3617
  server.headersTimeout = 1e4;
3618
+ server.maxHeadersCount = 64;
2303
3619
  await new Promise((done, reject) => {
2304
3620
  server.once("error", reject);
2305
3621
  server.listen(opts.port, opts.host ?? "127.0.0.1", done);
@@ -2318,7 +3634,7 @@ async function startGateway(opts) {
2318
3634
  import { fileURLToPath as fileURLToPath3 } from "url";
2319
3635
  var port = Number(process.env.PORT ?? 8080);
2320
3636
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid PORT");
2321
- 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 });
2322
3638
  process.stdout.write(`AgentTwin API listening on port ${port}
2323
3639
  `);
2324
3640
  for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {