@genspark/cli 1.0.15 → 1.0.16
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/dist/commands/design.d.ts +37 -0
- package/dist/commands/design.d.ts.map +1 -0
- package/dist/commands/design.js +990 -0
- package/dist/commands/design.js.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/gsk-design/SKILL.md +110 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gsk design [path]` — local cc-bridge daemon for Genspark Designer V2 / V3.
|
|
3
|
+
*
|
|
4
|
+
* Hosts two HTTP servers on 127.0.0.1, mirroring the protocol Tiffany's
|
|
5
|
+
* private Anthropic CC binary speaks to claude.ai/design. Once the URL is
|
|
6
|
+
* opened in the user's browser, the FE side of cc-bridge (see
|
|
7
|
+
* `frontend-nuxt/utils/cc_bridge.ts`) redeems the nonce, registers the 6
|
|
8
|
+
* cc_* tool handlers in the client-tool dispatcher, and dispatches LLM
|
|
9
|
+
* tool calls over this bridge.
|
|
10
|
+
*
|
|
11
|
+
* Servers:
|
|
12
|
+
* - Exchange: port 47820 (well-known), POST /exchange {nonce} →
|
|
13
|
+
* {port, token, prompt, cwd, ls_tree}. Single-use — second hit on
|
|
14
|
+
* the same nonce returns 410 Gone.
|
|
15
|
+
* - Data: random port, bearer-authenticated routes
|
|
16
|
+
* /ls /read /grep /exec /back_to_cc /context. Auth = Authorization:
|
|
17
|
+
* Bearer + X-Bridge-Token (both headers must match the issued token,
|
|
18
|
+
* to force a fetch/CORS preflight and block <form>/<img> CSRF).
|
|
19
|
+
*
|
|
20
|
+
* Security:
|
|
21
|
+
* - Bind 127.0.0.1 only — never 0.0.0.0.
|
|
22
|
+
* - Origin allowlist enforced via CORS preflight (`https://www.genspark.ai`
|
|
23
|
+
* + `http://localhost:3000-3099` for dev).
|
|
24
|
+
* - Host header strict-check: must be `127.0.0.1:<port>` or
|
|
25
|
+
* `localhost:<port>` — blocks DNS rebinding.
|
|
26
|
+
* - Path inputs are normalized + rejected on `..`, absolute paths,
|
|
27
|
+
* NUL bytes. Symlink escape blocked via realpath check.
|
|
28
|
+
* - cc_exec allowlist defaults to read-only inspection commands;
|
|
29
|
+
* `--allow-build-tools` opens up build CLIs (git/npm/python/etc).
|
|
30
|
+
* Per-arg blocklist refuses `-c`, `--exec`, shell metacharacters
|
|
31
|
+
* and newlines regardless of allowlist.
|
|
32
|
+
* - 30s exec timeout (overridable per call up to 300s), 900 KB
|
|
33
|
+
* stdout/stderr cap, 5 req/sec rate limit on /exec.
|
|
34
|
+
*/
|
|
35
|
+
import * as crypto from 'crypto';
|
|
36
|
+
import { spawn } from 'child_process';
|
|
37
|
+
import * as fs from 'fs';
|
|
38
|
+
import * as fsp from 'fs/promises';
|
|
39
|
+
import * as http from 'http';
|
|
40
|
+
import * as os from 'os';
|
|
41
|
+
import * as path from 'path';
|
|
42
|
+
import { debug, info, error as logError, output } from '../logger.js';
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Protocol constants — keep aligned with
|
|
45
|
+
// `backend/tool_call/designer2/cc_bridge_protocol.py` and
|
|
46
|
+
// `frontend-nuxt/utils/ccBridgeConstants.ts`. These three files form the
|
|
47
|
+
// parity contract; a drift in any one of them is a wire-level bug.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
const EXCHANGE_PORT = 47820;
|
|
50
|
+
const DEFAULT_LAUNCH_URL = 'https://www.genspark.ai/agents?type=design';
|
|
51
|
+
const DEFAULT_EXEC_ALLOWLIST = [
|
|
52
|
+
'ls',
|
|
53
|
+
'cat',
|
|
54
|
+
'pwd',
|
|
55
|
+
'echo',
|
|
56
|
+
'head',
|
|
57
|
+
'tail',
|
|
58
|
+
'wc',
|
|
59
|
+
'file',
|
|
60
|
+
'which',
|
|
61
|
+
'date',
|
|
62
|
+
'hostname',
|
|
63
|
+
'uname',
|
|
64
|
+
];
|
|
65
|
+
const BUILD_TOOLS_ALLOWLIST = [
|
|
66
|
+
'git',
|
|
67
|
+
'npm',
|
|
68
|
+
'pnpm',
|
|
69
|
+
'yarn',
|
|
70
|
+
'node',
|
|
71
|
+
'python',
|
|
72
|
+
'python3',
|
|
73
|
+
'pip',
|
|
74
|
+
'pip3',
|
|
75
|
+
'rg',
|
|
76
|
+
'grep',
|
|
77
|
+
'find',
|
|
78
|
+
'cargo',
|
|
79
|
+
'go',
|
|
80
|
+
];
|
|
81
|
+
// Per-arg blocklist for cc_exec. The earlier "global short-flag"
|
|
82
|
+
// blocklist (`-m`/`-r`/`-e`/`-p`) was too broad: those flags are
|
|
83
|
+
// legitimate for `git commit -m`, `grep -r`, `grep -e`, `git log -p`,
|
|
84
|
+
// etc. (Bugbot round 8 Medium). Switched to per-cmd dispatch — only
|
|
85
|
+
// the interpreters that actually accept those flags as inline-code
|
|
86
|
+
// shorthand pay the cost.
|
|
87
|
+
//
|
|
88
|
+
// Universal restrictions (any command, any arg):
|
|
89
|
+
// - Shell metacharacters that escape into a sub-shell.
|
|
90
|
+
// - `--exec` / `--inspect*` / `--upload-pack` / `--receive-pack`
|
|
91
|
+
// are interpreter-shaped flags that broadly enable code paths
|
|
92
|
+
// dangerous everywhere (e.g. `git --upload-pack='evil cmd' ...`).
|
|
93
|
+
const UNIVERSAL_BAD_ARG_RE = /^--exec(?:$|[=\-])|^--inspect|^--upload-pack|^--receive-pack|[;&|`$<>\n\r]/;
|
|
94
|
+
// Per-command blocklist. The values are exact short flags + an
|
|
95
|
+
// optional regex for long flags / prefix matches. A match → reject
|
|
96
|
+
// the arg. `longFlagRe` is omitted (rather than `/^$/`) for commands
|
|
97
|
+
// with no long-flag rules — `/^$/` would match the empty string,
|
|
98
|
+
// which is technically valid but semantically confusing (Bugbot
|
|
99
|
+
// round 11 Medium).
|
|
100
|
+
const PER_CMD_BAD_ARGS = {
|
|
101
|
+
// Bash/sh/zsh — `-c` runs an inline command string. The default
|
|
102
|
+
// allowlist doesn't even include bash, but a future --allow flag
|
|
103
|
+
// might, and bash is the most common smuggling vector.
|
|
104
|
+
bash: { shortFlags: new Set(['-c']), longFlagRe: /^--command/ },
|
|
105
|
+
sh: { shortFlags: new Set(['-c']), longFlagRe: /^--command/ },
|
|
106
|
+
zsh: { shortFlags: new Set(['-c']), longFlagRe: /^--command/ },
|
|
107
|
+
fish: { shortFlags: new Set(['-c']), longFlagRe: /^--command/ },
|
|
108
|
+
// Python — `-c` inline, `-m` runs a module by name (still arbitrary
|
|
109
|
+
// code), `-eval` not real but cheap to block. `--command` exists
|
|
110
|
+
// on some wrappers.
|
|
111
|
+
python: {
|
|
112
|
+
shortFlags: new Set(['-c', '-m']),
|
|
113
|
+
longFlagRe: /^--command/,
|
|
114
|
+
},
|
|
115
|
+
python3: {
|
|
116
|
+
shortFlags: new Set(['-c', '-m']),
|
|
117
|
+
longFlagRe: /^--command/,
|
|
118
|
+
},
|
|
119
|
+
// Node — `-e` (eval), `-p` (print = eval+print), `-r` (require)
|
|
120
|
+
// are all code execution. `--eval` / `--print` / `--require` are
|
|
121
|
+
// the long forms; `--inspect*` is universally blocked above.
|
|
122
|
+
node: {
|
|
123
|
+
shortFlags: new Set(['-e', '-p', '-r']),
|
|
124
|
+
longFlagRe: /^--(?:eval|print|require)/,
|
|
125
|
+
},
|
|
126
|
+
// Find — `-exec` / `-execdir` run arbitrary commands per match,
|
|
127
|
+
// `-delete` mass-removes. `-ok` is interactive but still exec.
|
|
128
|
+
// `find` has no `--`-prefixed dangerous flags (the dangerous ones
|
|
129
|
+
// are all single-dash); `longFlagRe` is omitted to mean "no extra
|
|
130
|
+
// long-flag matching".
|
|
131
|
+
find: {
|
|
132
|
+
shortFlags: new Set([
|
|
133
|
+
'-exec',
|
|
134
|
+
'-execdir',
|
|
135
|
+
'-delete',
|
|
136
|
+
'-ok',
|
|
137
|
+
'-okdir',
|
|
138
|
+
'-fprint',
|
|
139
|
+
'-fprintf',
|
|
140
|
+
]),
|
|
141
|
+
},
|
|
142
|
+
// Git — `-c <key>=<val>` can override `core.sshCommand`,
|
|
143
|
+
// `core.fsmonitor`, `core.pager`, etc. to run arbitrary commands
|
|
144
|
+
// during otherwise-safe git operations. `--exec-path` /
|
|
145
|
+
// `--upload-pack` / `--receive-pack` are caught by the universal
|
|
146
|
+
// regex; `-c` is short-flag and needs an explicit per-cmd entry
|
|
147
|
+
// (Bugbot round 15 Medium). The `longFlagRe` here doubles as a
|
|
148
|
+
// "starts with `-c`" check so the concatenated form `-ckey=val`
|
|
149
|
+
// (which git also accepts) is caught — Bugbot round 21 Medium.
|
|
150
|
+
// `^-c(?:[=A-Za-z].*)?$` matches `-c`, `-c=foo`, `-cfoo` but not
|
|
151
|
+
// `-cmd` (which isn't a real git flag anyway). The exact `-c` is
|
|
152
|
+
// already in shortFlags; this regex catches the suffix-attached
|
|
153
|
+
// variants.
|
|
154
|
+
git: {
|
|
155
|
+
shortFlags: new Set(['-c']),
|
|
156
|
+
longFlagRe: /^-c[=A-Za-z]/,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
function _argIsSafeForCmd(cmd, arg) {
|
|
160
|
+
if (typeof arg !== 'string')
|
|
161
|
+
return false;
|
|
162
|
+
if (arg.includes('\0'))
|
|
163
|
+
return false;
|
|
164
|
+
if (UNIVERSAL_BAD_ARG_RE.test(arg))
|
|
165
|
+
return false;
|
|
166
|
+
const rules = PER_CMD_BAD_ARGS[cmd];
|
|
167
|
+
if (rules) {
|
|
168
|
+
if (rules.shortFlags.has(arg))
|
|
169
|
+
return false;
|
|
170
|
+
if (rules.longFlagRe && rules.longFlagRe.test(arg))
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
const EXEC_DEFAULT_TIMEOUT_S = 30;
|
|
176
|
+
const EXEC_MAX_TIMEOUT_S = 300;
|
|
177
|
+
const EXEC_OUTPUT_CAP = 900 * 1024; // 900K
|
|
178
|
+
const EXEC_RATE_LIMIT_PER_SEC = 5;
|
|
179
|
+
const READ_DEFAULT_LIMIT = 2000;
|
|
180
|
+
const READ_HARD_LIMIT = 1_000_000;
|
|
181
|
+
const LS_DEFAULT_DEPTH = 2;
|
|
182
|
+
const LS_MAX_DEPTH = 6;
|
|
183
|
+
const LS_DEFAULT_IGNORE = [
|
|
184
|
+
'.git',
|
|
185
|
+
'node_modules',
|
|
186
|
+
'.next',
|
|
187
|
+
'.nuxt',
|
|
188
|
+
'dist',
|
|
189
|
+
'build',
|
|
190
|
+
'__pycache__',
|
|
191
|
+
'.venv',
|
|
192
|
+
'venv',
|
|
193
|
+
'.cache',
|
|
194
|
+
'target',
|
|
195
|
+
];
|
|
196
|
+
// Exact-match origin list — `startsWith` would let
|
|
197
|
+
// `https://genspark.ai.evil.com` match and bypass the allowlist. Each
|
|
198
|
+
// entry must equal `req.headers.origin` byte-for-byte (which is how
|
|
199
|
+
// browsers emit Origin headers — no trailing slash).
|
|
200
|
+
const ALLOWED_ORIGINS = new Set([
|
|
201
|
+
'https://www.genspark.ai',
|
|
202
|
+
'https://genspark.ai',
|
|
203
|
+
]);
|
|
204
|
+
// Localhost is allowed at any port for dev. `^…$` anchors prevent
|
|
205
|
+
// suffix-injection (e.g. http://localhost:3000.evil.com).
|
|
206
|
+
const LOCALHOST_ORIGIN_RE = /^http:\/\/localhost:\d+$/;
|
|
207
|
+
function newState(opts) {
|
|
208
|
+
let finished = () => { };
|
|
209
|
+
const finishedPromise = new Promise(r => {
|
|
210
|
+
finished = r;
|
|
211
|
+
});
|
|
212
|
+
const allow = new Set(DEFAULT_EXEC_ALLOWLIST);
|
|
213
|
+
if (opts.allowBuildTools) {
|
|
214
|
+
for (const c of BUILD_TOOLS_ALLOWLIST)
|
|
215
|
+
allow.add(c);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
cwd: opts.cwd,
|
|
219
|
+
prompt: opts.prompt,
|
|
220
|
+
port: 0,
|
|
221
|
+
token: crypto.randomBytes(32).toString('hex'),
|
|
222
|
+
nonce: crypto.randomBytes(32).toString('hex'),
|
|
223
|
+
lsTree: '',
|
|
224
|
+
closed: false,
|
|
225
|
+
execAllowlist: allow,
|
|
226
|
+
execHits: [],
|
|
227
|
+
exchangeServer: null,
|
|
228
|
+
dataServer: null,
|
|
229
|
+
finished,
|
|
230
|
+
finishedPromise,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Path / arg safety
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
function resolveInsideCwd(rel, cwd) {
|
|
237
|
+
if (typeof rel !== 'string')
|
|
238
|
+
return { ok: false, reason: 'path must be string' };
|
|
239
|
+
if (rel.includes('\0'))
|
|
240
|
+
return { ok: false, reason: 'NUL in path' };
|
|
241
|
+
if (path.isAbsolute(rel))
|
|
242
|
+
return { ok: false, reason: 'absolute path' };
|
|
243
|
+
const parts = rel.split(/[\\/]/);
|
|
244
|
+
if (parts.some(p => p === '..'))
|
|
245
|
+
return { ok: false, reason: 'traversal segment' };
|
|
246
|
+
const abs = path.resolve(cwd, rel);
|
|
247
|
+
const cwdRoot = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
|
|
248
|
+
if (abs !== cwd && !abs.startsWith(cwdRoot)) {
|
|
249
|
+
return { ok: false, reason: 'resolves outside cwd' };
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, abs };
|
|
252
|
+
}
|
|
253
|
+
async function realpathInsideCwd(abs, cwd) {
|
|
254
|
+
try {
|
|
255
|
+
const realCwd = await fsp.realpath(cwd);
|
|
256
|
+
const realAbs = await fsp.realpath(abs);
|
|
257
|
+
const root = realCwd.endsWith(path.sep) ? realCwd : realCwd + path.sep;
|
|
258
|
+
return realAbs === realCwd || realAbs.startsWith(root);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// FS helpers
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
async function lsTree(rootAbs, cwd, depth) {
|
|
268
|
+
const out = [];
|
|
269
|
+
async function walk(dirAbs, dirRel, d) {
|
|
270
|
+
let entries;
|
|
271
|
+
try {
|
|
272
|
+
entries = await fsp.readdir(dirAbs, { withFileTypes: true });
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
for (const e of entries) {
|
|
278
|
+
if (LS_DEFAULT_IGNORE.includes(e.name))
|
|
279
|
+
continue;
|
|
280
|
+
const childRel = dirRel ? `${dirRel}/${e.name}` : e.name;
|
|
281
|
+
out.push(e.isDirectory() ? childRel + '/' : childRel);
|
|
282
|
+
if (e.isDirectory() && d > 1) {
|
|
283
|
+
await walk(path.join(dirAbs, e.name), childRel, d - 1);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Compute initial relative dir from absolute.
|
|
288
|
+
const initialRel = rootAbs === cwd ? '' : path.relative(cwd, rootAbs);
|
|
289
|
+
await walk(rootAbs, initialRel, Math.max(1, Math.min(depth, LS_MAX_DEPTH)));
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
function isBinaryHeuristic(buf) {
|
|
293
|
+
const n = Math.min(buf.length, 8192);
|
|
294
|
+
for (let i = 0; i < n; i++)
|
|
295
|
+
if (buf[i] === 0)
|
|
296
|
+
return true;
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
// Hard cap on /read regardless of binary or text — without this, a
|
|
300
|
+
// multi-GB file in the user's cwd would OOM the daemon during the
|
|
301
|
+
// initial `fsp.readFile` call (loads the entire file into a Buffer
|
|
302
|
+
// before any heuristic runs). We `fs.stat` first and refuse oversized
|
|
303
|
+
// files outright; for binary, the on-disk cap also bounds the base64
|
|
304
|
+
// blowup (1.33×) on the response side.
|
|
305
|
+
const READ_FILE_HARD_CAP_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
306
|
+
const BINARY_READ_CAP_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
307
|
+
async function readFileSlice(abs, offset, limit) {
|
|
308
|
+
// Size gate before reading. `fs.stat` is cheap and avoids the
|
|
309
|
+
// round-trip where `fs.readFile` happily loads 4 GB before the cap
|
|
310
|
+
// check fires. Throwing here lands as the 400 response in the
|
|
311
|
+
// handler's try/catch — same shape as a permission denied read.
|
|
312
|
+
const stat = await fsp.stat(abs);
|
|
313
|
+
if (stat.size > READ_FILE_HARD_CAP_BYTES) {
|
|
314
|
+
throw new Error(`file exceeds ${READ_FILE_HARD_CAP_BYTES} bytes (${stat.size}) — too large to read`);
|
|
315
|
+
}
|
|
316
|
+
const buf = await fsp.readFile(abs);
|
|
317
|
+
if (isBinaryHeuristic(buf)) {
|
|
318
|
+
if (buf.length > BINARY_READ_CAP_BYTES) {
|
|
319
|
+
// Return the first BINARY_READ_CAP_BYTES so the LLM at least
|
|
320
|
+
// gets a head, with the truncation flag set. Mirrors cc_read's
|
|
321
|
+
// standard contract for text files that exceed line cap.
|
|
322
|
+
return {
|
|
323
|
+
binary: true,
|
|
324
|
+
truncated: true,
|
|
325
|
+
base64: buf.subarray(0, BINARY_READ_CAP_BYTES).toString('base64'),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return { binary: true, truncated: false, base64: buf.toString('base64') };
|
|
329
|
+
}
|
|
330
|
+
const text = buf.toString('utf8');
|
|
331
|
+
const lines = text.split('\n');
|
|
332
|
+
const end = Math.min(lines.length, offset + limit);
|
|
333
|
+
const slice = lines.slice(offset, end).join('\n');
|
|
334
|
+
return {
|
|
335
|
+
binary: false,
|
|
336
|
+
truncated: end < lines.length || lines.length > READ_HARD_LIMIT,
|
|
337
|
+
content: slice,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
// Pattern-length cap on grep — defense against ReDoS via
|
|
341
|
+
// catastrophic-backtracking patterns like `(a+)+$`. JS regex is
|
|
342
|
+
// synchronous and can hang the event loop indefinitely on a
|
|
343
|
+
// pathological pattern (Bugbot round 9 Medium). Combined with the
|
|
344
|
+
// nested-quantifier rejection below and the per-grep walltime budget,
|
|
345
|
+
// this bounds the damage. Long-term proper fix is to shell out to
|
|
346
|
+
// ripgrep (linear-time engine), but that adds a runtime dependency.
|
|
347
|
+
const GREP_PATTERN_MAX_LEN = 200;
|
|
348
|
+
// Reject `(<anything-with-*-or-+>)+` and `(<…>)*` shapes — the
|
|
349
|
+
// classic ReDoS catastrophic-backtracking signature. Not exhaustive
|
|
350
|
+
// but catches the common attack patterns.
|
|
351
|
+
const GREP_NESTED_QUANT_RE = /\([^)]*[+*][^)]*\)[+*]/;
|
|
352
|
+
// Total wall-clock budget for one /grep call. We bail out of the
|
|
353
|
+
// file walk past this point even if matches < 200; protects against
|
|
354
|
+
// "many small ReDoS-vulnerable files" attacks.
|
|
355
|
+
const GREP_TOTAL_BUDGET_MS = 5000;
|
|
356
|
+
async function grepFiles(cwd, pattern, subdir, glob) {
|
|
357
|
+
if (pattern.length > GREP_PATTERN_MAX_LEN) {
|
|
358
|
+
throw new Error(`pattern exceeds ${GREP_PATTERN_MAX_LEN} chars`);
|
|
359
|
+
}
|
|
360
|
+
if (GREP_NESTED_QUANT_RE.test(pattern)) {
|
|
361
|
+
throw new Error('pattern contains nested quantifiers (ReDoS-prone) — rewrite without nested * / + inside groups');
|
|
362
|
+
}
|
|
363
|
+
const startRel = subdir || '';
|
|
364
|
+
const start = resolveInsideCwd(startRel || '.', cwd);
|
|
365
|
+
if (!start.ok)
|
|
366
|
+
throw new Error(start.reason);
|
|
367
|
+
// Symlink-escape check on the search root — without this, a symlink
|
|
368
|
+
// under cwd pointing at /etc would let an LLM-supplied `path` arg
|
|
369
|
+
// smuggle a grep against system files. /ls and /read enforce the
|
|
370
|
+
// same check; keep them in sync.
|
|
371
|
+
if (!(await realpathInsideCwd(start.abs, cwd))) {
|
|
372
|
+
throw new Error('symlink escapes cwd');
|
|
373
|
+
}
|
|
374
|
+
let regex;
|
|
375
|
+
try {
|
|
376
|
+
regex = new RegExp(pattern);
|
|
377
|
+
}
|
|
378
|
+
catch (e) {
|
|
379
|
+
throw new Error(`bad regex: ${e.message}`);
|
|
380
|
+
}
|
|
381
|
+
const deadline = Date.now() + GREP_TOTAL_BUDGET_MS;
|
|
382
|
+
const globRe = glob
|
|
383
|
+
? new RegExp('^' + glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$')
|
|
384
|
+
: null;
|
|
385
|
+
const matches = [];
|
|
386
|
+
async function walk(dirAbs, dirRel) {
|
|
387
|
+
if (matches.length >= 200)
|
|
388
|
+
return;
|
|
389
|
+
if (Date.now() > deadline)
|
|
390
|
+
return;
|
|
391
|
+
let entries;
|
|
392
|
+
try {
|
|
393
|
+
entries = await fsp.readdir(dirAbs, { withFileTypes: true });
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
for (const e of entries) {
|
|
399
|
+
if (Date.now() > deadline)
|
|
400
|
+
return;
|
|
401
|
+
if (LS_DEFAULT_IGNORE.includes(e.name))
|
|
402
|
+
continue;
|
|
403
|
+
const rel = dirRel ? `${dirRel}/${e.name}` : e.name;
|
|
404
|
+
const abs = path.join(dirAbs, e.name);
|
|
405
|
+
if (e.isDirectory()) {
|
|
406
|
+
await walk(abs, rel);
|
|
407
|
+
}
|
|
408
|
+
else if (e.isFile()) {
|
|
409
|
+
if (globRe && !globRe.test(e.name))
|
|
410
|
+
continue;
|
|
411
|
+
try {
|
|
412
|
+
// Stat-gate the read — without this, grep walks a 4 GB
|
|
413
|
+
// database dump straight into memory (Bugbot round 4 Low).
|
|
414
|
+
// `/read` enforces the same cap explicitly; mirror that
|
|
415
|
+
// here. Skip silently rather than abort the whole grep
|
|
416
|
+
// because the LLM doesn't care about giant blobs anyway.
|
|
417
|
+
const stat = await fsp.stat(abs);
|
|
418
|
+
if (stat.size > READ_FILE_HARD_CAP_BYTES)
|
|
419
|
+
continue;
|
|
420
|
+
const buf = await fsp.readFile(abs);
|
|
421
|
+
if (isBinaryHeuristic(buf))
|
|
422
|
+
continue;
|
|
423
|
+
const lines = buf.toString('utf8').split('\n');
|
|
424
|
+
for (let i = 0; i < lines.length && matches.length < 200; i++) {
|
|
425
|
+
if (regex.test(lines[i])) {
|
|
426
|
+
matches.push(`${rel}:${i + 1}: ${lines[i].slice(0, 200)}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// unreadable file — skip
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (matches.length >= 200)
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
await walk(start.abs, startRel);
|
|
439
|
+
return matches;
|
|
440
|
+
}
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// HTTP helpers
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
function send(res, status, body, origin) {
|
|
445
|
+
// CORS must travel on the real response too — preflight OPTIONS only
|
|
446
|
+
// covers the predicate; the browser blocks the actual body if
|
|
447
|
+
// `Access-Control-Allow-Origin` is missing on the POST response.
|
|
448
|
+
// Without this every cc_* tool call from the browser ends in a CORS
|
|
449
|
+
// failure even though preflight succeeded. Caller passes
|
|
450
|
+
// `req.headers.origin` so we echo a single trusted origin rather
|
|
451
|
+
// than `*` (which Chrome rejects when combined with credentials).
|
|
452
|
+
res.writeHead(status, {
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
...corsHeaders(origin),
|
|
455
|
+
});
|
|
456
|
+
res.end(JSON.stringify(body));
|
|
457
|
+
}
|
|
458
|
+
function corsHeaders(origin) {
|
|
459
|
+
const allow = origin && originAllowed(origin) ? origin : '';
|
|
460
|
+
// First default origin acts as a "no echo" fallback so the header is
|
|
461
|
+
// always present (some browsers cache a no-CORS response harder than
|
|
462
|
+
// a wrong-origin response).
|
|
463
|
+
return {
|
|
464
|
+
'Access-Control-Allow-Origin': allow || 'https://www.genspark.ai',
|
|
465
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
466
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Bridge-Token',
|
|
467
|
+
'Access-Control-Max-Age': '600',
|
|
468
|
+
// PNA (Private Network Access) — Chrome 117+ requires this on cross-origin
|
|
469
|
+
// requests targeting localhost. Without it the browser blocks the
|
|
470
|
+
// preflight with `ERR_FAILED` and no useful console message.
|
|
471
|
+
'Access-Control-Allow-Private-Network': 'true',
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function originAllowed(origin) {
|
|
475
|
+
if (!origin)
|
|
476
|
+
return false;
|
|
477
|
+
if (ALLOWED_ORIGINS.has(origin))
|
|
478
|
+
return true;
|
|
479
|
+
if (LOCALHOST_ORIGIN_RE.test(origin))
|
|
480
|
+
return true;
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
function hostAllowed(host, port) {
|
|
484
|
+
if (!host)
|
|
485
|
+
return false;
|
|
486
|
+
return host === `127.0.0.1:${port}` || host === `localhost:${port}`;
|
|
487
|
+
}
|
|
488
|
+
// Body cap on incoming /back_to_cc payloads. The BE's
|
|
489
|
+
// `cc_bridge_export` endpoint allows up to 50 MB of *decoded* file
|
|
490
|
+
// bytes; base64 wrapping inflates that ~33% → ~67 MB on the wire.
|
|
491
|
+
// Add ~33% headroom on top of that for the surrounding JSON shape
|
|
492
|
+
// (paths, prompt, framing). Previously 50 MB which got rejected for
|
|
493
|
+
// any export with > ~37 MB of decoded content (Bugbot round 10
|
|
494
|
+
// Medium).
|
|
495
|
+
const DAEMON_BODY_CAP_BYTES = 96 * 1024 * 1024;
|
|
496
|
+
async function readJsonBody(req) {
|
|
497
|
+
const chunks = [];
|
|
498
|
+
let total = 0;
|
|
499
|
+
for await (const c of req) {
|
|
500
|
+
const buf = c;
|
|
501
|
+
total += buf.length;
|
|
502
|
+
if (total > DAEMON_BODY_CAP_BYTES)
|
|
503
|
+
throw new Error('body too large');
|
|
504
|
+
chunks.push(buf);
|
|
505
|
+
}
|
|
506
|
+
if (chunks.length === 0)
|
|
507
|
+
return {};
|
|
508
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
509
|
+
if (!text)
|
|
510
|
+
return {};
|
|
511
|
+
return JSON.parse(text);
|
|
512
|
+
}
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Exchange server (port 47820)
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
function startExchangeServer(state) {
|
|
517
|
+
return new Promise((resolve, reject) => {
|
|
518
|
+
const server = http.createServer((req, res) => {
|
|
519
|
+
const origin = req.headers.origin;
|
|
520
|
+
// Preflight
|
|
521
|
+
if (req.method === 'OPTIONS') {
|
|
522
|
+
res.writeHead(204, corsHeaders(origin));
|
|
523
|
+
res.end();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (!originAllowed(origin)) {
|
|
527
|
+
res.writeHead(403, corsHeaders(origin));
|
|
528
|
+
res.end(JSON.stringify({ error: 'origin not allowed' }));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
// Host strict-check — same DNS-rebinding defense as the data
|
|
532
|
+
// server. The exchange port is well-known (47820); a malicious
|
|
533
|
+
// page that resolves `genspark-bridge.com → 127.0.0.1` could
|
|
534
|
+
// otherwise send Origin=evil.com (origin-rejected above) — but
|
|
535
|
+
// also Origin=https://genspark.ai with `Host: rebound.com:47820`
|
|
536
|
+
// and bypass single-origin if the browser's DNS-rebound IP
|
|
537
|
+
// matches the server's bind. Defense in depth.
|
|
538
|
+
if (!hostAllowed(req.headers.host, EXCHANGE_PORT)) {
|
|
539
|
+
res.writeHead(403, corsHeaders(origin));
|
|
540
|
+
res.end(JSON.stringify({ error: 'host not allowed' }));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (req.method !== 'POST' || req.url !== '/exchange') {
|
|
544
|
+
res.writeHead(404, corsHeaders(origin));
|
|
545
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
void readJsonBody(req)
|
|
549
|
+
.then(body => {
|
|
550
|
+
const got = body.nonce;
|
|
551
|
+
if (!got || got !== state.nonce) {
|
|
552
|
+
res.writeHead(401, corsHeaders(origin));
|
|
553
|
+
res.end(JSON.stringify({ error: 'bad nonce' }));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
// Single-use: invalidate after first redeem.
|
|
557
|
+
state.nonce = '';
|
|
558
|
+
res.writeHead(200, {
|
|
559
|
+
'Content-Type': 'application/json',
|
|
560
|
+
...corsHeaders(origin),
|
|
561
|
+
});
|
|
562
|
+
res.end(JSON.stringify({
|
|
563
|
+
port: state.port,
|
|
564
|
+
token: state.token,
|
|
565
|
+
prompt: state.prompt,
|
|
566
|
+
cwd: state.cwd,
|
|
567
|
+
ls_tree: state.lsTree,
|
|
568
|
+
}));
|
|
569
|
+
})
|
|
570
|
+
.catch(() => {
|
|
571
|
+
res.writeHead(400, corsHeaders(origin));
|
|
572
|
+
res.end(JSON.stringify({ error: 'bad body' }));
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
server.on('error', reject);
|
|
576
|
+
server.listen(EXCHANGE_PORT, '127.0.0.1', () => {
|
|
577
|
+
state.exchangeServer = server;
|
|
578
|
+
resolve();
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Data server (random port)
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
function startDataServer(state) {
|
|
586
|
+
return new Promise((resolve, reject) => {
|
|
587
|
+
const server = http.createServer((req, res) => {
|
|
588
|
+
void handleDataRequest(req, res, state).catch(err => {
|
|
589
|
+
try {
|
|
590
|
+
// No closure-scoped `origin` here — read from the request
|
|
591
|
+
// we still have a reference to.
|
|
592
|
+
send(res, 500, { error: err.message }, req.headers.origin);
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
// ignore
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
server.on('error', reject);
|
|
600
|
+
server.listen(0, '127.0.0.1', () => {
|
|
601
|
+
const addr = server.address();
|
|
602
|
+
if (addr && typeof addr === 'object')
|
|
603
|
+
state.port = addr.port;
|
|
604
|
+
state.dataServer = server;
|
|
605
|
+
resolve();
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
async function handleDataRequest(req, res, state) {
|
|
610
|
+
const origin = req.headers.origin;
|
|
611
|
+
if (req.method === 'OPTIONS') {
|
|
612
|
+
res.writeHead(204, corsHeaders(origin));
|
|
613
|
+
res.end();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// 4-layer auth: origin → host → bearer → custom header
|
|
617
|
+
if (!originAllowed(origin)) {
|
|
618
|
+
send(res, 403, { error: 'origin not allowed' }, origin);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (!hostAllowed(req.headers.host, state.port)) {
|
|
622
|
+
send(res, 403, { error: 'host not allowed' }, origin);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (state.closed) {
|
|
626
|
+
send(res, 410, { error: 'bridge closed' }, origin);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const bearer = req.headers.authorization;
|
|
630
|
+
const xbridge = req.headers['x-bridge-token'];
|
|
631
|
+
if (!bearer || bearer !== `Bearer ${state.token}` || xbridge !== state.token) {
|
|
632
|
+
send(res, 401, { error: 'auth' }, origin);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const url = req.url || '';
|
|
636
|
+
switch (url) {
|
|
637
|
+
case '/ls': {
|
|
638
|
+
const body = (await readJsonBody(req));
|
|
639
|
+
const subdir = typeof body.path === 'string' ? body.path : '';
|
|
640
|
+
const d = typeof body.depth === 'number' ? body.depth : LS_DEFAULT_DEPTH;
|
|
641
|
+
const resolved = resolveInsideCwd(subdir || '.', state.cwd);
|
|
642
|
+
if (!resolved.ok) {
|
|
643
|
+
send(res, 400, { error: resolved.reason }, origin);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// Symlink-escape check (same discipline as /read): a symlink
|
|
647
|
+
// under cwd pointing at /etc would otherwise let an LLM-supplied
|
|
648
|
+
// `path` arg enumerate outside the bridge sandbox.
|
|
649
|
+
if (!(await realpathInsideCwd(resolved.abs, state.cwd))) {
|
|
650
|
+
send(res, 400, { error: 'symlink escapes cwd' }, origin);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const entries = await lsTree(resolved.abs, state.cwd, d);
|
|
654
|
+
send(res, 200, { entries }, origin);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
case '/read': {
|
|
658
|
+
const body = (await readJsonBody(req));
|
|
659
|
+
if (!body.path) {
|
|
660
|
+
send(res, 400, { error: 'path required' }, origin);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const r = resolveInsideCwd(body.path, state.cwd);
|
|
664
|
+
if (!r.ok) {
|
|
665
|
+
send(res, 400, { error: r.reason }, origin);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (!(await realpathInsideCwd(r.abs, state.cwd))) {
|
|
669
|
+
send(res, 400, { error: 'symlink escapes cwd' }, origin);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
// Clamp to non-negative — a negative limit / offset gives
|
|
673
|
+
// `lines.slice` undefined-behavior-looking results (e.g. limit
|
|
674
|
+
// = -1 → all-but-last line). Coerce to 0 and let the cap on
|
|
675
|
+
// the high side enforce the real bound (Bugbot round 14 Low).
|
|
676
|
+
const offsetRaw = typeof body.offset === 'number' ? body.offset : 0;
|
|
677
|
+
const limitRaw = typeof body.limit === 'number' ? body.limit : READ_DEFAULT_LIMIT;
|
|
678
|
+
const offset = Math.max(0, Math.floor(offsetRaw));
|
|
679
|
+
const limit = Math.max(0, Math.min(Math.floor(limitRaw), READ_HARD_LIMIT));
|
|
680
|
+
try {
|
|
681
|
+
const result = await readFileSlice(r.abs, offset, limit);
|
|
682
|
+
send(res, 200, result, origin);
|
|
683
|
+
}
|
|
684
|
+
catch (e) {
|
|
685
|
+
send(res, 400, { error: e.message }, origin);
|
|
686
|
+
}
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
case '/grep': {
|
|
690
|
+
const body = (await readJsonBody(req));
|
|
691
|
+
if (!body.pattern) {
|
|
692
|
+
send(res, 400, { error: 'pattern required' }, origin);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
const matches = await grepFiles(state.cwd, body.pattern, body.path || '', body.glob || '');
|
|
697
|
+
send(res, 200, { matches }, origin);
|
|
698
|
+
}
|
|
699
|
+
catch (e) {
|
|
700
|
+
send(res, 400, { error: e.message }, origin);
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
case '/exec': {
|
|
705
|
+
// Rate limit: drop hits older than 1s, then check count.
|
|
706
|
+
// CRITICAL: bump the counter SYNCHRONOUSLY before any await — JS
|
|
707
|
+
// is single-threaded between awaits but two concurrent requests
|
|
708
|
+
// could otherwise both pass the count check (both at length=4)
|
|
709
|
+
// and then both push (→ 6 concurrent execs, busting the cap).
|
|
710
|
+
// Push first, then await body parsing.
|
|
711
|
+
const now = Date.now();
|
|
712
|
+
state.execHits = state.execHits.filter(t => now - t < 1000);
|
|
713
|
+
if (state.execHits.length >= EXEC_RATE_LIMIT_PER_SEC) {
|
|
714
|
+
res.writeHead(429, {
|
|
715
|
+
...corsHeaders(origin),
|
|
716
|
+
'Content-Type': 'application/json',
|
|
717
|
+
'Retry-After': '1',
|
|
718
|
+
});
|
|
719
|
+
res.end(JSON.stringify({ error: 'rate limit' }));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
state.execHits.push(now);
|
|
723
|
+
const body = (await readJsonBody(req));
|
|
724
|
+
if (!body.cmd || !Array.isArray(body.args)) {
|
|
725
|
+
send(res, 400, { error: 'cmd + args required' }, origin);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (!state.execAllowlist.has(body.cmd)) {
|
|
729
|
+
send(res, 403, {
|
|
730
|
+
error: `command not allowed: ${body.cmd}. Run gsk design with --allow-build-tools to enable git/npm/python/etc.`,
|
|
731
|
+
}, origin);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
for (const arg of body.args) {
|
|
735
|
+
if (!_argIsSafeForCmd(body.cmd, arg)) {
|
|
736
|
+
send(res, 400, { error: `arg rejected: ${arg.slice(0, 80)}` }, origin);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const t = Math.min(Math.max(body.timeout_sec ?? EXEC_DEFAULT_TIMEOUT_S, 1), EXEC_MAX_TIMEOUT_S);
|
|
741
|
+
const result = await runCommand(state.cwd, body.cmd, body.args, t);
|
|
742
|
+
send(res, 200, result, origin);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
case '/back_to_cc': {
|
|
746
|
+
const body = (await readJsonBody(req));
|
|
747
|
+
if (typeof body.prompt !== 'string' || !body.prompt.trim()) {
|
|
748
|
+
send(res, 400, { error: 'prompt required' }, origin);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
// The FE may send either `paths` (legacy: names only — daemon
|
|
752
|
+
// resolves) OR `files` (V2: { path, content_b64 } pre-bundled by
|
|
753
|
+
// the FE because the daemon can't reach the design project repo).
|
|
754
|
+
// V2's preferred shape is `files`; `paths` is accepted for
|
|
755
|
+
// forward-compat with daemons that bundle server-side.
|
|
756
|
+
const handoff = await writeHandoff({
|
|
757
|
+
cwd: state.cwd,
|
|
758
|
+
prompt: body.prompt,
|
|
759
|
+
files: Array.isArray(body.files) ? body.files : [],
|
|
760
|
+
});
|
|
761
|
+
// Single-shot: future requests return 410.
|
|
762
|
+
state.closed = true;
|
|
763
|
+
send(res, 200, handoff, origin);
|
|
764
|
+
// Resolve main loop so the CLI can exit — but with a small
|
|
765
|
+
// grace period so the FE has a chance to observe the 410 cascade
|
|
766
|
+
// on any in-flight follow-up calls before the server socket
|
|
767
|
+
// closes. Mirrors V3 daemon's "stay alive briefly after close"
|
|
768
|
+
// behavior so client error messages are deterministic.
|
|
769
|
+
setTimeout(() => state.finished(), 3000);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
case '/context': {
|
|
773
|
+
// Dead-code in Tiffany bundle but we expose it anyway so daemons
|
|
774
|
+
// that DO call it (future versions of CC, custom integrations)
|
|
775
|
+
// get a sane response. Mirrors the documented shape.
|
|
776
|
+
send(res, 200, {
|
|
777
|
+
prompt: state.prompt,
|
|
778
|
+
cwd: state.cwd,
|
|
779
|
+
ls_tree: state.lsTree,
|
|
780
|
+
}, origin);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
default:
|
|
784
|
+
send(res, 404, { error: 'not found' }, origin);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function runCommand(cwd, cmd, args, timeoutSec) {
|
|
788
|
+
return new Promise(resolve => {
|
|
789
|
+
const child = spawn(cmd, args, {
|
|
790
|
+
cwd,
|
|
791
|
+
// No shell — exec the binary directly. shell:true would let
|
|
792
|
+
// metacharacters through despite per-arg blocklist.
|
|
793
|
+
shell: false,
|
|
794
|
+
env: { ...process.env, PATH: process.env.PATH || '' },
|
|
795
|
+
});
|
|
796
|
+
let stdout = '';
|
|
797
|
+
let stderr = '';
|
|
798
|
+
let truncated = false;
|
|
799
|
+
let timer = setTimeout(() => {
|
|
800
|
+
child.kill('SIGKILL');
|
|
801
|
+
}, timeoutSec * 1000);
|
|
802
|
+
child.stdout.on('data', (chunk) => {
|
|
803
|
+
if (stdout.length + chunk.length > EXEC_OUTPUT_CAP) {
|
|
804
|
+
stdout += chunk.subarray(0, EXEC_OUTPUT_CAP - stdout.length).toString('utf8');
|
|
805
|
+
truncated = true;
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
stdout += chunk.toString('utf8');
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
child.stderr.on('data', (chunk) => {
|
|
812
|
+
if (stderr.length + chunk.length > EXEC_OUTPUT_CAP) {
|
|
813
|
+
stderr += chunk.subarray(0, EXEC_OUTPUT_CAP - stderr.length).toString('utf8');
|
|
814
|
+
truncated = true;
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
stderr += chunk.toString('utf8');
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
child.on('close', code => {
|
|
821
|
+
if (timer)
|
|
822
|
+
clearTimeout(timer);
|
|
823
|
+
timer = null;
|
|
824
|
+
resolve({
|
|
825
|
+
exit_code: code ?? -1,
|
|
826
|
+
stdout,
|
|
827
|
+
stderr,
|
|
828
|
+
truncated,
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
child.on('error', err => {
|
|
832
|
+
if (timer)
|
|
833
|
+
clearTimeout(timer);
|
|
834
|
+
timer = null;
|
|
835
|
+
resolve({
|
|
836
|
+
exit_code: -1,
|
|
837
|
+
stdout: '',
|
|
838
|
+
stderr: `spawn failed: ${err.message}`,
|
|
839
|
+
truncated: false,
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
async function writeHandoff(opts) {
|
|
845
|
+
// ISO 8601 → compact form: 2026-05-12T01:24:56.789Z → 20260512T012456
|
|
846
|
+
const ts = new Date()
|
|
847
|
+
.toISOString()
|
|
848
|
+
.replace(/[-:]/g, '')
|
|
849
|
+
.replace(/\..+/, '');
|
|
850
|
+
const handoffDir = path.join(opts.cwd, '.cc-bridge-out', `handoff_${ts}`);
|
|
851
|
+
await fsp.mkdir(handoffDir, { recursive: true });
|
|
852
|
+
// Gitignore the parent so the handoff dir doesn't accidentally land
|
|
853
|
+
// in a commit. Mirrors V3 daemon convention.
|
|
854
|
+
await fsp.writeFile(path.join(opts.cwd, '.cc-bridge-out', '.gitignore'), '*\n');
|
|
855
|
+
// Save the build prompt next to the artifacts.
|
|
856
|
+
await fsp.writeFile(path.join(handoffDir, '_prompt.txt'), opts.prompt, 'utf8');
|
|
857
|
+
let written = 0;
|
|
858
|
+
for (const f of opts.files) {
|
|
859
|
+
if (typeof f.path !== 'string' || !f.path)
|
|
860
|
+
continue;
|
|
861
|
+
if (typeof f.content_b64 !== 'string')
|
|
862
|
+
continue;
|
|
863
|
+
// CRITICAL: validate the FE-supplied path BEFORE joining with the
|
|
864
|
+
// handoff dir prefix. A crafted `f.path` like
|
|
865
|
+
// `../../.bashrc` survives `path.join('.cc-bridge-out',
|
|
866
|
+
// 'handoff_<ts>', '../../.bashrc')` as `.bashrc`, which IS inside
|
|
867
|
+
// cwd, so the post-join `resolveInsideCwd` would happily allow it
|
|
868
|
+
// — a write to any file in the user's cwd (Bugbot round 18
|
|
869
|
+
// Medium). Reject the raw path against the *handoff subdir* root
|
|
870
|
+
// so escape attempts are caught before normpath collapses them.
|
|
871
|
+
if (path.isAbsolute(f.path) ||
|
|
872
|
+
f.path.split(/[\\/]/).some(p => p === '..') ||
|
|
873
|
+
f.path.includes('\0')) {
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
const joined = path.join('.cc-bridge-out', `handoff_${ts}`, f.path);
|
|
877
|
+
const safe = resolveInsideCwd(joined, opts.cwd);
|
|
878
|
+
if (!safe.ok)
|
|
879
|
+
continue;
|
|
880
|
+
// Belt-and-braces: confirm the resolved abs path is under the
|
|
881
|
+
// handoff dir specifically, not just under cwd. The pre-check
|
|
882
|
+
// above blocks this in practice, but the assertion keeps the
|
|
883
|
+
// invariant explicit and survives future refactors.
|
|
884
|
+
const handoffPrefix = handoffDir.endsWith(path.sep)
|
|
885
|
+
? handoffDir
|
|
886
|
+
: handoffDir + path.sep;
|
|
887
|
+
if (!safe.abs.startsWith(handoffPrefix))
|
|
888
|
+
continue;
|
|
889
|
+
await fsp.mkdir(path.dirname(safe.abs), { recursive: true });
|
|
890
|
+
await fsp.writeFile(safe.abs, Buffer.from(f.content_b64, 'base64'));
|
|
891
|
+
written++;
|
|
892
|
+
}
|
|
893
|
+
info(`✓ Wrote ${written} file(s) to ${handoffDir}`);
|
|
894
|
+
info(`✓ Build prompt:\n${opts.prompt}`);
|
|
895
|
+
return { handoff_dir: handoffDir, written_n: written };
|
|
896
|
+
}
|
|
897
|
+
// ---------------------------------------------------------------------------
|
|
898
|
+
// Browser launch
|
|
899
|
+
// ---------------------------------------------------------------------------
|
|
900
|
+
function openInBrowser(url) {
|
|
901
|
+
const platform = os.platform();
|
|
902
|
+
let cmd;
|
|
903
|
+
let args;
|
|
904
|
+
if (platform === 'darwin') {
|
|
905
|
+
cmd = 'open';
|
|
906
|
+
args = [url];
|
|
907
|
+
}
|
|
908
|
+
else if (platform === 'win32') {
|
|
909
|
+
cmd = 'cmd';
|
|
910
|
+
args = ['/c', 'start', '', url];
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
cmd = 'xdg-open';
|
|
914
|
+
args = [url];
|
|
915
|
+
}
|
|
916
|
+
try {
|
|
917
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
918
|
+
}
|
|
919
|
+
catch (e) {
|
|
920
|
+
info(`(Could not open browser automatically: ${e.message})`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
async function runDesignCommand(pathArg, opts) {
|
|
924
|
+
const cwd = path.resolve(pathArg || process.cwd());
|
|
925
|
+
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
|
|
926
|
+
logError(`Not a directory: ${cwd}`);
|
|
927
|
+
process.exit(1);
|
|
928
|
+
}
|
|
929
|
+
const state = newState({
|
|
930
|
+
cwd,
|
|
931
|
+
prompt: opts.prompt || 'Help me design a UI for this codebase.',
|
|
932
|
+
allowBuildTools: !!opts.allowBuildTools,
|
|
933
|
+
});
|
|
934
|
+
// Pre-fetch the depth-2 listing so the BE autostart hook can inline it
|
|
935
|
+
// into the synthetic first-turn message without a round-trip.
|
|
936
|
+
try {
|
|
937
|
+
const entries = await lsTree(cwd, cwd, 2);
|
|
938
|
+
state.lsTree = entries.join('\n');
|
|
939
|
+
}
|
|
940
|
+
catch (e) {
|
|
941
|
+
debug(`ls pre-fetch failed: ${e.message}`);
|
|
942
|
+
state.lsTree = '';
|
|
943
|
+
}
|
|
944
|
+
await startDataServer(state);
|
|
945
|
+
await startExchangeServer(state);
|
|
946
|
+
debug(`Started exchange on 127.0.0.1:${EXCHANGE_PORT}, data on 127.0.0.1:${state.port}`);
|
|
947
|
+
const baseLaunch = opts.launchUrl || DEFAULT_LAUNCH_URL;
|
|
948
|
+
const sep = baseLaunch.includes('#') ? '&' : '#';
|
|
949
|
+
const launchUrl = `${baseLaunch}${sep}ccbridge_nonce=${state.nonce}`;
|
|
950
|
+
info(`🔒 Bridge ready: data=127.0.0.1:${state.port} exchange=127.0.0.1:${EXCHANGE_PORT}`);
|
|
951
|
+
info(`👉 Open: ${launchUrl}`);
|
|
952
|
+
info(` (Browser opening automatically — close the tab to exit early.)`);
|
|
953
|
+
info(` cwd: ${cwd}`);
|
|
954
|
+
if (opts.allowBuildTools) {
|
|
955
|
+
info(` ⚠️ --allow-build-tools enabled: git / npm / python / etc allowed`);
|
|
956
|
+
}
|
|
957
|
+
if (!opts.noOpen) {
|
|
958
|
+
openInBrowser(launchUrl);
|
|
959
|
+
}
|
|
960
|
+
// Block until /back_to_cc fires (or Ctrl-C).
|
|
961
|
+
await state.finishedPromise;
|
|
962
|
+
// Graceful shutdown.
|
|
963
|
+
state.dataServer?.close();
|
|
964
|
+
state.exchangeServer?.close();
|
|
965
|
+
output({ ok: true, handoff: 'closed' });
|
|
966
|
+
}
|
|
967
|
+
export function registerDesignCommand(program) {
|
|
968
|
+
program
|
|
969
|
+
.command('design')
|
|
970
|
+
.description('Open a local cc-bridge so Claude Code can hand off design work to Genspark Designer V2.')
|
|
971
|
+
.argument('[path]', 'Working directory for the design session (default: current directory)')
|
|
972
|
+
.option('--prompt <text>', 'Initial build prompt shown to the design agent in the autostart message')
|
|
973
|
+
.option('--launch-url <url>', 'Override the design page URL. Default: https://www.genspark.ai/agents?type=design')
|
|
974
|
+
.option('--allow-build-tools', 'Allow cc_exec to run git/npm/python/etc on top of the default read-only allowlist')
|
|
975
|
+
.option('--no-open', 'Print the launch URL but do not open the browser')
|
|
976
|
+
.action(async (pathArg, opts) => {
|
|
977
|
+
try {
|
|
978
|
+
await runDesignCommand(pathArg, opts);
|
|
979
|
+
}
|
|
980
|
+
catch (err) {
|
|
981
|
+
// Surface the stack on unexpected errors — bare .message loses
|
|
982
|
+
// the call site, which matters for crashes in the HTTP layer
|
|
983
|
+
// or `commit_with_retry`-style retry paths inside lsTree.
|
|
984
|
+
const e = err;
|
|
985
|
+
logError(e.stack ? e.stack : e.message);
|
|
986
|
+
process.exit(1);
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
//# sourceMappingURL=design.js.map
|