droid-mode 0.0.1

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.
@@ -0,0 +1,213 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import vm from "node:vm";
4
+ import { nowIsoCompact, sha256Hex, safeIdentifier, ensureDir, getDroidModeDataDir, writeJson } from "./util.mjs";
5
+
6
+ /**
7
+ * Static, best-effort disallow list for sandboxed workflows.
8
+ * This is not meant to be perfect security; it is intended to prevent accidental escalation.
9
+ */
10
+ const DEFAULT_BANNED_PATTERNS = [
11
+ /\brequire\s*\(/,
12
+ /\bimport\s+/,
13
+ /\bprocess\b/,
14
+ /\bchild_process\b/,
15
+ /\bfs\b/,
16
+ /\bnet\b/,
17
+ /\bhttp\b/,
18
+ /\bhttps\b/,
19
+ /\bfetch\b/,
20
+ /\beval\s*\(/,
21
+ /\bFunction\s*\(/,
22
+ /\bWebAssembly\b/,
23
+ ];
24
+
25
+ /**
26
+ * @param {string} source
27
+ * @param {{ allowUnsafe?: boolean }} opts
28
+ */
29
+ export function validateWorkflowSource(source, opts = {}) {
30
+ if (opts.allowUnsafe) return;
31
+ for (const re of DEFAULT_BANNED_PATTERNS) {
32
+ if (re.test(source)) {
33
+ throw new Error(
34
+ `Workflow source contains a disallowed pattern (${re}).\n` +
35
+ `This runner executes workflows in a restricted sandbox; ` +
36
+ `use MCP tools via the provided 't' / 'tools' objects.`
37
+ );
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Create tool functions for the sandbox.
44
+ * @param {{
45
+ * client: import("./mcp_client.mjs").McpClient,
46
+ * toolNames: string[],
47
+ * perCallTimeoutMs?: number,
48
+ * maxRetries?: number,
49
+ * trace: any[],
50
+ * }} opts
51
+ */
52
+ export function createToolApi(opts) {
53
+ const perCallTimeoutMs =
54
+ typeof opts.perCallTimeoutMs === "number" ? opts.perCallTimeoutMs : 60_000;
55
+ const maxRetries = typeof opts.maxRetries === "number" ? opts.maxRetries : 3;
56
+
57
+ const call = async (name, args) => {
58
+ const startedAt = new Date();
59
+ const argsStr = JSON.stringify(args ?? {});
60
+ const traceItem = {
61
+ tool: name,
62
+ argsKeys: args && typeof args === "object" ? Object.keys(args) : [],
63
+ argsSha256: sha256Hex(argsStr),
64
+ startedAt: startedAt.toISOString(),
65
+ durationMs: null,
66
+ isError: null,
67
+ retries: 0,
68
+ };
69
+ opts.trace.push(traceItem);
70
+
71
+ let lastError;
72
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
73
+ try {
74
+ const res = await opts.client.callTool({
75
+ name,
76
+ arguments: args ?? {},
77
+ timeoutMs: perCallTimeoutMs,
78
+ });
79
+ traceItem.isError = !!res?.isError;
80
+ traceItem.durationMs = new Date() - startedAt;
81
+ traceItem.retries = attempt - 1;
82
+ traceItem.resultSummary = {
83
+ hasStructured: !!res?.structured,
84
+ textLength: (res?.text || "").length,
85
+ };
86
+ return res.structured ?? (res.text ? { text: res.text } : res.raw);
87
+ } catch (err) {
88
+ lastError = err;
89
+ traceItem.retries = attempt;
90
+ if (attempt < maxRetries) {
91
+ const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
92
+ await new Promise((r) => setTimeout(r, delay));
93
+ }
94
+ }
95
+ }
96
+
97
+ traceItem.isError = true;
98
+ traceItem.durationMs = new Date() - startedAt;
99
+ traceItem.error = String(lastError?.message || lastError);
100
+ throw lastError;
101
+ };
102
+
103
+ // safe-name wrapper map
104
+ const t = {};
105
+ const toolmap = {};
106
+
107
+ for (const toolName of opts.toolNames) {
108
+ const safe = safeIdentifier(toolName);
109
+ toolmap[safe] = toolName;
110
+ t[safe] = (args) => call(toolName, args);
111
+ }
112
+
113
+ const tools = {
114
+ call,
115
+ // raw mapping by original tool names as well (tools["tool-name"](args))
116
+ ...Object.fromEntries(opts.toolNames.map((n) => [n, (args) => call(n, args)])),
117
+ };
118
+
119
+ return { t, tools, toolmap, call };
120
+ }
121
+
122
+ /**
123
+ * Execute a workflow file inside a restricted vm context.
124
+ * Workflow file MUST set global `workflow = async () => { ... }`.
125
+ * @param {{
126
+ * workflowPath: string,
127
+ * toolApi: { t: any, tools: any, toolmap: any },
128
+ * timeoutMs?: number,
129
+ * allowUnsafe?: boolean,
130
+ * }} opts
131
+ */
132
+ export async function executeWorkflow(opts) {
133
+ const timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : 5 * 60_000;
134
+
135
+ const source = fs.readFileSync(opts.workflowPath, "utf-8");
136
+ validateWorkflowSource(source, { allowUnsafe: opts.allowUnsafe });
137
+
138
+ const logs = [];
139
+ const log = (...args) => {
140
+ const line = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
141
+ logs.push(line);
142
+ };
143
+
144
+ const sandbox = {
145
+ // Provide only the API surface we want.
146
+ t: opts.toolApi.t,
147
+ tools: opts.toolApi.tools,
148
+ toolmap: opts.toolApi.toolmap,
149
+ log,
150
+ console: { log }, // convenience
151
+ // Helpers
152
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
153
+ assert: (cond, msg) => {
154
+ if (!cond) throw new Error(msg || "Assertion failed");
155
+ },
156
+ workflow: undefined,
157
+ };
158
+
159
+ // Create context with code generation from strings disabled where supported.
160
+ const context = vm.createContext(sandbox, {
161
+ name: "droid-mode-workflow",
162
+ codeGeneration: { strings: false, wasm: false },
163
+ });
164
+
165
+ const script = new vm.Script(source, { filename: path.basename(opts.workflowPath) });
166
+
167
+ // This timeout only applies to synchronous execution of the top-level script.
168
+ script.runInContext(context, { timeout: 1_000 });
169
+
170
+ if (typeof sandbox.workflow !== "function") {
171
+ throw new Error(
172
+ `Workflow file must assign an async function to global variable 'workflow'.\n` +
173
+ `Example:\n workflow = async () => { return { ok: true } }`
174
+ );
175
+ }
176
+
177
+ const resultPromise = Promise.resolve().then(() => sandbox.workflow());
178
+ const timed = Promise.race([
179
+ resultPromise,
180
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Workflow timed out after ${timeoutMs}ms`)), timeoutMs)),
181
+ ]);
182
+
183
+ const result = await timed;
184
+ return { result, logs };
185
+ }
186
+
187
+ /**
188
+ * Persist a run artifact in the droid-mode data dir.
189
+ * @param {{
190
+ * serverName: string,
191
+ * workflowPath: string,
192
+ * tools: string[],
193
+ * output: any,
194
+ * trace: any[],
195
+ * }} opts
196
+ */
197
+ export function writeRunArtifact(opts) {
198
+ const dataDir = getDroidModeDataDir();
199
+ const ts = nowIsoCompact();
200
+ const outDir = path.join(dataDir, "runs", opts.serverName, ts);
201
+ ensureDir(outDir);
202
+ const payload = {
203
+ serverName: opts.serverName,
204
+ workflowPath: opts.workflowPath,
205
+ tools: opts.tools,
206
+ finishedAt: new Date().toISOString(),
207
+ output: opts.output,
208
+ trace: opts.trace,
209
+ };
210
+ const file = path.join(outDir, "run.json");
211
+ writeJson(file, payload);
212
+ return { outDir, file };
213
+ }
@@ -0,0 +1,67 @@
1
+ import { safeIdentifier } from "./util.mjs";
2
+
3
+ /** @param {string} s */
4
+ function tokenize(s) {
5
+ return (s || "")
6
+ .toLowerCase()
7
+ .split(/[^a-z0-9]+/g)
8
+ .filter(Boolean);
9
+ }
10
+
11
+ /**
12
+ * Very lightweight ranking for tool search.
13
+ * @param {{name?:string,title?:string,description?:string}} tool
14
+ * @param {string[]} qTokens
15
+ * @param {string} qRaw
16
+ */
17
+ function score(tool, qTokens, qRaw) {
18
+ const name = (tool?.name || "").toLowerCase();
19
+ const title = (tool?.title || "").toLowerCase();
20
+ const desc = (tool?.description || "").toLowerCase();
21
+
22
+ let s = 0;
23
+
24
+ if (qRaw && name.includes(qRaw)) s += 10;
25
+ if (qRaw && title.includes(qRaw)) s += 5;
26
+ if (qRaw && desc.includes(qRaw)) s += 3;
27
+
28
+ for (const tok of qTokens) {
29
+ if (name.includes(tok)) s += 4;
30
+ if (title.includes(tok)) s += 2;
31
+ if (desc.includes(tok)) s += 1;
32
+ }
33
+
34
+ // Prefer shorter tool names (minor)
35
+ s += Math.max(0, 2 - name.length / 30);
36
+
37
+ return s;
38
+ }
39
+
40
+ /**
41
+ * Search tools.
42
+ * @param {any[]} tools
43
+ * @param {string} query
44
+ * @param {{ limit?: number }} opts
45
+ */
46
+ export function searchTools(tools, query, opts = {}) {
47
+ const limit = typeof opts.limit === "number" ? opts.limit : 8;
48
+ const qRaw = (query || "").trim().toLowerCase();
49
+ const qTokens = tokenize(query);
50
+
51
+ const ranked = (tools || [])
52
+ .map((t) => ({
53
+ tool: t,
54
+ score: score(t, qTokens, qRaw),
55
+ }))
56
+ .filter((x) => x.score > 0)
57
+ .sort((a, b) => b.score - a.score)
58
+ .slice(0, limit);
59
+
60
+ return ranked.map((r) => ({
61
+ name: r.tool?.name || "",
62
+ title: r.tool?.title || "",
63
+ description: (r.tool?.description || "").replace(/\s+/g, " ").trim(),
64
+ score: r.score,
65
+ safeName: safeIdentifier(r.tool?.name || ""),
66
+ }));
67
+ }
@@ -0,0 +1,89 @@
1
+ import path from "node:path";
2
+ import { getDroidModeDataDir, readJsonFileIfExists, writeJson } from "./util.mjs";
3
+
4
+ /**
5
+ * @param {import("./mcp_client.mjs").McpClient} client
6
+ */
7
+ export async function fetchAllTools(client) {
8
+ const tools = [];
9
+ let cursor = undefined;
10
+ for (let i = 0; i < 10_000; i++) {
11
+ const res = await client.listTools({ cursor, timeoutMs: 60_000 });
12
+ if (Array.isArray(res?.tools)) tools.push(...res.tools);
13
+ cursor = res?.nextCursor;
14
+ if (!cursor) break;
15
+ }
16
+ return tools;
17
+ }
18
+
19
+ /**
20
+ * Load cached tools, optionally refresh if stale.
21
+ * @param {{
22
+ * serverName: string,
23
+ * client: import("./mcp_client.mjs").McpClient,
24
+ * refresh?: boolean,
25
+ * maxAgeMs?: number,
26
+ * }} opts
27
+ */
28
+ export async function getToolsCached(opts) {
29
+ const dataDir = getDroidModeDataDir();
30
+ const cacheFile = path.join(dataDir, "cache", opts.serverName, "tools.json");
31
+ const maxAgeMs = typeof opts.maxAgeMs === "number" ? opts.maxAgeMs : 30 * 60 * 1000; // 30 minutes
32
+
33
+ if (!opts.refresh) {
34
+ const cached = readJsonFileIfExists(cacheFile);
35
+ if (cached?.fetchedAt && Array.isArray(cached?.tools)) {
36
+ const age = Date.now() - new Date(cached.fetchedAt).getTime();
37
+ if (!Number.isNaN(age) && age < maxAgeMs) {
38
+ return { tools: cached.tools, cacheFile, cached: true, meta: cached };
39
+ }
40
+ }
41
+ }
42
+
43
+ // Refresh
44
+ await opts.client.init();
45
+ const tools = await fetchAllTools(opts.client);
46
+ const payload = {
47
+ fetchedAt: new Date().toISOString(),
48
+ serverName: opts.serverName,
49
+ protocolVersion: opts.client.negotiatedProtocolVersion,
50
+ serverInfo: opts.client.serverInfo,
51
+ capabilities: opts.client.serverCapabilities,
52
+ tools,
53
+ };
54
+ writeJson(cacheFile, payload);
55
+ return { tools, cacheFile, cached: false, meta: payload };
56
+ }
57
+
58
+ /**
59
+ * Extract required parameters from a tool's inputSchema.
60
+ * @param {any} tool
61
+ * @returns {string[]}
62
+ */
63
+ function getRequiredParams(tool) {
64
+ const schema = tool?.inputSchema;
65
+ if (!schema) return [];
66
+ const required = schema.required || [];
67
+ return Array.isArray(required) ? required : [];
68
+ }
69
+
70
+ /**
71
+ * Return a compact tool list for display.
72
+ * @param {any[]} tools
73
+ * @param {{ includeParams?: boolean }} opts
74
+ */
75
+ export function compactToolSummaries(tools, opts = {}) {
76
+ const includeParams = opts.includeParams !== false; // default true
77
+ return (tools || []).map((t) => {
78
+ const summary = {
79
+ name: t?.name || "",
80
+ title: t?.title || "",
81
+ description: (t?.description || "").replace(/\s+/g, " ").trim().slice(0, 200),
82
+ };
83
+ if (includeParams) {
84
+ const required = getRequiredParams(t);
85
+ summary.requires = required.length > 0 ? required.join(", ") : "-";
86
+ }
87
+ return summary;
88
+ });
89
+ }
@@ -0,0 +1,160 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import crypto from "node:crypto";
5
+
6
+ /** @returns {string} */
7
+ export function nowIsoCompact() {
8
+ const d = new Date();
9
+ const pad = (n) => String(n).padStart(2, "0");
10
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(
11
+ d.getHours()
12
+ )}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
13
+ }
14
+
15
+ /** @param {string} s */
16
+ export function sha256Hex(s) {
17
+ return crypto.createHash("sha256").update(s).digest("hex");
18
+ }
19
+
20
+ /**
21
+ * Convert a tool name (often snake_case or kebab-case) to a safe camelCase identifier.
22
+ * @param {string} toolName
23
+ */
24
+ export function safeIdentifier(toolName) {
25
+ const cleaned = toolName
26
+ .replace(/[^a-zA-Z0-9_ -]/g, " ")
27
+ .trim()
28
+ .replace(/[-\s]+/g, "_");
29
+ const parts = cleaned.split("_").filter(Boolean);
30
+ if (!parts.length) return "tool";
31
+ const [first, ...rest] = parts;
32
+ const camel =
33
+ first.toLowerCase() +
34
+ rest.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join("");
35
+ // Ensure identifier starts with a letter or underscore.
36
+ if (/^[a-zA-Z_]/.test(camel)) return camel;
37
+ return "_" + camel;
38
+ }
39
+
40
+ /**
41
+ * Lightweight CLI args parser (supports: --k=v, --k v, flags, positional args).
42
+ * Returns { _: string[], flags: Record<string, string|boolean> }.
43
+ * @param {string[]} argv
44
+ */
45
+ export function parseArgs(argv) {
46
+ const out = { _: [], flags: {} };
47
+ for (let i = 0; i < argv.length; i++) {
48
+ const a = argv[i];
49
+ if (!a.startsWith("--")) {
50
+ out._.push(a);
51
+ continue;
52
+ }
53
+ const eq = a.indexOf("=");
54
+ if (eq !== -1) {
55
+ const k = a.slice(2, eq);
56
+ const v = a.slice(eq + 1);
57
+ out.flags[k] = v;
58
+ continue;
59
+ }
60
+ const k = a.slice(2);
61
+ const next = argv[i + 1];
62
+ if (next && !next.startsWith("--")) {
63
+ out.flags[k] = next;
64
+ i++;
65
+ } else {
66
+ out.flags[k] = true;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** @param {string} p */
73
+ export function ensureDir(p) {
74
+ fs.mkdirSync(p, { recursive: true });
75
+ }
76
+
77
+ /** @param {string} filePath */
78
+ export function readJsonFileIfExists(filePath) {
79
+ try {
80
+ if (!fs.existsSync(filePath)) return null;
81
+ const txt = fs.readFileSync(filePath, "utf-8");
82
+ return JSON.parse(txt);
83
+ } catch (err) {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * @param {string} filePath
90
+ * @param {any} data
91
+ */
92
+ export function writeJson(filePath, data) {
93
+ ensureDir(path.dirname(filePath));
94
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
95
+ }
96
+
97
+ /**
98
+ * Walk upward from cwd to find a directory containing ".factory".
99
+ * Returns null if not found.
100
+ * @param {string} startDir
101
+ */
102
+ export function findProjectRoot(startDir = process.cwd()) {
103
+ let cur = path.resolve(startDir);
104
+ for (let i = 0; i < 50; i++) {
105
+ const candidate = path.join(cur, ".factory");
106
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return cur;
107
+ const parent = path.dirname(cur);
108
+ if (parent === cur) break;
109
+ cur = parent;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Returns the preferred working data dir:
116
+ * - <project>/.factory/droid-mode when in a project
117
+ * - ~/.factory/droid-mode otherwise
118
+ */
119
+ export function getDroidModeDataDir() {
120
+ const root = findProjectRoot();
121
+ if (root) return path.join(root, ".factory", "droid-mode");
122
+ return path.join(os.homedir(), ".factory", "droid-mode");
123
+ }
124
+
125
+ /**
126
+ * Print a compact table for humans.
127
+ * @param {Array<Record<string, string>>} rows
128
+ * @param {string[]} cols
129
+ */
130
+ export function printTable(rows, cols) {
131
+ const widths = {};
132
+ for (const c of cols) widths[c] = c.length;
133
+ for (const r of rows) {
134
+ for (const c of cols) widths[c] = Math.max(widths[c], String(r[c] ?? "").length);
135
+ }
136
+ const sep =
137
+ cols.map((c) => "-".repeat(widths[c])).join(" ") + "\n";
138
+ const header =
139
+ cols.map((c) => String(c).padEnd(widths[c])).join(" ") + "\n";
140
+ const body = rows
141
+ .map((r) => cols.map((c) => String(r[c] ?? "").padEnd(widths[c])).join(" "))
142
+ .join("\n");
143
+ process.stdout.write(header);
144
+ process.stdout.write(sep);
145
+ process.stdout.write(body + (body ? "\n" : ""));
146
+ }
147
+
148
+ /**
149
+ * Best-effort: remove secrets from an env object for logging.
150
+ * @param {Record<string,string>|undefined|null} envObj
151
+ */
152
+ export function redactEnvForDisplay(envObj) {
153
+ if (!envObj) return {};
154
+ const out = {};
155
+ for (const [k, v] of Object.entries(envObj)) {
156
+ const isSecret = /(key|token|secret|password|auth|bearer)/i.test(k);
157
+ out[k] = isSecret ? "***" : String(v);
158
+ }
159
+ return out;
160
+ }