@pugi/cli 0.1.0-beta.4 → 0.1.0-beta.41
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/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -25
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/commands/smoke.js +133 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/bash-classifier.js +108 -1
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +208 -0
- package/dist/core/compact/token-counter.js +108 -0
- 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/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -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/edits/worktree.js +322 -0
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +98 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +860 -211
- package/dist/core/engine/prompts.js +88 -2
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +1045 -36
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/file-cache.js +113 -1
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/client.js +776 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory-sync/queue.js +158 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/gate.js +278 -0
- package/dist/core/permissions/index.js +20 -0
- package/dist/core/permissions/mode.js +174 -0
- package/dist/core/permissions/state.js +241 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/session-review.js +557 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +1899 -38
- package/dist/core/repl/slash-commands.js +406 -21
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/repl/workspace-context.js +22 -0
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/session.js +92 -0
- package/dist/core/settings.js +80 -0
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -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/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +3073 -321
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/compact.js +297 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +242 -11
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +390 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +368 -0
- package/dist/runtime/commands/mcp.js +879 -0
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/permissions.js +112 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/prd-check.js +285 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/status.js +186 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +229 -0
- package/dist/tools/apply-patch.js +556 -0
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/powershell.js +156 -0
- package/dist/tools/registry.js +51 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +81 -0
- package/dist/tui/conversation-pane.js +82 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +69 -2
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/permissions-picker.js +86 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/repl-render.js +303 -13
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +72 -14
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/tool-stream-pane.js +52 -3
- package/dist/tui/update-banner.js +20 -2
- package/dist/tui/vim-input.js +267 -0
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +12 -6
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* todo_write tool — Leak L16 (TodoWrite single-in-progress invariant).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Claude Code's `TodoWrite` tool 1:1 so a model trained on the
|
|
5
|
+
* upstream grammar speaks Pugi's variant verbatim. The tool dispatches
|
|
6
|
+
* a BATCH replace of the workspace todo board (not an incremental
|
|
7
|
+
* mutation — the model emits the FULL list every call). At most ONE
|
|
8
|
+
* todo may carry `status: 'in_progress'` at any time; violations
|
|
9
|
+
* reject with the `TODO_INVARIANT_VIOLATED` sentinel and the board on
|
|
10
|
+
* disk is left unchanged.
|
|
11
|
+
*
|
|
12
|
+
* Relationship to `task_*` (β1 T1/T6, tools/tasks.ts):
|
|
13
|
+
* - `task_*` is GRANULAR (create/get/list/update one task at a
|
|
14
|
+
* time) with an append-only JSONL journal scoped to the SESSION.
|
|
15
|
+
* - `todo_write` is BATCH (snapshot the whole board) with an atomic
|
|
16
|
+
* JSON snapshot scoped to the WORKSPACE.
|
|
17
|
+
* They are complementary surfaces: agents that prefer the upstream
|
|
18
|
+
* TodoWrite grammar use `todo_write`; agents that want a fine-grained
|
|
19
|
+
* audit trail use `task_*`.
|
|
20
|
+
*
|
|
21
|
+
* Hard rules (enforced by Zod + dispatcher):
|
|
22
|
+
* - `todos.length` ≤ 50 (board overload guard).
|
|
23
|
+
* - Every item: id (≥1 char, ≤128), content (≥1 char), status enum.
|
|
24
|
+
* - At most ONE item with `status === 'in_progress'`.
|
|
25
|
+
* - All ids unique within the batch.
|
|
26
|
+
*
|
|
27
|
+
* Dispatch returns the persisted board as JSON; callers can read
|
|
28
|
+
* `todos: [...]` directly. Errors return the sentinel-prefixed message
|
|
29
|
+
* so the engine adapter can pattern-match.
|
|
30
|
+
*/
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import { saveTodoBoard } from '../core/todos/state.js';
|
|
33
|
+
/** Cap matches the `task_*` family's title cap for parity. */
|
|
34
|
+
export const TODO_CONTENT_MAX = 2_000;
|
|
35
|
+
/** id is opaque to us but must be slug-safe so file paths could embed it. */
|
|
36
|
+
export const TODO_ID_MIN = 1;
|
|
37
|
+
export const TODO_ID_MAX = 128;
|
|
38
|
+
/** Hard cap on board size. Beyond this the operator should split work. */
|
|
39
|
+
export const TODO_BATCH_MAX = 50;
|
|
40
|
+
export const todoItemSchema = z
|
|
41
|
+
.strictObject({
|
|
42
|
+
id: z
|
|
43
|
+
.string()
|
|
44
|
+
.min(TODO_ID_MIN)
|
|
45
|
+
.max(TODO_ID_MAX)
|
|
46
|
+
.describe('Stable id for this todo. Opaque, ≤128 chars.'),
|
|
47
|
+
content: z
|
|
48
|
+
.string()
|
|
49
|
+
.min(1)
|
|
50
|
+
.max(TODO_CONTENT_MAX)
|
|
51
|
+
.describe('Imperative task description. E.g. "Add invariant check".'),
|
|
52
|
+
status: z
|
|
53
|
+
.enum(['pending', 'in_progress', 'completed'])
|
|
54
|
+
.describe('Lifecycle status. At most ONE in_progress per board.'),
|
|
55
|
+
activeForm: z
|
|
56
|
+
.string()
|
|
57
|
+
.min(1)
|
|
58
|
+
.max(TODO_CONTENT_MAX)
|
|
59
|
+
.optional()
|
|
60
|
+
.describe('Present-continuous form. E.g. "Adding invariant check".'),
|
|
61
|
+
});
|
|
62
|
+
export const todoWriteArgsSchema = z.strictObject({
|
|
63
|
+
todos: z
|
|
64
|
+
.array(todoItemSchema)
|
|
65
|
+
.max(TODO_BATCH_MAX)
|
|
66
|
+
.describe(`Full todo board (batch replace, not incremental). Max ${TODO_BATCH_MAX} items. ` +
|
|
67
|
+
`At most ONE item may carry status="in_progress".`),
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* JSON-Schema fragment surfaced to the model via the tool-bridge
|
|
71
|
+
* `parameters` field. Mirrors the Zod schema 1:1 — kept hand-written
|
|
72
|
+
* (same convention as ask_user_question) because the runtime engine
|
|
73
|
+
* wires OpenAI-compatible JSON Schema and we have not greenlit the
|
|
74
|
+
* zod-to-json-schema transitive dep. Keep both in lockstep.
|
|
75
|
+
*/
|
|
76
|
+
export const todoWriteJsonSchema = {
|
|
77
|
+
type: 'object',
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
required: ['todos'],
|
|
80
|
+
properties: {
|
|
81
|
+
todos: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
maxItems: TODO_BATCH_MAX,
|
|
84
|
+
description: `Full todo board (batch replace, not incremental). Max ${TODO_BATCH_MAX} items. ` +
|
|
85
|
+
`At most ONE item may carry status="in_progress".`,
|
|
86
|
+
items: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
required: ['id', 'content', 'status'],
|
|
90
|
+
properties: {
|
|
91
|
+
id: {
|
|
92
|
+
type: 'string',
|
|
93
|
+
minLength: TODO_ID_MIN,
|
|
94
|
+
maxLength: TODO_ID_MAX,
|
|
95
|
+
description: 'Stable id for this todo. Opaque, ≤128 chars.',
|
|
96
|
+
},
|
|
97
|
+
content: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
minLength: 1,
|
|
100
|
+
maxLength: TODO_CONTENT_MAX,
|
|
101
|
+
description: 'Imperative task description.',
|
|
102
|
+
},
|
|
103
|
+
status: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
enum: ['pending', 'in_progress', 'completed'],
|
|
106
|
+
description: 'Lifecycle status. At most ONE in_progress per board.',
|
|
107
|
+
},
|
|
108
|
+
activeForm: {
|
|
109
|
+
type: 'string',
|
|
110
|
+
minLength: 1,
|
|
111
|
+
maxLength: TODO_CONTENT_MAX,
|
|
112
|
+
description: 'Present-continuous form.',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Sentinel prefix the dispatcher returns when Zod schema validation
|
|
121
|
+
* rejects the raw arguments. Distinct from `TODO_INVARIANT_VIOLATED`
|
|
122
|
+
* (>1 in_progress) and `TODO_DUPLICATE_ID` (collision within batch),
|
|
123
|
+
* which are emitted from `saveTodoBoard` AFTER schema parsing.
|
|
124
|
+
*
|
|
125
|
+
* Surfaced as a return string (not a throw) so the engine adapter sees
|
|
126
|
+
* a recoverable tool error and the model can self-correct its args,
|
|
127
|
+
* instead of the engine loop tearing down on an uncaught ZodError.
|
|
128
|
+
*/
|
|
129
|
+
export const TODO_INVALID_ARGS = 'INVALID_ARGS';
|
|
130
|
+
/**
|
|
131
|
+
* Render a ZodError into a deterministic `INVALID_ARGS: ...` sentinel
|
|
132
|
+
* the model can pattern-match. Each issue contributes one
|
|
133
|
+
* `path: message` clause; clauses are joined with `; ` so the model
|
|
134
|
+
* sees every offence in a single line. Path with the root scope is
|
|
135
|
+
* rendered as `<root>` to avoid an empty colon.
|
|
136
|
+
*/
|
|
137
|
+
function renderZodIssues(error) {
|
|
138
|
+
const parts = error.issues.map((issue) => {
|
|
139
|
+
const path = issue.path.length === 0 ? '<root>' : issue.path.join('.');
|
|
140
|
+
return `${path}: ${issue.message}`;
|
|
141
|
+
});
|
|
142
|
+
return `${TODO_INVALID_ARGS}: ${parts.join('; ')}`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Validate via Zod + persist atomically. Surfaces three sentinel
|
|
146
|
+
* families the dispatcher pattern-matches on:
|
|
147
|
+
* - `INVALID_ARGS: <path>: <issue>; ...` — Zod schema rejected
|
|
148
|
+
* the raw arguments (returned as STRING, not thrown).
|
|
149
|
+
* - `TODO_INVARIANT_VIOLATED: ...` — >1 in_progress
|
|
150
|
+
* (thrown by `saveTodoBoard`).
|
|
151
|
+
* - `TODO_DUPLICATE_ID: ...` — collision within batch
|
|
152
|
+
* (thrown by `saveTodoBoard`).
|
|
153
|
+
*
|
|
154
|
+
* Why the asymmetry: schema rejection means the model emitted malformed
|
|
155
|
+
* structure (missing field, wrong type) and CAN self-correct given a
|
|
156
|
+
* clear breakdown of the offending path. The invariant + duplicate-id
|
|
157
|
+
* paths mean the model emitted structurally-valid but semantically
|
|
158
|
+
* conflicting state — those still throw so the engine loop's tool-error
|
|
159
|
+
* hook can surface them through `PostToolUseFailure` for observability,
|
|
160
|
+
* mirroring how the file-tools layer surfaces `STALE_READ` / `PermissionDenied`.
|
|
161
|
+
*/
|
|
162
|
+
export function dispatchTodoWrite(ctx, rawArgs) {
|
|
163
|
+
// L16 P1 fix (2026-05-27): `.parse` throws a `ZodError` on validation
|
|
164
|
+
// failure. The previous implementation let that throw bubble through
|
|
165
|
+
// the engine adapter's catch arm as a free-form `error.message`,
|
|
166
|
+
// which (a) loses the issue-by-issue structure the model needs to
|
|
167
|
+
// self-correct, and (b) tears down the tool-call as a hard failure
|
|
168
|
+
// rather than a recoverable tool result. Switch to `safeParse` and
|
|
169
|
+
// emit a structured `INVALID_ARGS: <path>: <issue>; ...` sentinel
|
|
170
|
+
// string instead — the engine sees a successful tool call, the model
|
|
171
|
+
// sees the offending paths, and the dispatcher's catch arm reserves
|
|
172
|
+
// throws for the genuine semantic conflicts emitted by `saveTodoBoard`.
|
|
173
|
+
const parsed = todoWriteArgsSchema.safeParse(rawArgs);
|
|
174
|
+
if (!parsed.success) {
|
|
175
|
+
return renderZodIssues(parsed.error);
|
|
176
|
+
}
|
|
177
|
+
const stateCtx = {
|
|
178
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
179
|
+
...(ctx.now ? { now: ctx.now } : {}),
|
|
180
|
+
};
|
|
181
|
+
const board = saveTodoBoard(stateCtx, parsed.data.todos);
|
|
182
|
+
return JSON.stringify(board);
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=todo-write.js.map
|
package/dist/tools/web-fetch.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* Brand voice: brief / dispatch / ship / sentinel only. The
|
|
35
35
|
* brandbook §08 forbidden-word list applies — see CLAUDE.md.
|
|
36
36
|
*/
|
|
37
|
-
import { request } from 'undici';
|
|
37
|
+
import { request, Agent } from 'undici';
|
|
38
38
|
import { Readability } from '@mozilla/readability';
|
|
39
39
|
import { parseHTML } from 'linkedom';
|
|
40
40
|
import TurndownService from 'turndown';
|
|
@@ -45,6 +45,72 @@ let activeLookup = async (hostname) => await dnsLookup(hostname, { all: true, ve
|
|
|
45
45
|
export function _setLookupForTests(fn) {
|
|
46
46
|
activeLookup = fn ?? (async (hostname) => await dnsLookup(hostname, { all: true, verbatim: true }));
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* β1b #62 — DNS rebinding guard via pinned-address Dispatcher.
|
|
50
|
+
*
|
|
51
|
+
* Without this, the SSRF guard's `dns.lookup` and undici's `request()`
|
|
52
|
+
* connect(2) each issue independent DNS queries. A hostile resolver
|
|
53
|
+
* can answer "8.8.8.8" the first time (passes the SSRF guard) and
|
|
54
|
+
* "127.0.0.1" the second time (kernel connects to local metadata).
|
|
55
|
+
*
|
|
56
|
+
* Fix: resolve once, validate, then pin the resolved address into a
|
|
57
|
+
* per-call `Agent` via `connect.lookup`. The connect() path no longer
|
|
58
|
+
* touches DNS — it uses the IP we already approved.
|
|
59
|
+
*
|
|
60
|
+
* Test seam: spec suite uses MockAgent as the global dispatcher; the
|
|
61
|
+
* MockAgent path does not exercise real connect(), so pinning is both
|
|
62
|
+
* pointless and would break the MockAgent stub. Specs flip
|
|
63
|
+
* `_disablePinnedDispatcherForTests(true)` in beforeEach to keep the
|
|
64
|
+
* MockAgent flow intact while production hits the pinned path.
|
|
65
|
+
*/
|
|
66
|
+
let pinnedDispatcherDisabled = false;
|
|
67
|
+
export function _disablePinnedDispatcherForTests(disabled) {
|
|
68
|
+
pinnedDispatcherDisabled = disabled;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build a per-call undici Agent that always returns the pre-resolved
|
|
72
|
+
* `address` from its connect.lookup hook. Returns `undefined` when the
|
|
73
|
+
* test flag disabled pinning — caller then falls back to the global
|
|
74
|
+
* dispatcher (MockAgent or production default).
|
|
75
|
+
*/
|
|
76
|
+
async function buildPinnedDispatcher(hostname) {
|
|
77
|
+
if (pinnedDispatcherDisabled)
|
|
78
|
+
return undefined;
|
|
79
|
+
// Skip pinning when hostname is already a literal IP — there is no
|
|
80
|
+
// DNS step to race in that case.
|
|
81
|
+
if (isIPv4(hostname) || isIPv6(hostname))
|
|
82
|
+
return undefined;
|
|
83
|
+
let answers;
|
|
84
|
+
try {
|
|
85
|
+
answers = await activeLookup(hostname);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Best-effort — fall through without pinning; the SSRF guard will
|
|
89
|
+
// emit the canonical DNS-lookup-failed error on the caller's path.
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const pinned = answers[0];
|
|
93
|
+
if (!pinned)
|
|
94
|
+
return undefined;
|
|
95
|
+
// β1b r1: close the DNS rebinding window the original guard could
|
|
96
|
+
// not see. `validateHostnameForFetch` already ran one lookup; the
|
|
97
|
+
// call above is a SECOND lookup whose answer feeds the pin. A
|
|
98
|
+
// hostile resolver can return a public address to the guard and a
|
|
99
|
+
// private address here — re-validate the pinned literal before we
|
|
100
|
+
// hand it to the Agent. Throws so the caller surfaces a security
|
|
101
|
+
// refusal rather than silently dispatching to the wrong host.
|
|
102
|
+
const ipCheck = validateIpLiteralForFetch(pinned.address, pinned.family);
|
|
103
|
+
if (ipCheck !== null) {
|
|
104
|
+
throw new Error(`ssrf_pinned_address_blocked: ${ipCheck}`);
|
|
105
|
+
}
|
|
106
|
+
return new Agent({
|
|
107
|
+
connect: {
|
|
108
|
+
lookup: (_h, _opts, cb) => {
|
|
109
|
+
cb(null, pinned.address, pinned.family);
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
48
114
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
49
115
|
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; // 5 MiB
|
|
50
116
|
const MAX_REDIRECTS = 5;
|
|
@@ -231,6 +297,42 @@ function ipv4IsBlocked(ip) {
|
|
|
231
297
|
}
|
|
232
298
|
return false;
|
|
233
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Validate a single IP literal (v4 or v6) against the SSRF blocklist.
|
|
302
|
+
* Pure synchronous check — no DNS. Returns `null` on success (safe to
|
|
303
|
+
* connect), an error string when the address is blocked or not a
|
|
304
|
+
* recognized IP literal.
|
|
305
|
+
*
|
|
306
|
+
* Used by the pinned-dispatcher path (web-fetch + web-search) to
|
|
307
|
+
* RE-VALIDATE the address actually pinned into `connect.lookup` AFTER
|
|
308
|
+
* the second DNS round-trip. Without this check the original SSRF
|
|
309
|
+
* guard's lookup answers can diverge from the lookup answers that
|
|
310
|
+
* feed the pin (hostile resolver flips public→private between calls);
|
|
311
|
+
* re-checking the pinned literal closes that window.
|
|
312
|
+
*
|
|
313
|
+
* Exported for spec coverage.
|
|
314
|
+
*/
|
|
315
|
+
export function validateIpLiteralForFetch(address, family) {
|
|
316
|
+
if (!address)
|
|
317
|
+
return 'empty address';
|
|
318
|
+
// Trust family hint when present (LookupAddress.family is 4 or 6),
|
|
319
|
+
// otherwise infer from the string shape.
|
|
320
|
+
const isV4 = family === 4 || (family === undefined && isIPv4(address));
|
|
321
|
+
const isV6 = family === 6 || (family === undefined && isIPv6(address));
|
|
322
|
+
if (isV4) {
|
|
323
|
+
if (ipv4IsBlocked(address)) {
|
|
324
|
+
return `IP ${address} is in a blocked range (SSRF guard)`;
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
if (isV6) {
|
|
329
|
+
if (ipv6IsBlocked(address)) {
|
|
330
|
+
return `IPv6 ${address} is in a blocked range (SSRF guard)`;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
return `address ${address} is not a recognized IPv4/IPv6 literal`;
|
|
335
|
+
}
|
|
234
336
|
/**
|
|
235
337
|
* Resolve `hostname` via dns.lookup and reject if any answer maps to
|
|
236
338
|
* a private/loopback/link-local/CGNAT range. Returns `null` on success
|
|
@@ -395,10 +497,34 @@ export async function webFetchTool(input, ctx) {
|
|
|
395
497
|
let currentUrl = parsedUrl;
|
|
396
498
|
let hops = 0;
|
|
397
499
|
const controller = new AbortController();
|
|
500
|
+
// β1b #62: per-hop pinned Agent so the post-lookup connect(2) cannot
|
|
501
|
+
// be redirected to a private IP by a hostile resolver. Built lazily
|
|
502
|
+
// per hop because each redirect target may resolve to a different
|
|
503
|
+
// host. `undefined` falls back to the global dispatcher (spec
|
|
504
|
+
// MockAgent or production default), preserving the existing test
|
|
505
|
+
// path. The current Agent is closed at end-of-call so we do not leak
|
|
506
|
+
// open connections.
|
|
507
|
+
let activeAgent;
|
|
508
|
+
const closeActiveAgent = async () => {
|
|
509
|
+
if (activeAgent) {
|
|
510
|
+
try {
|
|
511
|
+
await activeAgent.close();
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
/* ignore — agent already closed */
|
|
515
|
+
}
|
|
516
|
+
activeAgent = undefined;
|
|
517
|
+
}
|
|
518
|
+
};
|
|
398
519
|
try {
|
|
399
520
|
while (true) {
|
|
521
|
+
// β1b #62: refresh the pinned Agent for the current hop.
|
|
522
|
+
await closeActiveAgent();
|
|
523
|
+
const hopHost = currentUrl.hostname.replace(/^\[|\]$/g, '');
|
|
524
|
+
activeAgent = await buildPinnedDispatcher(hopHost);
|
|
400
525
|
response = await request(currentUrl.toString(), {
|
|
401
526
|
method: 'GET',
|
|
527
|
+
...(activeAgent ? { dispatcher: activeAgent } : {}),
|
|
402
528
|
headers: {
|
|
403
529
|
'user-agent': USER_AGENT,
|
|
404
530
|
accept: 'text/html,application/xhtml+xml',
|
|
@@ -436,6 +562,7 @@ export async function webFetchTool(input, ctx) {
|
|
|
436
562
|
/* socket already closed — nothing to do */
|
|
437
563
|
}
|
|
438
564
|
}
|
|
565
|
+
await closeActiveAgent();
|
|
439
566
|
return { ok: false, error: `Exceeded ${MAX_REDIRECTS} redirect hops.` };
|
|
440
567
|
}
|
|
441
568
|
// Drain prior body so the socket can be reused.
|
|
@@ -445,9 +572,11 @@ export async function webFetchTool(input, ctx) {
|
|
|
445
572
|
nextUrl = new URL(locStr, currentUrl);
|
|
446
573
|
}
|
|
447
574
|
catch {
|
|
575
|
+
await closeActiveAgent();
|
|
448
576
|
return { ok: false, error: `Invalid redirect target: ${locStr}` };
|
|
449
577
|
}
|
|
450
578
|
if (nextUrl.protocol !== 'http:' && nextUrl.protocol !== 'https:') {
|
|
579
|
+
await closeActiveAgent();
|
|
451
580
|
return {
|
|
452
581
|
ok: false,
|
|
453
582
|
error: `Refusing redirect to unsupported scheme ${nextUrl.protocol}.`,
|
|
@@ -456,6 +585,7 @@ export async function webFetchTool(input, ctx) {
|
|
|
456
585
|
const nextHost = nextUrl.hostname.replace(/^\[|\]$/g, '');
|
|
457
586
|
const guard = await validateHostnameForFetch(nextHost);
|
|
458
587
|
if (guard) {
|
|
588
|
+
await closeActiveAgent();
|
|
459
589
|
return { ok: false, error: `SSRF refused on redirect: ${guard}` };
|
|
460
590
|
}
|
|
461
591
|
currentUrl = nextUrl;
|
|
@@ -465,13 +595,23 @@ export async function webFetchTool(input, ctx) {
|
|
|
465
595
|
}
|
|
466
596
|
}
|
|
467
597
|
catch (error) {
|
|
598
|
+
await closeActiveAgent();
|
|
468
599
|
const message = error instanceof Error ? error.message : String(error);
|
|
600
|
+
// β1b r1: the pinned-dispatcher path throws `ssrf_pinned_address_blocked: …`
|
|
601
|
+
// when the second DNS lookup answered a private IP. Surface that as a
|
|
602
|
+
// first-class SSRF refusal so callers (and specs) can match on it
|
|
603
|
+
// without grovelling through `Fetch failed:` prefixes.
|
|
604
|
+
if (message.startsWith('ssrf_pinned_address_blocked')) {
|
|
605
|
+
return { ok: false, error: `SSRF refused: ${message}` };
|
|
606
|
+
}
|
|
469
607
|
return { ok: false, error: `Fetch failed: ${message}` };
|
|
470
608
|
}
|
|
471
609
|
if (!response) {
|
|
610
|
+
await closeActiveAgent();
|
|
472
611
|
return { ok: false, error: 'No response received.' };
|
|
473
612
|
}
|
|
474
613
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
614
|
+
await closeActiveAgent();
|
|
475
615
|
return { ok: false, error: `HTTP ${response.statusCode} from ${currentUrl.toString()}` };
|
|
476
616
|
}
|
|
477
617
|
// content-length is advisory — never trust it for the size cap, but
|
|
@@ -489,6 +629,7 @@ export async function webFetchTool(input, ctx) {
|
|
|
489
629
|
catch {
|
|
490
630
|
/* ignore */
|
|
491
631
|
}
|
|
632
|
+
await closeActiveAgent();
|
|
492
633
|
return {
|
|
493
634
|
ok: false,
|
|
494
635
|
error: `Declared content-length ${n} exceeds ${MAX_RESPONSE_BYTES} byte cap.`,
|
|
@@ -499,11 +640,14 @@ export async function webFetchTool(input, ctx) {
|
|
|
499
640
|
const contentType = Array.isArray(contentTypeRaw) ? contentTypeRaw[0] : contentTypeRaw;
|
|
500
641
|
const mime = typeof contentType === 'string' ? contentType.split(';')[0]?.trim().toLowerCase() ?? '' : '';
|
|
501
642
|
if (!ALLOWED_CONTENT_TYPES.includes(mime)) {
|
|
643
|
+
await closeActiveAgent();
|
|
502
644
|
return { ok: false, error: `Disallowed content-type ${mime || '(none)'}; only HTML/XHTML/text.` };
|
|
503
645
|
}
|
|
504
646
|
const bodyResult = await readBodyWithCap(response.body, controller);
|
|
505
|
-
if (!bodyResult.ok)
|
|
647
|
+
if (!bodyResult.ok) {
|
|
648
|
+
await closeActiveAgent();
|
|
506
649
|
return bodyResult;
|
|
650
|
+
}
|
|
507
651
|
const html = bodyResult.buffer.toString('utf8');
|
|
508
652
|
// linkedom is the lightweight DOM Readability needs; jsdom would
|
|
509
653
|
// add ~3 MB to the install footprint for the same surface.
|
|
@@ -524,6 +668,7 @@ export async function webFetchTool(input, ctx) {
|
|
|
524
668
|
`Source: ${safeSource}\n\n` +
|
|
525
669
|
`${scrubbedMarkdown}\n` +
|
|
526
670
|
`</untrusted-content-${nonce}>`;
|
|
671
|
+
await closeActiveAgent();
|
|
527
672
|
return {
|
|
528
673
|
ok: true,
|
|
529
674
|
url: currentUrl.toString(),
|