agenttwin 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -15
- package/dist/api.js +796 -68
- package/dist/cli.js +693 -290
- package/dist/dashboard/assets/dashboard/agent-home.css +405 -0
- package/dist/dashboard/assets/dashboard/agent-home.js +264 -0
- package/dist/dashboard/assets/dashboard/dashboard.js +118 -133
- package/dist/dashboard/assets/dashboard/hosted.js +56 -17
- package/dist/dashboard/assets/dashboard/moonlit-coast.png +0 -0
- package/dist/dashboard/assets/dashboard/workflows.js +39 -0
- package/dist/dashboard/assets/dashboard/workspace.css +156 -0
- package/dist/dashboard/assets/vendor/dompurify.LICENSE +202 -0
- package/dist/dashboard/assets/vendor/marked.LICENSE +44 -0
- package/dist/dashboard/assets/vendor/marked.js +82 -0
- package/dist/dashboard/assets/vendor/purify.min.js +7 -0
- package/dist/dashboard/index.html +109 -32
- package/dist/gateway.js +798 -70
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,236 @@ var __export = (target, all) => {
|
|
|
9
9
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
// src/runtime/git.ts
|
|
13
|
+
import { execFile } from "child_process";
|
|
14
|
+
import { promisify } from "util";
|
|
15
|
+
async function captureGit(cwd, previousHead) {
|
|
16
|
+
const exec = promisify(execFile);
|
|
17
|
+
const git = async (...args) => (await exec("git", args, { cwd, timeout: 5e3, maxBuffer: 1024 * 1024 })).stdout.trim();
|
|
18
|
+
try {
|
|
19
|
+
const head = await git("rev-parse", "HEAD");
|
|
20
|
+
const [branch, total, status, history] = await Promise.all([git("branch", "--show-current"), git("rev-list", "--count", "HEAD"), git("status", "--porcelain", "--untracked-files=no"), git("log", "--since=14 days ago", "-100", "--format=%H %cI")]);
|
|
21
|
+
let changed = await git("diff", "--name-only", "HEAD");
|
|
22
|
+
if (previousHead && /^[a-f0-9]{40,64}$/.test(previousHead) && previousHead !== head) {
|
|
23
|
+
try {
|
|
24
|
+
changed += "\n" + await git("diff", "--name-only", previousHead, head);
|
|
25
|
+
} catch {
|
|
26
|
+
previousHead = void 0;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { head, branch: branch || "detached HEAD", totalCommits: Number(total), dirty: !!status, capturedAt: (/* @__PURE__ */ new Date()).toISOString(), commits: history ? history.split("\n").map((line) => {
|
|
30
|
+
const [sha, date] = line.split(" ");
|
|
31
|
+
return { sha, date };
|
|
32
|
+
}) : [], changedFiles: [...new Set(changed.split("\n").filter(Boolean))].slice(0, 1e3), ...previousHead ? { previousHead } : {} };
|
|
33
|
+
} catch {
|
|
34
|
+
return void 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function validateGit(value) {
|
|
38
|
+
if (value === void 0) return void 0;
|
|
39
|
+
const g = value;
|
|
40
|
+
const sha = (s) => typeof s === "string" && /^[a-f0-9]{40,64}$/.test(s);
|
|
41
|
+
const date = (s) => typeof s === "string" && s.length <= 40 && Number.isFinite(Date.parse(s));
|
|
42
|
+
if (!g || !sha(g.head) || g.previousHead !== void 0 && !sha(g.previousHead) || typeof g.branch !== "string" || g.branch.length > 240 || /[\x00-\x1f]/.test(g.branch) || !Number.isSafeInteger(g.totalCommits) || g.totalCommits < 0 || typeof g.dirty !== "boolean" || !date(g.capturedAt) || !Array.isArray(g.commits) || g.commits.length > 100 || g.commits.some((c) => !sha(c.sha) || !date(c.date)) || !Array.isArray(g.changedFiles) || g.changedFiles.length > 1e3 || g.changedFiles.some((p) => typeof p !== "string" || p.length > 500 || p.startsWith("/") || p.includes("\\") || p.split("/").includes("..") || /[\x00-\x1f]/.test(p))) throw new Error("Invalid Git metadata");
|
|
43
|
+
return { head: g.head, branch: g.branch, totalCommits: g.totalCommits, dirty: g.dirty, capturedAt: g.capturedAt, commits: g.commits.map((c) => ({ sha: c.sha, date: c.date })), changedFiles: [...g.changedFiles], ...g.previousHead ? { previousHead: g.previousHead } : {} };
|
|
44
|
+
}
|
|
45
|
+
var init_git = __esm({
|
|
46
|
+
"src/runtime/git.ts"() {
|
|
47
|
+
"use strict";
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// src/inventory/dashboard-transfer.ts
|
|
52
|
+
function discoveryTransfer(value) {
|
|
53
|
+
const input = value;
|
|
54
|
+
if (!input || input.schema !== "company-inventory/v1" || typeof input.name !== "string" || !Array.isArray(input.tools) || !Array.isArray(input.workflows) || input.tools.length > 1e3 || input.workflows.length > 5e3) throw new Error("Invalid discovery metadata");
|
|
55
|
+
const text = (v, limit = 240) => {
|
|
56
|
+
if (typeof v !== "string" || v.length > limit || /[\x00-\x1f]/.test(v)) throw new Error("Invalid discovery string");
|
|
57
|
+
return v;
|
|
58
|
+
};
|
|
59
|
+
const strings = (v) => {
|
|
60
|
+
if (!Array.isArray(v) || v.length > 1e3) throw new Error("Invalid discovery list");
|
|
61
|
+
return v.map((s) => text(s));
|
|
62
|
+
};
|
|
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;
|
|
67
|
+
};
|
|
68
|
+
const tools = input.tools.map((raw) => {
|
|
69
|
+
const t = raw;
|
|
70
|
+
return { id: text(t.id), title: text(t.title), kind: text(t.kind), category: text(t.category), confidence: text(t.confidence), interface: text(t.interface) };
|
|
71
|
+
});
|
|
72
|
+
const workflows = input.workflows.map((raw) => {
|
|
73
|
+
const w = raw;
|
|
74
|
+
if (!Array.isArray(w.steps) || w.steps.length > 1e3) throw new Error("Invalid workflow steps");
|
|
75
|
+
return { id: text(w.id), title: text(w.title), confidence: text(w.confidence), supported: w.supported === true, verified: false, tools: strings(w.tools), steps: w.steps.map((rawStep) => {
|
|
76
|
+
const step = rawStep;
|
|
77
|
+
return { tool: text(step.tool), operations: strings(step.operations) };
|
|
78
|
+
}), ...w.entrypoint ? { entrypoint: entrypoint(w.entrypoint) } : {} };
|
|
79
|
+
});
|
|
80
|
+
if (new Set(tools.map((t) => t.id)).size !== tools.length || new Set(workflows.map((w) => w.id)).size !== workflows.length) throw new Error("Duplicate discovery IDs");
|
|
81
|
+
const ids = new Set(tools.map((t) => t.id));
|
|
82
|
+
if (workflows.some((w) => w.tools.some((id) => !ids.has(id)) || w.steps.some((s) => !ids.has(s.tool)))) throw new Error("Unknown workflow tool");
|
|
83
|
+
const result = { schema: "company-inventory/v1", name: text(input.name), generatedAt: input.generatedAt ? text(input.generatedAt) : "", tools, workflows, ...input.git !== void 0 ? { git: validateGit(input.git) } : {} };
|
|
84
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 2 * 1024 * 1024) throw new Error("Discovery exceeds 2 MiB");
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
var init_dashboard_transfer = __esm({
|
|
88
|
+
"src/inventory/dashboard-transfer.ts"() {
|
|
89
|
+
"use strict";
|
|
90
|
+
init_git();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// src/inspect/sanitize.ts
|
|
95
|
+
function stripUrlCredentials(value) {
|
|
96
|
+
return value.replace(URL_CREDENTIALS, "$1");
|
|
97
|
+
}
|
|
98
|
+
function sanitizeOutput(value) {
|
|
99
|
+
return stripUrlCredentials(value).replace(TOKENISH, (m) => `${m.slice(0, 4)}***`).replace(EMAIL, "$1***$2");
|
|
100
|
+
}
|
|
101
|
+
function excerpt(value, max = 200) {
|
|
102
|
+
const clean = sanitizeOutput(value).replace(/\s+/g, " ").trim();
|
|
103
|
+
return clean.length > max ? `${clean.slice(0, max)}\u2026` : clean;
|
|
104
|
+
}
|
|
105
|
+
var URL_CREDENTIALS, TOKENISH, EMAIL;
|
|
106
|
+
var init_sanitize = __esm({
|
|
107
|
+
"src/inspect/sanitize.ts"() {
|
|
108
|
+
"use strict";
|
|
109
|
+
URL_CREDENTIALS = /(https?:\/\/)([^/@\s:]+)(:[^/@\s]+)?@/gi;
|
|
110
|
+
TOKENISH = /\b(sk|rk|pk|ghp|gho|ghs|glpat|xox[baprs]|AKIA|AIza)[A-Za-z0-9_-]{6,}\b/g;
|
|
111
|
+
EMAIL = /([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*(@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// src/commands/dashboard.ts
|
|
116
|
+
import { cp, mkdir, readFile as readFile3, writeFile } from "fs/promises";
|
|
117
|
+
import { basename, join as join3 } from "path";
|
|
118
|
+
import { fileURLToPath } from "url";
|
|
119
|
+
function dashboardInventory(cwd, inventory) {
|
|
120
|
+
return JSON.parse(sanitizeOutput(JSON.stringify({
|
|
121
|
+
schema: "company-inventory/v1",
|
|
122
|
+
generatedAt: inventory.generatedAt,
|
|
123
|
+
name: basename(cwd),
|
|
124
|
+
tools: inventory.tools.map((t) => ({
|
|
125
|
+
id: t.id,
|
|
126
|
+
title: t.title,
|
|
127
|
+
kind: t.kind,
|
|
128
|
+
category: t.category,
|
|
129
|
+
confidence: t.confidence ?? "low",
|
|
130
|
+
interface: t.kind === "runtime" ? "terminal" : "api"
|
|
131
|
+
})),
|
|
132
|
+
workflows: inventory.workflows.map((w) => ({ id: w.id, title: w.title ?? w.id, confidence: w.confidence ?? "low", supported: w.supported, verified: false, ...w.entrypoint ? { entrypoint: w.entrypoint } : {}, tools: w.tools, steps: w.steps.map((s) => ({ tool: s.tool, operations: s.operations })) }))
|
|
133
|
+
})));
|
|
134
|
+
}
|
|
135
|
+
async function writeDashboard(cwd, inventory) {
|
|
136
|
+
const directory = join3(cwd, ".agenttwin", "dashboard");
|
|
137
|
+
let bundle = fileURLToPath(new URL("./dashboard/", import.meta.url));
|
|
138
|
+
try {
|
|
139
|
+
await readFile3(join3(bundle, "index.html"));
|
|
140
|
+
} catch {
|
|
141
|
+
bundle = fileURLToPath(new URL("../../../../site/", import.meta.url));
|
|
142
|
+
}
|
|
143
|
+
await mkdir(join3(directory, "assets", "dashboard"), { recursive: true });
|
|
144
|
+
const html = await readFile3(join3(bundle, bundle.endsWith("/site/") ? "dashboard.html" : "index.html"), "utf8");
|
|
145
|
+
for (const folder of ["dashboard", "icons", "logo", "fonts", "vendor"]) {
|
|
146
|
+
await cp(join3(bundle, "assets", folder), join3(directory, "assets", folder), { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
await cp(join3(bundle, "assets", "brand.css"), join3(directory, "assets", "brand.css"));
|
|
149
|
+
const data = await loadDashboardDiscovery(cwd, inventory);
|
|
150
|
+
const payload = JSON.stringify(data).replace(/</g, "\\u003c");
|
|
151
|
+
const page2 = html.replaceAll('type="module"', "defer").replace('<script src="assets/dashboard/dashboard.js', `<script id="agenttwin-discovery" type="application/json">${payload}</script><script src="assets/dashboard/dashboard.js`).replace(/href="index.html"/g, 'href="../report.html"').replace(/href="docs\/"/g, 'href="../report.html"').replace("Docs \u2197", "Discovery report \u2197").replace("Runtime documentation \u2197", "Coverage report \u2197").replace("Back to website \u2197", "Discovery report \u2197");
|
|
152
|
+
await writeFile(join3(directory, "index.html"), page2, "utf8");
|
|
153
|
+
await writeFile(join3(directory, "assets", "dashboard", "inventory.json"), JSON.stringify(data), "utf8");
|
|
154
|
+
await writeFile(join3(directory, "assets", "dashboard", "runs.json"), '{"runs":[]}', "utf8");
|
|
155
|
+
return join3(directory, "index.html");
|
|
156
|
+
}
|
|
157
|
+
var init_dashboard = __esm({
|
|
158
|
+
"src/commands/dashboard.ts"() {
|
|
159
|
+
"use strict";
|
|
160
|
+
init_session();
|
|
161
|
+
init_sanitize();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// src/commands/session.ts
|
|
166
|
+
var session_exports = {};
|
|
167
|
+
__export(session_exports, {
|
|
168
|
+
installedOpenCode: () => installedOpenCode,
|
|
169
|
+
launchSession: () => launchSession,
|
|
170
|
+
loadDashboardDiscovery: () => loadDashboardDiscovery,
|
|
171
|
+
sessionTask: () => sessionTask,
|
|
172
|
+
writeSessionTask: () => writeSessionTask
|
|
173
|
+
});
|
|
174
|
+
import { spawn, spawnSync } from "child_process";
|
|
175
|
+
import { writeFile as writeFile2, readFile as readFile4 } from "fs/promises";
|
|
176
|
+
import { join as join4 } from "path";
|
|
177
|
+
function sessionTask() {
|
|
178
|
+
return `# Prepare this repository for AgentTwin
|
|
179
|
+
|
|
180
|
+
Read project instructions and .agenttwin/inventory.json first. Inspect tracked application source and configuration; skip secrets, .env values, private data, dependencies and build outputs. Use your configured coding-agent provider and normal permission controls.
|
|
181
|
+
|
|
182
|
+
Find distinct business workflows across the repository, including routes, queues, schedules, agent tools and exported functions. Do not cap discovery at a handful of examples. Trace callers and side effects across files. For each candidate record its real relative entrypoint, intent, inputs, dependency operations, expected observable outcome and plausible production failures. Distinguish observed code from hypotheses. Do not claim discovery is exhaustive; report skipped files and unresolved dynamic paths.
|
|
183
|
+
|
|
184
|
+
Write a human-readable .agenttwin/WORKFLOWS.md. For dashboard handoff, write .agenttwin/agent-discovery.json using the metadata-only company-inventory/v1 schema below. Start with the tools in .agenttwin/dashboard/assets/dashboard/inventory.json and retain their IDs; add candidates to workflows. Include no source bodies, credentials, PII or absolute paths. Never mark a candidate verified or executable merely because it was discovered.
|
|
185
|
+
|
|
186
|
+
Example shape (replace these descriptive placeholders with evidence from this repo):
|
|
187
|
+
{"schema":"company-inventory/v1","name":"project","tools":[],"workflows":[{"id":"agent:unique_workflow","title":"Workflow intent","entrypoint":"src/path.ts#function","confidence":"medium","supported":false,"verified":false,"tools":[],"steps":[]}]}
|
|
188
|
+
Each tool must have id, title, kind, category, confidence, interface. Each step must have tool (an existing tool ID) and operations (string array).
|
|
189
|
+
|
|
190
|
+
Before execution, ask the user for missing input, expected outcomes and any required synthetic fixtures. Prepare one real workflow at a time: an executable Node entrypoint plus its required application files, reviewed dependency mappings, synthetic starting state, assertions and an explicit fault when appropriate. Read agenttwin prepare --help and the existing preparation schema rather than guessing fields. The CLI runtime supports bundled Node workflows, bounded Slack operations and explicit custom JSON collections. PostgreSQL/Redis worlds require an explicitly selected cloud runner. Unsupported vendor SDKs, browser and desktop interactions are blockers; never represent generic JSON endpoints as full vendor twins.
|
|
191
|
+
|
|
192
|
+
Do not start Docker or containers locally. Run reviewed supported Node workflows through npx agenttwin run using in-process twins; this executes on the user\u2019s machine and is not a VM security boundary. Do not access production accounts or copy production data. Do not print credentials. Use --runtime hosted or --runtime e2b explicitly only for workflows that require a cloud machine. Never launch a cloud VM merely to analyze code or render dashboard results. Upload application files only after the user reviews the bundle. When a run fails, use the actual run receipt, logs, failed assertions and source digests to propose a fix. Explain uncertainty, edit the approved code, rebuild the bundle and retest using the appropriate runner; never report success without passing evidence.
|
|
193
|
+
|
|
194
|
+
After review, npx agenttwin sync sends discovery, Git metadata and CLI run evidence to the account dashboard using AGENTTWIN_TOKEN (and optional AGENTTWIN_API_URL). Review run evidence before syncing; it includes synthetic state and application output. It does not upload application source. The dashboard will list all candidate workflows and populate the environment map from these findings.
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
function installedOpenCode() {
|
|
198
|
+
return spawnSync("opencode", ["--version"], { stdio: "ignore", timeout: 5e3 }).status === 0;
|
|
199
|
+
}
|
|
200
|
+
async function writeSessionTask(cwd) {
|
|
201
|
+
const path = join4(cwd, ".agenttwin/AGENT-TASK.md");
|
|
202
|
+
await writeFile2(path, sessionTask(), "utf8");
|
|
203
|
+
return path;
|
|
204
|
+
}
|
|
205
|
+
async function launchSession(cwd) {
|
|
206
|
+
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.");
|
|
207
|
+
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.";
|
|
208
|
+
await new Promise((resolve5, reject) => {
|
|
209
|
+
const child = spawn("opencode", ["--prompt", prompt], { cwd, stdio: "inherit", shell: false });
|
|
210
|
+
child.once("error", reject);
|
|
211
|
+
child.once("exit", (code, signal) => code === 0 ? resolve5() : reject(new Error(`Coding session ended ${signal || `with exit code ${code}`}. Discovery files remain available.`)));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
async function loadDashboardDiscovery(cwd, inventory) {
|
|
215
|
+
const base = discoveryTransfer(dashboardInventory(cwd, inventory));
|
|
216
|
+
let raw;
|
|
217
|
+
try {
|
|
218
|
+
raw = await readFile4(join4(cwd, ".agenttwin/agent-discovery.json"), "utf8");
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error.code === "ENOENT") return base;
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
if (Buffer.byteLength(raw) > 2 * 1024 * 1024) throw new Error("Agent discovery exceeds 2 MiB");
|
|
224
|
+
const extra = discoveryTransfer(JSON.parse(raw));
|
|
225
|
+
const tools = new Map(base.tools.map((t) => [t.id, t]));
|
|
226
|
+
for (const tool of extra.tools) if (!tools.has(tool.id)) tools.set(tool.id, tool);
|
|
227
|
+
const workflows = new Map(base.workflows.map((w) => [w.id, w]));
|
|
228
|
+
for (const workflow of extra.workflows) if (!workflows.has(workflow.id)) workflows.set(workflow.id, { ...workflow, supported: false, verified: false });
|
|
229
|
+
const result = discoveryTransfer({ ...base, tools: [...tools.values()], workflows: [...workflows.values()] });
|
|
230
|
+
const ids = new Set(result.tools.map((t) => t.id));
|
|
231
|
+
if (result.workflows.some((w) => w.tools.some((id) => !ids.has(id)) || w.steps.some((s) => !ids.has(s.tool)))) throw new Error("Agent discovery references unknown tools. Correct .agenttwin/agent-discovery.json before syncing.");
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
var init_session = __esm({
|
|
235
|
+
"src/commands/session.ts"() {
|
|
236
|
+
"use strict";
|
|
237
|
+
init_dashboard_transfer();
|
|
238
|
+
init_dashboard();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
12
242
|
// src/discovery/questions/answers.ts
|
|
13
243
|
var answers_exports = {};
|
|
14
244
|
__export(answers_exports, {
|
|
@@ -53,17 +283,56 @@ var init_answers = __esm({
|
|
|
53
283
|
|
|
54
284
|
// src/cli.ts
|
|
55
285
|
import { readFileSync as readFileSync3 } from "fs";
|
|
56
|
-
import { dirname as dirname4, join as
|
|
286
|
+
import { dirname as dirname4, join as join18 } from "path";
|
|
57
287
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
58
288
|
import { parseArgs } from "util";
|
|
59
289
|
|
|
60
290
|
// src/commands/sync.ts
|
|
61
|
-
|
|
62
|
-
import {
|
|
291
|
+
init_git();
|
|
292
|
+
import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
|
|
293
|
+
import { join as join5 } from "path";
|
|
63
294
|
|
|
64
|
-
// src/runtime/
|
|
65
|
-
|
|
295
|
+
// src/runtime/receipt-sync.ts
|
|
296
|
+
init_git();
|
|
297
|
+
import { readFile, readdir } from "fs/promises";
|
|
66
298
|
import { join } from "path";
|
|
299
|
+
function portableLocalReceipt(value) {
|
|
300
|
+
const r = value;
|
|
301
|
+
const uuid = (v) => typeof v === "string" && /^[a-f0-9-]{36}$/.test(v);
|
|
302
|
+
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");
|
|
303
|
+
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");
|
|
304
|
+
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");
|
|
305
|
+
const git = validateGit(r.git);
|
|
306
|
+
const fields = ["schema", "runId", "name", "workflowId", "project", "createdAt", "durationMs", "status", "application", "assertions", "infrastructureAssertions", "fault", "initialState", "finalState", "events", "unsupported", "coverage", "sourceDigests", "specDigest"];
|
|
307
|
+
const result = Object.fromEntries(fields.filter((k) => r[k] !== void 0).map((k) => [k, r[k]]));
|
|
308
|
+
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 } : {} });
|
|
309
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 2 * 1024 * 1024) throw new Error("CLI receipt exceeds 2 MiB");
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
async function syncReceipts(cwd, base, token) {
|
|
313
|
+
let names;
|
|
314
|
+
try {
|
|
315
|
+
names = await readdir(join(cwd, ".agenttwin/runs"));
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error.code === "ENOENT") return 0;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
let synced = 0;
|
|
321
|
+
for (const name of names.filter((n) => /^[a-f0-9-]{36}$/.test(n)).slice(-100)) {
|
|
322
|
+
const path = join(cwd, ".agenttwin/runs", name, "run.json");
|
|
323
|
+
const r = JSON.parse(await readFile(path, "utf8"));
|
|
324
|
+
if (r.runtime !== "local") continue;
|
|
325
|
+
const receipt = portableLocalReceipt(r);
|
|
326
|
+
const response = await fetch(base + "/v1/receipts", { method: "POST", headers: { authorization: "Bearer " + token, "content-type": "application/json" }, body: JSON.stringify(receipt), redirect: "error", signal: AbortSignal.timeout(2e4) });
|
|
327
|
+
if (!response.ok) throw new Error("Run evidence sync failed: " + response.status);
|
|
328
|
+
synced++;
|
|
329
|
+
}
|
|
330
|
+
return synced;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/runtime/remote.ts
|
|
334
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
335
|
+
import { join as join2 } from "path";
|
|
67
336
|
|
|
68
337
|
// src/lib/exit.ts
|
|
69
338
|
var EXIT = {
|
|
@@ -136,14 +405,14 @@ function apiBase(value) {
|
|
|
136
405
|
if (url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) throw new CliError("AgentTwin API requires HTTPS (loopback HTTP allowed for development)", { code: EXIT.USAGE });
|
|
137
406
|
return value.replace(/\/$/, "");
|
|
138
407
|
}
|
|
139
|
-
async function executeRemote(stage, spec, log) {
|
|
408
|
+
async function executeRemote(stage, spec, log, metadata = {}) {
|
|
140
409
|
const base = apiBase(process.env.AGENTTWIN_API_URL ?? "");
|
|
141
410
|
const token = process.env.AGENTTWIN_TOKEN;
|
|
142
411
|
if (!token) throw new CliError("AGENTTWIN_TOKEN is not configured", { code: EXIT.BLOCKED });
|
|
143
412
|
const files = /* @__PURE__ */ Object.create(null);
|
|
144
|
-
for (const file of spec.files) files[file] = (await
|
|
413
|
+
for (const file of spec.files) files[file] = (await readFile2(join2(stage, "app", file))).toString("base64");
|
|
145
414
|
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
146
|
-
const response = await fetch(`${base}/v1/runs`, { method: "POST", headers, body: JSON.stringify({ spec, files }), redirect: "error", signal: AbortSignal.timeout(15e3) });
|
|
415
|
+
const response = await fetch(`${base}/v1/runs`, { method: "POST", headers, body: JSON.stringify({ spec, files, ...metadata }), redirect: "error", signal: AbortSignal.timeout(15e3) });
|
|
147
416
|
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 });
|
|
148
417
|
const job = await response.json();
|
|
149
418
|
if (!/^[a-f0-9-]{36}$/.test(job.id)) throw new CliError("API returned an invalid run ID");
|
|
@@ -163,206 +432,303 @@ async function executeRemote(stage, spec, log) {
|
|
|
163
432
|
throw new CliError(`Timed out waiting for hosted run ${job.id}; inspect this run in the API before retrying`);
|
|
164
433
|
}
|
|
165
434
|
|
|
166
|
-
// src/commands/session.ts
|
|
167
|
-
import { spawn, spawnSync } from "child_process";
|
|
168
|
-
import { writeFile as writeFile2, readFile as readFile3 } from "fs/promises";
|
|
169
|
-
import { join as join3 } from "path";
|
|
170
|
-
|
|
171
|
-
// src/inventory/dashboard-transfer.ts
|
|
172
|
-
function discoveryTransfer(value) {
|
|
173
|
-
const input = value;
|
|
174
|
-
if (!input || input.schema !== "company-inventory/v1" || typeof input.name !== "string" || !Array.isArray(input.tools) || !Array.isArray(input.workflows) || input.tools.length > 1e3 || input.workflows.length > 5e3) throw new Error("Invalid discovery metadata");
|
|
175
|
-
const text = (v, limit = 240) => {
|
|
176
|
-
if (typeof v !== "string" || v.length > limit || /[\x00-\x1f]/.test(v)) throw new Error("Invalid discovery string");
|
|
177
|
-
return v;
|
|
178
|
-
};
|
|
179
|
-
const strings = (v) => {
|
|
180
|
-
if (!Array.isArray(v) || v.length > 1e3) throw new Error("Invalid discovery list");
|
|
181
|
-
return v.map((s) => text(s));
|
|
182
|
-
};
|
|
183
|
-
const entrypoint = (v) => {
|
|
184
|
-
const path = text(v, 500);
|
|
185
|
-
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");
|
|
186
|
-
return path;
|
|
187
|
-
};
|
|
188
|
-
const tools = input.tools.map((raw) => {
|
|
189
|
-
const t = raw;
|
|
190
|
-
return { id: text(t.id), title: text(t.title), kind: text(t.kind), category: text(t.category), confidence: text(t.confidence), interface: text(t.interface) };
|
|
191
|
-
});
|
|
192
|
-
const workflows = input.workflows.map((raw) => {
|
|
193
|
-
const w = raw;
|
|
194
|
-
if (!Array.isArray(w.steps) || w.steps.length > 1e3) throw new Error("Invalid workflow steps");
|
|
195
|
-
return { id: text(w.id), title: text(w.title), confidence: text(w.confidence), supported: w.supported === true, verified: false, tools: strings(w.tools), steps: w.steps.map((rawStep) => {
|
|
196
|
-
const step = rawStep;
|
|
197
|
-
return { tool: text(step.tool), operations: strings(step.operations) };
|
|
198
|
-
}), ...w.entrypoint ? { entrypoint: entrypoint(w.entrypoint) } : {} };
|
|
199
|
-
});
|
|
200
|
-
if (new Set(tools.map((t) => t.id)).size !== tools.length || new Set(workflows.map((w) => w.id)).size !== workflows.length) throw new Error("Duplicate discovery IDs");
|
|
201
|
-
const ids = new Set(tools.map((t) => t.id));
|
|
202
|
-
if (workflows.some((w) => w.tools.some((id) => !ids.has(id)) || w.steps.some((s) => !ids.has(s.tool)))) throw new Error("Unknown workflow tool");
|
|
203
|
-
const result = { schema: "company-inventory/v1", name: text(input.name), generatedAt: input.generatedAt ? text(input.generatedAt) : "", tools, workflows };
|
|
204
|
-
if (Buffer.byteLength(JSON.stringify(result)) > 2 * 1024 * 1024) throw new Error("Discovery exceeds 2 MiB");
|
|
205
|
-
return result;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// src/commands/dashboard.ts
|
|
209
|
-
import { cp, mkdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
210
|
-
import { basename, join as join2 } from "path";
|
|
211
|
-
import { fileURLToPath } from "url";
|
|
212
|
-
|
|
213
|
-
// src/inspect/sanitize.ts
|
|
214
|
-
var URL_CREDENTIALS = /(https?:\/\/)([^/@\s:]+)(:[^/@\s]+)?@/gi;
|
|
215
|
-
var TOKENISH = /\b(sk|rk|pk|ghp|gho|ghs|glpat|xox[baprs]|AKIA|AIza)[A-Za-z0-9_-]{6,}\b/g;
|
|
216
|
-
var EMAIL = /([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*(@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
|
|
217
|
-
function stripUrlCredentials(value) {
|
|
218
|
-
return value.replace(URL_CREDENTIALS, "$1");
|
|
219
|
-
}
|
|
220
|
-
function sanitizeOutput(value) {
|
|
221
|
-
return stripUrlCredentials(value).replace(TOKENISH, (m) => `${m.slice(0, 4)}***`).replace(EMAIL, "$1***$2");
|
|
222
|
-
}
|
|
223
|
-
function excerpt(value, max = 200) {
|
|
224
|
-
const clean = sanitizeOutput(value).replace(/\s+/g, " ").trim();
|
|
225
|
-
return clean.length > max ? `${clean.slice(0, max)}\u2026` : clean;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// src/commands/dashboard.ts
|
|
229
|
-
function dashboardInventory(cwd, inventory) {
|
|
230
|
-
return JSON.parse(sanitizeOutput(JSON.stringify({
|
|
231
|
-
schema: "company-inventory/v1",
|
|
232
|
-
generatedAt: inventory.generatedAt,
|
|
233
|
-
name: basename(cwd),
|
|
234
|
-
tools: inventory.tools.map((t) => ({
|
|
235
|
-
id: t.id,
|
|
236
|
-
title: t.title,
|
|
237
|
-
kind: t.kind,
|
|
238
|
-
category: t.category,
|
|
239
|
-
confidence: t.confidence ?? "low",
|
|
240
|
-
interface: t.kind === "runtime" ? "terminal" : "api"
|
|
241
|
-
})),
|
|
242
|
-
workflows: inventory.workflows.map((w) => ({ id: w.id, title: w.title ?? w.id, confidence: w.confidence ?? "low", supported: w.supported, verified: false, ...w.entrypoint ? { entrypoint: w.entrypoint } : {}, tools: w.tools, steps: w.steps.map((s) => ({ tool: s.tool, operations: s.operations })) }))
|
|
243
|
-
})));
|
|
244
|
-
}
|
|
245
|
-
async function writeDashboard(cwd, inventory) {
|
|
246
|
-
const directory = join2(cwd, ".agenttwin", "dashboard");
|
|
247
|
-
let bundle = fileURLToPath(new URL("./dashboard/", import.meta.url));
|
|
248
|
-
try {
|
|
249
|
-
await readFile2(join2(bundle, "index.html"));
|
|
250
|
-
} catch {
|
|
251
|
-
bundle = fileURLToPath(new URL("../../../../site/", import.meta.url));
|
|
252
|
-
}
|
|
253
|
-
await mkdir(join2(directory, "assets", "dashboard"), { recursive: true });
|
|
254
|
-
const html = await readFile2(join2(bundle, bundle.endsWith("/site/") ? "dashboard.html" : "index.html"), "utf8");
|
|
255
|
-
for (const folder of ["dashboard", "icons", "logo", "fonts"]) {
|
|
256
|
-
await cp(join2(bundle, "assets", folder), join2(directory, "assets", folder), { recursive: true });
|
|
257
|
-
}
|
|
258
|
-
await cp(join2(bundle, "assets", "brand.css"), join2(directory, "assets", "brand.css"));
|
|
259
|
-
const data = await loadDashboardDiscovery(cwd, inventory);
|
|
260
|
-
const payload = JSON.stringify(data).replace(/</g, "\\u003c");
|
|
261
|
-
const page2 = html.replace('type="module"', "defer").replace('<script src="assets/dashboard/dashboard.js', `<script id="agenttwin-discovery" type="application/json">${payload}</script><script src="assets/dashboard/dashboard.js`).replace(/href="index.html"/g, 'href="../report.html"').replace(/href="docs\/"/g, 'href="../report.html"').replace("Docs \u2197", "Discovery report \u2197").replace("Runtime documentation \u2197", "Coverage report \u2197").replace("Back to website \u2197", "Discovery report \u2197");
|
|
262
|
-
await writeFile(join2(directory, "index.html"), page2, "utf8");
|
|
263
|
-
await writeFile(join2(directory, "assets", "dashboard", "inventory.json"), JSON.stringify(data), "utf8");
|
|
264
|
-
await writeFile(join2(directory, "assets", "dashboard", "runs.json"), '{"runs":[]}', "utf8");
|
|
265
|
-
return join2(directory, "index.html");
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// src/commands/session.ts
|
|
269
|
-
function sessionTask() {
|
|
270
|
-
return `# Prepare this repository for AgentTwin
|
|
271
|
-
|
|
272
|
-
Read project instructions and .agenttwin/inventory.json first. Inspect tracked application source and configuration; skip secrets, .env values, private data, dependencies and build outputs. Use your configured coding-agent provider and normal permission controls.
|
|
273
|
-
|
|
274
|
-
Find distinct business workflows across the repository, including routes, queues, schedules, agent tools and exported functions. Do not cap discovery at a handful of examples. Trace callers and side effects across files. For each candidate record its real relative entrypoint, intent, inputs, dependency operations, expected observable outcome and plausible production failures. Distinguish observed code from hypotheses. Do not claim discovery is exhaustive; report skipped files and unresolved dynamic paths.
|
|
275
|
-
|
|
276
|
-
Write a human-readable .agenttwin/WORKFLOWS.md. For dashboard handoff, write .agenttwin/agent-discovery.json using the metadata-only company-inventory/v1 schema below. Start with the tools in .agenttwin/dashboard/assets/dashboard/inventory.json and retain their IDs; add candidates to workflows. Include no source bodies, credentials, PII or absolute paths. Never mark a candidate verified or executable merely because it was discovered.
|
|
277
|
-
|
|
278
|
-
Example shape (replace these descriptive placeholders with evidence from this repo):
|
|
279
|
-
{"schema":"company-inventory/v1","name":"project","tools":[],"workflows":[{"id":"agent:unique_workflow","title":"Workflow intent","entrypoint":"src/path.ts#function","confidence":"medium","supported":false,"verified":false,"tools":[],"steps":[]}]}
|
|
280
|
-
Each tool must have id, title, kind, category, confidence, interface. Each step must have tool (an existing tool ID) and operations (string array).
|
|
281
|
-
|
|
282
|
-
Before execution, ask the user for missing input, expected outcomes and any required synthetic fixtures. Prepare one real workflow at a time: an executable Node entrypoint plus its required application files, reviewed dependency mappings, synthetic starting state, assertions and an explicit fault when appropriate. Read agenttwin prepare --help and the existing preparation schema rather than guessing fields. The current hosted runtime supports Node, PostgreSQL, Redis, bounded Slack operations and explicit custom JSON collections. Unsupported vendor SDKs, browser and desktop interactions are blockers; never represent generic JSON endpoints as full vendor twins.
|
|
283
|
-
|
|
284
|
-
Do not run application workflows, Docker or containers locally. Do not access production accounts or copy production data. Do not print credentials. Execute reviewed workflows only through npx agenttwin run in a fresh hosted sandbox with AgentTwin account credentials or E2B credentials. Upload application files only after the user reviews the bundle. When a run fails, use the actual run receipt, logs, failed assertions and source digests to propose a fix. Explain uncertainty, edit the approved code, rebuild the bundle and retest in a new sandbox; never report success without passing evidence.
|
|
285
|
-
|
|
286
|
-
After review, npx agenttwin sync sends discovery metadata to the account dashboard using AGENTTWIN_TOKEN (and optional AGENTTWIN_API_URL). It does not upload application source. The dashboard will list all candidate workflows and populate the environment map from these findings.
|
|
287
|
-
`;
|
|
288
|
-
}
|
|
289
|
-
function installedOpenCode() {
|
|
290
|
-
return spawnSync("opencode", ["--version"], { stdio: "ignore", timeout: 5e3 }).status === 0;
|
|
291
|
-
}
|
|
292
|
-
async function writeSessionTask(cwd) {
|
|
293
|
-
const path = join3(cwd, ".agenttwin/AGENT-TASK.md");
|
|
294
|
-
await writeFile2(path, sessionTask(), "utf8");
|
|
295
|
-
return path;
|
|
296
|
-
}
|
|
297
|
-
async function launchSession(cwd) {
|
|
298
|
-
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.");
|
|
299
|
-
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.";
|
|
300
|
-
await new Promise((resolve5, reject) => {
|
|
301
|
-
const child = spawn("opencode", ["--prompt", prompt], { cwd, stdio: "inherit", shell: false });
|
|
302
|
-
child.once("error", reject);
|
|
303
|
-
child.once("exit", (code, signal) => code === 0 ? resolve5() : reject(new Error(`Coding session ended ${signal || `with exit code ${code}`}. Discovery files remain available.`)));
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
async function loadDashboardDiscovery(cwd, inventory) {
|
|
307
|
-
const base = discoveryTransfer(dashboardInventory(cwd, inventory));
|
|
308
|
-
let raw;
|
|
309
|
-
try {
|
|
310
|
-
raw = await readFile3(join3(cwd, ".agenttwin/agent-discovery.json"), "utf8");
|
|
311
|
-
} catch (error) {
|
|
312
|
-
if (error.code === "ENOENT") return base;
|
|
313
|
-
throw error;
|
|
314
|
-
}
|
|
315
|
-
if (Buffer.byteLength(raw) > 2 * 1024 * 1024) throw new Error("Agent discovery exceeds 2 MiB");
|
|
316
|
-
const extra = discoveryTransfer(JSON.parse(raw));
|
|
317
|
-
const tools = new Map(base.tools.map((t) => [t.id, t]));
|
|
318
|
-
for (const tool of extra.tools) if (!tools.has(tool.id)) tools.set(tool.id, tool);
|
|
319
|
-
const workflows = new Map(base.workflows.map((w) => [w.id, w]));
|
|
320
|
-
for (const workflow of extra.workflows) if (!workflows.has(workflow.id)) workflows.set(workflow.id, { ...workflow, supported: false, verified: false });
|
|
321
|
-
const result = discoveryTransfer({ ...base, tools: [...tools.values()], workflows: [...workflows.values()] });
|
|
322
|
-
const ids = new Set(result.tools.map((t) => t.id));
|
|
323
|
-
if (result.workflows.some((w) => w.tools.some((id) => !ids.has(id)) || w.steps.some((s) => !ids.has(s.tool)))) throw new Error("Agent discovery references unknown tools. Correct .agenttwin/agent-discovery.json before syncing.");
|
|
324
|
-
return result;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
435
|
// src/commands/sync.ts
|
|
436
|
+
init_session();
|
|
328
437
|
async function runSync(cwd) {
|
|
329
438
|
const token = process.env.AGENTTWIN_TOKEN;
|
|
330
439
|
if (!token || !/^at_user_[a-f0-9]{64}$/.test(token)) throw new Error("Set AGENTTWIN_TOKEN to your individual account token.");
|
|
331
440
|
const base = apiBase(process.env.AGENTTWIN_API_URL || "https://agenttwin-api.212.56.40.125.sslip.io");
|
|
332
|
-
const inventory = JSON.parse(await
|
|
441
|
+
const inventory = JSON.parse(await readFile5(join5(cwd, ".agenttwin/inventory.json"), "utf8"));
|
|
333
442
|
const payload = await loadDashboardDiscovery(cwd, inventory);
|
|
443
|
+
let previousHead;
|
|
444
|
+
try {
|
|
445
|
+
previousHead = JSON.parse(await readFile5(join5(cwd, ".agenttwin/git.json"), "utf8")).head;
|
|
446
|
+
} catch {
|
|
447
|
+
}
|
|
448
|
+
const git = await captureGit(cwd, previousHead);
|
|
449
|
+
Object.assign(payload, git ? { git } : {});
|
|
334
450
|
const response = await fetch(base + "/v1/discovery", { method: "POST", headers: { authorization: "Bearer " + token, "content-type": "application/json" }, body: JSON.stringify(payload), redirect: "error", signal: AbortSignal.timeout(2e4) });
|
|
335
451
|
const result = await response.json();
|
|
336
452
|
if (!response.ok) throw new Error(result.error || "Discovery sync failed: " + response.status);
|
|
337
|
-
|
|
453
|
+
if (git) await writeFile3(join5(cwd, ".agenttwin/git.json"), JSON.stringify(git) + "\n", { mode: 384 });
|
|
454
|
+
const receipts = await syncReceipts(cwd, base, token);
|
|
455
|
+
return { receipts, synced: true, tools: payload.tools.length, workflows: payload.workflows.length, dashboard: "https://agenttwin.si/site/dashboard.html", uploaded: "discovery, Git metadata and run evidence; no application source or credentials" };
|
|
338
456
|
}
|
|
339
457
|
|
|
340
458
|
// src/commands/prepare.ts
|
|
341
|
-
|
|
342
|
-
import {
|
|
459
|
+
init_git();
|
|
460
|
+
import { mkdir as mkdir3, mkdtemp as mkdtemp2, readFile as readFile8, writeFile as writeFile5, rm as rm2 } from "fs/promises";
|
|
461
|
+
import { join as join8, resolve as resolve2, dirname as dirname2 } from "path";
|
|
343
462
|
import { tmpdir as tmpdir2 } from "os";
|
|
344
463
|
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
345
464
|
|
|
346
465
|
// src/commands/run.ts
|
|
347
|
-
import { mkdtemp, mkdir as mkdir2, readFile as
|
|
466
|
+
import { mkdtemp, mkdir as mkdir2, readFile as readFile7, writeFile as writeFile4, realpath, lstat, rm, copyFile } from "fs/promises";
|
|
348
467
|
import { tmpdir } from "os";
|
|
349
|
-
import { dirname, isAbsolute, join as
|
|
468
|
+
import { basename as basename2, dirname, isAbsolute, join as join7, relative, resolve, sep } from "path";
|
|
350
469
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
351
470
|
import { createHash, randomUUID } from "crypto";
|
|
352
471
|
|
|
472
|
+
// src/runtime/local.ts
|
|
473
|
+
import { spawn as spawn2 } from "child_process";
|
|
474
|
+
|
|
475
|
+
// src/runtime/http-world.ts
|
|
476
|
+
import { createServer } from "http";
|
|
477
|
+
import { randomBytes } from "crypto";
|
|
478
|
+
var HttpWorld = class {
|
|
479
|
+
constructor(spec) {
|
|
480
|
+
this.spec = spec;
|
|
481
|
+
}
|
|
482
|
+
spec;
|
|
483
|
+
events = [];
|
|
484
|
+
state = /* @__PURE__ */ Object.create(null);
|
|
485
|
+
credentials = /* @__PURE__ */ Object.create(null);
|
|
486
|
+
unsupported = [];
|
|
487
|
+
servers = [];
|
|
488
|
+
timers = /* @__PURE__ */ new Set();
|
|
489
|
+
fired = false;
|
|
490
|
+
record(service, operation, path, detail) {
|
|
491
|
+
if (this.events.length >= 5e4) return;
|
|
492
|
+
this.events.push({ sequence: this.events.length + 1, service, operation, path, detail });
|
|
493
|
+
}
|
|
494
|
+
async start() {
|
|
495
|
+
for (const service of this.spec.services) {
|
|
496
|
+
const token = `at_world_${randomBytes(24).toString("hex")}`;
|
|
497
|
+
const data = /* @__PURE__ */ Object.create(null);
|
|
498
|
+
this.state[service.name] = data;
|
|
499
|
+
for (const c of service.collections) data[c.path] = structuredClone(c.seed);
|
|
500
|
+
const keys = /* @__PURE__ */ new Map();
|
|
501
|
+
const server = createServer(async (req, res) => {
|
|
502
|
+
const send = (code, body) => {
|
|
503
|
+
if (!res.destroyed) {
|
|
504
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
505
|
+
res.end(JSON.stringify(body));
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
try {
|
|
509
|
+
if (req.headers.authorization !== `Bearer ${token}`) {
|
|
510
|
+
this.record(service.name, "auth-denied", req.url ?? "", "invalid test credential");
|
|
511
|
+
send(401, { error: "invalid_test_credential" });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (this.events.length >= 5e4 || this.unsupported.length >= 1e3) {
|
|
515
|
+
send(429, { error: "world_capacity" });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
519
|
+
let path = url.pathname;
|
|
520
|
+
const slack = service.adapter === "slack";
|
|
521
|
+
const slackMethod = path.replace(/^\/api\//, "");
|
|
522
|
+
let payload;
|
|
523
|
+
if (req.method === "POST") {
|
|
524
|
+
const chunks = [];
|
|
525
|
+
let bytes = 0;
|
|
526
|
+
for await (const chunk of req) {
|
|
527
|
+
bytes += chunk.length;
|
|
528
|
+
if (bytes > 1024 * 1024) {
|
|
529
|
+
send(413, { error: "body_too_large" });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
chunks.push(Buffer.from(chunk));
|
|
533
|
+
}
|
|
534
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
535
|
+
let parsed;
|
|
536
|
+
try {
|
|
537
|
+
parsed = slack && req.headers["content-type"]?.startsWith("application/x-www-form-urlencoded") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text);
|
|
538
|
+
} catch {
|
|
539
|
+
send(400, { error: "expected_json_object" });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
543
|
+
send(400, { error: "expected_json_object" });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
payload = parsed;
|
|
547
|
+
}
|
|
548
|
+
if (slack) {
|
|
549
|
+
const params = payload ?? Object.fromEntries(url.searchParams);
|
|
550
|
+
const allowed = slackMethod === "chat.postMessage" ? ["channel", "text"] : slackMethod === "conversations.history" ? ["channel"] : [];
|
|
551
|
+
if (Object.keys(params).some((k) => !allowed.includes(k))) {
|
|
552
|
+
this.unsupported.push(`${service.name}: unsupported parameters for ${slackMethod}`);
|
|
553
|
+
send(501, { ok: false, error: "unsupported_parameters", forwardedToProduction: false });
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (slackMethod === "chat.postMessage" && req.method === "POST") path = "/messages";
|
|
557
|
+
else if (slackMethod === "conversations.history" && ["GET", "POST"].includes(req.method ?? "")) path = "/messages";
|
|
558
|
+
else if (slackMethod === "conversations.list" && ["GET", "POST"].includes(req.method ?? "")) path = "/channels";
|
|
559
|
+
else {
|
|
560
|
+
this.unsupported.push(`${service.name}: ${req.method} ${url.pathname}`);
|
|
561
|
+
send(501, { ok: false, error: "unsupported_operation" });
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (!Object.hasOwn(data, path) || !["GET", "POST"].includes(req.method ?? "")) {
|
|
566
|
+
this.unsupported.push(`${service.name}: ${req.method} ${path}`);
|
|
567
|
+
send(501, { error: "unsupported_operation", forwardedToProduction: false });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (req.method === "GET" || slack && slackMethod !== "chat.postMessage") {
|
|
571
|
+
this.record(service.name, "read", path, `${data[path].length} records`);
|
|
572
|
+
if (slack && path === "/channels") send(200, { ok: true, channels: data[path], response_metadata: { next_cursor: "" } });
|
|
573
|
+
else if (slack) {
|
|
574
|
+
const channel = payload?.channel ?? url.searchParams.get("channel");
|
|
575
|
+
if (!data["/channels"].some((c) => c.id === channel)) send(200, { ok: false, error: "channel_not_found" });
|
|
576
|
+
else send(200, { ok: true, messages: data[path].filter((m) => m.channel === channel), has_more: false });
|
|
577
|
+
} else send(200, { data: data[path] });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const body = payload;
|
|
581
|
+
if (slack) {
|
|
582
|
+
const message = body;
|
|
583
|
+
if (!data["/channels"].some((c) => c.id === message.channel)) {
|
|
584
|
+
send(200, { ok: false, error: "channel_not_found" });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (typeof message.text !== "string" || !message.text) {
|
|
588
|
+
send(200, { ok: false, error: "no_text" });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (data[path].length >= 1e4 || this.events.length >= 5e4) {
|
|
593
|
+
send(429, { error: "world_capacity" });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const keyHeader = slack ? void 0 : req.headers["idempotency-key"];
|
|
597
|
+
const key = typeof keyHeader === "string" ? `${path}:${keyHeader}` : void 0;
|
|
598
|
+
const signature = JSON.stringify(body);
|
|
599
|
+
const previous = key ? keys.get(key) : void 0;
|
|
600
|
+
if (previous) {
|
|
601
|
+
if (previous.body !== signature) {
|
|
602
|
+
send(409, { error: "idempotency_conflict" });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
this.record(service.name, "replay", path, "same-key response replay");
|
|
606
|
+
send(200, previous.record);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
let index = data[path].length + 1;
|
|
610
|
+
while (data[path].some((r) => r.id === `${service.name}_${index}`)) index++;
|
|
611
|
+
const row = { ...body, id: `${service.name}_${index}` };
|
|
612
|
+
if (slack) row.ts = `${index}.000001`;
|
|
613
|
+
const reply = slack ? { ok: true, channel: row.channel, ts: row.ts, message: row } : row;
|
|
614
|
+
data[path].push(row);
|
|
615
|
+
if (key) keys.set(key, { body: signature, record: row });
|
|
616
|
+
this.record(service.name, "write", path, String(row.id));
|
|
617
|
+
const fault = this.spec.fault;
|
|
618
|
+
if (!this.fired && fault?.service === service.name && fault.path === path) {
|
|
619
|
+
this.fired = true;
|
|
620
|
+
this.record(service.name, "fault", path, `write committed; response delayed ${fault.delayMs}ms`);
|
|
621
|
+
const timer = setTimeout(() => {
|
|
622
|
+
this.timers.delete(timer);
|
|
623
|
+
send(slack ? 200 : 201, reply);
|
|
624
|
+
}, fault.delayMs);
|
|
625
|
+
this.timers.add(timer);
|
|
626
|
+
} else send(slack ? 200 : 201, reply);
|
|
627
|
+
} catch {
|
|
628
|
+
send(500, { error: "world_request_error" });
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
server.requestTimeout = 1e4;
|
|
632
|
+
await new Promise((resolve5, reject) => {
|
|
633
|
+
server.once("error", reject);
|
|
634
|
+
server.listen(0, "127.0.0.1", resolve5);
|
|
635
|
+
});
|
|
636
|
+
this.servers.push(server);
|
|
637
|
+
this.credentials[service.name] = { endpoint: `http://127.0.0.1:${server.address().port}`, token };
|
|
638
|
+
}
|
|
639
|
+
return this.credentials;
|
|
640
|
+
}
|
|
641
|
+
assertions() {
|
|
642
|
+
return this.spec.assertions.map((a) => {
|
|
643
|
+
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)));
|
|
644
|
+
return { ...a, actual: rows.length, passed: rows.length === a.count };
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
get faultTriggered() {
|
|
648
|
+
return this.fired;
|
|
649
|
+
}
|
|
650
|
+
async close() {
|
|
651
|
+
for (const timer of this.timers) clearTimeout(timer);
|
|
652
|
+
this.timers.clear();
|
|
653
|
+
await Promise.all(this.servers.map((server) => new Promise((resolve5) => {
|
|
654
|
+
server.close(() => resolve5());
|
|
655
|
+
server.closeAllConnections();
|
|
656
|
+
})));
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// src/runtime/local.ts
|
|
661
|
+
async function executeLocal(app, spec, signal) {
|
|
662
|
+
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.");
|
|
663
|
+
signal?.throwIfAborted();
|
|
664
|
+
const world = new HttpWorld(spec), started = Date.now();
|
|
665
|
+
try {
|
|
666
|
+
const credentials = await world.start();
|
|
667
|
+
const env = { PATH: process.env.PATH ?? "", NODE_ENV: "test" };
|
|
668
|
+
for (const [name, c] of Object.entries(credentials)) {
|
|
669
|
+
env[`AGENTTWIN_${name.toUpperCase()}_URL`] = c.endpoint;
|
|
670
|
+
env[`AGENTTWIN_${name.toUpperCase()}_TOKEN`] = c.token;
|
|
671
|
+
}
|
|
672
|
+
let stdout = "", stderr = "", timedOut = false;
|
|
673
|
+
const child = spawn2(process.execPath, spec.command.slice(1), { cwd: app, env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
674
|
+
child.stdout.on("data", (b) => {
|
|
675
|
+
stdout = (stdout + b.toString()).slice(-65536);
|
|
676
|
+
});
|
|
677
|
+
child.stderr.on("data", (b) => {
|
|
678
|
+
stderr = (stderr + b.toString()).slice(-65536);
|
|
679
|
+
});
|
|
680
|
+
const kill = () => {
|
|
681
|
+
if (child.pid) {
|
|
682
|
+
try {
|
|
683
|
+
process.platform === "win32" ? child.kill("SIGKILL") : process.kill(-child.pid, "SIGKILL");
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
const abort = () => kill();
|
|
689
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
690
|
+
const timer = setTimeout(() => {
|
|
691
|
+
timedOut = true;
|
|
692
|
+
kill();
|
|
693
|
+
}, spec.timeoutSeconds * 1e3);
|
|
694
|
+
const exitCode = await new Promise((resolve5, reject) => {
|
|
695
|
+
child.once("error", reject);
|
|
696
|
+
child.once("close", resolve5);
|
|
697
|
+
}).finally(() => {
|
|
698
|
+
clearTimeout(timer);
|
|
699
|
+
signal?.removeEventListener("abort", abort);
|
|
700
|
+
kill();
|
|
701
|
+
});
|
|
702
|
+
signal?.throwIfAborted();
|
|
703
|
+
await world.close();
|
|
704
|
+
for (const c of Object.values(credentials)) {
|
|
705
|
+
stdout = stdout.split(c.token).join("[test-token]");
|
|
706
|
+
stderr = stderr.split(c.token).join("[test-token]");
|
|
707
|
+
}
|
|
708
|
+
const assertions = world.assertions();
|
|
709
|
+
const status = world.unsupported.length || spec.fault && !world.faultTriggered ? "incomplete" : timedOut || exitCode !== 0 ? "error" : assertions.every((a) => a.passed) ? "passed" : "failed";
|
|
710
|
+
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" } };
|
|
711
|
+
} finally {
|
|
712
|
+
await world.close();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/commands/run.ts
|
|
717
|
+
init_git();
|
|
718
|
+
|
|
353
719
|
// src/runtime/e2b.ts
|
|
354
720
|
import { Sandbox } from "e2b";
|
|
355
|
-
import { readFile as
|
|
356
|
-
import { randomBytes } from "crypto";
|
|
721
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
722
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
357
723
|
import { homedir } from "os";
|
|
358
|
-
import { join as
|
|
724
|
+
import { join as join6 } from "path";
|
|
359
725
|
async function executeE2B(stage, spec, log, signal) {
|
|
360
726
|
signal?.throwIfAborted();
|
|
361
727
|
const apiKey = process.env.E2B_API_KEY;
|
|
362
728
|
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." });
|
|
363
729
|
let saved = {};
|
|
364
730
|
try {
|
|
365
|
-
saved = JSON.parse(await
|
|
731
|
+
saved = JSON.parse(await readFile6(join6(homedir(), ".agenttwin/runtime.json"), "utf8"));
|
|
366
732
|
} catch {
|
|
367
733
|
}
|
|
368
734
|
const template = process.env.E2B_TEMPLATE_ID ?? saved.templateId;
|
|
@@ -381,12 +747,12 @@ async function executeE2B(stage, spec, log, signal) {
|
|
|
381
747
|
signal?.throwIfAborted();
|
|
382
748
|
await sandbox.commands.run("mkdir -p /input/app && chmod 755 /input /input/app", { user: "root" });
|
|
383
749
|
for (const file of ["world.json", "worker.js", ...spec.files.map((f) => `app/${f}`)]) {
|
|
384
|
-
await sandbox.files.write(`/input/${file}`, new Uint8Array(await
|
|
750
|
+
await sandbox.files.write(`/input/${file}`, new Uint8Array(await readFile6(join6(stage, file))).buffer, { user: "root" });
|
|
385
751
|
}
|
|
386
752
|
await sandbox.commands.run("chmod -R a+rX /input", { user: "root" });
|
|
387
753
|
const runtimeEnv = {};
|
|
388
754
|
if (spec.postgres) {
|
|
389
|
-
const password =
|
|
755
|
+
const password = randomBytes2(24).toString("hex");
|
|
390
756
|
secrets.push(password);
|
|
391
757
|
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" });
|
|
392
758
|
await sandbox.files.write("/tmp/agenttwin-init.sql", `CREATE ROLE agenttwin LOGIN PASSWORD '${password}';
|
|
@@ -400,7 +766,7 @@ CREATE DATABASE agenttwin OWNER agenttwin;
|
|
|
400
766
|
}
|
|
401
767
|
}
|
|
402
768
|
if (spec.redis) {
|
|
403
|
-
const password =
|
|
769
|
+
const password = randomBytes2(24).toString("hex");
|
|
404
770
|
secrets.push(password);
|
|
405
771
|
await sandbox.files.write("/tmp/agenttwin-redis.conf", `bind 127.0.0.1
|
|
406
772
|
port 6379
|
|
@@ -504,12 +870,12 @@ async function stageApplication(root, files, destination) {
|
|
|
504
870
|
const source = await realpath(resolve(canonicalRoot, file));
|
|
505
871
|
const rel = relative(canonicalRoot, source);
|
|
506
872
|
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel) || source !== resolve(canonicalRoot, file) || !(await lstat(source)).isFile()) throw new CliError(`Files must be regular files within the project, without symlinks: ${file}`, { code: EXIT.USAGE });
|
|
507
|
-
const contents = await
|
|
873
|
+
const contents = await readFile7(source);
|
|
508
874
|
bytes += contents.length;
|
|
509
875
|
if (bytes > 20 * 1024 * 1024) throw new CliError("Application files exceed 20 MiB; supply a smaller built bundle", { code: EXIT.USAGE });
|
|
510
|
-
const target =
|
|
876
|
+
const target = join7(destination, file);
|
|
511
877
|
await mkdir2(dirname(target), { recursive: true });
|
|
512
|
-
await
|
|
878
|
+
await writeFile4(target, contents, { mode: 420 });
|
|
513
879
|
digests[file] = createHash("sha256").update(contents).digest("hex");
|
|
514
880
|
}
|
|
515
881
|
return digests;
|
|
@@ -518,40 +884,67 @@ async function runWorld(cwd, opts) {
|
|
|
518
884
|
const file = resolve(cwd, opts.file ?? "agenttwin.world.json");
|
|
519
885
|
let spec;
|
|
520
886
|
try {
|
|
521
|
-
spec = parseWorld(JSON.parse(await
|
|
887
|
+
spec = parseWorld(JSON.parse(await readFile7(file, "utf8")));
|
|
522
888
|
} catch (error) {
|
|
523
889
|
throw new CliError(String(error), { code: EXIT.USAGE, action: "Create agenttwin.world.json; run agenttwin example --out ./agenttwin-example for a working example." });
|
|
524
890
|
}
|
|
525
891
|
if (spec.workflowId) {
|
|
526
892
|
let inventory;
|
|
527
893
|
try {
|
|
528
|
-
inventory = JSON.parse(await
|
|
894
|
+
inventory = JSON.parse(await readFile7(join7(cwd, ".agenttwin/inventory.json"), "utf8"));
|
|
529
895
|
} catch {
|
|
530
896
|
throw new CliError("Linked workflow needs discovery inventory; run init first", { code: EXIT.BLOCKED });
|
|
531
897
|
}
|
|
532
|
-
if (!inventory.workflows?.some((w) => w.id === spec.workflowId))
|
|
898
|
+
if (!inventory.workflows?.some((w) => w.id === spec.workflowId)) {
|
|
899
|
+
const { loadDashboardDiscovery: loadDashboardDiscovery2 } = await Promise.resolve().then(() => (init_session(), session_exports));
|
|
900
|
+
const discovery = await loadDashboardDiscovery2(cwd, inventory);
|
|
901
|
+
if (!discovery.workflows.some((w) => w.id === spec.workflowId)) throw new CliError("workflowId is absent from the current inventory", { code: EXIT.BLOCKED });
|
|
902
|
+
}
|
|
533
903
|
}
|
|
534
|
-
const runtime = opts.runtime ??
|
|
535
|
-
if (!["e2b", "hosted"].includes(runtime)) throw new CliError("runtime must be hosted or e2b
|
|
904
|
+
const runtime = opts.runtime ?? "local";
|
|
905
|
+
if (!["local", "e2b", "hosted"].includes(runtime)) throw new CliError("runtime must be local, hosted or e2b", { code: EXIT.USAGE });
|
|
536
906
|
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." });
|
|
537
|
-
const stage = await mkdtemp(
|
|
907
|
+
const stage = await mkdtemp(join7(tmpdir(), "agenttwin-world-"));
|
|
538
908
|
try {
|
|
539
|
-
const sourceDigests = await stageApplication(dirname(file), spec.files,
|
|
540
|
-
await
|
|
541
|
-
const
|
|
542
|
-
|
|
909
|
+
const sourceDigests = await stageApplication(dirname(file), spec.files, join7(stage, "app"));
|
|
910
|
+
await writeFile4(join7(stage, "world.json"), JSON.stringify(spec), { mode: 420 });
|
|
911
|
+
const git = await captureGit(cwd);
|
|
912
|
+
let project;
|
|
913
|
+
try {
|
|
914
|
+
project = JSON.parse(await readFile7(join7(cwd, ".agenttwin/inventory.json"), "utf8")).name ?? basename2(cwd);
|
|
915
|
+
} catch {
|
|
916
|
+
}
|
|
917
|
+
if (runtime === "local") {
|
|
918
|
+
opts.log("Testing through the CLI with in-process service twins. No cloud VM or containers.");
|
|
919
|
+
const report2 = await executeLocal(join7(stage, "app"), spec, opts.signal);
|
|
920
|
+
const runId2 = randomUUID();
|
|
921
|
+
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" };
|
|
922
|
+
const out2 = resolve(cwd, opts.out ?? `.agenttwin/runs/${runId2}`);
|
|
923
|
+
await mkdir2(out2, { recursive: true });
|
|
924
|
+
const evidencePath = join7(out2, "run.json"), reportPath = join7(out2, "report.md");
|
|
925
|
+
await writeFile4(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
|
|
926
|
+
await writeFile4(reportPath, `# ${spec.name}: ${receipt2.status}
|
|
927
|
+
|
|
928
|
+
Runner: CLI
|
|
929
|
+
Git: ${git?.head ?? "unavailable"}
|
|
930
|
+
|
|
931
|
+
` + 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");
|
|
932
|
+
return { ...receipt2, reportPath, evidencePath };
|
|
933
|
+
}
|
|
934
|
+
const worker = join7(dirname(fileURLToPath2(import.meta.url)), "worker.js");
|
|
935
|
+
await copyFile(worker, join7(stage, "worker.js"));
|
|
543
936
|
if (runtime === "hosted") {
|
|
544
|
-
const receipt2 = await executeRemote(stage, spec, opts.log);
|
|
937
|
+
const receipt2 = await executeRemote(stage, spec, opts.log, { git, project });
|
|
545
938
|
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");
|
|
546
939
|
const expectedSpec = createHash("sha256").update(JSON.stringify(spec)).digest("hex");
|
|
547
940
|
const reportedSources = receipt2.sourceDigests;
|
|
548
941
|
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");
|
|
549
942
|
const out2 = resolve(cwd, opts.out ?? `.agenttwin/runs/${receipt2.runId}`);
|
|
550
943
|
await mkdir2(out2, { recursive: true });
|
|
551
|
-
const evidencePath =
|
|
552
|
-
const reportPath =
|
|
553
|
-
await
|
|
554
|
-
await
|
|
944
|
+
const evidencePath = join7(out2, "run.json");
|
|
945
|
+
const reportPath = join7(out2, "report.md");
|
|
946
|
+
await writeFile4(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
|
|
947
|
+
await writeFile4(reportPath, `# ${spec.name}: ${receipt2.status}
|
|
555
948
|
|
|
556
949
|
Run: ${receipt2.runId}
|
|
557
950
|
|
|
@@ -571,6 +964,8 @@ See run.json for assertions, service state, application output and isolation met
|
|
|
571
964
|
const runId = randomUUID();
|
|
572
965
|
const receipt = {
|
|
573
966
|
...report,
|
|
967
|
+
project,
|
|
968
|
+
git,
|
|
574
969
|
...spec.workflowId ? { workflowId: spec.workflowId } : {},
|
|
575
970
|
runId,
|
|
576
971
|
runtime,
|
|
@@ -584,17 +979,18 @@ See run.json for assertions, service state, application output and isolation met
|
|
|
584
979
|
};
|
|
585
980
|
const out = resolve(cwd, opts.out ?? `.agenttwin/runs/${runId}`);
|
|
586
981
|
await mkdir2(out, { recursive: true });
|
|
587
|
-
await
|
|
588
|
-
await
|
|
982
|
+
await writeFile4(join7(out, "run.json"), JSON.stringify(receipt, null, 2) + "\n", { mode: 384 });
|
|
983
|
+
await writeFile4(join7(out, "world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
|
|
589
984
|
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");
|
|
590
|
-
await
|
|
591
|
-
return { ...receipt, reportPath:
|
|
985
|
+
await writeFile4(join7(out, "report.md"), summary);
|
|
986
|
+
return { ...receipt, reportPath: join7(out, "report.md"), evidencePath: join7(out, "run.json") };
|
|
592
987
|
} finally {
|
|
593
988
|
await rm(stage, { recursive: true, force: true });
|
|
594
989
|
}
|
|
595
990
|
}
|
|
596
991
|
|
|
597
992
|
// src/commands/prepare.ts
|
|
993
|
+
init_session();
|
|
598
994
|
function reviewPreparation(inventory, input) {
|
|
599
995
|
const fail2 = (message) => {
|
|
600
996
|
throw new CliError(message, { code: EXIT.USAGE });
|
|
@@ -641,36 +1037,36 @@ function reviewPreparation(inventory, input) {
|
|
|
641
1037
|
async function runPrepare(cwd, opts) {
|
|
642
1038
|
if (!opts.file) throw new CliError("prepare requires --file preparation.json", { code: EXIT.USAGE });
|
|
643
1039
|
const file = resolve2(cwd, opts.file);
|
|
644
|
-
const inventory = JSON.parse(await
|
|
1040
|
+
const inventory = JSON.parse(await readFile8(join8(cwd, ".agenttwin/inventory.json"), "utf8"));
|
|
645
1041
|
const discovered = await loadDashboardDiscovery(cwd, inventory);
|
|
646
1042
|
const known = new Set(inventory.workflows.map((w) => w.id));
|
|
647
1043
|
for (const w of discovered.workflows) if (!known.has(w.id)) {
|
|
648
1044
|
inventory.workflows.push({ ...w, confidence: "low", apps: [], sharedEntities: [], evidence: [{ source: "agent-analysis", ref: ".agenttwin/agent-discovery.json" }], steps: w.steps.map((s) => ({ ...s, app: "", evidence: [] })), risk: "high", rank: 0, supported: false, hypothesis: true, linkingSignals: ["Coding-agent analysis requires user review"], scopeUncertain: true, verified: false });
|
|
649
1045
|
}
|
|
650
1046
|
for (const t of discovered.tools) if (!inventory.tools.some((tool) => tool.id === t.id)) throw new Error("Agent-discovered dependencies must be classified in init before preparation. Review the additional tool, then rerun init.");
|
|
651
|
-
const { spec, workflow, mappings } = reviewPreparation(inventory, JSON.parse(await
|
|
1047
|
+
const { spec, workflow, mappings } = reviewPreparation(inventory, JSON.parse(await readFile8(file, "utf8")));
|
|
652
1048
|
const directory = resolve2(cwd, opts.out ?? `.agenttwin/prepared/${spec.name}-${randomUUID2().slice(0, 8)}`);
|
|
653
|
-
const stage = await mkdtemp2(
|
|
1049
|
+
const stage = await mkdtemp2(join8(tmpdir2(), "agenttwin-prepare-"));
|
|
654
1050
|
try {
|
|
655
1051
|
const sourceDigests = await stageApplication(dirname2(file), spec.files, stage);
|
|
656
1052
|
const files = /* @__PURE__ */ Object.create(null);
|
|
657
|
-
for (const name of spec.files) files[name] = (await
|
|
658
|
-
const manifest = { schema: "agenttwin/prepared-v1", workflowId: workflow.id, invocation: spec.command, sourceDigests, specDigest: createHash2("sha256").update(JSON.stringify(spec)).digest("hex"), mappings, provenance: "user-reviewed invocation and operation contracts; discovery does not verify runtime fidelity", createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
659
|
-
await
|
|
660
|
-
await
|
|
661
|
-
await
|
|
662
|
-
await mkdir3(
|
|
663
|
-
await
|
|
1053
|
+
for (const name of spec.files) files[name] = (await readFile8(join8(stage, name))).toString("base64");
|
|
1054
|
+
const manifest = { project: discovered.name, git: await captureGit(cwd), schema: "agenttwin/prepared-v1", workflowId: workflow.id, invocation: spec.command, sourceDigests, specDigest: createHash2("sha256").update(JSON.stringify(spec)).digest("hex"), mappings, provenance: "user-reviewed invocation and operation contracts; discovery does not verify runtime fidelity", createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1055
|
+
await writeFile5(join8(stage, "agenttwin.world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
|
|
1056
|
+
await writeFile5(join8(stage, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 384 });
|
|
1057
|
+
await writeFile5(join8(stage, "agenttwin.bundle.json"), JSON.stringify({ schema: "agenttwin/upload-v1", spec, files, manifest }, null, 2) + "\n", { mode: 384 });
|
|
1058
|
+
await mkdir3(join8(stage, ".agenttwin"), { recursive: true });
|
|
1059
|
+
await writeFile5(join8(stage, ".agenttwin/inventory.json"), JSON.stringify({ name: discovered.name, workflows: [{ id: workflow.id }] }), { mode: 384 });
|
|
664
1060
|
await mkdir3(dirname2(directory), { recursive: true });
|
|
665
1061
|
await mkdir3(directory, { mode: 448 });
|
|
666
1062
|
try {
|
|
667
|
-
const { cp: cp2, readdir:
|
|
668
|
-
for (const entry of await
|
|
1063
|
+
const { cp: cp2, readdir: readdir3 } = await import("fs/promises");
|
|
1064
|
+
for (const entry of await readdir3(stage)) await cp2(join8(stage, entry), join8(directory, entry), { recursive: true, force: false, errorOnExist: true });
|
|
669
1065
|
} catch (error) {
|
|
670
1066
|
await rm2(directory, { recursive: true, force: true });
|
|
671
1067
|
throw error;
|
|
672
1068
|
}
|
|
673
|
-
return { directory, worldPath:
|
|
1069
|
+
return { directory, worldPath: join8(directory, "agenttwin.world.json"), bundlePath: join8(directory, "agenttwin.bundle.json"), manifest };
|
|
674
1070
|
} finally {
|
|
675
1071
|
await rm2(stage, { recursive: true, force: true });
|
|
676
1072
|
}
|
|
@@ -678,9 +1074,9 @@ async function runPrepare(cwd, opts) {
|
|
|
678
1074
|
|
|
679
1075
|
// src/commands/setup.ts
|
|
680
1076
|
import { Template } from "e2b";
|
|
681
|
-
import { mkdir as mkdir4, writeFile as
|
|
1077
|
+
import { mkdir as mkdir4, writeFile as writeFile6 } from "fs/promises";
|
|
682
1078
|
import { homedir as homedir2 } from "os";
|
|
683
|
-
import { join as
|
|
1079
|
+
import { join as join9 } from "path";
|
|
684
1080
|
async function runSetup(log) {
|
|
685
1081
|
if (!process.env.E2B_API_KEY) throw new CliError("Set E2B_API_KEY before creating your hosted runtime template", { code: EXIT.BLOCKED });
|
|
686
1082
|
log("Building the AgentTwin template on E2B (Node 22, PostgreSQL 15, Redis). No local containers are used.");
|
|
@@ -688,16 +1084,16 @@ async function runSetup(log) {
|
|
|
688
1084
|
const result = await Template.build(template, "agenttwin-node-v2", { apiKey: process.env.E2B_API_KEY, cpuCount: 2, memoryMB: 2048, onBuildLogs: (entry) => {
|
|
689
1085
|
if (!entry.message.includes("[stdout]") && !entry.message.includes("[stderr]")) log(entry.message);
|
|
690
1086
|
} });
|
|
691
|
-
const directory =
|
|
1087
|
+
const directory = join9(homedir2(), ".agenttwin");
|
|
692
1088
|
await mkdir4(directory, { recursive: true, mode: 448 });
|
|
693
|
-
const path =
|
|
694
|
-
await
|
|
1089
|
+
const path = join9(directory, "runtime.json");
|
|
1090
|
+
await writeFile6(path, JSON.stringify({ provider: "e2b", templateId: result.templateId, buildId: result.buildId }, null, 2) + "\n", { mode: 384 });
|
|
695
1091
|
return { provider: "e2b", templateId: result.templateId, buildId: result.buildId, configPath: path };
|
|
696
1092
|
}
|
|
697
1093
|
|
|
698
1094
|
// src/commands/example.ts
|
|
699
|
-
import { mkdir as mkdir5, writeFile as
|
|
700
|
-
import { resolve as resolve3, join as
|
|
1095
|
+
import { mkdir as mkdir5, writeFile as writeFile7, stat } from "fs/promises";
|
|
1096
|
+
import { resolve as resolve3, join as join10 } from "path";
|
|
701
1097
|
var exampleSource = `// Replace this file with your own bundled Node workflow.
|
|
702
1098
|
const safe = process.argv.includes('--safe');
|
|
703
1099
|
const crm = process.env.AGENTTWIN_CRM_URL;
|
|
@@ -742,26 +1138,26 @@ async function runExample(cwd, out) {
|
|
|
742
1138
|
const directory = resolve3(cwd, out ?? "agenttwin-example");
|
|
743
1139
|
if (await stat(directory).then(() => true, () => false)) throw new CliError(`Destination exists: ${directory}`, { code: EXIT.USAGE, action: "Choose a new --out directory." });
|
|
744
1140
|
await mkdir5(directory, { recursive: true });
|
|
745
|
-
await
|
|
746
|
-
await
|
|
747
|
-
await
|
|
748
|
-
await
|
|
1141
|
+
await writeFile7(join10(directory, "workflow.mjs"), exampleSource);
|
|
1142
|
+
await writeFile7(join10(directory, "agenttwin.world.json"), JSON.stringify(exampleWorld(), null, 2) + "\n");
|
|
1143
|
+
await writeFile7(join10(directory, "safe.world.json"), JSON.stringify(exampleWorld(true), null, 2) + "\n");
|
|
1144
|
+
await writeFile7(join10(directory, "README.md"), `# Support workflow
|
|
749
1145
|
|
|
750
|
-
Run with agenttwin run --file agenttwin.world.json (expected FAIL), then --file safe.world.json (expected PASS).
|
|
1146
|
+
Run with agenttwin run --file agenttwin.world.json (expected FAIL), then --file safe.world.json (expected PASS). Runs through the CLI with in-process service twins by default. No VM or containers. Use --runtime hosted or --runtime e2b explicitly only when a cloud machine is needed.
|
|
751
1147
|
|
|
752
1148
|
This is actual application code using a synthetic custom ticket API plus the supported Slack JSON subset. It is not a clone of a CRM vendor. Replace workflow.mjs with your own built code and edit the explicit file list, endpoints, seed records and assertions. No npm install runs in the sandbox; dependencies must be bundled into your application.
|
|
753
1149
|
`);
|
|
754
|
-
return { directory, worldPath:
|
|
1150
|
+
return { directory, worldPath: join10(directory, "agenttwin.world.json") };
|
|
755
1151
|
}
|
|
756
1152
|
|
|
757
1153
|
// src/commands/init.ts
|
|
758
1154
|
import { readFileSync as readFileSync2 } from "fs";
|
|
759
|
-
import { writeFile as
|
|
760
|
-
import { join as
|
|
1155
|
+
import { writeFile as writeFile11 } from "fs/promises";
|
|
1156
|
+
import { join as join17 } from "path";
|
|
761
1157
|
|
|
762
1158
|
// src/lib/files.ts
|
|
763
|
-
import { readdir, readFile as
|
|
764
|
-
import { join as
|
|
1159
|
+
import { readdir as readdir2, readFile as readFile9, stat as stat2 } from "fs/promises";
|
|
1160
|
+
import { join as join11, relative as relative2, sep as sep2 } from "path";
|
|
765
1161
|
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
766
1162
|
".git",
|
|
767
1163
|
".agenttwin",
|
|
@@ -812,14 +1208,14 @@ async function walkDir(root, maxFiles, budgetMs) {
|
|
|
812
1208
|
const dir = stack.pop();
|
|
813
1209
|
let entries;
|
|
814
1210
|
try {
|
|
815
|
-
entries = await
|
|
1211
|
+
entries = await readdir2(dir, { withFileTypes: true });
|
|
816
1212
|
} catch {
|
|
817
1213
|
limits.push(`Directory could not be read: ${relative2(root, dir) || "."}`);
|
|
818
1214
|
continue;
|
|
819
1215
|
}
|
|
820
1216
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
821
1217
|
for (const entry of entries) {
|
|
822
|
-
const full =
|
|
1218
|
+
const full = join11(dir, entry.name);
|
|
823
1219
|
if (entry.isDirectory()) {
|
|
824
1220
|
if (!shouldIgnoreDir(entry.name)) stack.push(full);
|
|
825
1221
|
} else if (entry.isFile()) {
|
|
@@ -853,7 +1249,7 @@ var RepoFileIndex = class {
|
|
|
853
1249
|
}
|
|
854
1250
|
async exists(path) {
|
|
855
1251
|
try {
|
|
856
|
-
await stat2(
|
|
1252
|
+
await stat2(join11(this.cwd, path));
|
|
857
1253
|
return true;
|
|
858
1254
|
} catch {
|
|
859
1255
|
return false;
|
|
@@ -862,7 +1258,7 @@ var RepoFileIndex = class {
|
|
|
862
1258
|
async read(path, maxBytes = MAX_BYTES) {
|
|
863
1259
|
const cached = this.cache.contents.get(path);
|
|
864
1260
|
if (cached !== void 0) return cached;
|
|
865
|
-
const full =
|
|
1261
|
+
const full = join11(this.cwd, path);
|
|
866
1262
|
try {
|
|
867
1263
|
const info2 = await stat2(full);
|
|
868
1264
|
if (!info2.isFile()) return void 0;
|
|
@@ -870,7 +1266,7 @@ var RepoFileIndex = class {
|
|
|
870
1266
|
this.cache.limits.push(`File excluded by size limit (${maxBytes} bytes): ${path}`);
|
|
871
1267
|
return void 0;
|
|
872
1268
|
}
|
|
873
|
-
const text = await
|
|
1269
|
+
const text = await readFile9(full, "utf8");
|
|
874
1270
|
this.cache.contents.set(path, text);
|
|
875
1271
|
return text;
|
|
876
1272
|
} catch {
|
|
@@ -1687,10 +2083,10 @@ var ciDetector = {
|
|
|
1687
2083
|
};
|
|
1688
2084
|
|
|
1689
2085
|
// src/discovery/detectors/containers.ts
|
|
1690
|
-
import { execFile } from "child_process";
|
|
2086
|
+
import { execFile as execFile2 } from "child_process";
|
|
1691
2087
|
function dockerImages() {
|
|
1692
2088
|
return new Promise((resolve5) => {
|
|
1693
|
-
|
|
2089
|
+
execFile2(
|
|
1694
2090
|
"docker",
|
|
1695
2091
|
["ps", "--format", "{{.Image}}"],
|
|
1696
2092
|
{ timeout: 2e3 },
|
|
@@ -1730,13 +2126,13 @@ var containerDetector = {
|
|
|
1730
2126
|
// src/discovery/detectors/cloud-creds.ts
|
|
1731
2127
|
import { stat as stat3 } from "fs/promises";
|
|
1732
2128
|
import { homedir as homedir3 } from "os";
|
|
1733
|
-
import { join as
|
|
2129
|
+
import { join as join12 } from "path";
|
|
1734
2130
|
var CREDENTIALS = [
|
|
1735
|
-
{ path:
|
|
1736
|
-
{ path:
|
|
1737
|
-
{ path:
|
|
1738
|
-
{ path:
|
|
1739
|
-
{ path:
|
|
2131
|
+
{ path: join12(homedir3(), ".aws", "config"), subject: "aws", title: "AWS" },
|
|
2132
|
+
{ path: join12(homedir3(), ".aws", "credentials"), subject: "aws", title: "AWS" },
|
|
2133
|
+
{ path: join12(homedir3(), ".config", "gcloud"), subject: "gcp", title: "Google Cloud" },
|
|
2134
|
+
{ path: join12(homedir3(), ".azure"), subject: "azure", title: "Azure" },
|
|
2135
|
+
{ path: join12(homedir3(), ".kube", "config"), subject: "kubernetes", title: "Kubernetes" }
|
|
1740
2136
|
];
|
|
1741
2137
|
async function exists(path) {
|
|
1742
2138
|
try {
|
|
@@ -2295,12 +2691,12 @@ var SimpleDetectorRegistry = class {
|
|
|
2295
2691
|
};
|
|
2296
2692
|
|
|
2297
2693
|
// src/discovery/passes/runtime.ts
|
|
2298
|
-
import { spawn as
|
|
2299
|
-
import { createServer } from "http";
|
|
2694
|
+
import { spawn as spawn3 } from "child_process";
|
|
2695
|
+
import { createServer as createServer2 } from "http";
|
|
2300
2696
|
import { connect } from "net";
|
|
2301
2697
|
import { readFileSync, rmSync, writeFileSync } from "fs";
|
|
2302
2698
|
import { tmpdir as tmpdir3 } from "os";
|
|
2303
|
-
import { join as
|
|
2699
|
+
import { join as join13 } from "path";
|
|
2304
2700
|
var EGRESS_HOOK = `
|
|
2305
2701
|
const fs = require('fs');
|
|
2306
2702
|
const dns = require('dns');
|
|
@@ -2342,7 +2738,7 @@ function delay(ms) {
|
|
|
2342
2738
|
}
|
|
2343
2739
|
function startProxy(onHost) {
|
|
2344
2740
|
return new Promise((resolve5) => {
|
|
2345
|
-
const server =
|
|
2741
|
+
const server = createServer2((req, res) => {
|
|
2346
2742
|
const host = (req.headers.host ?? "").split(":")[0];
|
|
2347
2743
|
if (host) onHost(host);
|
|
2348
2744
|
res.writeHead(502);
|
|
@@ -2370,8 +2766,8 @@ function startProxy(onHost) {
|
|
|
2370
2766
|
async function observeEgress(opts) {
|
|
2371
2767
|
const hosts = /* @__PURE__ */ new Set();
|
|
2372
2768
|
const { server, url } = await startProxy((host) => hosts.add(host));
|
|
2373
|
-
const logFile =
|
|
2374
|
-
const hookFile =
|
|
2769
|
+
const logFile = join13(tmpdir3(), `agenttwin-egress-${process.pid}.log`);
|
|
2770
|
+
const hookFile = join13(tmpdir3(), `agenttwin-egress-hook-${process.pid}.cjs`);
|
|
2375
2771
|
try {
|
|
2376
2772
|
writeFileSync(hookFile, EGRESS_HOOK, "utf8");
|
|
2377
2773
|
writeFileSync(logFile, "", "utf8");
|
|
@@ -2380,7 +2776,7 @@ async function observeEgress(opts) {
|
|
|
2380
2776
|
let child;
|
|
2381
2777
|
if (opts.run) {
|
|
2382
2778
|
opts.log(`Observing egress: spawning \`${opts.run}\` through ${url}`);
|
|
2383
|
-
child =
|
|
2779
|
+
child = spawn3(opts.run, {
|
|
2384
2780
|
shell: true,
|
|
2385
2781
|
env: {
|
|
2386
2782
|
...process.env,
|
|
@@ -3621,8 +4017,8 @@ async function buildInventory({
|
|
|
3621
4017
|
}
|
|
3622
4018
|
|
|
3623
4019
|
// src/inventory/store.ts
|
|
3624
|
-
import { mkdir as mkdir6, readFile as
|
|
3625
|
-
import { join as
|
|
4020
|
+
import { mkdir as mkdir6, readFile as readFile10, writeFile as writeFile8 } from "fs/promises";
|
|
4021
|
+
import { join as join14 } from "path";
|
|
3626
4022
|
var STORE_DIR = ".agenttwin";
|
|
3627
4023
|
var STORE_GITIGNORE = [
|
|
3628
4024
|
"inventory.json",
|
|
@@ -3638,24 +4034,24 @@ var STORE_GITIGNORE = [
|
|
|
3638
4034
|
""
|
|
3639
4035
|
].join("\n");
|
|
3640
4036
|
function storeDir(cwd) {
|
|
3641
|
-
return
|
|
4037
|
+
return join14(cwd, STORE_DIR);
|
|
3642
4038
|
}
|
|
3643
4039
|
function storePath(cwd, name) {
|
|
3644
|
-
return
|
|
4040
|
+
return join14(storeDir(cwd), name);
|
|
3645
4041
|
}
|
|
3646
4042
|
async function ensureStore(cwd) {
|
|
3647
4043
|
await mkdir6(storeDir(cwd), { recursive: true });
|
|
3648
|
-
await
|
|
4044
|
+
await writeFile8(storePath(cwd, ".gitignore"), STORE_GITIGNORE, "utf8");
|
|
3649
4045
|
}
|
|
3650
4046
|
async function readJson(path) {
|
|
3651
4047
|
try {
|
|
3652
|
-
return JSON.parse(await
|
|
4048
|
+
return JSON.parse(await readFile10(path, "utf8"));
|
|
3653
4049
|
} catch {
|
|
3654
4050
|
return void 0;
|
|
3655
4051
|
}
|
|
3656
4052
|
}
|
|
3657
4053
|
async function writeJson(path, value) {
|
|
3658
|
-
await
|
|
4054
|
+
await writeFile8(path, `${JSON.stringify(value, null, 2)}
|
|
3659
4055
|
`, "utf8");
|
|
3660
4056
|
}
|
|
3661
4057
|
var readInventory = (cwd) => readJson(storePath(cwd, "inventory.json"));
|
|
@@ -3858,13 +4254,14 @@ var REGISTRY = [
|
|
|
3858
4254
|
];
|
|
3859
4255
|
|
|
3860
4256
|
// src/inspect/runner.ts
|
|
3861
|
-
import { spawn as
|
|
4257
|
+
import { spawn as spawn4 } from "child_process";
|
|
4258
|
+
init_sanitize();
|
|
3862
4259
|
function runCommand(spec, cwd, signal) {
|
|
3863
4260
|
return new Promise((resolve5) => {
|
|
3864
4261
|
if (signal.aborted) {
|
|
3865
4262
|
return resolve5({ code: null, stdout: "", stderr: "", missing: false, timedOut: false, cancelled: true });
|
|
3866
4263
|
}
|
|
3867
|
-
const child =
|
|
4264
|
+
const child = spawn4(spec.cmd, spec.args, {
|
|
3868
4265
|
cwd,
|
|
3869
4266
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3870
4267
|
env: process.env
|
|
@@ -4402,12 +4799,12 @@ async function runExport(opts) {
|
|
|
4402
4799
|
const plan = await readPlan(opts.cwd);
|
|
4403
4800
|
const bundle = { schema: "export/v1", inventory, plan: plan ?? null };
|
|
4404
4801
|
if (opts.out) {
|
|
4405
|
-
const { mkdir: mkdir8, writeFile:
|
|
4406
|
-
const { join:
|
|
4802
|
+
const { mkdir: mkdir8, writeFile: writeFile12 } = await import("fs/promises");
|
|
4803
|
+
const { join: join19 } = await import("path");
|
|
4407
4804
|
await mkdir8(opts.out, { recursive: true });
|
|
4408
|
-
await
|
|
4805
|
+
await writeFile12(join19(opts.out, "inventory.json"), `${JSON.stringify(inventory, null, 2)}
|
|
4409
4806
|
`, "utf8");
|
|
4410
|
-
if (plan) await
|
|
4807
|
+
if (plan) await writeFile12(join19(opts.out, "plan.json"), `${JSON.stringify(plan, null, 2)}
|
|
4411
4808
|
`, "utf8");
|
|
4412
4809
|
return { schema: "export/v1", out: opts.out, wrote: ["inventory.json", ...plan ? ["plan.json"] : []] };
|
|
4413
4810
|
}
|
|
@@ -4807,16 +5204,16 @@ async function askQuestions(questions) {
|
|
|
4807
5204
|
init_answers();
|
|
4808
5205
|
|
|
4809
5206
|
// src/lib/config.ts
|
|
4810
|
-
import { readFile as
|
|
4811
|
-
import { join as
|
|
5207
|
+
import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
|
|
5208
|
+
import { join as join15 } from "path";
|
|
4812
5209
|
var CONFIG_FILENAME = "agenttwin.config.json";
|
|
4813
5210
|
var CONFIG_VERSION = 2;
|
|
4814
5211
|
function configPath(cwd) {
|
|
4815
|
-
return
|
|
5212
|
+
return join15(cwd, CONFIG_FILENAME);
|
|
4816
5213
|
}
|
|
4817
5214
|
async function readConfig(cwd) {
|
|
4818
5215
|
try {
|
|
4819
|
-
const text = await
|
|
5216
|
+
const text = await readFile11(configPath(cwd), "utf8");
|
|
4820
5217
|
return JSON.parse(text);
|
|
4821
5218
|
} catch {
|
|
4822
5219
|
return void 0;
|
|
@@ -4852,17 +5249,22 @@ function buildConfigFromInventory(inventory, existing) {
|
|
|
4852
5249
|
}
|
|
4853
5250
|
async function writeConfig(cwd, config, _options = {}) {
|
|
4854
5251
|
const path = configPath(cwd);
|
|
4855
|
-
await
|
|
5252
|
+
await writeFile9(path, `${JSON.stringify(config, null, 2)}
|
|
4856
5253
|
`, "utf8");
|
|
4857
5254
|
return { path };
|
|
4858
5255
|
}
|
|
4859
5256
|
|
|
5257
|
+
// src/commands/init.ts
|
|
5258
|
+
init_dashboard();
|
|
5259
|
+
init_session();
|
|
5260
|
+
|
|
4860
5261
|
// src/commands/preview.ts
|
|
4861
|
-
import { mkdir as mkdir7, readFile as
|
|
4862
|
-
import { dirname as dirname3, join as
|
|
5262
|
+
import { mkdir as mkdir7, readFile as readFile12, writeFile as writeFile10 } from "fs/promises";
|
|
5263
|
+
import { dirname as dirname3, join as join16, resolve as resolve4 } from "path";
|
|
4863
5264
|
|
|
4864
5265
|
// src/preview/html.ts
|
|
4865
|
-
|
|
5266
|
+
init_sanitize();
|
|
5267
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
4866
5268
|
function escapeHtml(value) {
|
|
4867
5269
|
return sanitizeOutput(String(value ?? "")).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
4868
5270
|
}
|
|
@@ -4888,7 +5290,7 @@ const feedback=$('feedback-form');
|
|
|
4888
5290
|
if(feedback)feedback.addEventListener('submit',event=>{event.preventDefault();const note=$('review-note').value.trim();if(!note){$('feedback-status').textContent='Add a note before downloading.';return}download('agenttwin-feedback.json',{schema:'preview-feedback/v1',createdAt:new Date().toISOString(),rating:$('review-rating').value,note,source:'local-browser-report'});$('feedback-status').textContent='Downloaded locally. Review the file, then share it with the person who invited you. Nothing was sent automatically.'});
|
|
4889
5291
|
`;
|
|
4890
5292
|
function page(title, content, data, navigation) {
|
|
4891
|
-
const nonce =
|
|
5293
|
+
const nonce = randomBytes3(16).toString("base64");
|
|
4892
5294
|
const json = JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
4893
5295
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'; connect-src 'none'"><title>${h(title)} \xB7 AgentTwin</title><style nonce="${nonce}">${STYLE}</style></head><body><header><strong>agenttwin</strong><span class="badge">Local preview</span></header><nav>${navigation.map(([id, label]) => `<a href="#${h(id)}">${h(label)}</a>`).join("")}<a href="#feedback">Leave feedback</a></nav><main>${content}${feedbackSection()}<footer>This report runs locally. No telemetry, remote fonts, or automatic uploads. Discovery results can contain internal system names; review before sharing.</footer></main><script type="application/json" id="report-data" nonce="${nonce}">${json}</script><script nonce="${nonce}">${SCRIPT}</script></body></html>`;
|
|
4894
5296
|
}
|
|
@@ -4916,16 +5318,17 @@ function renderBrowserReport(inventory, plan) {
|
|
|
4916
5318
|
}
|
|
4917
5319
|
|
|
4918
5320
|
// src/preview/refund.ts
|
|
4919
|
-
import { createServer as
|
|
4920
|
-
import { randomBytes as
|
|
5321
|
+
import { createServer as createServer3 } from "http";
|
|
5322
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
4921
5323
|
|
|
4922
5324
|
// src/commands/preview.ts
|
|
5325
|
+
init_sanitize();
|
|
4923
5326
|
async function writeBrowserReport(cwd, inventory, plan, out) {
|
|
4924
5327
|
const path = out ? resolve4(cwd, out) : storePath(cwd, "report.html");
|
|
4925
5328
|
await mkdir7(dirname3(path), { recursive: true });
|
|
4926
5329
|
const cleanInventory = JSON.parse(sanitizeOutput(JSON.stringify(inventory)));
|
|
4927
5330
|
const cleanPlan = JSON.parse(sanitizeOutput(JSON.stringify(plan)));
|
|
4928
|
-
await
|
|
5331
|
+
await writeFile10(path, renderBrowserReport(cleanInventory, cleanPlan), "utf8");
|
|
4929
5332
|
return path;
|
|
4930
5333
|
}
|
|
4931
5334
|
async function runReport(cwd, out) {
|
|
@@ -4940,13 +5343,13 @@ async function runFeedback(cwd, note) {
|
|
|
4940
5343
|
const path = storePath(cwd, "feedback.json");
|
|
4941
5344
|
let entries = [];
|
|
4942
5345
|
try {
|
|
4943
|
-
const existing = JSON.parse(await
|
|
5346
|
+
const existing = JSON.parse(await readFile12(path, "utf8"));
|
|
4944
5347
|
entries = Array.isArray(existing.items) ? existing.items : [];
|
|
4945
5348
|
} catch (error) {
|
|
4946
5349
|
if (error.code !== "ENOENT") throw new CliError("Existing feedback could not be read; it was not overwritten");
|
|
4947
5350
|
}
|
|
4948
5351
|
entries.push({ createdAt: (/* @__PURE__ */ new Date()).toISOString(), note: sanitizeOutput(note.trim()) });
|
|
4949
|
-
await
|
|
5352
|
+
await writeFile10(path, `${JSON.stringify({ schema: "preview-feedback/v1", items: entries }, null, 2)}
|
|
4950
5353
|
`, "utf8");
|
|
4951
5354
|
return { schema: "feedback-result/v1", saved: true, uploaded: false, path, notes: entries.length };
|
|
4952
5355
|
}
|
|
@@ -5099,8 +5502,8 @@ async function runInit(opts) {
|
|
|
5099
5502
|
await writeInventory(opts.cwd, finalInventory);
|
|
5100
5503
|
await writePlan(opts.cwd, plan);
|
|
5101
5504
|
await writeConfig(opts.cwd, config, { force: opts.force });
|
|
5102
|
-
await
|
|
5103
|
-
await
|
|
5505
|
+
await writeFile11(join17(opts.cwd, "ENVIRONMENT.md"), renderEnvironment(finalInventory), "utf8");
|
|
5506
|
+
await writeFile11(join17(opts.cwd, "REPLICATION.md"), renderReplication(finalInventory, plan), "utf8");
|
|
5104
5507
|
reportPath = await writeBrowserReport(opts.cwd, finalInventory, plan);
|
|
5105
5508
|
dashboardPath = await writeDashboard(opts.cwd, finalInventory);
|
|
5106
5509
|
wrote = true;
|
|
@@ -5164,7 +5567,7 @@ async function runInit(opts) {
|
|
|
5164
5567
|
function readVersion() {
|
|
5165
5568
|
const here = dirname4(fileURLToPath3(import.meta.url));
|
|
5166
5569
|
const pkg = JSON.parse(
|
|
5167
|
-
readFileSync3(
|
|
5570
|
+
readFileSync3(join18(here, "..", "package.json"), "utf8")
|
|
5168
5571
|
);
|
|
5169
5572
|
return pkg.version ?? "0.0.0";
|
|
5170
5573
|
}
|
|
@@ -5179,9 +5582,9 @@ function helpText() {
|
|
|
5179
5582
|
bold("Commands"),
|
|
5180
5583
|
` ${"setup".padEnd(12)}Build your hosted E2B runtime template (once per account)`,
|
|
5181
5584
|
` ${"prepare".padEnd(12)}Bundle a reviewed discovered workflow (--file preparation.json)`,
|
|
5182
|
-
` ${"run".padEnd(12)}
|
|
5585
|
+
` ${"run".padEnd(12)}Test your Node workflow through the CLI (--file world.json)`,
|
|
5183
5586
|
` ${"example".padEnd(12)}Write an editable multi-service workflow (--out directory)`,
|
|
5184
|
-
` ${"sync".padEnd(12)}
|
|
5587
|
+
` ${"sync".padEnd(12)}Sync discovery, Git metadata and CLI test results to your dashboard`,
|
|
5185
5588
|
` ${"init".padEnd(12)}Discover and write agenttwin.config.json + reports`,
|
|
5186
5589
|
` ${"discover".padEnd(12)}Build the company inventory (writes .agenttwin/inventory.json)`,
|
|
5187
5590
|
` ${"decide".padEnd(12)}Apply structured decisions (--file or --stdin)`,
|
|
@@ -5203,7 +5606,7 @@ function helpText() {
|
|
|
5203
5606
|
` ${"--yes / --non-interactive".padEnd(20)}Never prompt`,
|
|
5204
5607
|
` ${"--dry-run".padEnd(20)}init: discover without writing any files`,
|
|
5205
5608
|
` ${"--out <path>".padEnd(20)}run/example: output directory; report: HTML file`,
|
|
5206
|
-
` ${"--runtime <hosted|e2b>".padEnd(20)}run: hosted API or
|
|
5609
|
+
` ${"--runtime <local|hosted|e2b>".padEnd(20)}run: local CLI by default; hosted API or E2B when a cloud VM is needed`,
|
|
5207
5610
|
` ${"-v, --version".padEnd(20)}Print the installed version`,
|
|
5208
5611
|
` ${"-h, --help".padEnd(20)}Show this help`
|
|
5209
5612
|
].join("\n");
|
|
@@ -5318,7 +5721,7 @@ Run: agenttwin run --file "${result.worldPath}"`);
|
|
|
5318
5721
|
}
|
|
5319
5722
|
case "sync": {
|
|
5320
5723
|
const result = await runSync(cwd);
|
|
5321
|
-
printResult(result, `Synced ${result.tools} resources
|
|
5724
|
+
printResult(result, `Synced ${result.tools} resources, ${result.workflows} workflow candidates and ${result.receipts} CLI run receipts.
|
|
5322
5725
|
${result.dashboard}
|
|
5323
5726
|
${result.uploaded}`);
|
|
5324
5727
|
return;
|
|
@@ -5357,7 +5760,7 @@ ${result.uploaded}`);
|
|
|
5357
5760
|
result,
|
|
5358
5761
|
[
|
|
5359
5762
|
`Discovered ${counts.tools} tools (${counts.classified} classified) \xB7 ${counts.unclassified} unclassified \xB7 ${counts.withTwin} with twins`,
|
|
5360
|
-
`Wrote ${
|
|
5763
|
+
`Wrote ${join18(".agenttwin", "inventory.json")}`,
|
|
5361
5764
|
result.blocked.length > 0 ? `Blocked: ${result.blocked.join("; ")}` : "",
|
|
5362
5765
|
result.opportunities.length > 0 ? `Opportunities: ${result.opportunities.join("; ")}` : "",
|
|
5363
5766
|
result.reconciliation.splits.length > 0 ? `Splits: ${result.reconciliation.splits.map((s) => s.from).join(", ")}` : ""
|
|
@@ -5380,7 +5783,7 @@ ${result.uploaded}`);
|
|
|
5380
5783
|
printResult(
|
|
5381
5784
|
result,
|
|
5382
5785
|
`Plan: ${result.plan.items.length} tools \xB7 ${Object.entries(byStrategy).map(([k, v]) => `${k} ${v}`).join(", ")}
|
|
5383
|
-
Wrote ${
|
|
5786
|
+
Wrote ${join18(".agenttwin", "plan.json")}`
|
|
5384
5787
|
);
|
|
5385
5788
|
return;
|
|
5386
5789
|
}
|