agenttwin 0.3.5 → 0.3.7

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