@pugi/cli 0.1.0-beta.11 → 0.1.0-beta.13
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/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/engine/anvil-client.js +80 -5
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +663 -249
- package/dist/core/engine/prompts.js +52 -2
- package/dist/core/engine/tool-bridge.js +311 -9
- package/dist/core/lsp/client.js +57 -0
- package/dist/core/mcp/client.js +9 -0
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +328 -12
- package/dist/core/repl/slash-commands.js +18 -4
- package/dist/core/settings.js +43 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +534 -268
- package/dist/runtime/commands/lsp.js +165 -5
- package/dist/runtime/commands/mcp.js +537 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +192 -0
- package/dist/tools/apply-patch.js +62 -1
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +5 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/repl-render.js +109 -1
- package/dist/tui/repl.js +7 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/update-banner.js +20 -2
- package/package.json +5 -4
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web_search tool — β1b T4 (2026-05-26).
|
|
3
|
+
*
|
|
4
|
+
* One-shot web search via the Anvil-proxied Brave Search API. Mirrors
|
|
5
|
+
* the gate + SSRF + sanitize + rate-limit posture of
|
|
6
|
+
* `tools/web-fetch.ts`. Returns the top-N {title, url, snippet}
|
|
7
|
+
* results wrapped in an `<untrusted-search-NONCE>` sentinel so the
|
|
8
|
+
* downstream prompt treats every snippet as data, never as
|
|
9
|
+
* instructions.
|
|
10
|
+
*
|
|
11
|
+
* Why an Anvil proxy and not a direct Brave API call:
|
|
12
|
+
* - The Brave Search subscription key is server-side. Shipping it in
|
|
13
|
+
* a customer CLI bundle would burn the rotation budget the first
|
|
14
|
+
* time the binary lands on a phishing repo.
|
|
15
|
+
* - The Anvil proxy already terminates the customer's `PUGI_API_KEY`
|
|
16
|
+
* for tenancy + per-tier rate-limiting + audit logging. Routing
|
|
17
|
+
* search through the same boundary keeps the security model
|
|
18
|
+
* uniform with `pugi engine` and `pugi sync`.
|
|
19
|
+
* - For local dev / offline testing the env var
|
|
20
|
+
* `PUGI_SEARCH_PROXY_URL` overrides the default proxy endpoint;
|
|
21
|
+
* unit tests inject `_setSearchFetchForTests()` so no network is
|
|
22
|
+
* touched.
|
|
23
|
+
*
|
|
24
|
+
* Gate: the tool refuses unless one of:
|
|
25
|
+
* 1. `settings.web.search.enabled === true` (persistent opt-in)
|
|
26
|
+
* 2. `allowSearch === true` (CLI flag `--allow-search`, single dispatch)
|
|
27
|
+
*
|
|
28
|
+
* SSRF: the proxy URL itself runs through `validateHostnameForFetch`
|
|
29
|
+
* from `web-fetch.ts` so a configured `PUGI_SEARCH_PROXY_URL=
|
|
30
|
+
* http://169.254.169.254/...` cannot ride to cloud metadata. We also
|
|
31
|
+
* apply the β1b #62 DNS-rebinding guard via the shared
|
|
32
|
+
* `pinnedAddressAgent` helper (web-fetch.ts) — the same SSRF window
|
|
33
|
+
* that fetch closes, search closes.
|
|
34
|
+
*
|
|
35
|
+
* Rate limit: in-memory session bucket — 5 requests per rolling 60 s
|
|
36
|
+
* keyed by `sessionId`. Burst above the cap returns
|
|
37
|
+
* `{ ok: false, error: 'rate_limited' }` immediately, no network
|
|
38
|
+
* dispatch. Per-session because a noisy session should not starve a
|
|
39
|
+
* quieter one in the same workstation process.
|
|
40
|
+
*
|
|
41
|
+
* Response size cap: hard 1 MiB ceiling on the proxy body. Anything
|
|
42
|
+
* larger drops the entire response with `oversized_response` — the
|
|
43
|
+
* proxy already normalizes Brave's payload to a small JSON shape, so
|
|
44
|
+
* 1 MiB is generous for the typical 10-result payload (~5 KiB).
|
|
45
|
+
*
|
|
46
|
+
* Sanitization: snippet text is stripped of HTML tags before being
|
|
47
|
+
* surfaced to the model. Brave returns raw HTML in `description` for
|
|
48
|
+
* highlight markup; we keep the visible text and drop every `<...>`
|
|
49
|
+
* span via a conservative regex. Cross-checked by the `script/style`
|
|
50
|
+
* spec which asserts a hostile `<script>alert(1)</script>` snippet
|
|
51
|
+
* lands as the inner text only.
|
|
52
|
+
*
|
|
53
|
+
* Brand voice: brief / dispatch / ship / sentinel only — brandbook §08.
|
|
54
|
+
*/
|
|
55
|
+
import { request, Agent } from 'undici';
|
|
56
|
+
import { randomBytes } from 'node:crypto';
|
|
57
|
+
import { validateHostnameForFetch, validateIpLiteralForFetch } from './web-fetch.js';
|
|
58
|
+
let activeFetch = null;
|
|
59
|
+
/**
|
|
60
|
+
* Test seam — pass `null` to restore the production undici dispatcher.
|
|
61
|
+
* Mirrors the `_setLookupForTests` pattern from `web-fetch.ts`.
|
|
62
|
+
*/
|
|
63
|
+
export function _setSearchFetchForTests(fn) {
|
|
64
|
+
activeFetch = fn;
|
|
65
|
+
}
|
|
66
|
+
const SEARCH_TIMEOUT_MS = 10_000;
|
|
67
|
+
const MAX_RESPONSE_BYTES = 1 * 1024 * 1024; // 1 MiB hard cap
|
|
68
|
+
const MAX_RESULTS = 10;
|
|
69
|
+
const DEFAULT_RESULTS = 10;
|
|
70
|
+
const MAX_QUERY_LEN = 256;
|
|
71
|
+
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
72
|
+
const RATE_LIMIT_MAX = 5;
|
|
73
|
+
const USER_AGENT = 'pugi-cli/0.1 (+https://pugi.dev)';
|
|
74
|
+
const DEFAULT_PROXY_URL = 'https://api.pugi.io/api/pugi/web-search';
|
|
75
|
+
export function isWebSearchEnabled(ctx) {
|
|
76
|
+
if (ctx.allowSearch === true)
|
|
77
|
+
return true;
|
|
78
|
+
return ctx.settings.web?.search?.enabled === true;
|
|
79
|
+
}
|
|
80
|
+
const rateBuckets = new Map();
|
|
81
|
+
/**
|
|
82
|
+
* β1b r1: Map sweep cap so a long-running engine session that recycles
|
|
83
|
+
* sessionIds (or a misconfigured caller that passes per-call random
|
|
84
|
+
* ids) cannot grow `rateBuckets` unbounded. 1024 is well above any
|
|
85
|
+
* realistic concurrent-session count for a single CLI process; when
|
|
86
|
+
* we cross the cap we sweep expired buckets first, and if the Map is
|
|
87
|
+
* still over the cap we evict the oldest-touched bucket. Pure LRU
|
|
88
|
+
* would need another timestamp; oldest-by-insertion (Map iteration
|
|
89
|
+
* order) is close enough for a defense-in-depth memory bound.
|
|
90
|
+
*/
|
|
91
|
+
const RATE_BUCKETS_CAP = 1024;
|
|
92
|
+
/**
|
|
93
|
+
* Returns true when the dispatch is allowed and records the call;
|
|
94
|
+
* returns false when the per-session bucket is at cap. Pure
|
|
95
|
+
* in-process; CLI dispatch is short-lived so a Map is sufficient.
|
|
96
|
+
*
|
|
97
|
+
* Exported for spec reset between test cases.
|
|
98
|
+
*/
|
|
99
|
+
export function _checkRateLimit(sessionId, now = Date.now()) {
|
|
100
|
+
if (!sessionId)
|
|
101
|
+
return true;
|
|
102
|
+
let bucket = rateBuckets.get(sessionId);
|
|
103
|
+
if (!bucket) {
|
|
104
|
+
// β1b r1: sweep before allocating a new bucket so we never grow
|
|
105
|
+
// past the cap. Sweep is O(n) but only runs when we are about to
|
|
106
|
+
// breach the cap, so per-call cost stays effectively constant.
|
|
107
|
+
if (rateBuckets.size >= RATE_BUCKETS_CAP) {
|
|
108
|
+
sweepRateBuckets(now);
|
|
109
|
+
}
|
|
110
|
+
bucket = { timestamps: [] };
|
|
111
|
+
rateBuckets.set(sessionId, bucket);
|
|
112
|
+
}
|
|
113
|
+
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
|
114
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > cutoff);
|
|
115
|
+
if (bucket.timestamps.length >= RATE_LIMIT_MAX)
|
|
116
|
+
return false;
|
|
117
|
+
bucket.timestamps.push(now);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Drop expired buckets (all timestamps older than the rate-limit
|
|
122
|
+
* window) and, if still over the cap, evict the oldest-inserted
|
|
123
|
+
* buckets until under cap. Memory-bound only — never affects rate
|
|
124
|
+
* limit correctness for live sessions.
|
|
125
|
+
*/
|
|
126
|
+
function sweepRateBuckets(now) {
|
|
127
|
+
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
|
128
|
+
for (const [id, b] of rateBuckets) {
|
|
129
|
+
if (b.timestamps.every((ts) => ts <= cutoff)) {
|
|
130
|
+
rateBuckets.delete(id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
while (rateBuckets.size >= RATE_BUCKETS_CAP) {
|
|
134
|
+
const oldest = rateBuckets.keys().next();
|
|
135
|
+
if (oldest.done)
|
|
136
|
+
break;
|
|
137
|
+
rateBuckets.delete(oldest.value);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Test seam — clear rate buckets between specs. */
|
|
141
|
+
export function _resetRateLimitForTests() {
|
|
142
|
+
rateBuckets.clear();
|
|
143
|
+
}
|
|
144
|
+
/* ----------------------- sanitization ---------------------- */
|
|
145
|
+
/**
|
|
146
|
+
* Strip HTML tags + collapse whitespace. Brave's `description` carries
|
|
147
|
+
* highlight markup (`<strong>`); we keep the inner text only so the
|
|
148
|
+
* model never sees raw HTML it could mistake for a directive. The
|
|
149
|
+
* regex is conservative: `<[^>]*>` removes opening + closing tags +
|
|
150
|
+
* self-closing tags + comments + processing instructions. Script
|
|
151
|
+
* BODIES are removed via a second pass that drops the entire
|
|
152
|
+
* `<script>...</script>` and `<style>...</style>` block (case-
|
|
153
|
+
* insensitive) so even a server-rendered snippet cannot leak a
|
|
154
|
+
* `console.log` line into the model context.
|
|
155
|
+
*/
|
|
156
|
+
export function sanitizeSnippet(input) {
|
|
157
|
+
if (!input)
|
|
158
|
+
return '';
|
|
159
|
+
// Drop full script + style blocks (with their bodies). The
|
|
160
|
+
// non-greedy `.*?` handles "..." with embedded newlines via the `s`
|
|
161
|
+
// flag.
|
|
162
|
+
const noScripts = input
|
|
163
|
+
.replace(/<script\b[^>]*>.*?<\/script>/gis, '')
|
|
164
|
+
.replace(/<style\b[^>]*>.*?<\/style>/gis, '');
|
|
165
|
+
// Drop any remaining tag.
|
|
166
|
+
const noTags = noScripts.replace(/<[^>]*>/g, '');
|
|
167
|
+
// Collapse whitespace runs. Brave already trims but defensive.
|
|
168
|
+
return noTags.replace(/\s+/g, ' ').trim();
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* HTML-escape for sentinel body — mirrors `escapeForSentinelBody` from
|
|
172
|
+
* web-fetch.ts. Kept local to avoid coupling the modules tighter than
|
|
173
|
+
* the SSRF helpers already do.
|
|
174
|
+
*/
|
|
175
|
+
function escapeForSentinelBody(input) {
|
|
176
|
+
return input
|
|
177
|
+
.replace(/&/g, '&')
|
|
178
|
+
.replace(/</g, '<')
|
|
179
|
+
.replace(/>/g, '>')
|
|
180
|
+
.replace(/"/g, '"')
|
|
181
|
+
.replace(/'/g, ''');
|
|
182
|
+
}
|
|
183
|
+
/* ----------------------- dispatcher ---------------------- */
|
|
184
|
+
/**
|
|
185
|
+
* Resolve the search proxy URL. `PUGI_SEARCH_PROXY_URL` env override
|
|
186
|
+
* lets local dev point at a fixture server; production hits
|
|
187
|
+
* `api.pugi.io`.
|
|
188
|
+
*/
|
|
189
|
+
function resolveProxyUrl() {
|
|
190
|
+
const env = process.env.PUGI_SEARCH_PROXY_URL;
|
|
191
|
+
if (typeof env === 'string' && env.length > 0)
|
|
192
|
+
return env;
|
|
193
|
+
return DEFAULT_PROXY_URL;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Production fetch via undici with the β1b #62 DNS-rebinding guard
|
|
197
|
+
* applied. We resolve the proxy hostname, validate against the SSRF
|
|
198
|
+
* blocklist, then dispatch via an Agent pinned to the resolved IP so
|
|
199
|
+
* a hostile DNS server cannot answer a different address on the
|
|
200
|
+
* connect(2) than on the lookup.
|
|
201
|
+
*/
|
|
202
|
+
async function productionFetch(url, init) {
|
|
203
|
+
// β1b #62 — pinned-address Dispatcher to close the lookup→connect
|
|
204
|
+
// race. The Agent's `connect.lookup` always returns the address we
|
|
205
|
+
// already validated, so DNS cannot flip between the SSRF guard and
|
|
206
|
+
// the socket creation. Agent is per-call (small object) so process
|
|
207
|
+
// teardown does not need to keep a long-lived dispatcher around.
|
|
208
|
+
const parsed = new URL(url);
|
|
209
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
210
|
+
const dnsGuard = await validateHostnameForFetch(hostname);
|
|
211
|
+
if (dnsGuard) {
|
|
212
|
+
throw new Error(`SSRF refused: ${dnsGuard}`);
|
|
213
|
+
}
|
|
214
|
+
const { lookup: dnsLookup } = await import('node:dns/promises');
|
|
215
|
+
const answers = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
216
|
+
if (answers.length === 0) {
|
|
217
|
+
throw new Error(`DNS returned no answers for ${hostname}`);
|
|
218
|
+
}
|
|
219
|
+
// Pin the first answer of THIS lookup, then re-validate against the
|
|
220
|
+
// SSRF blocklist. β1b r1: closes the DNS rebinding window where a
|
|
221
|
+
// hostile resolver returns public IPs to `validateHostnameForFetch`
|
|
222
|
+
// above and private IPs here. The lookup that feeds the pin is the
|
|
223
|
+
// lookup whose answer the connect(2) will actually use; without a
|
|
224
|
+
// re-check on this literal the original guard's IP set can diverge
|
|
225
|
+
// from the IP we lock into `connect.lookup`.
|
|
226
|
+
const pinned = answers[0];
|
|
227
|
+
if (!pinned) {
|
|
228
|
+
throw new Error(`DNS returned no answers for ${hostname}`);
|
|
229
|
+
}
|
|
230
|
+
const ipCheck = validateIpLiteralForFetch(pinned.address, pinned.family);
|
|
231
|
+
if (ipCheck !== null) {
|
|
232
|
+
throw new Error(`SSRF refused: ssrf_pinned_address_blocked: ${ipCheck}`);
|
|
233
|
+
}
|
|
234
|
+
const agent = new Agent({
|
|
235
|
+
connect: {
|
|
236
|
+
// Force the connect path to use the pre-resolved address. undici
|
|
237
|
+
// accepts the standard Node `dns.LookupFunction` shape here.
|
|
238
|
+
lookup: (_h, _opts, cb) => {
|
|
239
|
+
cb(null, pinned.address, pinned.family);
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
return await request(url, { ...init, dispatcher: agent });
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
await agent.close().catch(() => {
|
|
248
|
+
/* best-effort */
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function readBodyWithCap(body, controller) {
|
|
253
|
+
const chunks = [];
|
|
254
|
+
let total = 0;
|
|
255
|
+
try {
|
|
256
|
+
for await (const chunk of body) {
|
|
257
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
258
|
+
total += buf.length;
|
|
259
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
260
|
+
controller.abort();
|
|
261
|
+
try {
|
|
262
|
+
if (typeof body.destroy === 'function')
|
|
263
|
+
body.destroy();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
/* ignore — already aborted */
|
|
267
|
+
}
|
|
268
|
+
return { ok: false, error: `oversized_response: > ${MAX_RESPONSE_BYTES} bytes` };
|
|
269
|
+
}
|
|
270
|
+
chunks.push(buf);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
275
|
+
return { ok: false, error: `body_read_failed: ${msg}` };
|
|
276
|
+
}
|
|
277
|
+
return { ok: true, buffer: Buffer.concat(chunks) };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Validate + clamp the optional `count` arg. Clamp into [1, MAX_RESULTS]
|
|
281
|
+
* and default to DEFAULT_RESULTS when missing. The bridge already
|
|
282
|
+
* validates the type — this enforces the bounds.
|
|
283
|
+
*/
|
|
284
|
+
function resolveCount(raw) {
|
|
285
|
+
if (raw === undefined)
|
|
286
|
+
return DEFAULT_RESULTS;
|
|
287
|
+
if (raw < 1)
|
|
288
|
+
return 1;
|
|
289
|
+
if (raw > MAX_RESULTS)
|
|
290
|
+
return MAX_RESULTS;
|
|
291
|
+
return Math.floor(raw);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Sanitize the operator query before it crosses the proxy boundary.
|
|
295
|
+
* Trim, hard-cap at MAX_QUERY_LEN, reject empty after trim. The proxy
|
|
296
|
+
* also validates, but a CLI-side check keeps the model's mistake
|
|
297
|
+
* visible to the operator (the failed call shows up in
|
|
298
|
+
* `.pugi/events.jsonl`) rather than blamed on a 400 from the proxy.
|
|
299
|
+
*/
|
|
300
|
+
function sanitizeQuery(raw) {
|
|
301
|
+
const trimmed = raw.trim();
|
|
302
|
+
if (trimmed.length === 0) {
|
|
303
|
+
throw new Error('empty_query');
|
|
304
|
+
}
|
|
305
|
+
if (trimmed.length > MAX_QUERY_LEN) {
|
|
306
|
+
return trimmed.slice(0, MAX_QUERY_LEN);
|
|
307
|
+
}
|
|
308
|
+
return trimmed;
|
|
309
|
+
}
|
|
310
|
+
export async function webSearchTool(input, ctx) {
|
|
311
|
+
if (!isWebSearchEnabled(ctx)) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
error: 'web_search disabled. Enable with --allow-search or set web.search.enabled=true in .pugi/settings.json.',
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
let query;
|
|
318
|
+
try {
|
|
319
|
+
query = sanitizeQuery(input.query);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
323
|
+
}
|
|
324
|
+
if (!_checkRateLimit(ctx.sessionId)) {
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
error: `rate_limited: max ${RATE_LIMIT_MAX} searches per ${RATE_LIMIT_WINDOW_MS / 1000}s per session`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const count = resolveCount(input.count);
|
|
331
|
+
const proxyUrl = resolveProxyUrl();
|
|
332
|
+
// Validate the proxy URL parses + scheme + SSRF. Even though the
|
|
333
|
+
// env var is operator-controlled, a careless export of
|
|
334
|
+
// `http://localhost:8080` would otherwise let an unrelated local
|
|
335
|
+
// server intercept the call. Default URL is `api.pugi.io` and
|
|
336
|
+
// resolves clean.
|
|
337
|
+
let parsedProxy;
|
|
338
|
+
try {
|
|
339
|
+
parsedProxy = new URL(proxyUrl);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return { ok: false, error: `invalid_proxy_url: ${proxyUrl}` };
|
|
343
|
+
}
|
|
344
|
+
if (parsedProxy.protocol !== 'http:' && parsedProxy.protocol !== 'https:') {
|
|
345
|
+
return { ok: false, error: `unsupported_proxy_scheme: ${parsedProxy.protocol}` };
|
|
346
|
+
}
|
|
347
|
+
const proxyHost = parsedProxy.hostname.replace(/^\[|\]$/g, '');
|
|
348
|
+
// SSRF guard applies to the proxy hostname even when activeFetch is
|
|
349
|
+
// a test stub — keeps the spec contract honest. Tests that exercise
|
|
350
|
+
// the rate limiter use a stub that bypasses productionFetch but the
|
|
351
|
+
// guard still runs at the entry above test stubs.
|
|
352
|
+
const ssrfErr = await validateHostnameForFetch(proxyHost);
|
|
353
|
+
if (ssrfErr) {
|
|
354
|
+
return { ok: false, error: `SSRF refused: ${ssrfErr}` };
|
|
355
|
+
}
|
|
356
|
+
const controller = new AbortController();
|
|
357
|
+
const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
|
|
358
|
+
// β1b: lifted PUGI_API_KEY from the runtime credential store would
|
|
359
|
+
// belong here for production — for the proxy call we forward whatever
|
|
360
|
+
// PUGI_API_KEY the operator has exported. Anonymous proxy access is
|
|
361
|
+
// permitted; the proxy treats it as the free-tier search quota.
|
|
362
|
+
const apiKey = process.env.PUGI_API_KEY ?? process.env.PUGI_LOGIN_TOKEN;
|
|
363
|
+
const headers = {
|
|
364
|
+
'user-agent': USER_AGENT,
|
|
365
|
+
'content-type': 'application/json',
|
|
366
|
+
accept: 'application/json',
|
|
367
|
+
};
|
|
368
|
+
if (typeof apiKey === 'string' && apiKey.length > 0) {
|
|
369
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
370
|
+
}
|
|
371
|
+
let response;
|
|
372
|
+
try {
|
|
373
|
+
const fetcher = activeFetch ?? (async (u, i) => (await productionFetch(u, i)));
|
|
374
|
+
response = (await fetcher(proxyUrl, {
|
|
375
|
+
method: 'POST',
|
|
376
|
+
headers,
|
|
377
|
+
body: JSON.stringify({ query, count }),
|
|
378
|
+
signal: controller.signal,
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
clearTimeout(timer);
|
|
383
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
384
|
+
return { ok: false, error: `search_failed: ${message}` };
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
}
|
|
389
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
390
|
+
// Drain the body so the socket can be reused / released.
|
|
391
|
+
try {
|
|
392
|
+
if (typeof response.body.destroy === 'function')
|
|
393
|
+
response.body.destroy();
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
/* ignore */
|
|
397
|
+
}
|
|
398
|
+
return { ok: false, error: `proxy_http_${response.statusCode}` };
|
|
399
|
+
}
|
|
400
|
+
const bodyResult = await readBodyWithCap(response.body, controller);
|
|
401
|
+
if (!bodyResult.ok)
|
|
402
|
+
return bodyResult;
|
|
403
|
+
let parsed;
|
|
404
|
+
try {
|
|
405
|
+
parsed = JSON.parse(bodyResult.buffer.toString('utf8'));
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
409
|
+
return { ok: false, error: `proxy_malformed_json: ${msg}` };
|
|
410
|
+
}
|
|
411
|
+
// Expect the proxy to normalize Brave's payload to `{results: [{title, url, snippet}]}`.
|
|
412
|
+
// When the proxy is missing the `results` array (or it is not an array)
|
|
413
|
+
// we surface a clean error instead of returning empty.
|
|
414
|
+
const obj = parsed;
|
|
415
|
+
if (!obj || typeof obj !== 'object') {
|
|
416
|
+
return { ok: false, error: 'proxy_malformed_response: not an object' };
|
|
417
|
+
}
|
|
418
|
+
const rawResults = obj.results;
|
|
419
|
+
if (!Array.isArray(rawResults)) {
|
|
420
|
+
return { ok: false, error: 'proxy_malformed_response: missing results array' };
|
|
421
|
+
}
|
|
422
|
+
const results = [];
|
|
423
|
+
for (const r of rawResults.slice(0, count)) {
|
|
424
|
+
if (!r || typeof r !== 'object')
|
|
425
|
+
continue;
|
|
426
|
+
const row = r;
|
|
427
|
+
const title = typeof row.title === 'string' ? sanitizeSnippet(row.title) : '';
|
|
428
|
+
const url = typeof row.url === 'string' ? row.url : '';
|
|
429
|
+
const snippet = typeof row.snippet === 'string' ? sanitizeSnippet(row.snippet) : '';
|
|
430
|
+
if (!url)
|
|
431
|
+
continue;
|
|
432
|
+
results.push({ title, url, snippet });
|
|
433
|
+
}
|
|
434
|
+
// Per-call nonce for the sentinel. Mirrors `web-fetch.ts` so the
|
|
435
|
+
// downstream prompt's nonce-matching escape logic works uniformly.
|
|
436
|
+
const nonce = randomBytes(8).toString('hex');
|
|
437
|
+
const renderedLines = [`<untrusted-search-${nonce}>`, `Query: ${escapeForSentinelBody(query)}`, ''];
|
|
438
|
+
for (let i = 0; i < results.length; i += 1) {
|
|
439
|
+
const r = results[i];
|
|
440
|
+
if (!r)
|
|
441
|
+
continue;
|
|
442
|
+
renderedLines.push(`${i + 1}. ${escapeForSentinelBody(r.title || r.url)}`);
|
|
443
|
+
renderedLines.push(` ${escapeForSentinelBody(r.url)}`);
|
|
444
|
+
if (r.snippet)
|
|
445
|
+
renderedLines.push(` ${escapeForSentinelBody(r.snippet)}`);
|
|
446
|
+
renderedLines.push('');
|
|
447
|
+
}
|
|
448
|
+
renderedLines.push(`</untrusted-search-${nonce}>`);
|
|
449
|
+
const content_md = renderedLines.join('\n').trimEnd();
|
|
450
|
+
return {
|
|
451
|
+
ok: true,
|
|
452
|
+
query,
|
|
453
|
+
results,
|
|
454
|
+
content_md,
|
|
455
|
+
fetched_at: new Date().toISOString(),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
//# sourceMappingURL=web-search.js.map
|
package/dist/tui/agent-tree.js
CHANGED
|
@@ -26,6 +26,13 @@ function statusGlyph(status) {
|
|
|
26
26
|
return '⏳';
|
|
27
27
|
case 'shipped':
|
|
28
28
|
return '✓';
|
|
29
|
+
// 2026-05-26 — `replied` = orchestrator finished talking but no
|
|
30
|
+
// tool/delegate side-effect emitted. Distinct arrow glyph so the
|
|
31
|
+
// operator can tell at a glance that no real work shipped, even
|
|
32
|
+
// though the LLM completed cleanly. Defeats the fake-shipped
|
|
33
|
+
// anti-pattern (memory feedback_no_fake_dispatch_promises).
|
|
34
|
+
case 'replied':
|
|
35
|
+
return '→';
|
|
29
36
|
case 'blocked':
|
|
30
37
|
return '✗';
|
|
31
38
|
case 'failed':
|
|
@@ -40,6 +47,9 @@ function statusColor(status) {
|
|
|
40
47
|
return 'cyan';
|
|
41
48
|
case 'shipped':
|
|
42
49
|
return 'green';
|
|
50
|
+
// Dim/grey-ish — succeeded technically but no work was shipped.
|
|
51
|
+
case 'replied':
|
|
52
|
+
return 'gray';
|
|
43
53
|
case 'blocked':
|
|
44
54
|
return 'yellow';
|
|
45
55
|
case 'failed':
|
package/dist/tui/repl-render.js
CHANGED
|
@@ -24,6 +24,7 @@ import { ReplSession, } from '../core/repl/session.js';
|
|
|
24
24
|
import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
|
|
25
25
|
import { SqliteSessionStore } from '../core/repl/store/index.js';
|
|
26
26
|
import { slugForCwd } from '../core/repl/history.js';
|
|
27
|
+
import { loadSettings } from '../core/settings.js';
|
|
27
28
|
import { WorkingSet, buildRepoSkeleton, loadPugiIgnore, PugiWatcher, } from '../core/context/index.js';
|
|
28
29
|
/**
|
|
29
30
|
* Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
|
|
@@ -50,12 +51,58 @@ export async function renderRepl(options) {
|
|
|
50
51
|
// top, and our finally{} restore drops the floor only after Ink
|
|
51
52
|
// has cleanly torn down (or never mounted on a bootstrap crash).
|
|
52
53
|
const bootstrap = claimTerminalForRepl();
|
|
54
|
+
// beta.13 auto-init wire (CEO dogfood 2026-05-26): scaffold the
|
|
55
|
+
// `.pugi/` workspace silently on REPL boot so launching `pugi` in a
|
|
56
|
+
// fresh cwd no longer demands an explicit `pugi init` round-trip.
|
|
57
|
+
// Idempotent — every helper inside scaffoldPugiWorkspace is a
|
|
58
|
+
// `*_IfMissing` write, so re-running over an existing workspace is
|
|
59
|
+
// a no-op. Fail-safe: any FS / perms error never blocks REPL launch.
|
|
60
|
+
// Operator escape hatch: PUGI_NO_AUTO_INIT=1.
|
|
61
|
+
//
|
|
62
|
+
// Beta.13 P2 fix 2026-05-26: gate the scaffold on project-root markers
|
|
63
|
+
// so launching `pugi` from `$HOME` / `/tmp` / arbitrary dirs does NOT
|
|
64
|
+
// sprinkle `.pugi/` directories all over the filesystem. The gate
|
|
65
|
+
// mirrors `isBoundWorkspace` from workspace-context.ts but also
|
|
66
|
+
// accepts non-JS roots (Cargo / pyproject / go.mod) because the CLI
|
|
67
|
+
// is language-agnostic and an operator working in a Rust repo deserves
|
|
68
|
+
// the same auto-init UX as a Node operator. Already-bound `.pugi/`
|
|
69
|
+
// dirs also opt back in so the scaffold can fill any missing
|
|
70
|
+
// sub-artifacts the operator deleted.
|
|
71
|
+
if (process.env.PUGI_NO_AUTO_INIT !== '1' && isProjectRoot(process.cwd())) {
|
|
72
|
+
try {
|
|
73
|
+
const { scaffoldPugiWorkspace } = await import('../runtime/cli.js');
|
|
74
|
+
await scaffoldPugiWorkspace({
|
|
75
|
+
cwd: process.cwd(),
|
|
76
|
+
noDefaults: true,
|
|
77
|
+
log: () => {
|
|
78
|
+
/* silent — never leak scaffold progress into the REPL alt-screen */
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
// Fail-safe: read-only FS or perms error never blocks REPL launch.
|
|
84
|
+
// Beta.13 P2 fix 2026-05-26: bare-catch swallowed the diagnostic;
|
|
85
|
+
// surface it on stderr under PUGI_DEBUG=1 so operator-triage on
|
|
86
|
+
// "why isn't .pugi/ being created?" has a starting point without
|
|
87
|
+
// having to re-instrument the bootstrap.
|
|
88
|
+
if (process.env.PUGI_DEBUG === '1') {
|
|
89
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
90
|
+
process.stderr.write(`[pugi-debug] auto-init failed: ${msg}\n`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
53
94
|
const transport = createProductionTransport();
|
|
54
95
|
// Auto-bind the workspace context from process.cwd() so Mira knows
|
|
55
96
|
// which repo the operator launched the CLI in. The resolver is
|
|
56
97
|
// best-effort — any FS error falls back to a basename-only summary,
|
|
57
98
|
// never blocks REPL launch. Wave 4 fix 2026-05-25.
|
|
58
99
|
const workspace = options.workspace ?? resolveWorkspaceContext(process.cwd());
|
|
100
|
+
// Beta.13 P1 fix 2026-05-26: read `ui.cyberZoo` from
|
|
101
|
+
// `.pugi/settings.json` so the operator's splash posture flows to
|
|
102
|
+
// admin-api on session open. Without this, the renderer's `cyberZoo`
|
|
103
|
+
// parameter (added beta.13) was always defaulted to 'on' regardless
|
|
104
|
+
// of the operator's actual setting.
|
|
105
|
+
const cyberZoo = readCyberZooSetting(process.cwd());
|
|
59
106
|
// α6.4: open the local SessionStore for `/resume` persistence. The
|
|
60
107
|
// store lives under `~/.pugi/projects/<slug>/`; failure is fail-safe
|
|
61
108
|
// — we log a one-line warning to stderr and continue with the REPL
|
|
@@ -83,6 +130,7 @@ export async function renderRepl(options) {
|
|
|
83
130
|
cliVersion: options.cliVersion,
|
|
84
131
|
transport,
|
|
85
132
|
workspace,
|
|
133
|
+
cyberZoo,
|
|
86
134
|
store,
|
|
87
135
|
localSessionId: openedSessionId,
|
|
88
136
|
repoSkeleton: skeleton,
|
|
@@ -242,6 +290,57 @@ export function drainBufferedStdin(stdin = process.stdin) {
|
|
|
242
290
|
return 0;
|
|
243
291
|
}
|
|
244
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Project-root probe — beta.13 P2 fix 2026-05-26.
|
|
295
|
+
*
|
|
296
|
+
* Beta.13 auto-init was unconditional and silently created `.pugi/` in
|
|
297
|
+
* every cwd the REPL was launched from, including `$HOME` and `/tmp`.
|
|
298
|
+
* Operators who ran `pugi` to ask a quick question outside of any
|
|
299
|
+
* project ended up with stray `.pugi/` directories polluting their
|
|
300
|
+
* filesystem. The gate looks for any of six project-root markers
|
|
301
|
+
* before scaffolding:
|
|
302
|
+
*
|
|
303
|
+
* - `package.json` — JS / TS workspaces
|
|
304
|
+
* - `.git` — any cloned repo regardless of language
|
|
305
|
+
* - `.pugi` — already-bound Pugi workspace (re-scaffold
|
|
306
|
+
* fills any missing artifacts the operator
|
|
307
|
+
* deleted, idempotent over existing files)
|
|
308
|
+
* - `Cargo.toml` — Rust crates
|
|
309
|
+
* - `pyproject.toml` — Python projects (PEP 518)
|
|
310
|
+
* - `go.mod` — Go modules
|
|
311
|
+
*
|
|
312
|
+
* The probe is six cheap `existsSync` calls; the cost is negligible
|
|
313
|
+
* compared with the alt-screen + Ink mount that follows. Exported so a
|
|
314
|
+
* future unit spec can lock the contract.
|
|
315
|
+
*/
|
|
316
|
+
export function isProjectRoot(cwd) {
|
|
317
|
+
// Local import keeps the bootstrap free of top-of-file `fs` calls
|
|
318
|
+
// that would run at module-load time — Ink + the SSE transport are
|
|
319
|
+
// happier when this file's side-effect surface stays small.
|
|
320
|
+
const { existsSync } = require('node:fs');
|
|
321
|
+
const { resolve } = require('node:path');
|
|
322
|
+
return (existsSync(resolve(cwd, 'package.json')) ||
|
|
323
|
+
existsSync(resolve(cwd, '.git')) ||
|
|
324
|
+
existsSync(resolve(cwd, '.pugi')) ||
|
|
325
|
+
existsSync(resolve(cwd, 'Cargo.toml')) ||
|
|
326
|
+
existsSync(resolve(cwd, 'pyproject.toml')) ||
|
|
327
|
+
existsSync(resolve(cwd, 'go.mod')));
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Read the operator's cyber-zoo posture from `.pugi/settings.json`.
|
|
331
|
+
* Best-effort: when the file is missing / malformed, fall through to
|
|
332
|
+
* the historical 'on' default so the REPL never refuses to launch on
|
|
333
|
+
* a settings error. Beta.13 P1 fix 2026-05-26.
|
|
334
|
+
*/
|
|
335
|
+
function readCyberZooSetting(cwd) {
|
|
336
|
+
try {
|
|
337
|
+
const settings = loadSettings(cwd);
|
|
338
|
+
return settings.ui?.cyberZoo ?? 'on';
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return 'on';
|
|
342
|
+
}
|
|
343
|
+
}
|
|
245
344
|
/**
|
|
246
345
|
* Open the local SessionStore for the REPL bootstrap. Returns
|
|
247
346
|
* `{ store: null, openedSessionId: undefined }` on any error so the
|
|
@@ -335,11 +434,18 @@ async function bootstrapContext(input) {
|
|
|
335
434
|
/* ------------------------------------------------------------------ */
|
|
336
435
|
export function createProductionTransport() {
|
|
337
436
|
return {
|
|
338
|
-
async createSession({ apiUrl, apiKey, workspace }) {
|
|
437
|
+
async createSession({ apiUrl, apiKey, workspace, cyberZoo }) {
|
|
339
438
|
// Forward the workspace bundle in the POST body so admin-api can
|
|
340
439
|
// surface `<workspace-context>` in Mira's prompt. Older admin-api
|
|
341
440
|
// builds ignore unknown fields, so this stays forward-compatible.
|
|
342
441
|
// Wave 4 fix 2026-05-25.
|
|
442
|
+
//
|
|
443
|
+
// Beta.13 P1 fix 2026-05-26: also forward `cyberZoo` so admin-api
|
|
444
|
+
// can render Mira's `<cyber-zoo>` marker matching the operator's
|
|
445
|
+
// `.pugi/settings.json::ui.cyberZoo` toggle instead of the
|
|
446
|
+
// historical 'on' default. Only included on the wire when set
|
|
447
|
+
// explicitly so a missing setting still survives older admin-api
|
|
448
|
+
// builds that do not declare the DTO field.
|
|
343
449
|
const body = {};
|
|
344
450
|
if (workspace?.workspaceCwd)
|
|
345
451
|
body.workspaceCwd = workspace.workspaceCwd;
|
|
@@ -347,6 +453,8 @@ export function createProductionTransport() {
|
|
|
347
453
|
body.workspaceSlug = workspace.workspaceSlug;
|
|
348
454
|
if (workspace?.workspaceSummary)
|
|
349
455
|
body.workspaceSummary = workspace.workspaceSummary;
|
|
456
|
+
if (cyberZoo === 'on' || cyberZoo === 'off')
|
|
457
|
+
body.cyberZoo = cyberZoo;
|
|
350
458
|
const response = await fetch(joinUrl(apiUrl, '/api/pugi/sessions'), {
|
|
351
459
|
method: 'POST',
|
|
352
460
|
headers: jsonHeaders(apiKey),
|
package/dist/tui/repl.js
CHANGED
|
@@ -195,7 +195,11 @@ export function Repl(props) {
|
|
|
195
195
|
// Slug from process.cwd() (full path) so two workspaces with
|
|
196
196
|
// the same basename do not share history. state.workspaceLabel
|
|
197
197
|
// is the basename only. Codex review P2.
|
|
198
|
-
workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct, dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel
|
|
198
|
+
workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct, dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel, lastCompletedOutcome: state.lastCompletedOutcome,
|
|
199
|
+
// α7 cost-meter sprint — surface accumulated session totals
|
|
200
|
+
// + per-turn delta flash on the status bar's top row. The
|
|
201
|
+
// session module owns accumulation; the bar is a pure render.
|
|
202
|
+
sessionTokensIn: state.sessionTokensIn, sessionTokensOut: state.sessionTokensOut, sessionCostUsd: state.sessionCostUsd, sessionStartedAtEpochMs: state.sessionStartedAtEpochMs, lastTurnDelta: state.lastTurnDelta })] })] }));
|
|
199
203
|
}
|
|
200
204
|
function Header({ state }) {
|
|
201
205
|
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "#3da9fc", children: ".io" }), _jsx(Text, { dimColor: true, children: ` · workspace: ${state.workspaceLabel} · v${state.cliVersion} · ` }), _jsx(Text, { color: "#3da9fc", children: state.connection === 'on_watch' ? 'on watch' : state.connection.replace('_', ' ') })] }));
|
|
@@ -270,8 +274,10 @@ function applyVerdictSideEffects(verdict, handlers) {
|
|
|
270
274
|
case 'consensus':
|
|
271
275
|
case 'diff':
|
|
272
276
|
case 'cost':
|
|
277
|
+
case 'quota':
|
|
273
278
|
case 'status':
|
|
274
279
|
case 'resume':
|
|
280
|
+
case 'mcp':
|
|
275
281
|
case 'stub':
|
|
276
282
|
// All non-overlay verdicts: the session module already appended
|
|
277
283
|
// any operator-visible system lines (and, for `ask`, set
|