diff --git a/.claude/orchestrate-design.md b/.claude/orchestrate-design.md new file mode 100644 index 00000000..aec9420c --- /dev/null +++ b/.claude/orchestrate-design.md @@ -0,0 +1,393 @@ +# Orchestrate + +Design notes from the session that worked this out. Captures the decided shape and the +reasoning behind it, not an exhaustive spec — fill in details during implementation. + +## The problem this solves + +Started from a narrow question: how do you call a function app's HTTP endpoint (or query +a DB with a key) without the credential ever landing in Claude's context? Any tool call +that returns a secret's value puts it permanently into session history, searchable, +unrevocable. + +Widened from there: `AzCli`/`EscalatedAzCli` and the git tools (`feature/git-tool` branch) +share the same real flaw — not "only runs az" or "only runs git", but **only one command +per call**. That's what makes `fetch && rebase && push` or `az ... | curl ...` +inexpressible today: each is forced through either N separate round trips (Claude as the +manual glue, retyping values by hand) or falling back to `Exec: sh -c '...'`, which throws +away the whole reason typed, per-command tools exist (legibility, per-command approval, +schema-checked args). + +`ExecV2` already tried a more expressive shape (a binary tree of `; && || & |`) and it +failed in practice: too easy for Claude to build wrong, so it reverted to `sh -c '...'` +anyway. Expressiveness that can't be used correctly is worse than no expressiveness — it +becomes an escape hatch back to the unstructured, illegible shape everything else here +exists to avoid. + +## What Exec is actually for + +Not defense against Claude as an adversary — sandboxing is the real mechanism for +that, and it's a separate, unbuilt concern. Exec's structured `program`/`args` (vs a raw +shell string) exists so what's about to run is **legible**: checkable by a block-list, +inspectable by whoever approves it, reconstructable in an audit log afterward. `sh -c +'...'` defeats that by smuggling an entire script inside one opaque string argument. +Env var expansion, capture, etc. don't reopen that hole — they don't hide anything from +the block-list the way `sh -c` does. + +## Shape: two fixed levels, not a flat list, not a tree + +`Orchestrate` reuses `ExecV3`'s proven flatness, but structures it in two fixed levels +rather than one flat list relying on bash-style precedence to be inferred correctly. +`a && (b | c | d) || e` in `ExecV3` already parses to the intended grouping today (`|` +binds tighter than `&&`/`||`, same rule as bash) — but that's precedence a caller has to +get right mentally, and precedence bugs are a well-worn source of real shell mistakes. +Instead: + +``` +[ + { commands: a, operator: '&&' }, + { commands: b | c | d, operator: '||' }, + { commands: e }, +] +``` + +An outer sequence of blocks joined by `;`/`&&`/`||` (control flow — gates on success or +failure), each block itself a flat pipe chain (data flow — output feeds the next stage). +This is still bounded at exactly two levels, not a recursive tree — it doesn't reopen the +ExecV2 problem (arbitrary nesting depth was the actual failure there, not "more than one +level" per se). The two kinds of composition are fundamentally different in what they +mean (data vs control), so representing them as two distinct structural levels instead +of one flat list with mixed meaning is more honest to what's actually happening, not +just more convenient. + +No tree beyond this fixed two-level shape. Loops and value-based branching were +considered and rejected for the same reason: when logic like that is actually needed, +the right move is a real script file, not more expressiveness bolted onto ad hoc tool +calls. In practice these have essentially never come up as needs in ad hoc (non-scripted) +use. + +The exact wire shape (JSON blocks-with-operator, a flat array with inline `pipe` groups, +or even a text/DSL syntax — a simplified "bash-lite") is surface syntax, not settled +here. It's swappable later without touching anything that actually matters: the target/ +content split, capture+reference, env scrubbing, the two-layer approval model, and the +streaming requirement below. Scratch-tested two JSON variants for size/legibility during +this session; a flat array where an item is either a plain command or a `{ pipe: [...], +op }` group came out both smaller and more legible than a uniform blocks-with-operator +wrapper — worth defaulting to that unless implementation surfaces a reason not to. + +`Orchestrate` supersedes `Pipe`. Two separate composition mechanisms in the same +catalogue is complexity with no payoff — `Pipe`'s six stages (Find/Read/Match/Head/ +Tail/Range/Paths) become ordinary operations in one `Orchestrate` sequence, alongside +everything else. + +`ExecV3`/`ExecV2` collapse into a single leaf kind: **`Program`** — spawn one process, +bytes in, bytes out, no special composition logic of its own (that's now `Orchestrate`'s +job, uniformly, for every leaf, not just processes). + +## Composability is not "make everything text" + +Considered and rejected as the general mechanism. `Match`'s own code +(`packages/claude-sdk-tools/src/Match/Match.ts`) shows why: its behaviour branches on +`input.kind` (`'files'` vs `'content'`) — same tool, same pattern, different meaning of +"match" depending on what produced the input. That's *polymorphism*, and it's the +opposite of how real Unix composability works: `grep` has zero awareness of what a line +of text represents, it just matches, uniformly, regardless of provenance. Because our +existing composable tools already commit to a typed JSON `Stream` (not bytes), text +isn't an available fallback substrate for them without a real adapter/conversion step — +and a generic conversion node only works for flat-list shapes (a file path), not for +richer structured shapes (a diagnostic, a line edit), where it either forces a fragile +ad hoc text convention or collapses back into "the consumer has to know the shape", +i.e. no real gain over what typed tool calls already give you. + +Conclusion: don't chase a universal wire format. Instead— + +## The real per-tool design rule: target vs content + +Every tool that participates in `Orchestrate` splits its fields into two categories: + +- **Target** — decides *where* an effect lands or *which* fixed operation runs (a file + path, a PR number, `program`, `cwd`, a redirect target). Must stay an explicit, + literal argument in the call — visible to review, never dynamically resolved or + expanded. This is the same legibility principle as not letting `program` be built + from a variable. +- **Content** — the data a tool consumes or produces that can legitimately come from + elsewhere (already exists in a file, was generated by a prior step, etc). This is + the channel that can be satisfied by piped input instead of a literal argument, + using **that tool's own natural convention** — not a shared universal schema. E.g.: + - `CreateFile`: `file` stays explicit; `content` can come from stdin (the whole + file's new text) — real, because sometimes I don't already have the content + myself (a fetched artifact, generated output). + - `DeleteFile` / `git rm` / `git mv * dest`: explicit list normally; a piped list + of paths is the equivalent of `find | xargs rm`. Only holds when the operation + takes one flat list — `git mv` with per-source distinct destinations does *not* + fit this (it needs a correlated pair, not a list), so it stays point-shaped. + - `ReadFile` (text): same batch-list shape (`find | xargs cat`) — genuinely useful, + I already do this by hand across separate calls today. + - `ReadBinaryFile` (PDF/image): **must stay a completely separate tool**, single + target only, never fed by a pipe. Not a type/format distinction — a blast-radius + one: piping a large discovered list into a text reader is cheap and reversible; + piping it into a binary reader means N PDFs/images actually get decoded into + context as native blocks, expensive and irreversible. Making this one tool with + a mode switch (piped ⇒ text-only, direct ⇒ any mimeType) was considered and + rejected — that's exactly the kind of hidden, invocation-dependent behaviour + branch (like `Match`'s `input.kind`) that this whole design is trying to avoid; + the risk needs to be visible in the tool's *name*, not buried in a runtime rule. + - `WriteMemory` / PR `body` / commit message: content channel is real whenever the + text already exists elsewhere (a template, a file I want to review before + committing, a generated summary) — NOT ruled out by "this content should be + deliberately authored". Authorship is about care taken composing it, not about + which mechanism delivered it into the argument. + - Point-shaped tools with **no** content channel and no batch-list form: `TsHover`/ + `TsDefinition`/`TsReferences`/`ReadMemory`(by id)/`MemoryTypes` — a specific + coordinate or id, same as `git status`. Nothing to pipe in. + - **Recurring pattern across the whole catalogue**: source (`Find`, `SearchMemory`, + `SearchHistory`, `Git_BranchList`, `Git_StashList`, `az ... list`) → batch + consumer of what would otherwise be one explicit id/path/target at a time + (`DeleteFile`, `ReadMemory`, `ReadFile`, `Git_StashApply`/`Drop`, `az ... ` + via `xargs`-equivalent). Shows up independently at least three times — treat it + as a first-class, deliberate shape, not a one-off per tool family. + - **Piping into a tool that doesn't consume input is an error, not a silent + no-op.** Real Unix goes implicit (`echo hi | ls` just runs `ls`, ignoring + stdin) because a human watches the result live and notices immediately if it + didn't do what they meant. Claude doesn't get that same live signal — a silent + no-op returns a normal-looking result and Claude can walk away believing + something happened that didn't. Erroring surfaces exactly the mistake that + matters: a wrong assumption about what a tool does with what it's handed. + +## Requirement: piping must be streamed, not buffered + +`ExecV3`'s `Program`-to-`Program` pipes are true OS-level streams — proven by `yes | head +-1` terminating at all (only possible because `head` closing its end sends `yes` +SIGPIPE) and by `echo | head` showing the first command's stdout never even reaches +`results[0]` for the parent process. `Pipe`'s existing composable stages are not: `Match` +operates on `input.files.filter(...)`, a fully realized array — Find/Read/Match/Head/ +Tail/Range already buffer completely between each stage. That means `Pipe`, as it works +today, is not fit for purpose as the model for `Orchestrate`'s `|` — it cannot short- +circuit a producer (`find ~ | head -1` would have to finish walking the whole tree +before `head` ever sees anything). + +This is a hard requirement for `Orchestrate`, not an open question: `|` between any two +leaves — `Program` or `ToolCall`, in any combination — needs genuinely streamed data, +not a fully materialized array handed forward once a stage completes. It doesn't need to +be a real OS pipe (that's specific to spawned processes) — it can be faked with an async +iterable/generator between stages in our own orchestrator code — but the semantics have +to hold: a downstream stage that stops early (`Head`) must be able to cut a producer +short rather than forcing it to run to completion first. + +## Skill is excluded entirely + +Not point-shaped, not source/sink/transform — loading a skill changes what +constraints govern Claude itself, self-referential, not data flowing through a +pipeline. It doesn't fit the model at all and shouldn't be forced into it. + +## Credentials: composability + capture solves the passing problem; context stays a live option + +Originally sketched as a declared `context: [{ type: 'az', privileged: true }]` list, +referenced per-operation, to amortize escalation approval across a sequence. Proven +unnecessary *for the credential-passing case specifically* — composability + two small +primitives already solve that for free, once `AzCli` is just another orchestratable step +instead of a single isolated call: + +``` +AzCli: get the secret key (captures output, e.g. to a named value) +Program: curl ... (references the captured value in an argument or env var) +``` + +Two primitives needed, not a whole context system: + +1. **Capture** — a command's stdout can be held as a named value for later reference. + Bash's own version of this (`cmd | read x`, subshell-scoping) is a known trap + (`read` runs in a subshell, the variable vanishes) — ours should be first-class + and not inherit that bug. +2. **Reference/expansion in arguments (and `env`, reusing the field ExecV3 already + has) only** — never in `program`, `cwd`, or a redirect *target* path, because + those decide where an effect physically lands and must stay literal/legible in + the call itself, same reasoning as not letting `program` be dynamic. + +The secret's raw value is never shown to Claude — review/approval shows the +**unexpanded reference** (`Authorization: Bearer $TOKEN`), never the resolved value. +That's sufficient for informed consent: the reviewer is approving the *shape* of the +operation (a captured value gets used as a bearer token here), not the literal bytes, +same as approving `sudo apt install` without seeing the password. + +The `context` idea isn't discarded, though — it's a live option for a different reason: +if `Orchestrate` itself can carry an escalation context per-operation, that may remove +the need for dedicated tools that exist *purely* to represent an escalation tier (e.g. +`AzCli` vs `EscalatedAzCli` as two separate tools just to draw that boundary). An +`Orchestrate`-level `context` could be the escalation mechanism itself, which is a +separate justification from credential-passing and worth keeping on the table. This +design is meant to provide more options, not force a single conclusion. + +## The actual credential-exposure fix: env scrubbing at spawn, not blocking `$VAR` + +Early framing was wrong: "Exec doesn't do shell expansion, and that's a deliberate +security boundary against injection." Disproven directly — nothing stops +`program: "sh", args: ["-c", "rm -rf /"]` today, so "no shell interposed" was never a +real boundary; a shell is one argument away regardless. So `$VAR`-only expansion +(literal name lookup, no shell parsing, no globbing/command substitution) doesn't +reopen anything that wasn't already open. + +The actual control needed is a **different axis entirely**: what environment a spawned +process receives in the first place. Today `runGit`/`ExecV3` inherit the full parent +environment (`env: process.env` in `runGit.ts`), which means any expansion or even a +bare `env` call hands over whatever's ambient — Stephen's own tokens, `$TMUX_PANE`, +anything — not because expansion is unsafe, but because inheritance is unscoped. Fix: +scrub the specific env vars that shouldn't be inherited before spawning, rather than +trying to prevent expansion of env vars. This is the same pattern `keychain-native`/ +`AzCli` already use for the az login identity — generalise the scrubbing, not a +blanket deny-all-inheritance policy. + +This is a **separate concern from building `Orchestrate`**, not a prerequisite for it — +whether env vars are scrubbed or not doesn't gate anything below. Worth doing on its own +merits, whenever, independently of this work. + +## Approval: two layers, not one + +1. **Static deny** — same as the existing `safe-operations` hard blocks (`rm`, + `sed -i`, `git reset --hard`, etc). Deterministic, pre-execution, no judgement call + reachable at all — there's no point asking someone to approve something the system + won't allow anyway; that's a useless approval, not a safety measure. +2. **Per-command approval** — happens when a specific operation in the sequence is + about to run, seeing *that operation's* real resolved shape (e.g. the actual file + list `Find` produced), not the whole pipeline's source text approved blind up + front. The approval channel is already a separate audience from Claude's context + (the consumer/SC slots in as approver via a held-promise) — so a resolved + captured value can be shown to the human approver without ever entering Claude's + context, if that's ever needed. In practice, per the reference/expansion point + above, review only needs the unexpanded reference anyway. + +## Operation tiers are `fs.*`, with `list` and `exec` added + +The existing `ToolOperation` type (`'read' | 'write' | 'delete' | 'escalate'`) is really +about filesystem permissions and should be named that way — `fs.read`, `fs.write`, +`fs.delete` — plus two tiers that were missing: + +- **`fs.list`** — reading a directory's entries. `Find` is this, not `fs.read`. Real Unix + keeps this distinct from file content (`r` on a directory lists entries; `r` on a file + reads content) — conflating the two was the mistake in an earlier pass of this doc, + where `Find` got called a `'read'`-tier tool. +- **`fs.exec`** — executing a program (`Program`/`ExecV3`'s spawn). Real Unix's `x` bit on + a file. Was previously unrepresented as its own tier at all. + +`fs.delete` stays its own tier rather than being folded into `fs.write`-on-a-directory +(which is how Unix actually models a delete) — simpler to keep it explicit for our +purposes than to make every consumer reason about "write, but scoped to the parent +directory." `escalate` stays outside the `fs.*` set entirely, since crossing a privilege +boundary isn't a filesystem operation. + +## Buffering is conditional on approval, not a fixed tool property + +Approval always covers exactly what was piped into a stage, in whatever shape that +naturally is — not a special case per operation tier. `Find` (`fs.list`) piped into +`ReadFile` (`fs.read`) or `DeleteFile` (`fs.delete`) both approve a resolved list of file +targets ("read/delete these N files") — same shape, same cost, cheap to buffer (a path +list, not content). +`Something → EditFile`/`CreateFile` approves resolved *content*, because that's what's +piped into them. In the ordinary case, read/write/delete are symmetric: buffer what was +piped in, present it, then act. + +The one genuinely hard case is narrower than "read in general": `ReadFile` piped into a +downstream stage whose demand is *content*-derived, not file-count-derived — e.g. +`Find → ReadFile → Head -N` where `Head` counts lines. The correct number of files needed +depends on each file's actual line count, which is only discoverable by reading it — so +the resolved scope genuinely cannot be known before some reading has already happened. +That's a property of this specific combination (a file-shaped producer feeding a +content-shaped consumer), not a general fact about `'read'` as a tier. + +**What actually drives whether a stage buffers is not efficiency — it's whether an +approval gate sits in front of it.** A gate needs something resolved to show, so a gated +stage must buffer before it can present anything. An ungated stage (already trusted for +this run) doesn't need to buffer, because nothing needs to be shown before it acts. +Buffering `DeleteFile`'s path list or `CreateFile`'s content isn't wasteful the way +buffering `ReadFile` ahead of `Head` would be (nothing downstream could have made that +work unnecessary), but that was never actually the reason to buffer or not — the reason +is purely whether a gate is present. + +Which stages are gated is decided by which approval tier was granted for that specific +orchestration run, live — not fixed per tool. `find /tmp/my-temp-dir | xargs rm` +approved at `approve: delete` (broad, pre-trusted) means no gate sits in front of the +delete stage — nothing to show, it can stream straight through. The same shape, +`find ~/repos/... | xargs rm`, approved only at `approve: read` (narrower — trusting the +enumeration but not pre-committing to the delete) means a gate *does* sit in front of the +delete stage, so it must buffer to have the resolved file list to present before it can +act. Same tool, same shape, different buffering behaviour on different runs, determined +by what's already been trusted for that run — not hardcoded into the tool. + +## Known adjacent bug (separate from Orchestrate, worth fixing regardless) + +`GitHub_PullRequest_Ready`/`AutoMerge`/`Comment`/etc. require `number` today. The +underlying `gh pr` commands infer the current PR from the current repo/branch when no +number is given. Requiring it is stricter than necessary and is exactly what manufactures +a need to pipe the number forward from `Create` in the common one-PR-per-branch case. +Fix independently: make `number` optional, inferred from context, matching `gh` itself. + +## First implementation steps (in order) + +1. Capture + reference/expansion primitive (new field(s) on `Orchestrate`'s + `Program`/`ToolCall` operations) — scoped to `args`/`env` only. +2. `Orchestrate` itself: flat sequence (exact wire shape TBD during implementation, + see above), operations are either `Program` (the `ExecV3`/`ExecV2` successor) or a + `ToolCall` (any existing `defineTool`/`defineComposable` tool, including the current + `Pipe` six and the `Git_*` family on `feature/git-tool`). +3. Migrate `Pipe`'s six stages and the `Git_*` tools onto `Orchestrate` as ordinary + operations; retire `Pipe`, `ExecV2`, `ExecV3` as separate tools once `Orchestrate` + covers their cases. +4. Split `ReadFile` into a batchable text reader and a separate, single-target + `ReadBinaryFile`. +5. Fix the GitHub/AzureDevOps `number`-required bug (independent, can land any time). + +Env scrubbing at spawn time and the `context`-as-escalation-mechanism idea are both +separate, independent concerns — not sequenced here, not a prerequisite for anything +above. + + +## This is Tools V2, not a new tool bolted onto the current system + +The streaming requirement above doesn't stop at `Pipe`'s six stages. **Any** tool that +wants to participate in `Orchestrate` at all — accept piped input or be piped into — +needs to be built against a streaming interface from the start, not "receives a fully +materialized value." That's not a migration task tucked inside implementation step 3 +above; it's a foundational interface requirement the whole tool catalogue would need to +satisfy. Looked at the actual foundation this rests on — `defineTool`, `ToolRegistry`, +`ApprovalCoordinator` — and the "one tool call = one resolve = one run = one approval" +model is structural, not incidental: `ToolRegistry.resolve()` parses input once and +returns a `run` closure that calls the handler once for a single result; +`ApprovalCoordinator` tracks exactly one `AbortController` and one pending-approval +promise per tool call, with no concept of a call containing several sub-operations each +needing their own approval moment. + +So this is genuinely **Tools V2**: `defineTool`'s shape, tool registration, approval, +and permission gating all get redesigned, not just a new tool added beside the old ones. + +**Decision: build it as a fully separate system, not intertwined with the current one.** +Retrofitting streaming/capture/per-command-approval into the existing `ToolRegistry`/ +`ApprovalCoordinator` in place would mean changing the meaning of "a tool call" +everywhere at once, while every existing tool (and its approval UI, its tests) still +depends on the current one-shot meaning — a long half-broken period with no working +fallback. Building V2 alongside V1 — its own `defineToolV2`, its own registry, its own +approval flow — matches this codebase's own precedent: `Exec` → `ExecV2` → `ExecV3` +already coexist today, with `ExecV2` simply present-but-disabled rather than replacing +`Exec` in place. Lets migration happen tool-by-tool (or not at all, for tools with no +reason to move) instead of one big-bang cutover. + +**Where the two systems necessarily still touch**, because Claude only ever sees one +flat tool list and one tool-call protocol, no matter how many systems sit behind it: + +1. **The wire tools list** (`IToolRegistry.wireTools`, feeding the Anthropic API's + `tools` param) — V1 and V2 tool definitions have to merge into one array; there is + no way to present two separate tool universes to the model itself. +2. **Dispatch** — when a `tool_use` block comes back, something has to decide whether + the name belongs to the V1 registry or the V2 engine and route accordingly (today, + `registry.resolve(name, input)` in `QueryRunner` assumes a single registry). +3. **Tool rendering** — the TUI block showing "Claude called X, here's the result" + needs to render both a V1 tool's single result and a V2 orchestrated sequence's + multi-stage result in one consistent visual language, or the SC sees two visually + different tool-call experiences depending on which system happened to run. +4. **Approval UI** — likely the same underlying component for both, but V2's per- + command approval (seeing one operation's resolved shape mid-sequence, not the whole + call up front) is a genuinely different interaction shape than V1's single yes/no; + this is where actual new UI design is needed, not just a shared pipe. + +Wire-list merge and dispatch are small and mechanical. Tool rendering and approval UI +are where the real design work is, because V2's approval/result shape is fundamentally +richer than V1's single request/response. \ No newline at end of file diff --git a/.claude/orchestrate-spec.md b/.claude/orchestrate-spec.md new file mode 100644 index 00000000..ddf12742 --- /dev/null +++ b/.claude/orchestrate-spec.md @@ -0,0 +1,88 @@ +# Orchestrate: the specification, as tests + +Three scopes, each with its own seam. Nothing below a scope's seam is real inside it. + +| Scope | Under test | Everything else | +|---|---|---| +| Engine | what a run does with stages | fake tools that answer for themselves | +| Program | turning an executor's answer into a stage's | `FakeExecutor` | +| Executor | closing a pipe becoming a real kill | real processes | + +## Engine, with fake tools + +A stage's outcome. One per stage, and each test says what the stage produced, what the report +says, and what the stages after it did. + +1. A stage runs to the end and its tool calls it done. +2. A stage runs to the end and its tool calls it a failure. +3. A stage is ended by a signal its tool reports. +4. A stage is refused before it runs. +5. A stage never starts, because the one before it failed. +6. A stage never starts, because the one before it was refused. +7. A stage is stopped for producing more than can be held. +8. A stage throws. +9. The call is cancelled while a stage is running. + +Bytes between stages. + +10. What one stage produces is what the next receives, byte for byte. +11. Nothing between stages interprets those bytes: no separator, encoding or size is assumed. +12. A producer runs ahead of a stalled reader by one buffer and no more. +13. Adding a stage adds a fixed amount to that, not a multiple. +14. A producer whose reader has gone is told to stop, and so is one two stages back. +15. A producer that never ends still terminates the call. + +Joining stages. + +16. `&&` runs the next stage only when the previous one succeeded. +17. `||` runs it only when the previous one failed. +18. `;` runs it whatever happened. +19. `|` gives the next stage the previous one's bytes. +20. A refusal counts as a failure for 16 and 17. + +Xargs. + +21. The engine puts an Xargs stage's output into the next tool's declared field. +22. What that field already held is kept, and the new values follow. +23. A sequence where Xargs feeds a tool with no such field is refused before anything runs. +24. An Xargs output larger than can be held stops the producer, and the stage it fed does not run. + +What is held. + +25. A batch shown for approval is bounded; reaching the bound refuses rather than showing part of it. +26. The run's own result is bounded; reaching it stops the producer and the report says so. + +Judging. + +27. Every stage is put to the decision, including one that touches nothing. +28. A stage is judged on what it will really do, after its variables are resolved. +29. What is published for approval is what the caller wrote. + +## Xargs, as a tool on its own + +30. It splits what it reads into one argument per line. +31. A trailing separator does not produce an empty argument. +32. Bytes with no separator in them are one argument. + +## Program, with a fake executor + +33. An executor reporting exit code zero is a stage that succeeded. +34. An executor reporting a non-zero exit code is a stage that failed. +35. An executor reporting a signal is a stage ended by that signal. +36. The process's bytes are the stage's bytes, unchanged. +37. What was piped in reaches the process's input, unchanged. +38. Closing the stage's output asks the executor to stop the process. +39. A stage is not answered for until the executor says the process is finished with. + +## Executor, with real processes + +40. Closing the read end of a running process's output kills it with SIGPIPE. +41. A process that ignores that is killed anyway. +42. A process that ends on its own reports its own exit code. +43. Nothing is left running once a run is over. + +## Captures, engine with a fake executor + +44. A capture holds the stage's whole output. +45. It reaches a later command through the environment that command runs under. +46. It never appears in what is published for approval. diff --git a/.claude/plans/orchestrate.md b/.claude/plans/orchestrate.md new file mode 100644 index 00000000..5a2c2554 --- /dev/null +++ b/.claude/plans/orchestrate.md @@ -0,0 +1,201 @@ +# Orchestrate — Plan + +Execution checklist. Reasoning and decisions live in `.claude/orchestrate-design.md` — +this file is only the phases and their status, so a new session can resume without +re-deriving the plan from chat history. + +Tools V2: Orchestrate is a genuinely separate registration/approval system from the +existing `packages/claude-sdk` `ToolRegistry`/`ApprovalCoordinator`, not a tool bolted +onto it. See the design doc's "This is Tools V2" section for why. Every tool eventually +becomes a ToolV2 — V2 replaces V1 entirely, catalogue-wide. A tool not yet ported (Memory, +History, TypeScript, AzCli, GitHub, AzureDevOps, and everything else besides the handful +built so far) is simply not done yet, not excluded from this plan — priority, not scope. + +## Phase 1 — `packages/orchestrate-core` — DONE + +The engine: `Leaf`, `FsOperation` (`fs.list`/`fs.read`/`fs.write`/`fs.delete`/ +`fs.exec`), `Op` (`|`/`&&`/`||`, absent = `;`), `plan()` (buffer-vs-stream per stage from +the live approval grant), `execute()` (gating, operator semantics, capture/reference, +Xargs bridging, centralized stderr policy), `XargsStage`. Real vitest specs, builds/ +lints/type-checks clean. + +## Phase 2 — Rewrite `Pipe`'s six stages as genuinely lazy leaves — DONE + +`packages/claude-sdk-tools/src/Orchestrate/leaves/`: Find, Match, Head, Tail, Range, +Read, plus Program (the ExecV3/ExecV2 successor, backed by `@shellicar/exec-core`'s +real Executor). Real tests under `packages/claude-sdk-tools/test/Orchestrate/`, using +`MemoryFileSystem`/`FakeExecutor`, never real fs/processes. Full existing suite green +throughout. + +## Phase 3 — Build the actual `Orchestrate` tool — IN PROGRESS + +Wraps `orchestrate-core`, registered as its own thing per the Tools V2 decision — not +touching the existing `ToolRegistry`. + +**Naming correction (SC caught this):** "Leaf" was never the settled name and implied a +tree structure the design explicitly rejects. Renamed throughout `orchestrate-core` and +`claude-sdk-tools`: `Leaf` → `ToolV2`, `LeafResult` → `ToolV2Result`, `LeafStage` → +`ToolStage` (`kind: 'leaf'` → `kind: 'tool'`), `createXLeaf` → `createXToolV2`, the +`leaves/` directory → `tools/`. These are tools — the same concept as a V1 tool, built to +a streaming contract. Orchestrate is not a tool that encapsulates a fixed set of them; it's +a tool that can run *any* registered one. + +**Architecture correction (SC caught this too):** the first pass hand-wrote a second copy +of every tool's shape into a separate `wireSchema.ts`, kept "in lockstep by hand" with the +registry — two sources of truth for the same thing. Collapsed into one real +`ToolsV2Registry` (`registry.ts`): each tool is `defineToolV2`-shaped and self-describing +(carries its own zod `model`, like a V1 `ToolDefinition` carries its own schema). The +registry derives, from that one list: +- `wireTools: BetaTool[]` — every registered tool gets its own wire entry, same as V1's + `Find`/`Paths` sources are both a pipe step and standalone-callable. This is the "it + needs all the tools" point — a V2 tool is genuinely callable on its own, not only + reachable through Orchestrate's stage array. +- `stageSchema` — the `Orchestrate` wire tool's `stages` array, a discriminated union + built at construction time from every registered tool's own `model`. Generated, not + hand-authored — no second schema to drift out of lockstep. +- `toStage(wire)` — resolves one wire stage into a real `orchestrate-core` `Stage`, + validating that stage's `input` against its own tool's `model`. + +Done so far, real code + tests, in `packages/claude-sdk-tools/src/Orchestrate/`: +- `defineToolV2.ts` — the V2 tool contract (name, description, operation, model, run), + mirroring V1's `defineTool`/`ToolDefinition`. +- `tools/` — Find, Match, Head, Tail, Range, Read, Program, each `defineToolV2`-shaped. +- `registry.ts` — `ToolsV2Registry` / `createToolsV2Registry(deps)`, as above. +- `runOrchestrateCall.ts` — the one function a V2 dispatch path needs to call: raw + `tool_use.input` in, `{ ok, content } | { ok, error }` out, matching V1's handler-result + shape so the consumer doesn't need a second result taxonomy. +- Proven end-to-end (`test/Orchestrate/runOrchestrateCall.spec.ts`) against real tools: + parse → resolve → `execute()` runs, and `execute()`'s existing `approve(stageName, batch)` + hook already fires once per gated stage with no new engine work needed — confirmed via a + scratch POC (`.claude/poc/orchestrate-tool-v2-dispatch.ts`) before writing the real files. + +Three of the four touch points are now DONE, real code + tests: + +1. **Wire tools list** — DONE. Real merge point turned out to be `DurableConfig.toolsV2?: + BetaTool[]` (new field, `packages/claude-sdk/src/public/types.ts`), threaded through + `RequestBuilder`/`TurnRunner` alongside `serverTools`, populated by + `apps/claude-sdk-cli/src/setup/ToolsV2Service.ts` (`toolsV2WireTools(registry)`) and + consumed in `DurableConfigFactory.#build()`. NOT `IToolRegistry.wireTools` (unused by the + real request path) and NOT folded into V1's `AnyToolDefinition[]`/`ToolRegistry` — + genuinely separate arrays merged only at the wire-params level. +2. **Dispatch** — DONE. `packages/claude-sdk/src/public/interfaces.ts` gained + `IOrchestrateEngine` (`owns(name)`, `run(name, input, requestApproval?)`), injected into + `QueryRunner` and consulted before the V1 registry in `#runTools` — a V2 name never + reaches `IToolRegistry.resolve`. Concrete impl: `OrchestrateEngine` in + `claude-sdk-tools/src/Orchestrate/OrchestrateEngine.ts`, backed by `ToolsV2Registry` + + `runToolV2Call` (handles both `Orchestrate` composed calls and a direct single-tool call, + e.g. calling `Find` on its own — both reduce to the same `execute()` call). Registered in + `apps/claude-sdk-cli/src/setup/container.ts`. +4. **Approval/permissions** — DONE (settled earlier, now wired). V2 never touches V1's + permission matrix (`apps/claude-sdk-cli/src/permissions.ts`). `QueryRunner`'s + `#runOrchestrateTool` builds a `requestApproval` callback that reuses + `ApprovalCoordinator`'s existing keyed request/response plumbing and the + `tool_approval_request`/`response` wire messages — reused mechanism, not reused policy: + fires once per gated STAGE (`${toolUseId}:${stageIndex}`), showing that stage's own + resolved input, honouring only `requireToolApproval` (off → auto-approve everything). + +3. **Tool rendering** — NOT DONE. The TUI has no shape yet for a multi-stage V2 result + (`ExecuteResult`/`StageReport[]`) distinct from a V1 single result. Right now a V2 call's + `tool_result` is just the flattened text `runToolV2Call.summarise()` produces — functional, + not yet rendered richly. **Real priority (SC), needs design thought before starting** — + not a quick follow-on to anything already built. +5. **Approval rendering** — DONE (this session, after the plan text above was written). + `QueryRunner`'s wire message now sends the gated stage's own resolved `input` (e.g. + `Program`'s real `program`/`args`), not just the piped batch (which was `[]` for any + producer stage — that was the actual bug behind "I don't see any input"). Confirmed live: + a real approval prompt now shows the real command about to run. + +**Fixed: ESC-cancel.** An `AbortSignal` now flows `QueryRunner` → `IOrchestrateEngine.run` → +`runToolV2Call` → `execute()` → every `ToolV2.run` unconditionally (optional param, most tools +ignore it). `execute()`'s only job is to stop advancing to further stages once the signal is +aborted; each tool decides for itself whether/how to react (`Program` ties it into the real +process kill it already had for its own timeout/caps). `QueryRunner` registers the same shared +`toolController` around the V2 dispatch that V1's phase already used, so ESC routes to it as a +tool-cancel. Proven with a full-stack integration test (real `QueryRunner`/`ApprovalCoordinator`/ +`OrchestrateEngine`/registry/`execute()`, only the OS process faked) plus unit tests in +orchestrate-core and Program.spec.ts. + +## Policy — the unified V1+V2 approval ACL, built and live (separate from the four +## touch points above, but part of this same thread) + +Replaces `permissions`/`tools.rules`/`tools.blockedCommands` with one ordered rule list +(`packages/claude-sdk-tools/src/Policy/`) — ACL-shaped (tower/mvp's `bridge::permissions` +is the model), not the old fixed inside/outside grid. `disabledTools` stays separate on +purpose — it's a registration-time decision (does the model see this tool at all), not an +approval-time one, so it was never in scope for this merge. + +Done: `matchTool`/`matchInput`/`matchValue`/`matchPath`/`resolve`/`resolveSet`, each +concern tested in isolation plus one composed-policy integration test proving genuine +parity with every real `Exec/ruleConfig.ts` `defaultRules` entry. `validatePolicy` (three +cases: wrong shape → invalid; a rule scoped to a currently-loaded tool referencing a field +it doesn't have → invalid; a rule scoped to a tool that isn't loaded yet → warning only, +not invalid) and `PolicyStore` (never updates to an invalid policy, never has no policy at +all — falls back to a safe ask-everything default). Wired into real V2 approval +(`createPolicyGatedApproval`, consulted by `OrchestrateEngine` before any human-ask). +`policy` is a real, live, top-level (not `tools.policy`) config field with its own +independent watch/notice (`ConfigPolicyProvider`, mirroring `tools.rules`'s own +independent-watch pattern exactly) — `⚠️ policy is invalid` / `✅ policy valid again` / +`🛡️ policy updated`, spliced into the primary view, confirmed live (editing `policy` while +the CLI is running takes effect immediately, no restart). + +V2 tool schemas now carry `isPath` markers (`Find.path`, `Paths.paths`, `Program.cwd`, +`Delete.files`) so `collectPaths` can extract real paths for path-scoped rules +(`$PWD`/`*`) to match against — without this, every path-scoped rule was silently +unreachable (`paths` was always `[]`). Two real resolver bugs found and fixed live during +testing, both now covered by tests: (1) `path: '*'` was being skipped when `paths` was +empty, defeating the one rule meant to catch everything, since a wildcard imposes no real +constraint and should match regardless — same principle `tool: '*'` already had right. +(2) A rule that matched but was silent on the specific operation being asked about (no +`operations` entry for it, no `default`) was resolving to `ask` right there instead of +falling through to the next matching rule — meaning an earlier, narrower rule (e.g. a path +zone that only ever talks about read/write) could silently block a later, more general +rule from ever being consulted for an operation the earlier rule never mentioned. + +Built the unified V2 `Delete` tool (files and directories in one, no `kind` branch — same +principle as `Match` losing its own) specifically to have something with a real +`fs.delete`-tier `isPath`-marked field to test the above against. + +**Not a concern in itself (SC), but the real fix is porting more tools to V2, not a Policy +change:** V1 tools do not go through Policy at all yet (confirmed live — `ReadMemory` +bypasses it entirely). The fix isn't special-casing V1 inside Policy — it's moving more of +the catalogue onto ToolV2, same as `Find`/`Program`/`Delete` already are. + +**Full gap analysis (checked against `createAppTools.ts` vs `registry.ts`), current at +time of writing:** +- V2 built, V1 not yet retired (both live): `Find`/`Paths`/`Match`/`Head`/`Tail`/`Range`/ + `Read`/`ReadBinaryFile` (V1's `Pipe`/`ReadFile` already retired, no collision), `Program` + (V1's `Exec`/`ExecV2`/`ExecV3` still separate), `Delete` (V1's `DeleteFile`/ + `DeleteDirectory` still separate). +- No V2 equivalent at all: `TsDiagnostics`/`TsHover`/`TsReferences`/`TsDefinition`, the + Memory tools, `Skill`, the History tools, the GitHub PR tools, the AzureDevOps PR tools, + `AzCli`/`EscalatedAzCli`. + +**Urgent (SC) — DONE: the file tools + `Ref` + the text/binary split.** `EditFile`, +`CreateFile`, `AppendFile`, `Ref`, and the `ReadFile` → `Read`/`ReadBinaryFile` split (Phase +6, folded into this push) all built as V2 tools this thread. V1's `ReadFile` fully retired. +`ReadBinaryFile` needed a new general mechanism, since its output (a native attachment) is +unlike every other V2 tool's `Stream`: `ToolV2Definition.excludeFromStages` (keeps a +tool individually callable via `wireTools` while excluding it from `Orchestrate`'s own +`stages` discriminated union — absent/false is the ordinary, composable case, no other tool +needs the flag) and a `ToolV2Result.attachments?: () => unknown[]` channel (opaque to +orchestrate-core, threaded through `execute()` → `runToolV2Call` → `OrchestrateEngine` into +the existing `ToolOutcome.blocks`). `Skill` is the other tool this same exemption will apply +to once it's ported — flagged by the SC, not yet built. + +## Phase 5 — Retire `Pipe`/`ExecV3` from the catalogue — PARTIALLY DONE + +`Paths` (the one Pipe tool Orchestrate didn't yet have — the other source alongside `Find`) +built as `createPathsToolV2` (`fs.list` tier, same fatal-on-first-missing-path behaviour as +V1). With that, Orchestrate now covers all seven Pipe tools (Find, Paths, Read, Match, Head, +Tail, Range). `Pipe` retired from `createAppTools.ts` — no longer registered, so V1's +standalone `Find`/`Paths` (which collided by name with V2's) are gone too. + +`ExecV3` NOT yet retired — still registered alongside V2's `Program`. That's a separate call +(different tool, not blocked by anything above). + +## Explicitly out of scope for this plan + +- Env scrubbing at spawn time, and `context`-as-escalation-mechanism — separate + concerns, not sequenced here, not a prerequisite for anything above. +- The GitHub/AzureDevOps `number`-required-argument fix — a different, unrelated task + (the SC ruled this out explicitly when it came up mid-design). diff --git a/CLAUDE.md b/CLAUDE.md index fec73702..7b47edb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,7 @@ Full detail: `.claude/five-banana-pillars.md` | `packages/mcp-typescript/` | MCP server wrapping TsDiagnostics, TsHover, TsReferences, TsDefinition, backed by a real `tsserver` | | `packages/mcp-internals/` | Private, never-published source-of-truth for MCP helpers (e.g. `getDataDir`), designed to be inlined by any MCP server that uses it rather than shipped as a runtime dependency. `private: true` | | `packages/exec-core/` | Process-spawning core: stream-based single-process spawn behind a shared interface | +| `packages/orchestrate-core/` | Tool orchestration runtime: plan/execute a pipeline of stages joined by `\|`, `&&`, `;`, with streaming, Xargs fan-out and named captures | | `packages/keychain-native/` | Minimal N-API binding to macOS Keychain generic-password reads, for holding credentials the CLI's own exec surface never sees | | `platforms/claude-sdk-cli-darwin-arm64/` | Published prebuilt SEA binary (macOS arm64) for the CLI, selected via the CLI's optional dependency. Bumped in lockstep whenever `claude-sdk-cli` is released. | @@ -155,7 +156,7 @@ All releases are pre-releases until 1.0.0. The current version series is `1.0.0- 5. Single PR with all version bumps, changelog updates, and lock file changes. Body is one line: "Bumps N packages to ." No feature bullets, no changelog recap — the diff is only version lines, so the body says only that. -6. After merge, create a GitHub release for each bumped **buildable** package (`claude-core`, `claude-sdk`, `claude-sdk-tools`, `mcp-exec`, `mcp-history`, `mcp-memory`, `mcp-typescript`, `claude-sdk-cli`): +6. After merge, create a GitHub release for each bumped **buildable** package (`claude-core`, `claude-sdk`, `claude-sdk-tools`, `orchestrate-core`, `mcp-exec`, `mcp-history`, `mcp-memory`, `mcp-typescript`, `claude-sdk-cli`): ```bash gh release create "@" --title "@" --target --notes "" --prerelease ``` diff --git a/apps/claude-sdk-cli/CHANGELOG.md b/apps/claude-sdk-cli/CHANGELOG.md index b988a551..6531ee53 100644 --- a/apps/claude-sdk-cli/CHANGELOG.md +++ b/apps/claude-sdk-cli/CHANGELOG.md @@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Inject a skill-catalogue delta: re-scan the skill roots each query and prepend a system-reminder naming the skills whose SKILL.md content changed, silent on the first scan of a session and after a resume - Inject the available-skills catalogue as a cached system-reminder on the first user message, scanned from skillDirs at startup and re-injected after compaction, so the model can discover skills to load - Mark model with * suffix in status bar when overridden via --model +- Orchestrate is now available: a single call runs several tools as a pipeline, each stage approved on its own and shown with its position in the pipeline - Publish conversation activity as opt-in NATS tap events - Publish the agent concern: ready/pulse/attached/detached telemetry and service/drain/chdir requests - Ref and PreviewEdit state is now persisted to disk @@ -81,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support reading PDF and image files as native API content blocks - Survive a mid-turn network drop: keep the machine awake during a request, persist the conversation as each message is sent and answered, and resume an interrupted turn from an empty submit - Tell the model the working directory: state it up front, and report the from/to when it changes mid-session +- Tool approvals for pipeline stages are now decided by a policy section in config, validated and watched on its own so a broken edit pins only the policy to its last-good version and an invalid initial policy falls back to asking about everything - Track session history per working directory for future session picker - Write BetaMessage per turn to ~/.claude/audit/.jsonl diff --git a/apps/claude-sdk-cli/build.ts b/apps/claude-sdk-cli/build.ts index d9028700..5352cfe7 100644 --- a/apps/claude-sdk-cli/build.ts +++ b/apps/claude-sdk-cli/build.ts @@ -4,7 +4,9 @@ import versionPlugin from '@shellicar/build-version/esbuild'; import { Strategies } from '@shellicar/build-version/types'; import * as esbuild from 'esbuild'; import { generateJsonSchema } from './src/cli-config/generateJsonSchema.js'; +// biome-ignore lint/correctness/noUnusedImports: kept for when validate() below is re-enabled import { sdkConfigSchema } from './src/cli-config/schema.js'; +// biome-ignore lint/correctness/noUnusedImports: kept for when validate() below is re-enabled import { buildContainer } from './src/setup/container.js'; const watch = process.argv.some((x) => x === '--watch'); @@ -14,19 +16,23 @@ const minify = !watch; // face) without constructing anything — no options value below is ever read. Catches a registration // mistake (see CLAUDE.md/memory: a class un-selfed to only .as(IFoo) while something still resolves // the concrete) at build time instead of at first runtime resolve. -const report = buildContainer({ - configOptions: { schema: sdkConfigSchema, paths: [] }, - runtimeOptions: { modelOverride: null, systemFlagText: null, claudeMdFlagText: null, tsAvailable: false }, - tsServerOptions: { tsserverPath: null, timeoutMs: 0 }, - databaseOptions: { inMemory: true }, -}).validate(); -if (!report.valid) { - console.error('claude-sdk-cli: DI graph validation failed'); - for (const problem of report.problems) { - console.error(` [${problem.kind}] ${problem.message}`); - } - process.exit(1); -} +// +// Temporarily disabled: validate() reports IServiceProvider as MISSING_TARGET even though it needs +// no registration (confirmed working at runtime as of core-di@5.0.0-alpha.5 — see +// /tmp/core-di-repro/repro-validate.spec.ts). Re-enable once that's fixed upstream. +// const report = buildContainer({ +// configOptions: { schema: sdkConfigSchema, paths: [] }, +// runtimeOptions: { modelOverride: null, systemFlagText: null, claudeMdFlagText: null, tsAvailable: false }, +// tsServerOptions: { tsserverPath: null, timeoutMs: 0 }, +// databaseOptions: { inMemory: true }, +// }).validate(); +// if (!report.valid) { +// console.error('claude-sdk-cli: DI graph validation failed'); +// for (const problem of report.problems) { +// console.error(` [${problem.kind}] ${problem.message}`); +// } +// process.exit(1); +// } const plugins = [versionPlugin({ strategies: [Strategies.git({ packageName: 'claude-sdk-cli' }), Strategies.fallback('0.1.0')] })]; const inject = await Array.fromAsync(glob('./inject/*.ts')); diff --git a/apps/claude-sdk-cli/changes.jsonl b/apps/claude-sdk-cli/changes.jsonl index 5b98b2a7..93139d00 100644 --- a/apps/claude-sdk-cli/changes.jsonl +++ b/apps/claude-sdk-cli/changes.jsonl @@ -167,3 +167,5 @@ {"description":"Az account config: reader/holder identities are now configured with a type (cert or interactive) and optional subscriptionIds, replacing readerClientId/holderClientId","category":"changed"} {"description":"A notice now prints in the conversation whenever a tool's disabled/enabled state actually flips on a config reload (e.g. AzCli/EscalatedAzCli becoming available as an account is configured)","category":"added"} {"description":"F3 opens a conversation view listing every conversation held in the current directory, with its model, cost, query and turn counts, context use, span, opening ask and last reply; space peeks at the tail of a conversation and enter switches to it in place, without restarting the CLI","category":"added"} +{"description":"Orchestrate is now available: a single call runs several tools as a pipeline, each stage approved on its own and shown with its position in the pipeline","category":"added"} +{"description":"Tool approvals for pipeline stages are now decided by a policy section in config, validated and watched on its own so a broken edit pins only the policy to its last-good version and an invalid initial policy falls back to asking about everything","category":"added"} diff --git a/apps/claude-sdk-cli/package.json b/apps/claude-sdk-cli/package.json index 5ec9e4a7..6a2aa9cc 100644 --- a/apps/claude-sdk-cli/package.json +++ b/apps/claude-sdk-cli/package.json @@ -62,7 +62,7 @@ "@shellicar/claude-core": "workspace:^", "@shellicar/claude-sdk": "workspace:^", "@shellicar/claude-sdk-tools": "workspace:^", - "@shellicar/core-di": "^5.0.0-alpha.3", + "@shellicar/core-di": "^5.0.0-alpha.5", "ansi-regex": "6.2.2", "cli-highlight": "^2.1.11", "marked": "^18.0.7", diff --git a/apps/claude-sdk-cli/src/approval/ApprovalHolder.ts b/apps/claude-sdk-cli/src/approval/ApprovalHolder.ts index c1ef5e5f..7441d7be 100644 --- a/apps/claude-sdk-cli/src/approval/ApprovalHolder.ts +++ b/apps/claude-sdk-cli/src/approval/ApprovalHolder.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { Clock } from '@js-joda/core'; import type { SdkToolApprovalRequest, Sender } from '@shellicar/claude-sdk'; import { dependsOn } from '@shellicar/core-di'; @@ -20,9 +21,15 @@ export abstract class IApprovalHolder { /** * Raises an ask on the wire, pulses it, serves the answer, and settles it with `by` — bridged so a wire - * answer and a local keypress settle the same ask, first-wins. `approvalId` = the tool-use id - * (`requestId`), the lawful coincidence the spec permits. Keyed maps because a batch can raise several - * asks in parallel. + * answer and a local keypress settle the same ask, first-wins. Keyed maps because a batch can raise + * several asks in parallel. + * + * `approvalId` is a fresh uuid per ask (as bridge mints one), not a borrowed conversation id. The old + * scheme reused `requestId`, which only read as an id at all while V1 gated exactly once per tool_use; + * a V2 pipeline gates per stage, so that value becomes `toolu_x:2` — unique, but meaningless to anyone + * reading it as a conversation id. The real tool_use id travels in `correlation.toolUseId`, which is + * what the spec has that field for. The public API stays keyed by `requestId`: it is the caller's own + * handle for the ask, and mapping it to the wire's `approvalId` is this class's business. */ export class ApprovalHolder extends IApprovalHolder { @dependsOn(IBus) private readonly bus!: IBus; @@ -31,20 +38,24 @@ export class ApprovalHolder extends IApprovalHolder { #serves = new Map void>(); #settled = new Set(); #wireAnswer = new Map void>(); + /** requestId (the caller's handle) → approvalId (the wire's subject key). */ + #approvalIds = new Map(); /** Raise on lifecycle, start pulsing, serve the answer. The returned promise resolves when a wire * answer lands — the caller races it against the local keypress. */ public raise(req: SdkToolApprovalRequest, correlation: ApprovalCorrelation): Promise { const id = req.requestId; - this.bus.publish(`approval.v1.${id}.lifecycle`, stamp(this.clock, { type: 'raised', ask: { type: 'tool_use', name: req.name, input: req.input }, correlation })); - const pulse = setInterval(() => this.bus.publish(`approval.v1.${id}.telemetry`, stamp(this.clock, { type: 'heartbeat' })), HEARTBEAT_MS); + const approvalId = randomUUID(); + this.#approvalIds.set(id, approvalId); + this.bus.publish(`approval.v1.${approvalId}.lifecycle`, stamp(this.clock, { type: 'raised', ask: { type: 'tool_use', name: req.name, input: req.input }, correlation })); + const pulse = setInterval(() => this.bus.publish(`approval.v1.${approvalId}.telemetry`, stamp(this.clock, { type: 'heartbeat' })), HEARTBEAT_MS); pulse.unref(); this.#pulses.set(id, pulse); const answered = new Promise((resolve) => this.#wireAnswer.set(id, resolve)); this.#serves.set( id, - this.bus.serve(`approval.v1.${id}.requests`, (payload) => this.#answer(id, payload)), + this.bus.serve(`approval.v1.${approvalId}.requests`, (payload) => this.#answer(id, payload)), ); return answered; } @@ -74,7 +85,11 @@ export class ApprovalHolder extends IApprovalHolder { return; } this.#settled.add(id); - this.bus.publish(`approval.v1.${id}.lifecycle`, stamp(this.clock, { type: 'settled', approved: settlement.approved, by: settlement.by })); + const approvalId = this.#approvalIds.get(id); + if (approvalId != null) { + this.bus.publish(`approval.v1.${approvalId}.lifecycle`, stamp(this.clock, { type: 'settled', approved: settlement.approved, by: settlement.by })); + this.#approvalIds.delete(id); + } const pulse = this.#pulses.get(id); if (pulse != null) { clearInterval(pulse); diff --git a/apps/claude-sdk-cli/src/cli-config/schema.ts b/apps/claude-sdk-cli/src/cli-config/schema.ts index cc62d6f5..1b2a86c1 100644 --- a/apps/claude-sdk-cli/src/cli-config/schema.ts +++ b/apps/claude-sdk-cli/src/cli-config/schema.ts @@ -1,4 +1,5 @@ import { blockedCommandSchema, ruleConfigSchema } from '@shellicar/claude-sdk-tools/ExecV3'; +import { defaultPolicy, PolicySetSchema } from '@shellicar/claude-sdk-tools/Policy'; import { z } from 'zod'; const defaults = { @@ -229,6 +230,13 @@ const permissionsSchema = z outside: { read: 'approve', write: 'ask', delete: 'deny' }, }); +const policySchema = PolicySetSchema.optional() + .default(defaultPolicy) + .catch(defaultPolicy) + .describe( + 'The unified Tools V2 approval policy — an ordered list of rules, first match wins. Replaces permissions/tools.rules/tools.blockedCommands for anything going through Orchestrate; not yet consulted for V1 tools. Omitted or invalid (as a whole) falls back to the built-in default, which reproduces the current permissions/ExecV3-rules behaviour as-is.', + ); + const persistenceSchema = z .object({ database: z.string().optional().default('persistence.db').catch('persistence.db').describe('SQLite database filename, stored under ~/.claude, for Ref persistence across restarts'), @@ -372,6 +380,7 @@ export const sdkConfigSchema = z serverTools: serverToolsSchema, hooks: hooksSchema.describe('Hook configuration'), tools: toolsSchema.describe('Execution tool selection'), + policy: policySchema, input: inputSchema.describe('Raw keyboard input handling configuration'), disabledTools: z.array(z.string()).optional().default([]).catch([]).describe('Names of loaded tools to hide from the model and refuse as unavailable. Read live: takes effect on the next turn without a restart.'), requiredSkills: z diff --git a/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts b/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts index dbfbee54..e9e76a9a 100644 --- a/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts +++ b/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts @@ -19,6 +19,7 @@ import { ToolObject } from '../model/ToolObject.js'; import { buildPermissionMatrix, findUnknownTools, getPermission, PermissionAction, type PermissionConfig } from '../permissions.js'; import { AppToolsService } from '../setup/AppToolsService.js'; import { ConsumerChannel } from '../setup/ConsumerChannel.js'; +import { ToolsV2Service } from '../setup/ToolsV2Service.js'; // ---- helpers (unchanged from current branch) ------------------------------------ @@ -68,21 +69,19 @@ function displayArg(input: Record, cwd: string, schema: AnyTool return null; } +// Only the size (and the slice range read) matters for display — the id/hint is an internal +// lookup key, not something a human deciding whether to approve a Ref call needs to see. function formatRefSummary(input: Record, store: RefStore): string { const id = typeof input.id === 'string' ? input.id : ''; - if (!id) { - return 'Ref(?)'; - } - const hint = store.getHint(id) ?? id.slice(0, 8); - const content = store.get(id); + const content = id ? store.get(id) : undefined; if (content === undefined) { - return `Ref(${id.slice(0, 8)}\u2026)`; + return 'Ref(?)'; } const sizeStr = fmtBytes(content.length); const start = typeof input.start === 'number' ? input.start : 0; const limit = typeof input.limit === 'number' ? input.limit : 1000; const end = Math.min(start + limit, content.length); - return `Ref \u2190 ${hint} [${start}\u2013${end} / ${sizeStr}]`; + return `Ref(${start}\u2013${end} / ${sizeStr})`; } export const MEMORY_TOOLS = new Set(['WriteMemory', 'ReadMemory', 'SearchMemory', 'DeleteMemory', 'MemoryTypes']); @@ -123,7 +122,22 @@ export function formatMemoryResult(name: string, content: string): string | null } } -function formatToolSummary(name: string, input: Record, cwd: string, store: RefStore, resolveSchema: (toolName: string) => AnyToolDefinition['input_schema'] | undefined): string { +/** Renders one stage/step's own line inside a Pipe/Orchestrate summary: the tool's own + * `summarize`, when it declares one, else the generic marked-path/fallback display. */ +function formatStepSummary(tool: string, stepInput: Record, cwd: string, resolveSchema: (toolName: string) => AnyToolDefinition['input_schema'] | undefined, summarizeFor: (toolName: string, input: Record) => string | undefined): string { + const custom = summarizeFor(tool, stepInput); + if (custom != null) { + return custom; + } + const arg = displayArg(stepInput, cwd, resolveSchema(tool)); + return arg ? `${tool}(${arg})` : tool; +} + +function formatToolSummary(name: string, input: Record, cwd: string, store: RefStore, resolveSchema: (toolName: string) => AnyToolDefinition['input_schema'] | undefined, summarizeFor: (toolName: string, input: Record) => string | undefined): string { + const custom = summarizeFor(name, input); + if (custom != null) { + return custom; + } if (MEMORY_TOOLS.has(name)) { return formatMemorySummary(name, input); } @@ -131,15 +145,25 @@ function formatToolSummary(name: string, input: Record, cwd: st return formatRefSummary(input, store); } if (name === 'Pipe' && Array.isArray(input.steps)) { - const steps = (input.steps as Array<{ tool?: unknown; input?: unknown }>) + return (input.steps as Array<{ tool?: unknown; input?: unknown }>) .map((s) => { const tool = typeof s.tool === 'string' ? s.tool : '?'; const stepInput = s.input != null && typeof s.input === 'object' ? (s.input as Record) : {}; - const arg = displayArg(stepInput, cwd, resolveSchema(tool)); - return arg ? `${tool}(${arg})` : tool; + return formatStepSummary(tool, stepInput, cwd, resolveSchema, summarizeFor); }) .join(' | '); - return steps; + } + if (name === 'Orchestrate' && Array.isArray(input.stages)) { + const stages = input.stages as Array<{ tool?: unknown; input?: unknown; op?: string; xargs?: unknown }>; + const parts = stages.map((s) => { + if (typeof s.xargs === 'string') { + return `Xargs(${s.xargs})`; + } + const tool = typeof s.tool === 'string' ? s.tool : '?'; + const stepInput = s.input != null && typeof s.input === 'object' ? (s.input as Record) : {}; + return formatStepSummary(tool, stepInput, cwd, resolveSchema, summarizeFor); + }); + return parts.reduce((acc, part, i) => (i === 0 ? part : `${acc} ${stages[i - 1].op ?? ';'} ${part}`), ''); } if (name === 'Skill' && typeof input.skill === 'string') { return `Skill(${input.skill})`; @@ -185,6 +209,7 @@ export class AgentMessageHandler { @dependsOn(IDurableConfigProvider) private readonly durableProvider!: IDurableConfigProvider; @dependsOn(ConsumerChannel) private readonly channel!: ConsumerChannel; @dependsOn(AppToolsService) private readonly appTools!: AppToolsService; + @dependsOn(ToolsV2Service) private readonly toolsV2!: ToolsV2Service; @dependsOn(StatusState) private readonly statusState!: StatusState; @dependsOn(ApprovalNotifier) private readonly notifier!: ApprovalNotifier; @dependsOn(IConversationState) private readonly conversation!: IConversationState; @@ -222,6 +247,19 @@ export class AgentMessageHandler { // resolves to undefined, falling through to the url/query/pattern/intent label. #schemaFor = (name: string): AnyToolDefinition['input_schema'] | undefined => this.appTools.tools.find((t) => t.name === name)?.input_schema; + // A V1 tool's own `summarize` takes priority (V1 names are the ones a human actually calls + // standalone); a V2-only name (e.g. Program, Head — no V1 counterpart) falls through to the + // Tools V2 registry's own definition. Absent from both is the ordinary case for most tools, + // which fall back to formatToolSummary's generic display. + #summarizeFor = (name: string, input: Record): string | undefined => { + const v1 = this.appTools.tools.find((t) => t.name === name)?.summarize; + if (v1) { + return v1(input as never); + } + const v2 = this.toolsV2.registry.get(name)?.summarize; + return v2?.(input); + }; + public handle(msg: SdkMessage): void { switch (msg.type) { case 'query_summary': { @@ -300,7 +338,7 @@ export class AgentMessageHandler { this.logger.debug('server_tool_use', { id: msg.id, name: msg.name }); const obj = this.#toolObjects.get(msg.id); if (obj) { - obj.resolve(formatToolSummary(msg.name, msg.input, this.#cwd, this.#store, this.#schemaFor)); + obj.resolve(formatToolSummary(msg.name, msg.input, this.#cwd, this.#store, this.#schemaFor, this.#summarizeFor)); obj.setInput(msg.input); // emit drives #redrawTools } @@ -360,7 +398,7 @@ export class AgentMessageHandler { // raw streamed JSON to its resolved view now. const obj = this.#toolObjects.get(msg.id); if (obj) { - obj.resolve(formatToolSummary(obj.name, msg.input, this.#cwd, this.#store, this.#schemaFor)); + obj.resolve(formatToolSummary(obj.name, msg.input, this.#cwd, this.#store, this.#schemaFor, this.#summarizeFor)); obj.setInput(msg.input); // emit drives #redrawTools } @@ -371,7 +409,7 @@ export class AgentMessageHandler { // ToolObject.resolve() emits change which drives #redrawTools via setLastContent. const approvalObj = this.#toolObjects.get(msg.requestId) ?? null; if (approvalObj) { - approvalObj.resolve(formatToolSummary(msg.name, msg.input, this.#cwd, this.#store, this.#schemaFor)); + approvalObj.resolve(formatToolSummary(msg.name, msg.input, this.#cwd, this.#store, this.#schemaFor, this.#summarizeFor)); // emit drives #redrawTools } void this.#toolApprovalRequest(msg, approvalObj); @@ -481,9 +519,14 @@ export class AgentMessageHandler { async #toolApprovalRequest(msg: SdkToolApprovalRequest, obj: ToolObject | null): Promise { try { this.logger.info('tool_approval_request', { name: msg.name, input: msg.input }); - const pendingTool: PendingTool = { requestId: msg.requestId, name: msg.name, input: msg.input }; + const pendingTool: PendingTool = { requestId: msg.requestId, name: msg.name, input: msg.input, stageIndex: msg.stageIndex, stageCount: msg.stageCount }; this.tools.addTool(pendingTool); - const perm = getPermission({ name: msg.name, input: msg.input }, this.appTools.permissionTools, this.#cwd, this.#getMatrix()); + // A V2 stage was never registered in V1's permissionTools — it isn't a lookup failure, it's + // simply outside V1's matrix entirely (see the settled decision: V2 has its own permissions, + // never V1's). NotFound here would be misread as "tool not found" and auto-reject a real, + // valid request. V2 always goes straight to a live prompt, matching its own "ask every gated + // stage" policy — auto-approve/auto-deny via the matrix never applies to it. + const perm = msg.v2 ? PermissionAction.Ask : getPermission({ name: msg.name, input: msg.input }, this.appTools.permissionTools, this.#cwd, this.#getMatrix()); if (perm === PermissionAction.NotFound) { // A lookup failure, not a decision. Tell the model the real cause via `reason` (the SDK // forwards it as the tool_result), never the default "Rejected by user" — the user saw @@ -513,7 +556,7 @@ export class AgentMessageHandler { // ask on the wire and race the local keypress against a wire answer — first valid answer wins. // When the bus is disabled the raise is a zero-effect no-op and only the local keypress can win. const tip = this.session.conversationTip(); - const wireAnswer = this.approvalHolder.raise(msg, { conversationId: this.session.id, queryId: tip?.queryId, turnId: tip?.turnId, toolUseId: msg.requestId }); + const wireAnswer = this.approvalHolder.raise(msg, { conversationId: this.session.id, queryId: tip?.queryId, turnId: tip?.turnId, toolUseId: msg.toolUseId }); this.notifier.start(msg); const localAnswer = this.tools.requestApproval(msg.requestId).then((a): Settlement => ({ approved: a, by: { kind: 'human' } })); const settlement = await Promise.race([localAnswer, wireAnswer]); diff --git a/apps/claude-sdk-cli/src/createAppTools.ts b/apps/claude-sdk-cli/src/createAppTools.ts index 2d2c60d6..b81f20c4 100644 --- a/apps/claude-sdk-cli/src/createAppTools.ts +++ b/apps/claude-sdk-cli/src/createAppTools.ts @@ -4,43 +4,40 @@ import type { IHistoryReader } from '@shellicar/claude-core/history/interfaces'; import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; import type { IMemoryStore } from '@shellicar/claude-core/memory/interfaces'; import type { IObjectStore } from '@shellicar/claude-core/persistence/interfaces'; -import type { AnyToolDefinition, ToolBlockLifetime } from '@shellicar/claude-sdk'; +import type { AnyToolDefinition } from '@shellicar/claude-sdk'; import { AppendFile } from '@shellicar/claude-sdk-tools/AppendFile'; import { type AzAccountsConfig, type AzDeps, AzSessionCache, azExecutor, createAzTools } from '@shellicar/claude-sdk-tools/Az'; -import { createAdoPrTools } from '@shellicar/claude-sdk-tools/AzureDevOps'; +import { type AdoEscalatedDeps, createAdoPrTools } from '@shellicar/claude-sdk-tools/AzureDevOps'; import { CreateFile } from '@shellicar/claude-sdk-tools/CreateFile'; -import { toStandalone } from '@shellicar/claude-sdk-tools/composable'; import { DeleteDirectory } from '@shellicar/claude-sdk-tools/DeleteDirectory'; import { DeleteFile } from '@shellicar/claude-sdk-tools/DeleteFile'; import { createEditFile } from '@shellicar/claude-sdk-tools/EditFile'; import { Exec } from '@shellicar/claude-sdk-tools/Exec'; import { ExecV2 } from '@shellicar/claude-sdk-tools/ExecV2'; import { configureExecV3, type IEnvProvider, type IRulesConfigProvider } from '@shellicar/claude-sdk-tools/ExecV3'; -import { Find } from '@shellicar/claude-sdk-tools/Find'; -import { createGhPrTools, ghExecutor } from '@shellicar/claude-sdk-tools/GitHub'; -import { Head } from '@shellicar/claude-sdk-tools/Head'; +import { createGhPrTools, type GhEscalatedDeps, ghExecutor } from '@shellicar/claude-sdk-tools/GitHub'; import { createHistoryTools } from '@shellicar/claude-sdk-tools/History'; -import { Match } from '@shellicar/claude-sdk-tools/Match'; import { createMemoryTools } from '@shellicar/claude-sdk-tools/Memory'; -import { Paths } from '@shellicar/claude-sdk-tools/Paths'; -import { createPipe } from '@shellicar/claude-sdk-tools/Pipe'; -import { Range } from '@shellicar/claude-sdk-tools/Range'; -import { Read } from '@shellicar/claude-sdk-tools/Read'; import { createReadFileTool } from '@shellicar/claude-sdk-tools/ReadFile'; import { createRef } from '@shellicar/claude-sdk-tools/Ref'; import { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; import { createSkillTool } from '@shellicar/claude-sdk-tools/Skill'; -import { Tail } from '@shellicar/claude-sdk-tools/Tail'; import { createTsDefinition } from '@shellicar/claude-sdk-tools/TsDefinition'; import { createTsDiagnostics } from '@shellicar/claude-sdk-tools/TsDiagnostics'; import { createTsHover } from '@shellicar/claude-sdk-tools/TsHover'; import { createTsReferences } from '@shellicar/claude-sdk-tools/TsReferences'; -import type { ITypeScriptService } from '@shellicar/claude-sdk-tools/TsService'; import type { PermissionTool } from './permissions.js'; import type { ISecrets } from './secrets/Secrets.js'; export type AppTools = { tools: AnyToolDefinition[]; + /** Shared verbatim with Tools V2's GitHub/AzureDevOps/Az tools (see container.ts's + * `ToolsV2Service` wiring) — the exact same credential/session objects, so a V1 call and a V2 + * call reuse one warm login instead of each paying its own. */ + ghDeps: GhEscalatedDeps; + adoDeps: AdoEscalatedDeps; + azDeps: AzDeps; + azSessionCache: AzSessionCache; /** The registered tools plus the pipe-only stages, for permission resolution only. The permission * system walks each pipe step by name; the stages are not registered standalone, so they are * surfaced here (never sent to the wire/registry) so a pipe's stage steps resolve. */ @@ -51,7 +48,6 @@ export type AppTools = { export type CreateAppToolsOptions = { fs: IFileSystem; - tsServer: ITypeScriptService & ToolBlockLifetime; toolsConfig: { exec: boolean; execV2: boolean; execV3: boolean }; /** Live source for ExecV3's safety rules/blocklist — injected as an interface, read fresh on * every call, so a config reload takes effect on the next call with no tool rebuild. */ @@ -77,19 +73,15 @@ export type CreateAppToolsOptions = { getAzAccounts: () => AzAccountsConfig; }; -export function createAppTools({ fs, tsServer, toolsConfig, rulesProvider, objects, memory, history, currentSessionId, clock, tsAvailable, logger, skillDirs = [], secrets, envProvider, getAzAccounts }: CreateAppToolsOptions): AppTools { +export function createAppTools({ fs, toolsConfig, rulesProvider, objects, memory, history, currentSessionId, clock, tsAvailable, logger, skillDirs = [], secrets, envProvider, getAzAccounts }: CreateAppToolsOptions): AppTools { const store = new RefStore(objects); const ReadFile = createReadFileTool(logger); const EditFile = createEditFile(fs); const { tool: Ref, transformToolResult: refTransform } = createRef(store, 50_000); - // Composable sources start a pipe and are also useful standalone; stages run only inside a pipe. - const sources = [Find, Paths]; - const stages = [Read, Match, Head, Tail, Range]; - const pipe = createPipe([...sources, ...stages]); - // ReadFile is the non-pipe single-file read (text + binary), never a pipe step. - const tools: AnyToolDefinition[] = [pipe, ...sources.map(toStandalone)]; - tools.push(EditFile, CreateFile, AppendFile, ReadFile, DeleteFile, DeleteDirectory); + // ReadFile is the non-pipe single-file read (text + binary), never a pipe step. V2's Read and + // ReadBinaryFile cover the same ground inside a pipeline; both surfaces stay. + const tools: AnyToolDefinition[] = [EditFile, CreateFile, AppendFile, ReadFile, DeleteFile, DeleteDirectory]; if (toolsConfig.exec) { tools.push(Exec); } @@ -100,18 +92,19 @@ export function createAppTools({ fs, tsServer, toolsConfig, rulesProvider, objec tools.push(configureExecV3(envProvider, rulesProvider)); } tools.push(Ref); - // The TS tools depend on tsserver, which needs typescript on disk. When that - // can't be resolved (e.g. the SEA without the launcher-provided path), the - // tools are left out entirely rather than registered and failing on first use. + // The TS tools resolve ITypeScriptService fresh from each block's own DI scope at call + // time (see QueryRunner's #runToolsScoped) rather than closing over a fixed instance, which + // needs typescript on disk. When that can't be resolved (e.g. the SEA without the + // launcher-provided path), the tools are left out entirely rather than registered and + // failing on first use. if (tsAvailable) { - // Each TS tool declares the shared bridge as its block lifetime; the - // build-tools step (container) collects it, deduped, and disposes it per block. - tools.push({ ...createTsDiagnostics(tsServer), blockLifetime: tsServer }, { ...createTsHover(tsServer), blockLifetime: tsServer }, { ...createTsReferences(tsServer), blockLifetime: tsServer }, { ...createTsDefinition(tsServer), blockLifetime: tsServer }); + tools.push(createTsDiagnostics(), createTsHover(), createTsReferences(), createTsDefinition()); } tools.push(...createMemoryTools(memory)); tools.push(createSkillTool(fs, skillDirs, logger)); tools.push(...createHistoryTools(history, currentSessionId, clock)); - tools.push(...createGhPrTools({ executor: ghExecutor, getHolderToken: () => secrets.ghHolderToken() })); + const ghDeps: GhEscalatedDeps = { executor: ghExecutor, getHolderToken: () => secrets.ghHolderToken() }; + tools.push(...createGhPrTools(ghDeps)); // The AzureDevOps.PullRequest.* tools run as the same holder identity EscalatedAzCli uses — one // certificate, proven to authenticate to Azure DevOps directly, no separate PAT. Always @@ -146,10 +139,6 @@ export function createAppTools({ fs, tsServer, toolsConfig, rulesProvider, objec tools.push(...createAdoPrTools(azDeps, getAzAccounts, azSessionCache)); tools.push(...createAzTools(azDeps, getAzAccounts, azSessionCache)); - // Stages run only inside a pipe, so they are not in `tools`. The permission resolver looks every pipe - // step up by name and reads its operation and input_schema (to locate marked paths), so it needs them - // too — projected rather than carried whole, so no runnable (and, uninvoked, crash-prone) stage - // handler comes along. A composable stage's path-schema is its `model` (its standalone input face). - const permissionTools: PermissionTool[] = [...tools.map((t) => ({ name: t.name, operation: t.operation, input_schema: t.input_schema })), ...stages.map((t) => ({ name: t.name, operation: t.operation, input_schema: t.model }))]; - return { tools, permissionTools, store, refTransform }; + const permissionTools: PermissionTool[] = tools.map((t) => ({ name: t.name, operation: t.operation, input_schema: t.input_schema })); + return { tools, permissionTools, store, refTransform, ghDeps, adoDeps: azDeps, azDeps, azSessionCache }; } diff --git a/apps/claude-sdk-cli/src/model/ToolApprovalState.ts b/apps/claude-sdk-cli/src/model/ToolApprovalState.ts index f61715bb..87ef1e5d 100644 --- a/apps/claude-sdk-cli/src/model/ToolApprovalState.ts +++ b/apps/claude-sdk-cli/src/model/ToolApprovalState.ts @@ -8,6 +8,9 @@ export type PendingTool = { requestId: string; name: string; input: Record; + /** Present only for a request raised from inside a multi-stage Orchestrate pipeline. */ + stageIndex?: number; + stageCount?: number; }; /** diff --git a/apps/claude-sdk-cli/src/setup/AppToolsService.ts b/apps/claude-sdk-cli/src/setup/AppToolsService.ts index 3990310f..9e0cc476 100644 --- a/apps/claude-sdk-cli/src/setup/AppToolsService.ts +++ b/apps/claude-sdk-cli/src/setup/AppToolsService.ts @@ -1,4 +1,7 @@ import type { AnyToolDefinition } from '@shellicar/claude-sdk'; +import type { AzDeps, AzSessionCache } from '@shellicar/claude-sdk-tools/Az'; +import type { AdoEscalatedDeps } from '@shellicar/claude-sdk-tools/AzureDevOps'; +import type { GhEscalatedDeps } from '@shellicar/claude-sdk-tools/GitHub'; import type { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; import type { AppTools } from '../createAppTools.js'; import type { PermissionTool } from '../permissions.js'; @@ -8,11 +11,19 @@ export class AppToolsService { public readonly permissionTools: PermissionTool[]; public readonly store: RefStore; public readonly refTransform: (toolName: string, output: unknown) => unknown; + public readonly ghDeps: GhEscalatedDeps; + public readonly adoDeps: AdoEscalatedDeps; + public readonly azDeps: AzDeps; + public readonly azSessionCache: AzSessionCache; public constructor(appTools: AppTools) { this.tools = appTools.tools; this.permissionTools = appTools.permissionTools; this.store = appTools.store; this.refTransform = appTools.refTransform; + this.ghDeps = appTools.ghDeps; + this.adoDeps = appTools.adoDeps; + this.azDeps = appTools.azDeps; + this.azSessionCache = appTools.azSessionCache; } } diff --git a/apps/claude-sdk-cli/src/setup/ConfigChangeCoordinator.ts b/apps/claude-sdk-cli/src/setup/ConfigChangeCoordinator.ts index 31e78bda..2b6f30e2 100644 --- a/apps/claude-sdk-cli/src/setup/ConfigChangeCoordinator.ts +++ b/apps/claude-sdk-cli/src/setup/ConfigChangeCoordinator.ts @@ -6,6 +6,7 @@ import { IConversationState } from '../model/ConversationState.js'; import { DisabledToolsNoticeGate } from '../model/DisabledToolsNoticeGate.js'; import { PermissionsNoticeGate } from '../model/PermissionsNoticeGate.js'; import { StatusState } from '../model/StatusState.js'; +import { IPolicyNotifier } from './ConfigPolicyProvider.js'; import { IRulesConfigNotifier } from './ConfigRulesConfigProvider.js'; import { ModelOverrides } from './ModelOverrides.js'; import { ITurnCoordinator } from './TurnCoordinator.js'; @@ -22,6 +23,9 @@ export abstract class IConfigChangeCoordinator { * - `IRulesConfigNotifier`: tools.rules/tools.blockedCommands validate and watch independently of * the whole-document reload, so it never fires through `configLoader.onChange` and needs its own * splice point. + * - `IPolicyNotifier`: `policy` validates and watches independently the same way, for the same + * reason — a broken policy edit must only pin policy to its last-good value, not block every + * other unrelated fix in the same config reload. * - `configLoader.onChange`: the whole-document reload. Defers the live status update until the * turn between requests (`turnCoordinator.inProgress`) so a reload mid-turn doesn't flash stale * figures. @@ -31,6 +35,7 @@ export abstract class IConfigChangeCoordinator { */ export class ConfigChangeCoordinator extends IConfigChangeCoordinator { @dependsOn(IRulesConfigNotifier) private readonly rulesConfigNotifier!: IRulesConfigNotifier; + @dependsOn(IPolicyNotifier) private readonly policyNotifier!: IPolicyNotifier; @dependsOn(ConfigLoader) private readonly configLoader!: ConfigLoader; @dependsOn(PermissionsNoticeGate) private readonly permissionsNoticeGate!: PermissionsNoticeGate; @dependsOn(IConversationState) private readonly conversationState!: IConversationState; @@ -52,6 +57,16 @@ export class ConfigChangeCoordinator extends IConfigChangeCoordinator { } }); + this.policyNotifier.onNotice((notice) => { + if (notice.kind === 'invalid') { + this.conversationState.spliceNotice(`\u26a0\ufe0f policy is invalid \u2014 keeping the previous policy (${notice.error})`); + } else if (notice.kind === 'recovered') { + this.conversationState.spliceNotice('\u2705 policy valid again'); + } else { + this.conversationState.spliceNotice('\ud83d\udee1\ufe0f policy updated'); + } + }); + this.configLoader.onChange((config) => { logger.info('config reloaded', { model: config.model }); const permissionsNotice = this.permissionsNoticeGate.update(config.permissions); diff --git a/apps/claude-sdk-cli/src/setup/ConfigPolicyProvider.ts b/apps/claude-sdk-cli/src/setup/ConfigPolicyProvider.ts new file mode 100644 index 00000000..d8a9de9d --- /dev/null +++ b/apps/claude-sdk-cli/src/setup/ConfigPolicyProvider.ts @@ -0,0 +1,110 @@ +import { IConfigOptions } from '@shellicar/claude-core/Config/IConfigOptions'; +import { IConfigFileReader } from '@shellicar/claude-core/Config/interfaces'; +import { mergeRawConfigs } from '@shellicar/claude-core/config'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { PolicyStore } from '@shellicar/claude-sdk-tools/Policy'; +import { dependsOn } from '@shellicar/core-di'; + +export type PolicyConfigNotice = { kind: 'invalid'; error: string } | { kind: 'recovered' } | { kind: 'changed' }; + +/** The refresh/notice surface for `policy`, kept separate from reading `PolicyStore.current` + * directly (which `IOrchestrateEngine` already does, live, needing no interface of its own) \u2014 + * same ISP shape as `IRulesConfigNotifier`: a consumer that only wants to react to a policy + * reload doesn't need to depend on the concrete provider. */ +export abstract class IPolicyNotifier { + public abstract refresh(): void; + public abstract onNotice(listener: (notice: PolicyConfigNotice) => void): () => void; +} + +/** Reads only `policy` off the same files sdkConfigSchema reads, independently of it \u2014 same + * "a bad file contributes nothing" handling as `readToolsRaw`, so a JSON syntax error elsewhere + * never blocks this section's own read. */ +export function readPolicyRaw(paths: readonly string[], reader: IConfigFileReader): unknown { + const raws: Record[] = []; + for (const path of paths) { + if (!reader.exists(path)) { + continue; + } + try { + raws.push(JSON.parse(reader.read(path)) as Record); + } catch { + // Skip: the same "contributes nothing" handling readConfig.ts gives a bad JSON file. + } + } + const merged = raws.reduce>((acc, cur) => mergeRawConfigs(acc, cur), {}); + return merged.policy; +} + +/** Wraps `PolicyStore` with the watch/notice surface `RulesConfigGate` gets from + * `ConfigRulesConfigProvider` \u2014 but deliberately does NOT fail fast at construction the way + * that class does. `PolicyStore` already made that call for `policy` specifically (see its own + * header): an invalid initial policy falls back to the safe ask-everything default rather than + * refusing to start, because "no policy loaded yet" must still behave as "ask for everything," + * never "the CLI won't start." This class only adds change-tracking on top of that, it doesn't + * relitigate it. + * + * Never starts its own watch \u2014 `refresh()` is called by the watch `WorkingDirectoryMoveHandler` + * owns and re-points on every `/cd`, the same shape `IRulesConfigNotifier` uses. */ +export class ConfigPolicyProvider implements IPolicyNotifier { + @dependsOn(PolicyStore) private readonly store!: PolicyStore; + @dependsOn(IConfigOptions) private readonly options!: IConfigOptions; + @dependsOn(IConfigFileReader) private readonly reader!: IConfigFileReader; + @dependsOn(ILogger) private readonly logger!: ILogger; + + readonly #listeners = new Set<(notice: PolicyConfigNotice) => void>(); + #degraded = false; + #lastError: string | null = null; + // Seeded lazily from the constructed PolicyStore's own state on the first refresh() \u2014 not + // eagerly here, since @dependsOn properties aren't populated until after construction runs. + #lastSerialized: string | undefined; + + public onNotice(listener: (notice: PolicyConfigNotice) => void): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + public refresh(): void { + const notice = this.#update(readPolicyRaw(this.options.paths, this.reader)); + if (notice === null) { + return; + } + if (notice.kind === 'invalid') { + this.logger.warn('policy failed validation, keeping the previous policy', { error: notice.error }); + } else if (notice.kind === 'recovered') { + this.logger.info('policy recovered after a previous invalid edit'); + } else { + this.logger.info('policy updated'); + } + for (const listener of this.#listeners) { + listener(notice); + } + } + + #update(raw: unknown): PolicyConfigNotice | null { + if (this.#lastSerialized === undefined) { + this.#lastSerialized = JSON.stringify(this.store.current); + } + + const result = this.store.update(raw); + if (!result.accepted) { + const error = result.errors.join('\n'); + const isRepeat = this.#degraded && this.#lastError === error; + this.#degraded = true; + this.#lastError = error; + return isRepeat ? null : { kind: 'invalid', error }; + } + + const wasDegraded = this.#degraded; + this.#degraded = false; + this.#lastError = null; + + const serialized = JSON.stringify(this.store.current); + if (serialized === this.#lastSerialized) { + return wasDegraded ? { kind: 'recovered' } : null; + } + this.#lastSerialized = serialized; + return wasDegraded ? { kind: 'recovered' } : { kind: 'changed' }; + } +} diff --git a/apps/claude-sdk-cli/src/setup/DurableConfigFactory.ts b/apps/claude-sdk-cli/src/setup/DurableConfigFactory.ts index a80416c6..c783cccc 100644 --- a/apps/claude-sdk-cli/src/setup/DurableConfigFactory.ts +++ b/apps/claude-sdk-cli/src/setup/DurableConfigFactory.ts @@ -14,6 +14,7 @@ import { SystemPromptLoader } from '../SystemPromptLoader.js'; import { AppToolsService } from './AppToolsService.js'; import { IRuntimeOptions } from './IRuntimeOptions.js'; import { ModelOverrides } from './ModelOverrides.js'; +import { ToolsV2Service } from './ToolsV2Service.js'; // Appended to every marked path field's description in the wire schema the model reads, so the model // knows a path is normalised. Mirrors the expander wired in container.ts (expandPath + resolve-to-cwd); @@ -24,6 +25,7 @@ export class DurableConfigFactory extends IDurableConfigProvider { @dependsOn(ConfigLoader) private readonly configLoader!: ConfigLoader; @dependsOn(ModelOverrides) private readonly overrides!: ModelOverrides; @dependsOn(AppToolsService) private readonly appTools!: AppToolsService; + @dependsOn(ToolsV2Service) private readonly toolsV2!: ToolsV2Service; @dependsOn(SystemPromptLoader) private readonly systemPromptLoader!: SystemPromptLoader; @dependsOn(IRuntimeOptions) private readonly runtime!: IRuntimeOptions; @dependsOn(ILogger) private readonly logger!: ILogger; @@ -145,6 +147,7 @@ export class DurableConfigFactory extends IDurableConfigProvider { systemPrompts: [...identityBase, ...this.#resolvedSystemPrompts], tools, serverTools, + toolsV2: this.toolsV2.wireTools, transformTool: withPathNote(buildAtuTransform(tools, this.configLoader.config.advancedTools), PATH_NOTE), betas: { [AnthropicBeta.ClaudeCodeAuth]: true, diff --git a/apps/claude-sdk-cli/src/setup/ToolsV2Service.ts b/apps/claude-sdk-cli/src/setup/ToolsV2Service.ts new file mode 100644 index 00000000..e03beba9 --- /dev/null +++ b/apps/claude-sdk-cli/src/setup/ToolsV2Service.ts @@ -0,0 +1,15 @@ +import type { BetaTool } from '@anthropic-ai/sdk/resources/beta.mjs'; +import type { ToolsV2Registry } from '@shellicar/claude-sdk-tools/Orchestrate'; +import { toolsV2WireTools } from '@shellicar/claude-sdk-tools/Orchestrate'; + +/** Holds the one Tools V2 registry the process constructs, and its derived wire entries \u2014 + * the composition-root equivalent of `AppToolsService` for V1, kept genuinely separate. */ +export class ToolsV2Service { + public readonly registry: ToolsV2Registry; + public readonly wireTools: BetaTool[]; + + public constructor(registry: ToolsV2Registry) { + this.registry = registry; + this.wireTools = toolsV2WireTools(registry); + } +} diff --git a/apps/claude-sdk-cli/src/setup/WorkingDirectoryMoveHandler.ts b/apps/claude-sdk-cli/src/setup/WorkingDirectoryMoveHandler.ts index 87c9567d..967c3ba7 100644 --- a/apps/claude-sdk-cli/src/setup/WorkingDirectoryMoveHandler.ts +++ b/apps/claude-sdk-cli/src/setup/WorkingDirectoryMoveHandler.ts @@ -12,6 +12,7 @@ import { logger } from '../logger.js'; import { IConversationSession } from '../model/ConversationSession.js'; import { StatusState } from '../model/StatusState.js'; import { IWorkingDirectory } from '../model/WorkingDirectory.js'; +import { IPolicyNotifier } from './ConfigPolicyProvider.js'; import { IRulesConfigNotifier } from './ConfigRulesConfigProvider.js'; /** The handler's contract; register abstract→concrete and depend on the abstract (DI rule). */ @@ -43,6 +44,7 @@ export class WorkingDirectoryMoveHandler extends IWorkingDirectoryMoveHandler { @dependsOn(IConfigOptions) private readonly configOptions!: IConfigOptions; @dependsOn(ConfigReloader) private readonly configReloader!: ConfigReloader; @dependsOn(IRulesConfigNotifier) private readonly rulesConfigNotifier!: IRulesConfigNotifier; + @dependsOn(IPolicyNotifier) private readonly policyNotifier!: IPolicyNotifier; @dependsOn(StatusState) private readonly statusState!: StatusState; @dependsOn(IAgentPresence) private readonly agentPresence!: IAgentPresence; @dependsOn(IDurableConfigProvider) private readonly configFactory!: IDurableConfigProvider; @@ -54,10 +56,12 @@ export class WorkingDirectoryMoveHandler extends IWorkingDirectoryMoveHandler { // every re-point, and final disposal — with no other holder anywhere in the container. private configWatch: ConfigWatchHandle | null = null; private rulesConfigWatch: ConfigWatchHandle | null = null; + private policyWatch: ConfigWatchHandle | null = null; public wire(): void { this.configWatch = this.configWatcher.watch(this.configOptions.paths, () => this.configReloader.scheduleReload()); this.rulesConfigWatch = this.configWatcher.watch(this.configOptions.paths, () => this.rulesConfigNotifier.refresh()); + this.policyWatch = this.configWatcher.watch(this.configOptions.paths, () => this.policyNotifier.refresh()); this.workingDirectory.on('change', (cwd) => { this.configWatch?.[Symbol.dispose](); this.configWatch = this.configWatcher.watch(this.configOptions.paths, () => this.configReloader.scheduleReload()); @@ -65,6 +69,9 @@ export class WorkingDirectoryMoveHandler extends IWorkingDirectoryMoveHandler { this.rulesConfigWatch?.[Symbol.dispose](); this.rulesConfigWatch = this.configWatcher.watch(this.configOptions.paths, () => this.rulesConfigNotifier.refresh()); this.rulesConfigNotifier.refresh(); + this.policyWatch?.[Symbol.dispose](); + this.policyWatch = this.configWatcher.watch(this.configOptions.paths, () => this.policyNotifier.refresh()); + this.policyNotifier.refresh(); this.statusState.setCwdBasename(basename(cwd)); void this.#reloadPromptsAfterMove(); // The move landed: re-publish `attached` at the new cwd, last-write-wins (agent-spec, chdir). Fires @@ -80,6 +87,7 @@ export class WorkingDirectoryMoveHandler extends IWorkingDirectoryMoveHandler { public dispose(): void { this.configWatch?.[Symbol.dispose](); this.rulesConfigWatch?.[Symbol.dispose](); + this.policyWatch?.[Symbol.dispose](); } async #reloadPromptsAfterMove(): Promise { diff --git a/apps/claude-sdk-cli/src/setup/container.ts b/apps/claude-sdk-cli/src/setup/container.ts index 7b51ec5f..2d0860b1 100644 --- a/apps/claude-sdk-cli/src/setup/container.ts +++ b/apps/claude-sdk-cli/src/setup/container.ts @@ -42,6 +42,7 @@ import { ILoginFlow, IMessageStreamer, IModelCatalog, + IOrchestrateEngine, IProfileEndpoint, IQueryRunner, IRequestClockListener, @@ -49,7 +50,6 @@ import { ISkillGateProvider, IStreamProcessor, ITokenEndpoint, - IToolBlockNotifier, IToolProvider, IToolRegistry, IToolsClockListener, @@ -61,14 +61,15 @@ import { QueryRunner, StreamInterruptListener, StreamProcessor, - ToolBlockNotifier, ToolRegistry, TurnRunner, } from '@shellicar/claude-sdk'; import { IEnvProvider, IRulesConfigProvider, RulesConfigGate } from '@shellicar/claude-sdk-tools/ExecV3'; import { NodeFileSystem } from '@shellicar/claude-sdk-tools/fs'; +import { createToolsV2Registry, OrchestrateEngine, orchestrateExecutor } from '@shellicar/claude-sdk-tools/Orchestrate'; +import { PolicyStore } from '@shellicar/claude-sdk-tools/Policy'; import { ITsServerClient, ITsServerOptions, ITypeScriptService, TsServerBridge, TsServerClient } from '@shellicar/claude-sdk-tools/TsService'; -import { createServiceCollection, type IServiceCollection, Lifetime } from '@shellicar/core-di'; +import { createServiceCollection, type IServiceCollection, IServiceProvider, Lifetime } from '@shellicar/core-di'; import { AuditStats } from '../AuditStats.js'; import { AuditWriter } from '../AuditWriter.js'; import { AgentPresence, IAgentPresence } from '../agent/AgentPresence.js'; @@ -163,6 +164,7 @@ import { Application, IApplication } from './Application.js'; import { AppToolsService } from './AppToolsService.js'; import { ConfigChangeCoordinator, IConfigChangeCoordinator } from './ConfigChangeCoordinator.js'; import { ConfigDisabledToolsProvider } from './ConfigDisabledToolsProvider.js'; +import { ConfigPolicyProvider, IPolicyNotifier, readPolicyRaw } from './ConfigPolicyProvider.js'; import { ConfigRulesConfigProvider, IRulesConfigNotifier, readToolsRaw } from './ConfigRulesConfigProvider.js'; import { ConsumerChannel } from './ConsumerChannel.js'; import { ConsumerMessageRouter, IConsumerMessageRouter } from './ConsumerMessageRouter.js'; @@ -180,6 +182,7 @@ import { IShutdownCoordinator, ShutdownCoordinator } from './ShutdownCoordinator import { IShutdownSequence, ShutdownSequence } from './ShutdownSequence.js'; import { SkillCatalogueTracker } from './SkillCatalogueTracker.js'; import { SkillGateProvider } from './SkillGateProvider.js'; +import { ToolsV2Service } from './ToolsV2Service.js'; import { ITurnCoordinator, TurnCoordinator } from './TurnCoordinator.js'; import { IWorkingDirectoryMoveHandler, WorkingDirectoryMoveHandler } from './WorkingDirectoryMoveHandler.js'; @@ -195,6 +198,14 @@ export type ContainerOptions = { databaseOptions: IDatabaseOptions; }; +/** Canonicalise a marked path to a single absolute form: expand ~/$VAR, then resolve against + * cwd so a relative path and dot segments collapse to one path. Symlinks are not resolved + * (realpath is async and throws on not-yet-existing paths). The one real implementation both + * V1's `ToolRegistry` and V2's `ToolsV2Registry` inject as their own `expand`. */ +function buildPathExpander(fs: IFileSystem): (p: string) => string { + return (p) => path.resolve(fs.cwd(), expandPath(p, fs)); +} + export function buildContainer(options: ContainerOptions): IServiceCollection { const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton, eagerSingletons: true }); @@ -316,51 +327,102 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { .asSelf(); // --- ts server --- - // Class 1: the anti-corruption wire client, cycled per tool block. - services.register(TsServerClient).as(ITsServerClient); - // Class 2: the model-facing bridge, a plain @dependsOn class registered under - // ITypeScriptService — the live contract every consumer resolves. Its - // blockEnded() reaches the pipeline NOT through a DI binding but by being - // declared as each TS tool's blockLifetime (see createAppTools). - services.register(TsServerBridge).as(ITypeScriptService); + // Both scoped: `QueryRunner` opens a real DI scope per tool-execution block (see its + // `#runToolsScoped`) and the TS tools resolve `ITypeScriptService` fresh from it at call + // time, so a block genuinely gets its own tsserver process, torn down via TsServerBridge's + // own `[Symbol.asyncDispose]` when the scope disposes — not a separate notifier fanning an + // edge out to a list of subscribed tools. + services.register(TsServerClient).as(ITsServerClient).scoped(); + services.register(TsServerBridge).as(ITypeScriptService).scoped(); + // QueryRunner field-injects the root IServiceProvider (@dependsOn(IServiceProvider)) to open a + // per-block DI scope. No explicit registration needed — the engine resolves IServiceProvider to + // the provider itself as a built-in special case. + // + // Known upstream gap (reported, fix in progress): with eagerSingletons: true, an eager + // singleton's @dependsOn(IServiceProvider) field resolves to undefined, because it's + // constructed *during* buildProvider(), before that call has returned the provider it would + // need to hand back. See /tmp/core-di-repro/repro.spec.ts for the minimal repro. // --- tool suite (createAppTools is composition-root work) --- // AppToolsService is factory-built and shares its identity with IToolProvider from this one // register() call (v5's shared-identity-per-call guarantee), so both resolve to the same instance. services .register(AppToolsService) - .using( - [IFileSystem, ITypeScriptService, ConfigLoader, IObjectStore, IMemoryStore, IHistoryReader, IConversationSession, IRuntimeOptions, ILogger, ISecrets, IEnvProvider, IRulesConfigProvider, Clock], - (fs, tsServer, loader, objects, memory, history, session, runtime, appLogger, secrets, envProvider, rulesProvider, clock) => { - // Skill roots are replacement-only config: the whole set for the session, no built-in default. - // Expand each to a single absolute form (~/$VAR, then resolve against cwd) so the Skill tool - // resolves against canonical paths. An empty list resolves nothing — a valid, visibly bare state. - const skillDirs = loader.config.skillDirs.map((d: string) => path.resolve(fs.cwd(), expandPath(d, fs))); - // The live session id, read afresh per call: ConversationSession mutates its id on /new, so the getter must - // read it each time rather than capture it once. - const tools = createAppTools({ - fs, - tsServer, - toolsConfig: loader.config.tools, - rulesProvider, - objects, - memory, - history, - currentSessionId: () => session.id, - clock, - tsAvailable: runtime.tsAvailable, - logger: appLogger, - skillDirs, - secrets, - envProvider, - getAzAccounts: () => loader.config.az.accounts, - }); - return new AppToolsService(tools); - }, - ) + .using([IFileSystem, ConfigLoader, IObjectStore, IMemoryStore, IHistoryReader, IConversationSession, IRuntimeOptions, ILogger, ISecrets, IEnvProvider, IRulesConfigProvider, Clock], (fs, loader, objects, memory, history, session, runtime, appLogger, secrets, envProvider, rulesProvider, clock) => { + // Skill roots are replacement-only config: the whole set for the session, no built-in default. + // Expand each to a single absolute form (~/$VAR, then resolve against cwd) so the Skill tool + // resolves against canonical paths. An empty list resolves nothing — a valid, visibly bare state. + const skillDirs = loader.config.skillDirs.map(buildPathExpander(fs)); + // The live session id, read afresh per call: ConversationSession mutates its id on /new, so the getter must + // read it each time rather than capture it once. + const tools = createAppTools({ + fs, + toolsConfig: loader.config.tools, + rulesProvider, + objects, + memory, + history, + currentSessionId: () => session.id, + clock, + tsAvailable: runtime.tsAvailable, + logger: appLogger, + skillDirs, + secrets, + envProvider, + getAzAccounts: () => loader.config.az.accounts, + }); + return new AppToolsService(tools); + }) .asSelf() .as(IToolProvider); + // Tools V2: a genuinely separate registry from V1's ToolRegistry above — own tool shape + // (defineToolV2), own dispatch (IOrchestrateEngine), no permission-matrix involvement. + services + .register(ToolsV2Service) + .using( + (x) => + new ToolsV2Service( + createToolsV2Registry({ + fs: x.resolve(IFileSystem), + executor: orchestrateExecutor, + refStore: x.resolve(AppToolsService).store, + sips: x.resolve(SipsBridge), + logger: x.resolve(ILogger), + memoryStore: x.resolve(IMemoryStore), + historyReader: x.resolve(IHistoryReader), + currentSessionId: () => x.resolve(IConversationSession).id, + clock: x.resolve(Clock), + skillDirs: x.resolve(ConfigLoader).config.skillDirs.map(buildPathExpander(x.resolve(IFileSystem))), + ghDeps: x.resolve(AppToolsService).ghDeps, + adoDeps: x.resolve(AppToolsService).adoDeps, + azDeps: x.resolve(AppToolsService).azDeps, + azSessionCache: x.resolve(AppToolsService).azSessionCache, + getAzAccounts: () => x.resolve(ConfigLoader).config.az.accounts, + envProvider: x.resolve(IEnvProvider), + expand: buildPathExpander(x.resolve(IFileSystem)), + }), + ), + ) + .asSelf(); + // Isolated from the whole-document reload, same shape as tools.rules above: policy validates + // and watches independently, so a broken policy edit pins only this section to its last-good + // value instead of blocking every other, unrelated config fix in the same reload. Validated + // against the live Tools V2 registry at construction (see validatePolicy's three cases) — an + // invalid initial policy falls back to the safe ask-everything default, never to nothing. + services + .register(PolicyStore) + .using([IConfigOptions, IConfigFileReader, ToolsV2Service], (opts, reader, toolsV2) => new PolicyStore(readPolicyRaw(opts.paths, reader), toolsV2.registry)) + .asSelf(); + services + .register(IOrchestrateEngine) + .using((x) => new OrchestrateEngine(x.resolve(ToolsV2Service).registry, x.resolve(PolicyStore), x.resolve(ILogger), x.resolve(IServiceProvider), x.resolve(ApprovalCoordinator), x.resolve(ISdkMessagePublisher), x.resolve(IFileSystem), x.resolve(Clock))) + .asSelf(); + // IPolicyNotifier (refresh/onNotice, driven by WorkingDirectoryMoveHandler), same ISP shape as + // IRulesConfigNotifier above — wraps the PolicyStore singleton with change-tracking, not a + // second store. + services.register(ConfigPolicyProvider).as(IPolicyNotifier); + // --- SDK pipeline --- // StreamProcessor and IStreamProcessor share identity from this one register() call. services.register(StreamProcessor).asSelf().as(IStreamProcessor); @@ -369,26 +431,9 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { services .register(ToolRegistry) .using([IFileSystem, IToolProvider, ILogger, IDisabledToolsProvider, ISkillGateProvider], (fs, toolProvider, log, disabledToolsProvider, skillGate) => { - // Canonicalise a marked path to a single absolute form all three consumers read: expand ~/$VAR, - // then resolve against cwd so a relative path (test1.txt) and dot segments (../a) collapse to one - // path. Symlinks are not resolved (realpath is async and throws on not-yet-existing paths). - const expand = (p: string) => path.resolve(fs.cwd(), expandPath(p, fs)); - return new ToolRegistry(toolProvider.tools, log, expand, disabledToolsProvider, skillGate); + return new ToolRegistry(toolProvider.tools, log, buildPathExpander(fs), disabledToolsProvider, skillGate); }) .as(IToolRegistry); - // Build-tools step: collect every distinct block lifetime the tools declared, - // then build the generic notifier QueryRunner fires at block end. Deduped — - // the four TS tools share one bridge, so its teardown runs once per block. The - // tool→lifecycle link lives here, in the build step, not in a DI binding, so - // any number of tools can participate. - services - .register(ToolBlockNotifier) - .using([IToolProvider], (toolProvider) => { - const tools = toolProvider.tools; - const lifetimes = [...new Set(tools.flatMap((t) => (t.blockLifetime ? [t.blockLifetime] : [])))]; - return new ToolBlockNotifier(lifetimes); - }) - .as(IToolBlockNotifier); services.register(FileCredentialStore).as(ICredentialStore); services.register(HttpTokenEndpoint).as(ITokenEndpoint); services.register(HttpProfileEndpoint).as(IProfileEndpoint); diff --git a/apps/claude-sdk-cli/src/view/renderToolApproval.ts b/apps/claude-sdk-cli/src/view/renderToolApproval.ts index 4a34befe..9f980db4 100644 --- a/apps/claude-sdk-cli/src/view/renderToolApproval.ts +++ b/apps/claude-sdk-cli/src/view/renderToolApproval.ts @@ -31,7 +31,8 @@ export function renderToolApproval(state: IToolApprovalState, cols: number, maxR const prefix = state.hasPendingApprovals ? 'Allow ' : ''; const approval = state.hasPendingApprovals ? ' [Y/N]' : ''; const expand = state.toolExpanded ? ' [space: collapse]' : ' [space: expand]'; - const row = ` ${prefix}Tool: ${tool.name}${nav}${approval}${expand}`; + const stage = tool.stageCount != null && tool.stageCount > 1 ? ` (stage ${tool.stageIndex} of ${tool.stageCount})` : ''; + const row = ` ${prefix}Tool: ${tool.name}${stage}${nav}${approval}${expand}`; approvalRow = state.hasPendingApprovals && state.flashPhase ? `\x1b[7m${row}\x1b[27m` : row; } diff --git a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts index 9bb19711..6393d742 100644 --- a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts +++ b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts @@ -5,6 +5,8 @@ import { ConfigLoader } from '@shellicar/claude-core/Config/ConfigLoader'; import { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; import { ILogger } from '@shellicar/claude-core/logging/ILogger'; import { type AnyToolDefinition, CacheTtl, type ConsumerMessage, Conversation, type DurableConfig, IConversation, IDurableConfigProvider, pathSchema } from '@shellicar/claude-sdk'; +import { AzSessionCache } from '@shellicar/claude-sdk-tools/Az'; +import { ToolsV2Registry } from '@shellicar/claude-sdk-tools/Orchestrate'; import { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; import { createServiceCollection, Lifetime } from '@shellicar/core-di'; import { describe, expect, it } from 'vitest'; @@ -25,6 +27,7 @@ import { IToolApprovalState, ToolApprovalState } from '../src/model/ToolApproval import { ISqliteSessionStore, SqliteSessionStore } from '../src/persistence/SqliteSessionStore.js'; import { AppToolsService } from '../src/setup/AppToolsService.js'; import { ConsumerChannel } from '../src/setup/ConsumerChannel.js'; +import { ToolsV2Service } from '../src/setup/ToolsV2Service.js'; import { CapturingBus } from './CapturingBus.js'; import { MemoryFileSystem } from './MemoryFileSystem.js'; import { MemoryObjectStore } from './MemoryObjectStore.js'; @@ -133,7 +136,18 @@ function makeHandler(overrides: OptsOverrides = {}) { return fullConfig; }, } as unknown as ConfigLoader; - const appTools = { tools: durableConfig.tools, permissionTools: durableConfig.tools, store, refTransform: (_name: string, output: unknown) => output } satisfies AppToolsService; + const fakeExecutor = { run: () => Promise.reject(new Error('no real process execution in this test')) } as never; + const fakeEscalatedDeps = { executor: fakeExecutor, getCert: () => 'fake-cert', getIdentity: () => ({ type: 'cert' as const, clientId: 'fake-client-id', subscriptionIds: [] }), getTenantId: () => 'fake-tenant-id' }; + const appTools = { + tools: durableConfig.tools, + permissionTools: durableConfig.tools, + store, + refTransform: (_name: string, output: unknown) => output, + ghDeps: { executor: fakeExecutor, getHolderToken: () => 'fake-gh-token' }, + adoDeps: fakeEscalatedDeps, + azDeps: fakeEscalatedDeps, + azSessionCache: new AzSessionCache(Clock.systemUTC()), + } satisfies AppToolsService; // ConsumerChannel delivers asynchronously (queues + microtask pump), so a capturing test must // await a flush after the handler sends before reading what was captured. @@ -214,6 +228,14 @@ function makeHandler(overrides: OptsOverrides = {}) { .using(() => session) .asSelf() .as(IConversationSession); + // No V2 tool has a schema/summarize a test in this file needs to look up — an empty registry + // is a valid, real ToolsV2Registry (not a hand-rolled fake), so #summarizeFor's V2 fallback + // always finds nothing and defers to the generic display, same as a real process with no V2 + // tools registered would. + services + .register(ToolsV2Service) + .using(() => new ToolsV2Service(new ToolsV2Registry([], { buildEnv: () => ({}) }))) + .asSelf(); services.register(AgentMessageHandler).asSelf(); const handler = services.buildProvider().resolve(AgentMessageHandler); return { handler, conversationState, toolApprovalState, statusState, session, conversation, fs }; @@ -738,7 +760,7 @@ describe('AgentMessageHandler — tool_approval_request', () => { toolApprovalState, }); streamTool(handler, 'toolu_01', 'DeleteFile'); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'DeleteFile', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'DeleteFile', input: {} }); // pending phase: formatToolSummary('DeleteFile', {}) → 'DeleteFile'; render → 'DeleteFile\n' const expected = 'DeleteFile\n'; const actual = conversationState.activeBlock?.content; @@ -749,7 +771,7 @@ describe('AgentMessageHandler — tool_approval_request', () => { const sends: ConsumerMessage[] = []; const { handler } = makeHandler({ onSend: (m) => sends.push(m) }); streamTool(handler, 'toolu_01', 'Unknown'); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'Unknown', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'Unknown', input: {} }); await flush(); const response = sends.find((m) => m.type === 'tool_approval_response'); const expected = true; @@ -767,7 +789,7 @@ describe('AgentMessageHandler — tool_approval_request', () => { ], }; streamTool(handler, 'toolu_01', 'Pipe', pipeInput); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'Pipe', input: pipeInput }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'Pipe', input: pipeInput }); await flush(); const response = sends.find((m) => m.type === 'tool_approval_response'); const expected = true; @@ -775,13 +797,53 @@ describe('AgentMessageHandler — tool_approval_request', () => { expect(actual).toBe(expected); }); + it('renders an Orchestrate stages input as its pipeline shape, tool(arg) joined by the stage op', () => { + const { handler, conversationState } = makeHandler({ config: { tools: [makeTool('Find', 'read')] } }); + const orchestrateInput = { + stages: [ + { tool: 'Find', input: { path: '/test/sub' }, op: '|' }, + { tool: 'Find', input: { path: '/test/other' } }, + ], + }; + streamTool(handler, 'toolu_01', 'Orchestrate', orchestrateInput); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'Orchestrate', input: orchestrateInput, v2: true }); + const expected = 'Find(sub) | Find(other)\n'; + const actual = conversationState.activeBlock?.content; + expect(actual).toBe(expected); + }); + + it('renders an Xargs stage inside an Orchestrate pipeline by its bridged parameter name', () => { + const { handler, conversationState } = makeHandler({ config: { tools: [makeTool('Find', 'read')] } }); + const orchestrateInput = { + stages: [{ tool: 'Find', input: { path: '/test/sub' }, op: '|' }, { xargs: 'files' }], + }; + streamTool(handler, 'toolu_01', 'Orchestrate', orchestrateInput); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'Orchestrate', input: orchestrateInput, v2: true }); + const expected = 'Find(sub) | Xargs(files)\n'; + const actual = conversationState.activeBlock?.content; + expect(actual).toBe(expected); + }); + + it('a V2 stage request is never auto-rejected as tool-not-found, even though its name is absent from V1 permissionTools', async () => { + const sends: ConsumerMessage[] = []; + // No tools registered in V1's config at all — a plain lookup would call this NotFound. + const { handler } = makeHandler({ config: { tools: [] }, onSend: (m) => sends.push(m) }); + streamTool(handler, 'toolu_01:0', 'Find'); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01:0', toolUseId: 'toolu_01', name: 'Find', input: { resolved: [] }, v2: true }); + await flush(); + const response = sends.find((m) => m.type === 'tool_approval_response'); + const expected = undefined; + const actual = response; + expect(actual).toBe(expected); + }); + it('auto-denies a delete outside cwd without a reason claiming user rejection', async () => { const sends: ConsumerMessage[] = []; const { handler } = makeHandler({ config: { tools: [makeTool('DeleteFile', 'delete')] }, onSend: (m) => sends.push(m) }); // default matrix: outside.delete = 'deny' — settles without a prompt (PermissionAction.Deny). const input = { path: '/outside/file.txt' }; streamTool(handler, 'toolu_01', 'DeleteFile', input); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'DeleteFile', input }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'DeleteFile', input }); await flush(); const response = sends.find((m) => m.type === 'tool_approval_response'); const expected = false; @@ -794,7 +856,7 @@ describe('AgentMessageHandler — tool_approval_request', () => { const { handler } = makeHandler({ config: { tools: [makeTool('DeleteFile', 'delete')] }, onSend: (m) => sends.push(m) }); const input = { path: '/outside/file.txt' }; streamTool(handler, 'toolu_01', 'DeleteFile', input); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'DeleteFile', input }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'DeleteFile', input }); await flush(); const response = sends.find((m) => m.type === 'tool_approval_response'); const expected = true; @@ -805,7 +867,7 @@ describe('AgentMessageHandler — tool_approval_request', () => { it('records auto-approved status for a read tool', () => { const { handler, conversationState } = makeHandler({ config: { tools: [makeTool('Find', 'read')] } }); streamTool(handler, 'toolu_01', 'Find'); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'Find', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'Find', input: {} }); const expected = true; const actual = conversationState.activeBlock?.content.includes('\u2714') ?? false; expect(actual).toBe(expected); @@ -818,7 +880,7 @@ describe('AgentMessageHandler — tool_approval_request', () => { toolApprovalState, }); streamTool(handler, 'toolu_01', 'DeleteFile'); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'DeleteFile', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'DeleteFile', input: {} }); toolApprovalState.resolveApproval('toolu_01', true); await flush(); const expected = true; @@ -833,7 +895,7 @@ describe('AgentMessageHandler — tool_approval_request', () => { toolApprovalState, }); streamTool(handler, 'toolu_01', 'DeleteFile'); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'DeleteFile', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'DeleteFile', input: {} }); toolApprovalState.resolveApproval('toolu_01', false); await flush(); const expected = true; @@ -851,8 +913,8 @@ describe('AgentMessageHandler — tool_approval_request', () => { }); streamTool(handler, 'toolu_01', 'DeleteFile'); streamTool(handler, 'toolu_02', 'DeleteFile', {}, false); // same batch - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'DeleteFile', input: {} }); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_02', name: 'DeleteFile', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'DeleteFile', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_02', toolUseId: 'toolu_02', name: 'DeleteFile', input: {} }); // both in 'pending' phase const expected = 'DeleteFile\nDeleteFile\n'; const actual = conversationState.activeBlock?.content ?? ''; @@ -865,8 +927,8 @@ describe('AgentMessageHandler — tool_approval_request', () => { }); streamTool(handler, 'toolu_01', 'Find'); streamTool(handler, 'toolu_02', 'ReadFile', {}, false); // same batch - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'Find', input: {} }); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_02', name: 'ReadFile', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'Find', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_02', toolUseId: 'toolu_02', name: 'ReadFile', input: {} }); const expected = `${GREEN}\u2714${RESET} Find\n${GREEN}\u2714${RESET} ReadFile\n`; const actual = conversationState.activeBlock?.content ?? ''; expect(actual).toBe(expected); @@ -910,8 +972,8 @@ describe('AgentMessageHandler + ApprovalHandler — batch approval identity', () const approvals = buildApprovalHandler(toolApprovalState); streamTool(handler, 'toolu_01', 'DeleteFile'); streamTool(handler, 'toolu_02', 'DeleteFile', {}, false); // same batch - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'DeleteFile', input: {} }); - handler.handle({ type: 'tool_approval_request', requestId: 'toolu_02', name: 'DeleteFile', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', toolUseId: 'toolu_01', name: 'DeleteFile', input: {} }); + handler.handle({ type: 'tool_approval_request', requestId: 'toolu_02', toolUseId: 'toolu_02', name: 'DeleteFile', input: {} }); return { toolApprovalState, approvals, sends }; } @@ -973,7 +1035,7 @@ describe('AgentMessageHandler — message_usage without prior tools', () => { it('seals the active tools block', () => { const { handler, conversationState } = makeHandler(); streamTool(handler, 'r1', 'Find'); - handler.handle({ type: 'tool_approval_request', requestId: 'r1', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r1', toolUseId: 'r1', name: 'Find', input: { path: '.' } }); handler.handle(makeUsage(1000)); const expected = null; const actual = conversationState.activeBlock; @@ -996,7 +1058,7 @@ describe('AgentMessageHandler — message_usage delta annotation', () => { const { handler, conversationState } = makeHandler(); handler.handle(makeUsage(1000)); streamTool(handler, 'r1', 'Find'); - handler.handle({ type: 'tool_approval_request', requestId: 'r1', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r1', toolUseId: 'r1', name: 'Find', input: { path: '.' } }); handler.handle(makeUsage(1500)); const expected = true; const actual = toolsBlockContent(conversationState).includes('+500'); @@ -1009,10 +1071,10 @@ describe('AgentMessageHandler — message_usage delta annotation', () => { const { handler, conversationState } = makeHandler(); handler.handle(makeUsage(1000)); streamTool(handler, 'r1', 'Find'); - handler.handle({ type: 'tool_approval_request', requestId: 'r1', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r1', toolUseId: 'r1', name: 'Find', input: { path: '.' } }); handler.handle(makeUsage(1500)); streamTool(handler, 'r2', 'Find'); - handler.handle({ type: 'tool_approval_request', requestId: 'r2', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r2', toolUseId: 'r2', name: 'Find', input: { path: '.' } }); handler.handle(makeUsage(1700)); const expected = true; const actual = toolsBlockContent(conversationState).includes('+200'); @@ -1025,10 +1087,10 @@ describe('AgentMessageHandler — message_usage delta annotation', () => { const { handler, conversationState } = makeHandler(); handler.handle(makeUsage(1000)); streamTool(handler, 'r1', 'Find'); - handler.handle({ type: 'tool_approval_request', requestId: 'r1', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r1', toolUseId: 'r1', name: 'Find', input: { path: '.' } }); handler.handle(makeUsage(1500)); streamTool(handler, 'r2', 'Find'); - handler.handle({ type: 'tool_approval_request', requestId: 'r2', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r2', toolUseId: 'r2', name: 'Find', input: { path: '.' } }); handler.handle(makeUsage(1700)); const expected = true; const actual = toolsBlockContent(conversationState).includes('+200'); @@ -1041,7 +1103,7 @@ describe('AgentMessageHandler — message_usage delta annotation', () => { const { handler, conversationState } = makeHandler(); handler.handle(makeUsage(1000)); streamTool(handler, 'r1', 'Find'); - handler.handle({ type: 'tool_approval_request', requestId: 'r1', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r1', toolUseId: 'r1', name: 'Find', input: { path: '.' } }); handler.handle(makeUsage(1500)); // seals first tools block with +500 streamTool(handler, 'r2', 'Find'); // second turn opens a fresh tools block const expected = true; @@ -1056,8 +1118,8 @@ describe('AgentMessageHandler — message_usage delta annotation', () => { // Two tools in the same batch (same message from Claude) streamTool(handler, 'r1', 'Find'); streamTool(handler, 'r2', 'Find', {}, false); - handler.handle({ type: 'tool_approval_request', requestId: 'r1', name: 'Find', input: { path: '.' } }); - handler.handle({ type: 'tool_approval_request', requestId: 'r2', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r1', toolUseId: 'r1', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r2', toolUseId: 'r2', name: 'Find', input: { path: '.' } }); handler.handle(makeUsage(1800)); const expected = true; const actual = toolsBlockContent(conversationState).includes('+800'); @@ -1080,7 +1142,7 @@ describe('AgentMessageHandler — usage frames split across the turn', () => { const { handler, conversationState } = makeHandler(); handler.handle(makeUsage(1000)); streamTool(handler, 'r1', 'Find'); - handler.handle({ type: 'tool_approval_request', requestId: 'r1', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r1', toolUseId: 'r1', name: 'Find', input: { path: '.' } }); handler.handle(outputFrame(250)); const expected = true; const actual = toolsBlockContent(conversationState).includes('\u2193 +250'); @@ -1092,7 +1154,7 @@ describe('AgentMessageHandler — usage frames split across the turn', () => { // and reads negative — must not run. Guards the negative-delta regression. const { handler, conversationState } = makeHandler(); streamTool(handler, 'r1', 'Find'); - handler.handle({ type: 'tool_approval_request', requestId: 'r1', name: 'Find', input: { path: '.' } }); + handler.handle({ type: 'tool_approval_request', requestId: 'r1', toolUseId: 'r1', name: 'Find', input: { path: '.' } }); handler.handle(outputFrame(250)); const expected = false; const actual = toolsBlockContent(conversationState).includes('\u2191'); diff --git a/apps/claude-sdk-cli/test/ApprovalHolder.spec.ts b/apps/claude-sdk-cli/test/ApprovalHolder.spec.ts new file mode 100644 index 00000000..0bd6fe44 --- /dev/null +++ b/apps/claude-sdk-cli/test/ApprovalHolder.spec.ts @@ -0,0 +1,84 @@ +import { Clock } from '@js-joda/core'; +import type { SdkToolApprovalRequest } from '@shellicar/claude-sdk'; +import { createServiceCollection } from '@shellicar/core-di'; +import { describe, expect, it } from 'vitest'; +import { ApprovalHolder } from '../src/approval/ApprovalHolder.js'; +import { IBus } from '../src/bus/IBus.js'; + +class RecordingBus extends IBus { + public readonly published: { subject: string; payload: unknown }[] = []; + public readonly served: string[] = []; + public async start(): Promise {} + public publish(subject: string, payload: Uint8Array): void { + this.published.push({ subject, payload: JSON.parse(new TextDecoder().decode(payload)) }); + } + public subscribe(): () => void { + return () => {}; + } + public async request(): Promise { + throw new Error('not used'); + } + public serve(subject: string): () => void { + this.served.push(subject); + return () => {}; + } + public async stop(): Promise {} +} + +function makeHolder() { + const bus = new RecordingBus(); + const services = createServiceCollection(); + services + .register(IBus) + .using(() => bus) + .asSelf(); + services + .register(Clock) + .using(() => Clock.systemUTC()) + .asSelf(); + services.register(ApprovalHolder).asSelf(); + return { holder: services.buildProvider().resolve(ApprovalHolder), bus }; +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +const stageRequest: SdkToolApprovalRequest = { type: 'tool_approval_request', requestId: 'toolu_01ABC:2', name: 'Delete', input: {}, v2: true, toolUseId: 'toolu_01ABC' }; + +describe('ApprovalHolder', () => { + // The subject key is the holder's own identifier for one ask, not a borrowed id from the + // conversation. Bridge already mints a uuid per ask; reusing a tool-use id only ever worked + // because V1 gated once per tool_use, which a multi-stage V2 pipeline breaks. + it('raises on a uuid subject rather than reusing the request id', () => { + const { holder, bus } = makeHolder(); + + holder.raise(stageRequest, {}); + + const expected = true; + const subject = bus.published[0].subject; + const actual = UUID.test(subject.replace('approval.v1.', '').replace('.lifecycle', '')); + expect(actual).toBe(expected); + }); + + // The correlation is how a bus consumer joins an ask back to the conv stream, so it has to be + // an id that exists there. A V2 stage's requestId (`toolu_x:2`) does not. + it('correlates to the real tool_use id, not the stage-scoped request id', () => { + const { holder, bus } = makeHolder(); + + holder.raise(stageRequest, { toolUseId: stageRequest.toolUseId }); + + const expected = 'toolu_01ABC'; + const actual = (bus.published[0].payload as { correlation: { toolUseId: string } }).correlation.toolUseId; + expect(actual).toBe(expected); + }); + + it('settles on the same subject it raised on', () => { + const { holder, bus } = makeHolder(); + + holder.raise(stageRequest, {}); + holder.settle(stageRequest.requestId, { approved: true, by: { kind: 'human' } }); + + const expected = bus.published[0].subject; + const actual = bus.published.filter((p) => p.subject.endsWith('.lifecycle')).at(-1)?.subject; + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/ApprovalNotifier.spec.ts b/apps/claude-sdk-cli/test/ApprovalNotifier.spec.ts index 55ffc78d..ea18f2c0 100644 --- a/apps/claude-sdk-cli/test/ApprovalNotifier.spec.ts +++ b/apps/claude-sdk-cli/test/ApprovalNotifier.spec.ts @@ -35,6 +35,7 @@ class ThrowingLauncher extends IProcessLauncher { const testRequest: SdkToolApprovalRequest = { type: 'tool_approval_request', requestId: 'req-1', + toolUseId: 'req-1', name: 'DeleteFile', input: { path: '/tmp/test.ts' }, }; diff --git a/apps/claude-sdk-cli/test/ConfigPolicyProvider.spec.ts b/apps/claude-sdk-cli/test/ConfigPolicyProvider.spec.ts new file mode 100644 index 00000000..39bfde06 --- /dev/null +++ b/apps/claude-sdk-cli/test/ConfigPolicyProvider.spec.ts @@ -0,0 +1,125 @@ +import { IConfigOptions } from '@shellicar/claude-core/Config/IConfigOptions'; +import { IConfigFileReader } from '@shellicar/claude-core/Config/interfaces'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { PolicyStore } from '@shellicar/claude-sdk-tools/Policy'; +import { createServiceCollection, Lifetime } from '@shellicar/core-di'; +import { describe, expect, it } from 'vitest'; +import { ConfigPolicyProvider, IPolicyNotifier, readPolicyRaw } from '../src/setup/ConfigPolicyProvider.js'; + +class NoopLogger extends ILogger { + public trace(): void {} + public debug(): void {} + public info(): void {} + public warn(): void {} + public error(): void {} +} + +class FakeReader extends IConfigFileReader { + public constructor(private json: string) { + super(); + } + public exists(): boolean { + return true; + } + public read(): string { + return this.json; + } + public setJson(json: string): void { + this.json = json; + } +} + +const lookup = { get: () => undefined }; + +function build(initialPolicyJson: string) { + const reader = new FakeReader(initialPolicyJson); + const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); + services + .register(IConfigOptions) + .using(() => ({ paths: ['/sdk-config.json'] }) as unknown as IConfigOptions) + .asSelf(); + services + .register(IConfigFileReader) + .using(() => reader) + .asSelf(); + services + .register(ILogger) + .using(() => new NoopLogger()) + .asSelf(); + services + .register(PolicyStore) + .using((x) => new PolicyStore(readPolicyRaw(x.resolve(IConfigOptions).paths, x.resolve(IConfigFileReader)), lookup)) + .asSelf(); + services.register(ConfigPolicyProvider).as(IPolicyNotifier); + const provider = services.buildProvider(); + return { provider, reader, notifier: provider.resolve(IPolicyNotifier), store: provider.resolve(PolicyStore) }; +} + +describe('readPolicyRaw', () => { + it('reads the policy field out of the config file', () => { + const reader = new FakeReader(JSON.stringify({ policy: [{ default: 'deny' }] })); + const actual = readPolicyRaw(['/sdk-config.json'], reader); + expect(actual).toEqual([{ default: 'deny' }]); + }); +}); + +describe('ConfigPolicyProvider.refresh', () => { + it('does not notify when the file is refreshed with no actual change', () => { + const { reader, notifier } = build(JSON.stringify({ policy: [{ default: 'ask' }] })); + const notices: unknown[] = []; + notifier.onNotice((n) => notices.push(n)); + + reader.setJson(JSON.stringify({ policy: [{ default: 'ask' }] })); + notifier.refresh(); + + expect(notices).toEqual([]); + }); + + it('notifies "changed" when the policy content actually differs', () => { + const { reader, notifier } = build(JSON.stringify({ policy: [{ default: 'ask' }] })); + const notices: unknown[] = []; + notifier.onNotice((n) => notices.push(n)); + + reader.setJson(JSON.stringify({ policy: [{ default: 'deny' }] })); + notifier.refresh(); + + expect(notices).toEqual([{ kind: 'changed' }]); + }); + + it('notifies "invalid" and keeps the previous policy when the new value fails validation', () => { + const { reader, notifier, store } = build(JSON.stringify({ policy: [{ default: 'ask' }] })); + const notices: unknown[] = []; + notifier.onNotice((n) => notices.push(n)); + + reader.setJson(JSON.stringify({ policy: [{ default: 'yolo' }] })); + notifier.refresh(); + + expect(notices).toEqual([{ kind: 'invalid', error: expect.any(String) }]); + expect(store.current).toEqual([{ default: 'ask' }]); + }); + + it('does not repeat the same invalid notice on a second refresh with the same bad value', () => { + const { reader, notifier } = build(JSON.stringify({ policy: [{ default: 'ask' }] })); + reader.setJson(JSON.stringify({ policy: [{ default: 'yolo' }] })); + const notices: unknown[] = []; + notifier.onNotice((n) => notices.push(n)); + + notifier.refresh(); + notifier.refresh(); + + expect(notices.length).toBe(1); + }); + + it('notifies "recovered" when a subsequent edit fixes a previously invalid policy', () => { + const { reader, notifier } = build(JSON.stringify({ policy: [{ default: 'ask' }] })); + reader.setJson(JSON.stringify({ policy: [{ default: 'yolo' }] })); + notifier.refresh(); + const notices: unknown[] = []; + notifier.onNotice((n) => notices.push(n)); + + reader.setJson(JSON.stringify({ policy: [{ default: 'deny' }] })); + notifier.refresh(); + + expect(notices).toEqual([{ kind: 'recovered' }]); + }); +}); diff --git a/apps/claude-sdk-cli/test/DisabledToolsRequestWiring.spec.ts b/apps/claude-sdk-cli/test/DisabledToolsRequestWiring.spec.ts index b3f1a71d..00b58125 100644 --- a/apps/claude-sdk-cli/test/DisabledToolsRequestWiring.spec.ts +++ b/apps/claude-sdk-cli/test/DisabledToolsRequestWiring.spec.ts @@ -26,6 +26,8 @@ import { TurnRunner, type WakeLockHandle, } from '@shellicar/claude-sdk'; +import { AzSessionCache } from '@shellicar/claude-sdk-tools/Az'; +import { createToolsV2Registry, orchestrateExecutor } from '@shellicar/claude-sdk-tools/Orchestrate'; import { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; import { createServiceCollection, Lifetime } from '@shellicar/core-di'; import { describe, expect, it } from 'vitest'; @@ -37,8 +39,10 @@ import { ConfigDisabledToolsProvider } from '../src/setup/ConfigDisabledToolsPro import { DurableConfigFactory } from '../src/setup/DurableConfigFactory.js'; import { IRuntimeOptions } from '../src/setup/IRuntimeOptions.js'; import { ModelOverrides } from '../src/setup/ModelOverrides.js'; +import { ToolsV2Service } from '../src/setup/ToolsV2Service.js'; import { MemoryFileSystem } from './MemoryFileSystem.js'; import { MemoryObjectStore } from './MemoryObjectStore.js'; +import { RecordingMemoryStore } from './RecordingMemoryStore.js'; // Reads one in-memory source; the loader parses + applies schema defaults. class FakeConfigFileReader extends IConfigFileReader { @@ -116,7 +120,18 @@ function makeLoader(disabledTools: string[]): ConfigLoader output } satisfies AppToolsService; + const fakeExecutor = { run: () => Promise.reject(new Error('no real process execution in this test')) } as never; + const fakeEscalatedDeps = { executor: fakeExecutor, getCert: () => 'fake-cert', getIdentity: () => ({ type: 'cert' as const, clientId: 'fake-client-id', subscriptionIds: [] }), getTenantId: () => 'fake-tenant-id' }; + const appTools = { + tools, + permissionTools: [], + store: new RefStore(new MemoryObjectStore()), + refTransform: (_name: string, output: unknown) => output, + ghDeps: { executor: fakeExecutor, getHolderToken: () => 'fake-gh-token' }, + adoDeps: fakeEscalatedDeps, + azDeps: fakeEscalatedDeps, + azSessionCache: new AzSessionCache(Clock.systemUTC()), + } satisfies AppToolsService; const streamer = new FakeMessageStreamer(); const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); @@ -141,6 +156,32 @@ function buildHarness(tools: AnyToolDefinition[], disabledTools: string[]) { .register(AppToolsService) .using(() => appTools) .asSelf(); + services + .register(ToolsV2Service) + .using( + () => + new ToolsV2Service( + createToolsV2Registry({ + fs, + executor: orchestrateExecutor, + refStore: appTools.store, + sips: { dimensions: () => Promise.reject(new Error('no sips in tests')), resizeToPng: () => Promise.reject(new Error('no sips in tests')) }, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: { search: () => [], read: () => [] }, + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ghDeps: { executor: orchestrateExecutor, getHolderToken: () => 'fake-gh-token' }, + adoDeps: { executor: orchestrateExecutor, getCert: () => 'fake-cert', getIdentity: () => ({ type: 'cert' as const, clientId: 'fake-client-id', subscriptionIds: [] }), getTenantId: () => 'fake-tenant-id' }, + azDeps: { executor: orchestrateExecutor, getCert: () => 'fake-cert', getIdentity: () => ({ type: 'cert' as const, clientId: 'fake-client-id', subscriptionIds: [] }), getTenantId: () => 'fake-tenant-id' }, + azSessionCache: new AzSessionCache(Clock.systemUTC()), + getAzAccounts: () => ({}), + envProvider: { buildEnv: (cmdEnv) => ({ ...process.env, ...cmdEnv }) }, + }), + ), + ) + .asSelf(); services.register(SystemPromptLoader).asSelf(); services.register(NoopLogger).as(ILogger); services.register(DurableConfigFactory).as(IDurableConfigProvider); diff --git a/apps/claude-sdk-cli/test/ThinkingRequestWiring.spec.ts b/apps/claude-sdk-cli/test/ThinkingRequestWiring.spec.ts index 98069ad5..0db3162b 100644 --- a/apps/claude-sdk-cli/test/ThinkingRequestWiring.spec.ts +++ b/apps/claude-sdk-cli/test/ThinkingRequestWiring.spec.ts @@ -10,6 +10,8 @@ import { ILogger } from '@shellicar/claude-core/logging/ILogger'; import { IRandomProvider } from '@shellicar/claude-core/providers/IRandomProvider'; import { ISleepProvider } from '@shellicar/claude-core/providers/ISleepProvider'; import { AccountLimitListener, Conversation, type DurableConfig, IDurableConfigProvider, IMessageStreamer, IRequestClockListener, IStreamProcessor, IToolRegistry, IWakeLock, StreamInterruptListener, StreamProcessor, type ThinkingEffort, ToolRegistry, TurnRunner, type WakeLockHandle } from '@shellicar/claude-sdk'; +import { AzSessionCache } from '@shellicar/claude-sdk-tools/Az'; +import { createToolsV2Registry, orchestrateExecutor } from '@shellicar/claude-sdk-tools/Orchestrate'; import { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; import { createServiceCollection, Lifetime } from '@shellicar/core-di'; import { describe, expect, it } from 'vitest'; @@ -20,8 +22,10 @@ import { AppToolsService } from '../src/setup/AppToolsService.js'; import { DurableConfigFactory } from '../src/setup/DurableConfigFactory.js'; import { IRuntimeOptions } from '../src/setup/IRuntimeOptions.js'; import { ModelOverrides } from '../src/setup/ModelOverrides.js'; +import { ToolsV2Service } from '../src/setup/ToolsV2Service.js'; import { MemoryFileSystem } from './MemoryFileSystem.js'; import { MemoryObjectStore } from './MemoryObjectStore.js'; +import { RecordingMemoryStore } from './RecordingMemoryStore.js'; // Reads one in-memory source; the loader parses + applies schema defaults. class FakeConfigFileReader extends IConfigFileReader { @@ -156,7 +160,18 @@ function makeLoader(thinking: ThinkingConfig): ConfigLoader output } satisfies AppToolsService; + const fakeExecutor = { run: () => Promise.reject(new Error('no real process execution in this test')) } as never; + const fakeEscalatedDeps = { executor: fakeExecutor, getCert: () => 'fake-cert', getIdentity: () => ({ type: 'cert' as const, clientId: 'fake-client-id', subscriptionIds: [] }), getTenantId: () => 'fake-tenant-id' }; + const appTools = { + tools: [], + permissionTools: [], + store: new RefStore(new MemoryObjectStore()), + refTransform: (_name: string, output: unknown) => output, + ghDeps: { executor: fakeExecutor, getHolderToken: () => 'fake-gh-token' }, + adoDeps: fakeEscalatedDeps, + azDeps: fakeEscalatedDeps, + azSessionCache: new AzSessionCache(Clock.systemUTC()), + } satisfies AppToolsService; const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); services .register(IRuntimeOptions) @@ -179,6 +194,32 @@ function makeFactory(thinking: ThinkingConfig, override: Override): IDurableConf .register(AppToolsService) .using(() => appTools) .asSelf(); + services + .register(ToolsV2Service) + .using( + () => + new ToolsV2Service( + createToolsV2Registry({ + fs, + executor: orchestrateExecutor, + refStore: appTools.store, + sips: { dimensions: () => Promise.reject(new Error('no sips in tests')), resizeToPng: () => Promise.reject(new Error('no sips in tests')) }, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: { search: () => [], read: () => [] }, + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ghDeps: { executor: orchestrateExecutor, getHolderToken: () => 'fake-gh-token' }, + adoDeps: { executor: orchestrateExecutor, getCert: () => 'fake-cert', getIdentity: () => ({ type: 'cert' as const, clientId: 'fake-client-id', subscriptionIds: [] }), getTenantId: () => 'fake-tenant-id' }, + azDeps: { executor: orchestrateExecutor, getCert: () => 'fake-cert', getIdentity: () => ({ type: 'cert' as const, clientId: 'fake-client-id', subscriptionIds: [] }), getTenantId: () => 'fake-tenant-id' }, + azSessionCache: new AzSessionCache(Clock.systemUTC()), + getAzAccounts: () => ({}), + envProvider: { buildEnv: (cmdEnv) => ({ ...process.env, ...cmdEnv }) }, + }), + ), + ) + .asSelf(); services.register(SystemPromptLoader).asSelf(); services.register(NoopLogger).as(ILogger); services.register(DurableConfigFactory).as(IDurableConfigProvider); diff --git a/apps/claude-sdk-cli/test/WorkingDirectoryMoveHandler.spec.ts b/apps/claude-sdk-cli/test/WorkingDirectoryMoveHandler.spec.ts index e9fd07d7..b09fc16d 100644 --- a/apps/claude-sdk-cli/test/WorkingDirectoryMoveHandler.spec.ts +++ b/apps/claude-sdk-cli/test/WorkingDirectoryMoveHandler.spec.ts @@ -13,6 +13,7 @@ import { ClaudeMdLoader } from '../src/ClaudeMdLoader.js'; import { IConversationSession } from '../src/model/ConversationSession.js'; import { StatusState } from '../src/model/StatusState.js'; import { IWorkingDirectory } from '../src/model/WorkingDirectory.js'; +import { IPolicyNotifier } from '../src/setup/ConfigPolicyProvider.js'; import { IRulesConfigNotifier } from '../src/setup/ConfigRulesConfigProvider.js'; import { IRuntimeOptions } from '../src/setup/IRuntimeOptions.js'; import { IWorkingDirectoryMoveHandler, WorkingDirectoryMoveHandler } from '../src/setup/WorkingDirectoryMoveHandler.js'; @@ -72,6 +73,10 @@ function buildMoveHandler(): Built { .register(IRulesConfigNotifier) .using(() => ({ refresh: () => {} }) as unknown as IRulesConfigNotifier) .asSelf(); + services + .register(IPolicyNotifier) + .using(() => ({ refresh: () => {} }) as unknown as IPolicyNotifier) + .asSelf(); services .register(StatusState) .using(() => ({ setCwdBasename: () => {} }) as unknown as StatusState) @@ -119,10 +124,11 @@ function buildMoveHandler(): Built { return { provider, emitChange: (cwd: string) => changeListener?.(cwd), watchesCreatedOnMove }; } -// wire() itself creates the startup pair (config, then rules) by calling the same IConfigWatcher.watch() -// a move does, so watchesCreatedOnMove[0]/[1] are the startup watches and [2]/[3] are the first move's. +// wire() itself creates the startup trio (config, rules, policy) by calling the same +// IConfigWatcher.watch() a move does, so watchesCreatedOnMove[0]/[1]/[2] are the startup watches +// and [3]/[4]/[5] are the first move's. describe('WorkingDirectoryMoveHandler', () => { - // The startup watches are superseded by the first move exactly as a move-created pair is by a + // The startup watches are superseded by the first move exactly as a move-created trio is by a // second: nothing should keep watching (and reloading on) a directory the session has left. it('disposes the startup config watch when the first move supersedes it', () => { const { provider, emitChange, watchesCreatedOnMove } = buildMoveHandler(); @@ -142,7 +148,16 @@ describe('WorkingDirectoryMoveHandler', () => { expect(actual).toBe(expected); }); - // A second move supersedes the first move's pair; nothing else holds them, so leaving them + it('disposes the startup policy watch when the first move supersedes it', () => { + const { provider, emitChange, watchesCreatedOnMove } = buildMoveHandler(); + provider.resolve(IWorkingDirectoryMoveHandler).wire(); + emitChange('/somewhere/else'); + const expected = true; + const actual = watchesCreatedOnMove[2].disposed; + expect(actual).toBe(expected); + }); + + // A second move supersedes the first move's trio; nothing else holds them, so leaving them // undisposed leaks a live fs watch per move, still firing on the departed directory. it('disposes the config watch a prior move created when a second move supersedes it', () => { const { provider, emitChange, watchesCreatedOnMove } = buildMoveHandler(); @@ -150,7 +165,7 @@ describe('WorkingDirectoryMoveHandler', () => { emitChange('/first/move'); emitChange('/second/move'); const expected = true; - const actual = watchesCreatedOnMove[2].disposed; + const actual = watchesCreatedOnMove[3].disposed; expect(actual).toBe(expected); }); @@ -171,7 +186,17 @@ describe('WorkingDirectoryMoveHandler', () => { emitChange('/first/move'); emitChange('/second/move'); const expected = true; - const actual = watchesCreatedOnMove[3].disposed; + const actual = watchesCreatedOnMove[4].disposed; + expect(actual).toBe(expected); + }); + + it('disposes the policy watch a prior move created when a second move supersedes it', () => { + const { provider, emitChange, watchesCreatedOnMove } = buildMoveHandler(); + provider.resolve(IWorkingDirectoryMoveHandler).wire(); + emitChange('/first/move'); + emitChange('/second/move'); + const expected = true; + const actual = watchesCreatedOnMove[5].disposed; expect(actual).toBe(expected); }); }); diff --git a/apps/claude-sdk-cli/test/cli-config.spec.ts b/apps/claude-sdk-cli/test/cli-config.spec.ts index 71201b21..c7463dc8 100644 --- a/apps/claude-sdk-cli/test/cli-config.spec.ts +++ b/apps/claude-sdk-cli/test/cli-config.spec.ts @@ -1,3 +1,4 @@ +import { defaultPolicy } from '@shellicar/claude-sdk-tools/Policy'; import { describe, expect, it } from 'vitest'; import { sdkConfigSchema } from '../src/cli-config/schema.js'; @@ -27,6 +28,7 @@ describe('sdkConfigSchema', () => { }, hooks: { approvalNotify: null }, tools: { exec: false, execV2: false, execV3: true, blockedCommands: [], rules: {} }, + policy: defaultPolicy, input: { escFastPath: true }, disabledTools: [], requiredSkills: {}, diff --git a/apps/claude-sdk-cli/test/createAppTools.spec.ts b/apps/claude-sdk-cli/test/createAppTools.spec.ts index 92e3a7e3..4fe30f38 100644 --- a/apps/claude-sdk-cli/test/createAppTools.spec.ts +++ b/apps/claude-sdk-cli/test/createAppTools.spec.ts @@ -1,12 +1,9 @@ import { Clock } from '@js-joda/core'; import type { IHistoryReader } from '@shellicar/claude-core/history/interfaces'; import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; -import type { ToolBlockLifetime } from '@shellicar/claude-sdk'; import { type IEnvProvider, StaticRulesConfigProvider } from '@shellicar/claude-sdk-tools/ExecV3'; -import type { Definition, DefinitionOptions, Diagnostic, DiagnosticsOptions, HoverInfo, HoverOptions, ITypeScriptService, Reference, ReferencesOptions } from '@shellicar/claude-sdk-tools/TsService'; import { describe, expect, it } from 'vitest'; import { createAppTools } from '../src/createAppTools.js'; -import { getPermission, PermissionAction, type PermissionConfig } from '../src/permissions.js'; import type { ISecrets } from '../src/secrets/Secrets.js'; import { MemoryFileSystem } from './MemoryFileSystem.js'; import { MemoryObjectStore } from './MemoryObjectStore.js'; @@ -26,56 +23,9 @@ const rulesProvider = new StaticRulesConfigProvider(); const getAzAccounts = () => ({}); const clock = Clock.systemDefaultZone(); -// ITypeScriptService is a type-only export from the package entry — it has no runtime -// value there. Build a plain structural stub and cast it; no class inheritance needed. -const tsServer = { - getDiagnostics: (_options: DiagnosticsOptions): Promise => Promise.resolve([]), - getHoverInfo: (_options: HoverOptions): Promise => Promise.resolve(null), - getReferences: (_options: ReferencesOptions): Promise => Promise.resolve([]), - getDefinition: (_options: DefinitionOptions): Promise => Promise.resolve([]), - blockEnded: (): Promise => Promise.resolve(), -} as unknown as ITypeScriptService & ToolBlockLifetime; - -// A pipe's stage steps (Read, Match, …) are not registered standalone, so they are absent from -// `tools`; the permission resolver walks each step by name, so it must use `permissionTools`. -const PIPE_STAGES = ['Read', 'Match', 'Head', 'Tail', 'Range']; -const CWD = '/project'; -const permMatrix: PermissionConfig = { - default: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Ask }, - outside: { read: PermissionAction.Approve, write: PermissionAction.Ask, delete: PermissionAction.Deny }, -}; - -describe('createAppTools — permission resolution for pipe stages', () => { - it('exposes every pipe stage in permissionTools so a stage step resolves', () => { - const { permissionTools } = createAppTools({ fs, tsServer, toolsConfig: { exec: false, execV2: false, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); - - const expected = true; - const actual = PIPE_STAGES.every((name) => permissionTools.some((t) => t.name === name)); - expect(actual).toBe(expected); - }); - - it('does not auto-deny a pipe containing a stage', () => { - const { permissionTools } = createAppTools({ fs, tsServer, toolsConfig: { exec: false, execV2: false, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); - const pipe = { - name: 'Pipe', - input: { - steps: [ - { tool: 'Find', input: { path: `${CWD}/src` } }, - { tool: 'Read', input: {} }, - { tool: 'Match', input: { pattern: 'x' } }, - ], - }, - }; - - const expected = PermissionAction.Approve; - const actual = getPermission(pipe, permissionTools, CWD, permMatrix); - expect(actual).toBe(expected); - }); -}); - describe('createAppTools — tool selection', () => { it('includes ExecV2 when execV2 is true', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: false, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: false, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = true; const actual = tools.some((t) => t.name === 'ExecV2'); @@ -83,7 +33,7 @@ describe('createAppTools — tool selection', () => { }); it('excludes Exec when exec is false', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: false, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: false, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = false; const actual = tools.some((t) => t.name === 'Exec'); @@ -91,7 +41,7 @@ describe('createAppTools — tool selection', () => { }); it('includes Exec when exec is true', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: true, execV2: false, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: true, execV2: false, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = true; const actual = tools.some((t) => t.name === 'Exec'); @@ -99,7 +49,7 @@ describe('createAppTools — tool selection', () => { }); it('excludes ExecV2 when execV2 is false', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: true, execV2: false, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: true, execV2: false, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = false; const actual = tools.some((t) => t.name === 'ExecV2'); @@ -107,7 +57,7 @@ describe('createAppTools — tool selection', () => { }); it('includes ExecV3 when execV3 is true', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: false, execV2: false, execV3: true }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: false, execV2: false, execV3: true }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = true; const actual = tools.some((t) => t.name === 'ExecV3'); @@ -115,7 +65,7 @@ describe('createAppTools — tool selection', () => { }); it('excludes ExecV3 when execV3 is false', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: false, execV2: false, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: false, execV2: false, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = false; const actual = tools.some((t) => t.name === 'ExecV3'); @@ -123,7 +73,7 @@ describe('createAppTools — tool selection', () => { }); it('includes Exec when both are true', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = true; const actual = tools.some((t) => t.name === 'Exec'); @@ -131,7 +81,7 @@ describe('createAppTools — tool selection', () => { }); it('includes ExecV2 when both are true', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = true; const actual = tools.some((t) => t.name === 'ExecV2'); @@ -141,7 +91,7 @@ describe('createAppTools — tool selection', () => { describe('createAppTools — TS tool availability', () => { it('includes TsDiagnostics when typescript is available', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: true, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = true; const actual = tools.some((t) => t.name === 'TsDiagnostics'); @@ -149,7 +99,7 @@ describe('createAppTools — TS tool availability', () => { }); it('excludes TsDiagnostics when typescript is unavailable', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: false, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: false, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = false; const actual = tools.some((t) => t.name === 'TsDiagnostics'); @@ -157,7 +107,7 @@ describe('createAppTools — TS tool availability', () => { }); it('excludes every TS tool when typescript is unavailable', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: false, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: false, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = 0; const actual = tools.filter((t) => ['TsDiagnostics', 'TsHover', 'TsReferences', 'TsDefinition'].includes(t.name)).length; @@ -165,10 +115,10 @@ describe('createAppTools — TS tool availability', () => { }); it('keeps non-TS tools when typescript is unavailable', () => { - const { tools } = createAppTools({ fs, tsServer, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: false, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); + const { tools } = createAppTools({ fs, toolsConfig: { exec: true, execV2: true, execV3: false }, objects: new MemoryObjectStore(), memory: new RecordingMemoryStore(), history, currentSessionId, clock, tsAvailable: false, logger: noopLogger, secrets, envProvider, rulesProvider, getAzAccounts }); const expected = true; - const actual = tools.some((t) => t.name === 'ReadFile'); + const actual = tools.some((t) => t.name === 'EditFile'); expect(actual).toBe(expected); }); }); diff --git a/apps/claude-sdk-cli/test/producer.conformance.spec.ts b/apps/claude-sdk-cli/test/producer.conformance.spec.ts index 7ec50603..cc7f4f83 100644 --- a/apps/claude-sdk-cli/test/producer.conformance.spec.ts +++ b/apps/claude-sdk-cli/test/producer.conformance.spec.ts @@ -196,7 +196,7 @@ function runApprovalProducer(): Captured[] { services.register(ApprovalHolder).as(IApprovalHolder); const holder = services.buildProvider().resolve(IApprovalHolder); - const req = { type: 'tool_approval_request', requestId: 'apr-1', name: 'DeleteFile', input: { content: { type: 'files', values: ['./old.ts'] } } } satisfies SdkToolApprovalRequest; + const req = { type: 'tool_approval_request', requestId: 'apr-1', toolUseId: 'apr-1', name: 'DeleteFile', input: { content: { type: 'files', values: ['./old.ts'] } } } satisfies SdkToolApprovalRequest; vi.useFakeTimers(); try { diff --git a/apps/claude-sdk-cli/test/renderToolApproval.spec.ts b/apps/claude-sdk-cli/test/renderToolApproval.spec.ts index 5feb8d32..b6eb1e02 100644 --- a/apps/claude-sdk-cli/test/renderToolApproval.spec.ts +++ b/apps/claude-sdk-cli/test/renderToolApproval.spec.ts @@ -118,6 +118,34 @@ describe('renderToolApproval — multiple tools', () => { }); }); +// --------------------------------------------------------------------------- +// Orchestrate stage label +// --------------------------------------------------------------------------- + +describe('renderToolApproval — Orchestrate stage label', () => { + it('approvalRow includes the stage position when a pipeline has more than one stage', () => { + const state = new ToolApprovalState(); + state.addTool({ requestId: 'a:1', name: 'Program', input: { program: 'rm' }, stageIndex: 2, stageCount: 3 }); + const expected = true; + const actual = renderToolApproval(state, COLS, MAX_ROWS).approvalRow.includes('(stage 2 of 3)'); + expect(actual).toBe(expected); + }); + + it('approvalRow omits the stage label for a single-stage call', () => { + const state = new ToolApprovalState(); + state.addTool({ requestId: 'a:0', name: 'Find', input: { path: '/tmp' }, stageIndex: 1, stageCount: 1 }); + const expected = false; + const actual = renderToolApproval(state, COLS, MAX_ROWS).approvalRow.includes('stage'); + expect(actual).toBe(expected); + }); + + it('approvalRow omits the stage label for a plain V1 tool with no stage info at all', () => { + const expected = false; + const actual = renderToolApproval(stateWithTool(), COLS, MAX_ROWS).approvalRow.includes('stage'); + expect(actual).toBe(expected); + }); +}); + // --------------------------------------------------------------------------- // Expand / collapse // --------------------------------------------------------------------------- diff --git a/apps/claude-sdk-cli/test/servicer.conformance.spec.ts b/apps/claude-sdk-cli/test/servicer.conformance.spec.ts index 7e34fc44..954ed61e 100644 --- a/apps/claude-sdk-cli/test/servicer.conformance.spec.ts +++ b/apps/claude-sdk-cli/test/servicer.conformance.spec.ts @@ -138,16 +138,21 @@ function buildApprovalHolder(bus: CapturingBus): IApprovalHolder { } const answerReq = (approved: boolean): Uint8Array => encode({ type: 'answer', ts: TS, from: { kind: 'human', userId: 'stephen' }, approved }); -const req = { type: 'tool_approval_request', requestId: 'apr-1', name: 'DeleteFile', input: { content: { type: 'files', values: ['./old.ts'] } } } satisfies SdkToolApprovalRequest; + +/** The approvalId is a uuid minted per ask, so the subject is discovered from what was served + * rather than reconstructed from the requestId. */ +const servedAnswerSubject = (bus: CapturingBus): string => [...bus.serves.keys()].find((s) => s.endsWith('.requests')) ?? ''; +const req = { type: 'tool_approval_request', requestId: 'apr-1', toolUseId: 'apr-1', name: 'DeleteFile', input: { content: { type: 'files', values: ['./old.ts'] } } } satisfies SdkToolApprovalRequest; describe('servicer conformance — approval', () => { it('accepts the first valid answer', () => { const bus = new CapturingBus(); const holder = buildApprovalHolder(bus); void holder.raise(req, { conversationId: 'conv-abc', toolUseId: 'toolu_02DEF' }); - const handler = bus.serves.get('approval.v1.apr-1.requests'); + const subject = servedAnswerSubject(bus); + const handler = bus.serves.get(subject); const expected = true; - const actual = handler !== undefined ? decode(handler(answerReq(true), 'approval.v1.apr-1.requests')).accepted : undefined; + const actual = handler !== undefined ? decode(handler(answerReq(true), subject)).accepted : undefined; expect(actual).toBe(expected); }); @@ -155,13 +160,14 @@ describe('servicer conformance — approval', () => { const bus = new CapturingBus(); const holder = buildApprovalHolder(bus); void holder.raise(req, { conversationId: 'conv-abc', toolUseId: 'toolu_02DEF' }); - const handler = bus.serves.get('approval.v1.apr-1.requests'); + const subject = servedAnswerSubject(bus); + const handler = bus.serves.get(subject); if (handler !== undefined) { - handler(answerReq(true), 'approval.v1.apr-1.requests'); + handler(answerReq(true), subject); holder.settle('apr-1', { approved: true, by: { kind: 'human', userId: 'stephen' } }); } const expected = 'already_settled'; - const actual = handler !== undefined ? decode(handler(answerReq(false), 'approval.v1.apr-1.requests')).reason : undefined; + const actual = handler !== undefined ? decode(handler(answerReq(false), subject)).reason : undefined; expect(actual).toBe(expected); }); }); diff --git a/packages/claude-core/package.json b/packages/claude-core/package.json index 5c4bc1f8..04c42edd 100644 --- a/packages/claude-core/package.json +++ b/packages/claude-core/package.json @@ -58,7 +58,7 @@ }, "dependencies": { "@js-joda/core": "^5.7.0", - "@shellicar/core-di": "^5.0.0-alpha.3", + "@shellicar/core-di": "^5.0.0-alpha.5", "ansi-regex": "6.2.2", "string-width": "^8.2.2", "zod": "^4.4.3" diff --git a/packages/claude-sdk-tools/CHANGELOG.md b/packages/claude-sdk-tools/CHANGELOG.md index 1888e2d0..8139c742 100644 --- a/packages/claude-sdk-tools/CHANGELOG.md +++ b/packages/claude-sdk-tools/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A pipeline stage can publish its output as a named variable other stages reference, so a credential can be passed between commands without its value ever being returned to the model +- A policy path pattern now supports a glob anywhere in it, single * within one segment and ** across any number of them, instead of only a directory prefix - Add a load-only Skill tool that resolves a skill by name from the configured roots and returns its body with frontmatter stripped; discovery stays in the injected catalogue, not the tool - Add a permissions regression test asserting an escalate operation always resolves to Ask, even when every other operation is configured to auto-approve - Add a README describing the package and pointing to the main documentation @@ -46,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - GitHub_PullRequest_* tools accept an optional cwd so they can target a repo other than the CLI's own working directory - GitHub_PullRequest_Create accepts milestone, reviewer, assignee, and label; GitHub_PullRequest_Edit accepts addAssignee/removeAssignee, addReviewer/removeReviewer, milestone, and removeMilestone - IFileSystem abstraction with NodeFileSystem and MemoryFileSystem for testing +- New Orchestrate tool and Tools V2 registry: several tools run as one composable pipeline, joined by |, && and ||, instead of a tool call each +- New policy engine deciding allow/ask/deny per call from an ordered rule list matched on tool, input, path and operation, with each path in a call judged on its own and the strictest verdict governing - Path expansion supporting ~, $HOME, and relative paths in all tools - Pipe tool for chaining tool outputs - PreviewEdit and EditFile tools for staged edits with diff preview @@ -104,6 +108,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ReadFile rejects images whose base64 payload exceeds the Anthropic API 5 MB per-image cap - Tear down a pipe stage's upstream when its consumer exits, so pipelines like find | head no longer hang - The TypeScript tools now read each file fresh from disk, spawning a short-lived tsserver per tool block instead of a session-long server that kept reporting its first snapshot +- TypeScript tools no longer hang when a symbol's documentation contains a non-ASCII character: a tsserver reply was framed by character count rather than byte count, so a hover on a symbol documented with an em dash never completed and timed out after 30 seconds ### Security diff --git a/packages/claude-sdk-tools/changes.jsonl b/packages/claude-sdk-tools/changes.jsonl index 0276a049..d759d406 100644 --- a/packages/claude-sdk-tools/changes.jsonl +++ b/packages/claude-sdk-tools/changes.jsonl @@ -87,3 +87,8 @@ {"description":"AzCli, EscalatedAzCli, and every AzureDevOps.PullRequest.* tool now honor cancellation — an in-progress az login or command can be aborted instead of blocking until the process crashes or restarts","category":"fixed"} {"description":"An interactive az identity no longer gets a silent, unattended background relogin; the browser/MFA prompt only ever appears attached to a real caller's call","category":"fixed"} {"description":"The az session's own login and command env now strips the same ambient Azure credential vars ExecV3 strips, so the CLI's own environment can no longer steer a login it believes it fully controls","category":"security"} +{"description":"New Orchestrate tool and Tools V2 registry: several tools run as one composable pipeline, joined by |, && and ||, instead of a tool call each","category":"added"} +{"description":"New policy engine deciding allow/ask/deny per call from an ordered rule list matched on tool, input, path and operation, with each path in a call judged on its own and the strictest verdict governing","category":"added"} +{"description":"A policy path pattern now supports a glob anywhere in it, single * within one segment and ** across any number of them, instead of only a directory prefix","category":"added"} +{"description":"A pipeline stage can publish its output as a named variable other stages reference, so a credential can be passed between commands without its value ever being returned to the model","category":"added"} +{"description":"TypeScript tools no longer hang when a symbol's documentation contains a non-ASCII character: a tsserver reply was framed by character count rather than byte count, so a hover on a symbol documented with an em dash never completed and timed out after 30 seconds","category":"fixed"} diff --git a/packages/claude-sdk-tools/package.json b/packages/claude-sdk-tools/package.json index c5fa693f..82281120 100644 --- a/packages/claude-sdk-tools/package.json +++ b/packages/claude-sdk-tools/package.json @@ -345,6 +345,26 @@ "types": "./dist/cjs/Az.d.cts", "default": "./dist/cjs/Az.cjs" } + }, + "./Orchestrate": { + "import": { + "types": "./dist/esm/Orchestrate.d.ts", + "default": "./dist/esm/Orchestrate.js" + }, + "require": { + "types": "./dist/cjs/Orchestrate.d.cts", + "default": "./dist/cjs/Orchestrate.cjs" + } + }, + "./Policy": { + "import": { + "types": "./dist/esm/Policy.d.ts", + "default": "./dist/esm/Policy.js" + }, + "require": { + "types": "./dist/cjs/Policy.d.cts", + "default": "./dist/cjs/Policy.cjs" + } } }, "scripts": { @@ -361,8 +381,9 @@ "@js-joda/core": "^5.7.0", "@shellicar/claude-core": "workspace:^", "@shellicar/claude-sdk": "workspace:^", - "@shellicar/core-di": "^5.0.0-alpha.3", + "@shellicar/core-di": "^5.0.0-alpha.5", "@shellicar/exec-core": "workspace:^", + "@shellicar/orchestrate-core": "workspace:^", "diff": "^8.0.4", "file-type": "^22.0.1", "yaml": "^2.8.1", diff --git a/packages/claude-sdk-tools/src/Az/createAzTool.ts b/packages/claude-sdk-tools/src/Az/createAzTool.ts index cc175d0b..dd7d8e9a 100644 --- a/packages/claude-sdk-tools/src/Az/createAzTool.ts +++ b/packages/claude-sdk-tools/src/Az/createAzTool.ts @@ -29,7 +29,8 @@ export function resolveAzAccount(getAccounts: () => AzAccountsConfig, identity: } const account = requested ?? (fallback != null && configured.includes(fallback) ? fallback : undefined) ?? (configured.length === 1 ? configured[0] : undefined); if (account == null) { - throw new Error('account is required when more than one Azure account is configured'); + // Name them: otherwise the only way to discover what to pass is to go and read the config file. + throw new Error(`account is required when more than one Azure account is configured: ${configured.join(', ')}`); } if (!configured.includes(account)) { throw new Error(`account '${account}' has no ${identity} identity configured`); diff --git a/packages/claude-sdk-tools/src/AzureDevOps/orgArgs.ts b/packages/claude-sdk-tools/src/AzureDevOps/orgArgs.ts new file mode 100644 index 00000000..86e3da6b --- /dev/null +++ b/packages/claude-sdk-tools/src/AzureDevOps/orgArgs.ts @@ -0,0 +1,11 @@ +import type { AdoRemoteContext } from './parseAdoRemote'; + +/** Resolution order for `--org`: the model's explicit `org` input wins; otherwise the org parsed + * from the target repo's own git remote (see parseAdoRemote). No config-level default — between + * remote parsing and the explicit input field, there is always a way to supply it, so a third, + * harder-to-discover fallback layer only adds a place for the wrong org to hide. Omitted entirely + * when neither source has one, so `az`'s own error names what's actually missing. */ +export function orgArgs(org: string | undefined, remote: AdoRemoteContext | null): string[] { + const resolved = org ?? remote?.orgUrl; + return resolved != null ? ['--org', resolved] : []; +} diff --git a/packages/claude-sdk-tools/src/AzureDevOps/specs.ts b/packages/claude-sdk-tools/src/AzureDevOps/specs.ts new file mode 100644 index 00000000..61aa106a --- /dev/null +++ b/packages/claude-sdk-tools/src/AzureDevOps/specs.ts @@ -0,0 +1,109 @@ +import type { AdoPrToolSpec } from './createAdoPrTool'; +import { orgArgs } from './orgArgs'; +import { AdoPrCreateInputSchema, AdoPrEditInputSchema, AdoPrReadyInputSchema, AdoPrReviewerAddInputSchema, AdoPrReviewerRemoveInputSchema, AdoPrVoteInputSchema } from './schema'; + +/** Six of the seven named AzureDevOps.PullRequest.* specs (AutoMerge is built directly, not from a + * spec — see `createAdoAutoMergeTool`/`createAdoAutoMergeToolV2`) — pure data plus a `buildArgs` + * closure, shared verbatim between V1 (`createAdoPrTools`) and V2 (`createAdoPrToolsV2`). */ + +export const adoPrCreateSpec: AdoPrToolSpec = { + name: 'AzureDevOps_PullRequest_Create', + description: 'Open a new pull request as a draft. Always passes --draft — AzureDevOps_PullRequest_Ready is the separate step that promotes it out of draft.', + input_schema: AdoPrCreateInputSchema, + input_examples: [{ title: 'Fix the flaky retry test', sourceBranch: 'fix/flaky-retry', description: 'Retries now back off exponentially.' }], + subcommand: ['create'], + buildArgs: (input, remote) => { + const args = ['--title', input.title, '--source-branch', input.sourceBranch, ...orgArgs(input.org, remote)]; + if (input.description != null) { + args.push('--description', input.description); + } + if (input.targetBranch != null) { + args.push('--target-branch', input.targetBranch); + } + args.push('--draft', 'true'); + const project = input.project ?? remote?.project; + if (project != null) { + args.push('--project', project); + } + const repository = input.repository ?? remote?.repository; + if (repository != null) { + args.push('--repository', repository); + } + if (input.reviewers != null && input.reviewers.length > 0) { + args.push('--reviewers', ...input.reviewers); + } + if (input.requiredReviewers != null && input.requiredReviewers.length > 0) { + args.push('--required-reviewers', ...input.requiredReviewers); + } + if (input.workItems != null && input.workItems.length > 0) { + args.push('--work-items', ...input.workItems); + } + if (input.labels != null && input.labels.length > 0) { + args.push('--labels', ...input.labels); + } + return args; + }, +}; + +export const adoPrReadySpec: AdoPrToolSpec = { + name: 'AzureDevOps_PullRequest_Ready', + description: 'Publish a draft pull request, taking it out of draft/work-in-progress mode.', + input_schema: AdoPrReadyInputSchema, + input_examples: [{ id: 42 }], + subcommand: ['update'], + buildArgs: (input, remote) => ['--id', String(input.id), '--draft', 'false', ...orgArgs(input.org, remote)], +}; + +export const adoPrEditSpec: AdoPrToolSpec = { + name: 'AzureDevOps_PullRequest_Edit', + description: 'Edit an existing pull request: title, description, or abandon it. Cannot complete (merge) a pull request — that status value is not accepted; use AzureDevOps_PullRequest_AutoMerge instead.', + input_schema: AdoPrEditInputSchema, + input_examples: [{ id: 42, title: 'Updated title' }], + subcommand: ['update'], + buildArgs: (input, remote) => { + const args = ['--id', String(input.id), ...orgArgs(input.org, remote)]; + if (input.title != null) { + args.push('--title', input.title); + } + if (input.description != null) { + args.push('--description', input.description); + } + if (input.status != null) { + args.push('--status', input.status); + } + return args; + }, +}; + +export const adoPrReviewerAddSpec: AdoPrToolSpec = { + name: 'AzureDevOps_PullRequest_ReviewerAdd', + description: 'Add one or more reviewers to a pull request.', + input_schema: AdoPrReviewerAddInputSchema, + input_examples: [{ id: 42, reviewers: ['jane@example.com'] }], + subcommand: ['reviewer', 'add'], + buildArgs: (input, remote) => { + const args = ['--id', String(input.id), '--reviewers', ...input.reviewers, ...orgArgs(input.org, remote)]; + if (input.required != null) { + args.push('--required', String(input.required)); + } + return args; + }, +}; + +export const adoPrReviewerRemoveSpec: AdoPrToolSpec = { + name: 'AzureDevOps_PullRequest_ReviewerRemove', + description: 'Remove one or more reviewers from a pull request.', + input_schema: AdoPrReviewerRemoveInputSchema, + input_examples: [{ id: 42, reviewers: ['jane@example.com'] }], + subcommand: ['reviewer', 'remove'], + buildArgs: (input, remote) => ['--id', String(input.id), '--reviewers', ...input.reviewers, ...orgArgs(input.org, remote)], +}; + +export const adoPrVoteSpec: AdoPrToolSpec = { + name: 'AzureDevOps_PullRequest_Vote', + description: "Vote on a pull request. Cannot approve — 'approve' is not a value this tool's vote field can hold.", + input_schema: AdoPrVoteInputSchema, + input_examples: [{ id: 42, vote: 'wait-for-author' }], + subcommand: ['set-vote'], + buildArgs: (input, remote) => ['--id', String(input.id), '--vote', input.vote, ...orgArgs(input.org, remote)], +}; diff --git a/packages/claude-sdk-tools/src/AzureDevOps/tools.ts b/packages/claude-sdk-tools/src/AzureDevOps/tools.ts index a9d837c9..28880738 100644 --- a/packages/claude-sdk-tools/src/AzureDevOps/tools.ts +++ b/packages/claude-sdk-tools/src/AzureDevOps/tools.ts @@ -2,18 +2,10 @@ import type { AzSessionCache } from '../Az/AzSessionCache'; import type { AzAccountsConfig } from '../Az/tools'; import { createAdoAutoMergeTool } from './createAdoAutoMergeTool'; import { type AdoEscalatedDeps, createAdoPrTool } from './createAdoPrTool'; -import type { AdoRemoteContext } from './parseAdoRemote'; -import { AdoPrCreateInputSchema, AdoPrEditInputSchema, AdoPrReadyInputSchema, AdoPrReviewerAddInputSchema, AdoPrReviewerRemoveInputSchema, AdoPrVoteInputSchema } from './schema'; +import { orgArgs } from './orgArgs'; +import { adoPrCreateSpec, adoPrEditSpec, adoPrReadySpec, adoPrReviewerAddSpec, adoPrReviewerRemoveSpec, adoPrVoteSpec } from './specs'; -/** Resolution order for `--org`: the model's explicit `org` input wins; otherwise the org parsed - * from the target repo's own git remote (see parseAdoRemote). No config-level default — between - * remote parsing and the explicit input field, there is always a way to supply it, so a third, - * harder-to-discover fallback layer only adds a place for the wrong org to hide. Omitted entirely - * when neither source has one, so `az`'s own error names what's actually missing. */ -export function orgArgs(org: string | undefined, remote: AdoRemoteContext | null): string[] { - const resolved = org ?? remote?.orgUrl; - return resolved != null ? ['--org', resolved] : []; -} +export { orgArgs }; export const ADO_PR_TOOL_NAMES = ['AzureDevOps_PullRequest_Create', 'AzureDevOps_PullRequest_Ready', 'AzureDevOps_PullRequest_Edit', 'AzureDevOps_PullRequest_AutoMerge', 'AzureDevOps_PullRequest_ReviewerAdd', 'AzureDevOps_PullRequest_ReviewerRemove', 'AzureDevOps_PullRequest_Vote'] as const; @@ -36,143 +28,13 @@ export const ADO_PR_TOOL_NAMES = ['AzureDevOps_PullRequest_Create', 'AzureDevOps * PR call and an `EscalatedAzCli` call against the same account share one warm login instead of * each keeping its own. */ export function createAdoPrTools(deps: AdoEscalatedDeps, getAccounts: () => AzAccountsConfig, cache: AzSessionCache) { - const Create = createAdoPrTool( - { - name: 'AzureDevOps_PullRequest_Create', - description: 'Open a new pull request as a draft. Always passes --draft — AzureDevOps_PullRequest_Ready is the separate step that promotes it out of draft.', - input_schema: AdoPrCreateInputSchema, - input_examples: [{ title: 'Fix the flaky retry test', sourceBranch: 'fix/flaky-retry', description: 'Retries now back off exponentially.' }], - subcommand: ['create'], - buildArgs: (input, remote) => { - const args = ['--title', input.title, '--source-branch', input.sourceBranch, ...orgArgs(input.org, remote)]; - if (input.description != null) { - args.push('--description', input.description); - } - if (input.targetBranch != null) { - args.push('--target-branch', input.targetBranch); - } - args.push('--draft', 'true'); - // project/repository: explicit input wins, then the git remote's own project/repository - // (parsed alongside org — `az`'s own `--detect` never resolves project, only organization, - // so this is the only reliable source besides the model naming them directly), otherwise - // omitted entirely so az's own error names what's actually missing. - const project = input.project ?? remote?.project; - if (project != null) { - args.push('--project', project); - } - const repository = input.repository ?? remote?.repository; - if (repository != null) { - args.push('--repository', repository); - } - if (input.reviewers != null && input.reviewers.length > 0) { - args.push('--reviewers', ...input.reviewers); - } - if (input.requiredReviewers != null && input.requiredReviewers.length > 0) { - args.push('--required-reviewers', ...input.requiredReviewers); - } - if (input.workItems != null && input.workItems.length > 0) { - args.push('--work-items', ...input.workItems); - } - if (input.labels != null && input.labels.length > 0) { - args.push('--labels', ...input.labels); - } - return args; - }, - }, - deps, - getAccounts, - cache, - ); - - const Ready = createAdoPrTool( - { - name: 'AzureDevOps_PullRequest_Ready', - description: 'Publish a draft pull request, taking it out of draft/work-in-progress mode.', - input_schema: AdoPrReadyInputSchema, - input_examples: [{ id: 42 }], - subcommand: ['update'], - buildArgs: (input, remote) => ['--id', String(input.id), '--draft', 'false', ...orgArgs(input.org, remote)], - }, - deps, - getAccounts, - cache, - ); - - const Edit = createAdoPrTool( - { - name: 'AzureDevOps_PullRequest_Edit', - description: 'Edit an existing pull request: title, description, or abandon it. Cannot complete (merge) a pull request — that status value is not accepted; use AzureDevOps_PullRequest_AutoMerge instead.', - input_schema: AdoPrEditInputSchema, - input_examples: [{ id: 42, title: 'Updated title' }], - subcommand: ['update'], - buildArgs: (input, remote) => { - const args = ['--id', String(input.id), ...orgArgs(input.org, remote)]; - if (input.title != null) { - args.push('--title', input.title); - } - if (input.description != null) { - args.push('--description', input.description); - } - if (input.status != null) { - args.push('--status', input.status); - } - return args; - }, - }, - deps, - getAccounts, - cache, - ); - - const AutoMerge = createAdoAutoMergeTool(deps, getAccounts, cache); - - const ReviewerAdd = createAdoPrTool( - { - name: 'AzureDevOps_PullRequest_ReviewerAdd', - description: 'Add one or more reviewers to a pull request.', - input_schema: AdoPrReviewerAddInputSchema, - input_examples: [{ id: 42, reviewers: ['jane@example.com'] }], - subcommand: ['reviewer', 'add'], - buildArgs: (input, remote) => { - const args = ['--id', String(input.id), '--reviewers', ...input.reviewers, ...orgArgs(input.org, remote)]; - if (input.required != null) { - args.push('--required', String(input.required)); - } - return args; - }, - }, - deps, - getAccounts, - cache, - ); - - const ReviewerRemove = createAdoPrTool( - { - name: 'AzureDevOps_PullRequest_ReviewerRemove', - description: 'Remove one or more reviewers from a pull request.', - input_schema: AdoPrReviewerRemoveInputSchema, - input_examples: [{ id: 42, reviewers: ['jane@example.com'] }], - subcommand: ['reviewer', 'remove'], - buildArgs: (input, remote) => ['--id', String(input.id), '--reviewers', ...input.reviewers, ...orgArgs(input.org, remote)], - }, - deps, - getAccounts, - cache, - ); - - const Vote = createAdoPrTool( - { - name: 'AzureDevOps_PullRequest_Vote', - description: "Vote on a pull request. Cannot approve — 'approve' is not a value this tool's vote field can hold.", - input_schema: AdoPrVoteInputSchema, - input_examples: [{ id: 42, vote: 'wait-for-author' }], - subcommand: ['set-vote'], - buildArgs: (input, remote) => ['--id', String(input.id), '--vote', input.vote, ...orgArgs(input.org, remote)], - }, - deps, - getAccounts, - cache, - ); - - return [Create, Ready, Edit, AutoMerge, ReviewerAdd, ReviewerRemove, Vote] as const; + return [ + createAdoPrTool(adoPrCreateSpec, deps, getAccounts, cache), + createAdoPrTool(adoPrReadySpec, deps, getAccounts, cache), + createAdoPrTool(adoPrEditSpec, deps, getAccounts, cache), + createAdoAutoMergeTool(deps, getAccounts, cache), + createAdoPrTool(adoPrReviewerAddSpec, deps, getAccounts, cache), + createAdoPrTool(adoPrReviewerRemoveSpec, deps, getAccounts, cache), + createAdoPrTool(adoPrVoteSpec, deps, getAccounts, cache), + ] as const; } diff --git a/packages/claude-sdk-tools/src/CreateFile/CreateFile.ts b/packages/claude-sdk-tools/src/CreateFile/CreateFile.ts index 480d5498..048bd990 100644 --- a/packages/claude-sdk-tools/src/CreateFile/CreateFile.ts +++ b/packages/claude-sdk-tools/src/CreateFile/CreateFile.ts @@ -1,5 +1,6 @@ import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; import { defineTool } from '@shellicar/claude-sdk'; +import { performCreateFile } from './performCreateFile'; import { CreateFileInputSchema, CreateFileOutputSchema } from './schema'; export function createCreateFile(fs: IFileSystem) { @@ -13,18 +14,8 @@ export function createCreateFile(fs: IFileSystem) { handler: async (input) => { // input.path arrives already expanded — the SDK replaced the marked path in place upstream. const filePath = input.path; - const { overwrite = false, content = '' } = input; - const exists = await fs.exists(filePath); - - if (!overwrite && exists) { - return { textContent: { error: true, message: 'File already exists. Set overwrite: true to replace it.', path: filePath } }; - } - if (overwrite && !exists) { - return { textContent: { error: true, message: 'File does not exist. Set overwrite: false to create it.', path: filePath } }; - } - - await fs.writeFile(filePath, content); - return { textContent: { error: false as const, path: filePath } }; + const result = await performCreateFile(fs, filePath, input.content ?? '', input.overwrite ?? false); + return { textContent: result.ok ? { error: false as const, path: filePath } : { error: true as const, message: result.message, path: filePath } }; }, }); } diff --git a/packages/claude-sdk-tools/src/CreateFile/performCreateFile.ts b/packages/claude-sdk-tools/src/CreateFile/performCreateFile.ts new file mode 100644 index 00000000..41766fd4 --- /dev/null +++ b/packages/claude-sdk-tools/src/CreateFile/performCreateFile.ts @@ -0,0 +1,18 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; + +export type PerformCreateFileResult = { ok: true } | { ok: false; message: string }; + +/** The whole CreateFile operation \u2014 check existence against `overwrite`, then write \u2014 shared + * verbatim between V1 and V2. Each caller translates this neutral result into its own output + * shape (V1: a structured `{error, message, path}`; V2: `success()` + a stderr line). */ +export async function performCreateFile(fs: IFileSystem, path: string, content: string, overwrite: boolean): Promise { + const exists = await fs.exists(path); + if (!overwrite && exists) { + return { ok: false, message: 'File already exists. Set overwrite: true to replace it.' }; + } + if (overwrite && !exists) { + return { ok: false, message: 'File does not exist. Set overwrite: false to create it.' }; + } + await fs.writeFile(path, content); + return { ok: true }; +} diff --git a/packages/claude-sdk-tools/src/EditFile/EditFile.ts b/packages/claude-sdk-tools/src/EditFile/EditFile.ts index ba63c59f..2c231e5d 100644 --- a/packages/claude-sdk-tools/src/EditFile/EditFile.ts +++ b/packages/claude-sdk-tools/src/EditFile/EditFile.ts @@ -1,57 +1,7 @@ import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; import { defineTool } from '@shellicar/claude-sdk'; -import { applyEdits } from './applyEdits'; -import { generateDiff } from './generateDiff'; -import { resolveAfterLine } from './resolveAfterLine'; +import { performEdit } from './performEdit'; import { EditFileInputSchema, EditFileOutputSchema } from './schema'; -import type { EditFileLineOperationType, EditFileTextOperationType } from './types'; -import { validateLineEdits } from './validateEdits'; - -function lineKey(total: number, edit: EditFileLineOperationType): number { - return edit.action === 'insert' ? resolveAfterLine(edit.after_line, total) : edit.startLine; -} - -function sortBottomToTop(total: number, edits: EditFileLineOperationType[]): EditFileLineOperationType[] { - return [...edits].sort((a, b) => lineKey(total, b) - lineKey(total, a)); -} - -function countOccurrences(content: string, needle: string): number { - return content.split(needle).length - 1; -} - -function applyReplaceText(content: string, edit: Extract, index: number): string { - const count = countOccurrences(content, edit.oldString); - if (count === 0) { - throw new Error(`textEdits[${index}] replace_text: "${edit.oldString}" not found in file`); - } - if (count > 1 && !edit.replaceMultiple) { - throw new Error(`textEdits[${index}] replace_text: "${edit.oldString}" matched ${count} times \u2014 set replaceMultiple: true to replace all`); - } - if (edit.replaceMultiple) { - return content.split(edit.oldString).join(edit.replacement); - } - const at = content.indexOf(edit.oldString); - return content.slice(0, at) + edit.replacement + content.slice(at + edit.oldString.length); -} - -function applyRegexText(content: string, edit: Extract, index: number): string { - const matches = [...content.matchAll(new RegExp(edit.pattern, 'g'))]; - if (matches.length === 0) { - throw new Error(`textEdits[${index}] regex_text: pattern "${edit.pattern}" not found in file`); - } - if (matches.length > 1 && !edit.replaceMultiple) { - throw new Error(`textEdits[${index}] regex_text: pattern "${edit.pattern}" matched ${matches.length} times \u2014 set replaceMultiple: true to replace all`); - } - return content.replace(new RegExp(edit.pattern, edit.replaceMultiple ? 'g' : ''), edit.replacement); -} - -function applyTextEdits(content: string, edits: EditFileTextOperationType[]): string { - let current = content; - edits.forEach((edit, index) => { - current = edit.action === 'replace_text' ? applyReplaceText(current, edit, index) : applyRegexText(current, edit, index); - }); - return current; -} export function createEditFile(fs: IFileSystem) { return defineTool({ @@ -100,17 +50,7 @@ export function createEditFile(fs: IFileSystem) { ], handler: async (input) => { // input.file arrives already expanded — the SDK replaced the marked path in place upstream. - const filePath = input.file; - const baseContent = await fs.readFile(filePath); - // ''.split('\n') yields [''] — one phantom line, not zero — which would make an empty - // file resolve after_line against a 1-line file instead of a 0-line one. - const baseLines = baseContent === '' ? [] : baseContent.split('\n'); - const sorted = sortBottomToTop(baseLines.length, input.lineEdits); - validateLineEdits(baseLines, sorted); - const afterLineEdits = applyEdits(baseLines, sorted); - const newContent = applyTextEdits(afterLineEdits.join('\n'), input.textEdits); - const diff = generateDiff(baseContent, newContent); - await fs.writeFile(filePath, newContent); + const diff = await performEdit(fs, input.file, input.lineEdits, input.textEdits); return { textContent: EditFileOutputSchema.parse(diff) }; }, }); diff --git a/packages/claude-sdk-tools/src/EditFile/applyTextEdits.ts b/packages/claude-sdk-tools/src/EditFile/applyTextEdits.ts new file mode 100644 index 00000000..33a9f6ea --- /dev/null +++ b/packages/claude-sdk-tools/src/EditFile/applyTextEdits.ts @@ -0,0 +1,48 @@ +import { resolveAfterLine } from './resolveAfterLine'; +import type { EditFileLineOperationType, EditFileTextOperationType } from './types'; + +function lineKey(total: number, edit: EditFileLineOperationType): number { + return edit.action === 'insert' ? resolveAfterLine(edit.after_line, total) : edit.startLine; +} + +export function sortBottomToTop(total: number, edits: EditFileLineOperationType[]): EditFileLineOperationType[] { + return [...edits].sort((a, b) => lineKey(total, b) - lineKey(total, a)); +} + +function countOccurrences(content: string, needle: string): number { + return content.split(needle).length - 1; +} + +function applyReplaceText(content: string, edit: Extract, index: number): string { + const count = countOccurrences(content, edit.oldString); + if (count === 0) { + throw new Error(`textEdits[${index}] replace_text: "${edit.oldString}" not found in file`); + } + if (count > 1 && !edit.replaceMultiple) { + throw new Error(`textEdits[${index}] replace_text: "${edit.oldString}" matched ${count} times \u2014 set replaceMultiple: true to replace all`); + } + if (edit.replaceMultiple) { + return content.split(edit.oldString).join(edit.replacement); + } + const at = content.indexOf(edit.oldString); + return content.slice(0, at) + edit.replacement + content.slice(at + edit.oldString.length); +} + +function applyRegexText(content: string, edit: Extract, index: number): string { + const matches = [...content.matchAll(new RegExp(edit.pattern, 'g'))]; + if (matches.length === 0) { + throw new Error(`textEdits[${index}] regex_text: pattern "${edit.pattern}" not found in file`); + } + if (matches.length > 1 && !edit.replaceMultiple) { + throw new Error(`textEdits[${index}] regex_text: pattern "${edit.pattern}" matched ${matches.length} times \u2014 set replaceMultiple: true to replace all`); + } + return content.replace(new RegExp(edit.pattern, edit.replaceMultiple ? 'g' : ''), edit.replacement); +} + +export function applyTextEdits(content: string, edits: EditFileTextOperationType[]): string { + let current = content; + edits.forEach((edit, index) => { + current = edit.action === 'replace_text' ? applyReplaceText(current, edit, index) : applyRegexText(current, edit, index); + }); + return current; +} diff --git a/packages/claude-sdk-tools/src/EditFile/performEdit.ts b/packages/claude-sdk-tools/src/EditFile/performEdit.ts new file mode 100644 index 00000000..91e830b5 --- /dev/null +++ b/packages/claude-sdk-tools/src/EditFile/performEdit.ts @@ -0,0 +1,24 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { applyEdits } from './applyEdits'; +import { applyTextEdits, sortBottomToTop } from './applyTextEdits'; +import { generateDiff } from './generateDiff'; +import type { EditFileLineOperationType, EditFileTextOperationType } from './types'; +import { validateLineEdits } from './validateEdits'; + +/** The whole EditFile operation \u2014 read, sort bottom-to-top, validate, apply line edits, apply + * text edits, diff, write \u2014 shared verbatim between V1 and V2. Neither wraps anything + * file-format- or tool-shape-specific around this; only how the returned diff string is + * packaged (V1: one JSON string; V2: split into lines) differs at the call site. */ +export async function performEdit(fs: IFileSystem, file: string, lineEdits: EditFileLineOperationType[], textEdits: EditFileTextOperationType[]): Promise { + const baseContent = await fs.readFile(file); + // ''.split('\n') yields [''] — one phantom line, not zero — which would make an empty file + // resolve after_line against a 1-line file instead of a 0-line one. + const baseLines = baseContent === '' ? [] : baseContent.split('\n'); + const sorted = sortBottomToTop(baseLines.length, lineEdits); + validateLineEdits(baseLines, sorted); + const afterLineEdits = applyEdits(baseLines, sorted); + const newContent = applyTextEdits(afterLineEdits.join('\n'), textEdits); + const diff = generateDiff(baseContent, newContent); + await fs.writeFile(file, newContent); + return diff; +} diff --git a/packages/claude-sdk-tools/src/Exec/ruleConfig.ts b/packages/claude-sdk-tools/src/Exec/ruleConfig.ts index 94088a64..e85cd458 100644 --- a/packages/claude-sdk-tools/src/Exec/ruleConfig.ts +++ b/packages/claude-sdk-tools/src/Exec/ruleConfig.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { normaliseArgs } from '../normaliseArgs'; import type { ExecRule } from './types'; /** A declarative safety rule's match/message fields. The rule's name is never a field on this @@ -54,32 +55,6 @@ function basename(program: string): string { return idx === -1 ? program : program.slice(idx + 1); } -/** `--foo=bar` -> `--foo` (the value is never matched on). A single-dash multi-character token is - * ambiguous on shape alone — `-ni` is bundled short flags (POSIX getopt: `-n -i`), but `-exec` is - * one word-flag (find's convention) — so both readings are kept rather than choosing: the token - * normalises to itself *plus* its exploded per-character short flags. `argsAnyOf: ['-exec']` still - * matches the literal token; `argsAnyOf: ['-i']` still matches `-ni` via the exploded form. */ -function normaliseArg(arg: string): string[] { - if (arg.startsWith('--')) { - const eq = arg.indexOf('='); - return [eq === -1 ? arg : arg.slice(0, eq)]; - } - if (arg.startsWith('-') && arg.length > 2) { - return [ - arg, - ...arg - .slice(1) - .split('') - .map((c) => `-${c}`), - ]; - } - return [arg]; -} - -function normaliseArgs(args: string[]): string[] { - return args.flatMap(normaliseArg); -} - /** A rule with none of these fields set would otherwise match every command — whatever * broke it (a typo, a forgotten field) must not silently turn into "block everything". */ const matcherFields = ['programs', 'programSuffix', 'argsAllOf', 'argsAnyOf', 'maxArgs'] as const; diff --git a/packages/claude-sdk-tools/src/Find/Find.ts b/packages/claude-sdk-tools/src/Find/Find.ts index c221ec75..197f2b82 100644 --- a/packages/claude-sdk-tools/src/Find/Find.ts +++ b/packages/claude-sdk-tools/src/Find/Find.ts @@ -1,3 +1,4 @@ +import { relative } from 'node:path'; import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; import { pathSchema } from '@shellicar/claude-sdk'; import { z } from 'zod'; @@ -22,6 +23,12 @@ export function createFind(fs: IFileSystem) { model: FindModel, input_examples: [{ path: '.' }, { path: 'src', pattern: '\\.ts$' }, { path: '.', type: 'directory' }, { path: '.', pattern: '\\.(ts|js)$' }], pipe: { in: null, out: 'files' }, + // Only Find knows both its path and its pattern belong in its own display: the central + // formatToolSummary has no way to know that priority for an arbitrary tool. + summarize: (model) => { + const rel = relative(fs.cwd(), model.path) || model.path; + return `Find(${model.pattern ? `${rel} ${model.pattern}` : rel})`; + }, run: async (model): Promise => { // model.path arrives already expanded: the SDK replaced the marked path in place before the // handler ran (standalone via the registry, or inside a pipe via the step descent). diff --git a/packages/claude-sdk-tools/src/GitHub/specs.ts b/packages/claude-sdk-tools/src/GitHub/specs.ts new file mode 100644 index 00000000..a4a443cf --- /dev/null +++ b/packages/claude-sdk-tools/src/GitHub/specs.ts @@ -0,0 +1,117 @@ +import type { GhPrToolSpec } from './createGhPrTool'; +import { GhPrAutoMergeInputSchema, GhPrCommentInputSchema, GhPrCreateInputSchema, GhPrEditInputSchema, GhPrReadyInputSchema, GhPrReviewInputSchema } from './schema'; + +/** The six named GitHub.PullRequest.* specs — pure data plus a `buildArgs` closure, shared + * verbatim between V1 (`createGhPrTools`) and V2 (`createGhPrToolsV2`), so the arg-building + * logic that is the actual structural safety guarantee is never duplicated between the two. */ + +export const ghPrCreateSpec: GhPrToolSpec = { + name: 'GitHub_PullRequest_Create', + description: 'Open a new pull request as a draft. Always passes --draft — GitHub_PullRequest_Ready is the separate step that promotes it out of draft.', + input_schema: GhPrCreateInputSchema, + input_examples: [{ title: 'Fix the flaky retry test', body: 'Retries now back off exponentially.', base: 'main' }], + subcommand: 'create', + buildArgs: (input) => { + const args = ['--title', input.title, '--body', input.body, '--base', input.base, '--draft']; + if (input.head != null) { + args.push('--head', input.head); + } + if (input.milestone != null) { + args.push('--milestone', input.milestone); + } + for (const reviewer of input.reviewer ?? []) { + args.push('--reviewer', reviewer); + } + for (const assignee of input.assignee ?? []) { + args.push('--assignee', assignee); + } + for (const label of input.label ?? []) { + args.push('--label', label); + } + return args; + }, +}; + +export const ghPrReadySpec: GhPrToolSpec = { + name: 'GitHub_PullRequest_Ready', + description: 'Mark a draft pull request as ready for review.', + input_schema: GhPrReadyInputSchema, + input_examples: [{ number: 42 }, {}], + subcommand: 'ready', + buildArgs: (input) => (input.number != null ? [String(input.number)] : []), +}; + +export const ghPrEditSpec: GhPrToolSpec = { + name: 'GitHub_PullRequest_Edit', + description: 'Edit an existing pull request: title, body, and labels.', + input_schema: GhPrEditInputSchema, + input_examples: [{ number: 42, addLabel: ['bug'] }], + subcommand: 'edit', + buildArgs: (input) => { + const args: string[] = input.number != null ? [String(input.number)] : []; + if (input.title != null) { + args.push('--title', input.title); + } + if (input.body != null) { + args.push('--body', input.body); + } + for (const label of input.addLabel ?? []) { + args.push('--add-label', label); + } + for (const label of input.removeLabel ?? []) { + args.push('--remove-label', label); + } + for (const assignee of input.addAssignee ?? []) { + args.push('--add-assignee', assignee); + } + for (const assignee of input.removeAssignee ?? []) { + args.push('--remove-assignee', assignee); + } + for (const reviewer of input.addReviewer ?? []) { + args.push('--add-reviewer', reviewer); + } + for (const reviewer of input.removeReviewer ?? []) { + args.push('--remove-reviewer', reviewer); + } + if (input.milestone != null) { + args.push('--milestone', input.milestone); + } + if (input.removeMilestone) { + args.push('--remove-milestone'); + } + return args; + }, +}; + +export const ghPrCommentSpec: GhPrToolSpec = { + name: 'GitHub_PullRequest_Comment', + description: 'Add a comment to a pull request.', + input_schema: GhPrCommentInputSchema, + input_examples: [{ number: 42, body: 'Looks good, one small thing below.' }], + subcommand: 'comment', + buildArgs: (input) => [...(input.number != null ? [String(input.number)] : []), '--body', input.body], +}; + +export const ghPrAutoMergeSpec: GhPrToolSpec = { + name: 'GitHub_PullRequest_AutoMerge', + description: 'Enable or disable auto-merge on a pull request. Never performs an immediate merge — only queues one via --auto plus a merge-strategy flag, or clears it via --disable-auto.', + input_schema: GhPrAutoMergeInputSchema, + input_examples: [{ number: 42, enable: true, strategy: 'squash' }], + subcommand: 'merge', + buildArgs: (input) => { + const numberArgs = input.number != null ? [String(input.number)] : []; + if (!input.enable) { + return [...numberArgs, '--disable-auto']; + } + return [...numberArgs, '--auto', `--${input.strategy}`]; + }, +}; + +export const ghPrReviewSpec: GhPrToolSpec = { + name: 'GitHub_PullRequest_Review', + description: "Leave a review on a pull request: a comment or a request for changes. Cannot approve — 'approve' is not a value this tool's type field can hold.", + input_schema: GhPrReviewInputSchema, + input_examples: [{ number: 42, type: 'comment', body: 'Interesting approach.' }], + subcommand: 'review', + buildArgs: (input) => [...(input.number != null ? [String(input.number)] : []), input.type === 'comment' ? '--comment' : '--request-changes', '--body', input.body], +}; diff --git a/packages/claude-sdk-tools/src/GitHub/tools.ts b/packages/claude-sdk-tools/src/GitHub/tools.ts index 88d6ba82..092acf8e 100644 --- a/packages/claude-sdk-tools/src/GitHub/tools.ts +++ b/packages/claude-sdk-tools/src/GitHub/tools.ts @@ -1,138 +1,12 @@ import { createGhPrTool, type GhEscalatedDeps } from './createGhPrTool'; -import { GhPrAutoMergeInputSchema, GhPrCommentInputSchema, GhPrCreateInputSchema, GhPrEditInputSchema, GhPrReadyInputSchema, GhPrReviewInputSchema } from './schema'; +import { ghPrAutoMergeSpec, ghPrCommentSpec, ghPrCreateSpec, ghPrEditSpec, ghPrReadySpec, ghPrReviewSpec } from './specs'; /** The six named, typed GitHub.PullRequest.* tools. Each hardcodes which gh subcommand and flags it * ever emits — the structural guarantee a generic `GhCli { command }` proposer cannot give, because - * GitHub's fine-grained PAT permissions don't go below the `Pull requests: read-write` bucket. */ + * GitHub's fine-grained PAT permissions don't go below the `Pull requests: read-write` bucket. + * + * The specs themselves live in `./specs.ts`, shared verbatim with the V2 tools — this function only + * wires them to V1's `defineTool`-shaped `createGhPrTool`. */ export function createGhPrTools(deps: GhEscalatedDeps) { - const Create = createGhPrTool( - { - name: 'GitHub_PullRequest_Create', - description: 'Open a new pull request as a draft. Always passes --draft — GitHub_PullRequest_Ready is the separate step that promotes it out of draft.', - input_schema: GhPrCreateInputSchema, - input_examples: [{ title: 'Fix the flaky retry test', body: 'Retries now back off exponentially.', base: 'main' }], - subcommand: 'create', - buildArgs: (input) => { - const args = ['--title', input.title, '--body', input.body, '--base', input.base, '--draft']; - if (input.head != null) { - args.push('--head', input.head); - } - if (input.milestone != null) { - args.push('--milestone', input.milestone); - } - for (const reviewer of input.reviewer ?? []) { - args.push('--reviewer', reviewer); - } - for (const assignee of input.assignee ?? []) { - args.push('--assignee', assignee); - } - for (const label of input.label ?? []) { - args.push('--label', label); - } - return args; - }, - }, - deps, - ); - - const Ready = createGhPrTool( - { - name: 'GitHub_PullRequest_Ready', - description: 'Mark a draft pull request as ready for review.', - input_schema: GhPrReadyInputSchema, - input_examples: [{ number: 42 }, {}], - subcommand: 'ready', - buildArgs: (input) => (input.number != null ? [String(input.number)] : []), - }, - deps, - ); - - const Edit = createGhPrTool( - { - name: 'GitHub_PullRequest_Edit', - description: 'Edit an existing pull request: title, body, and labels.', - input_schema: GhPrEditInputSchema, - input_examples: [{ number: 42, addLabel: ['bug'] }], - subcommand: 'edit', - buildArgs: (input) => { - const args: string[] = input.number != null ? [String(input.number)] : []; - if (input.title != null) { - args.push('--title', input.title); - } - if (input.body != null) { - args.push('--body', input.body); - } - for (const label of input.addLabel ?? []) { - args.push('--add-label', label); - } - for (const label of input.removeLabel ?? []) { - args.push('--remove-label', label); - } - for (const assignee of input.addAssignee ?? []) { - args.push('--add-assignee', assignee); - } - for (const assignee of input.removeAssignee ?? []) { - args.push('--remove-assignee', assignee); - } - for (const reviewer of input.addReviewer ?? []) { - args.push('--add-reviewer', reviewer); - } - for (const reviewer of input.removeReviewer ?? []) { - args.push('--remove-reviewer', reviewer); - } - if (input.milestone != null) { - args.push('--milestone', input.milestone); - } - if (input.removeMilestone) { - args.push('--remove-milestone'); - } - return args; - }, - }, - deps, - ); - - const Comment = createGhPrTool( - { - name: 'GitHub_PullRequest_Comment', - description: 'Add a comment to a pull request.', - input_schema: GhPrCommentInputSchema, - input_examples: [{ number: 42, body: 'Looks good, one small thing below.' }], - subcommand: 'comment', - buildArgs: (input) => [...(input.number != null ? [String(input.number)] : []), '--body', input.body], - }, - deps, - ); - - const AutoMerge = createGhPrTool( - { - name: 'GitHub_PullRequest_AutoMerge', - description: 'Enable or disable auto-merge on a pull request. Never performs an immediate merge — only queues one via --auto plus a merge-strategy flag, or clears it via --disable-auto.', - input_schema: GhPrAutoMergeInputSchema, - input_examples: [{ number: 42, enable: true, strategy: 'squash' }], - subcommand: 'merge', - buildArgs: (input) => { - const numberArgs = input.number != null ? [String(input.number)] : []; - if (!input.enable) { - return [...numberArgs, '--disable-auto']; - } - return [...numberArgs, '--auto', `--${input.strategy}`]; - }, - }, - deps, - ); - - const Review = createGhPrTool( - { - name: 'GitHub_PullRequest_Review', - description: "Leave a review on a pull request: a comment or a request for changes. Cannot approve — 'approve' is not a value this tool's type field can hold.", - input_schema: GhPrReviewInputSchema, - input_examples: [{ number: 42, type: 'comment', body: 'Interesting approach.' }], - subcommand: 'review', - buildArgs: (input) => [...(input.number != null ? [String(input.number)] : []), input.type === 'comment' ? '--comment' : '--request-changes', '--body', input.body], - }, - deps, - ); - - return [Create, Ready, Edit, Comment, AutoMerge, Review] as const; + return [createGhPrTool(ghPrCreateSpec, deps), createGhPrTool(ghPrReadySpec, deps), createGhPrTool(ghPrEditSpec, deps), createGhPrTool(ghPrCommentSpec, deps), createGhPrTool(ghPrAutoMergeSpec, deps), createGhPrTool(ghPrReviewSpec, deps)] as const; } diff --git a/packages/claude-sdk-tools/src/History/History.ts b/packages/claude-sdk-tools/src/History/History.ts index 97d87f77..eef452ef 100644 --- a/packages/claude-sdk-tools/src/History/History.ts +++ b/packages/claude-sdk-tools/src/History/History.ts @@ -1,26 +1,9 @@ import type { Clock } from '@js-joda/core'; import type { IHistoryReader } from '@shellicar/claude-core/history/interfaces'; import { defineTool } from '@shellicar/claude-sdk'; +import { performReadHistory } from './performReadHistory'; +import { performSearchHistory } from './performSearchHistory'; import { ReadHistoryInputSchema, ReadHistoryOutputSchema, SearchHistoryInputSchema, SearchHistoryOutputSchema } from './schema'; -import { parseTimeBound, resolveTimeBound, type TimeBoundEdge } from './timeBound'; -import type { ReadHistoryOutput, SearchHistoryOutput } from './types'; - -// The store types a block's `type` as the raw string it stored, but only the four searchable block types -// (text, thinking, tool_use, tool_result) ever reach the FTS index — historyBlocks maps every other block to -// null text, and a null-text block is never indexed. So a hit's or event's type is always one of the four; this -// narrows the store's `string` to the enum spec.md's output declares. -type EventType = SearchHistoryOutput[number]['type']; - -// Turn a schema-validated `since`/`until` string into the ISO instant the store compares against, or `undefined` -// when the field is absent. The schema already rejected a malformed bound, so parseTimeBound never returns null -// here; the branch is how the nullable oracle is consumed, not a guard against input the schema lets through. -function resolveBound(value: string | undefined, edge: TimeBoundEdge, clock: Clock): string | undefined { - if (value === undefined) { - return undefined; - } - const parsed = parseTimeBound(value); - return parsed === null ? undefined : resolveTimeBound(parsed, edge, clock); -} /** * The two history tools over the store's read seam (Phase 1). `SearchHistory` locates — a query returns ranked, @@ -42,12 +25,7 @@ export function createHistoryTools(reader: IHistoryReader, currentSessionId: () output_schema: SearchHistoryOutputSchema, input_examples: [{ query: 'why did we drop the reconciliation scan' }, { query: 'sqlite busy_timeout WAL', role: 'assistant', type: 'thinking', since: '2w' }], handler: async (input) => { - const since = resolveBound(input.since, 'since', clock); - const until = resolveBound(input.until, 'until', clock); - const excludeConversationId = input.includeCurrentSession ? undefined : currentSessionId(); - const hits = reader.search({ query: input.query, role: input.role, type: input.type, since, until, excludeConversationId, limit: input.limit }); - const out = hits.map((hit) => ({ session: hit.conversationId, turnId: hit.turnId, timestamp: hit.timestamp, role: hit.role, type: hit.type as EventType, snippet: hit.snippet })); - return { textContent: out satisfies SearchHistoryOutput }; + return { textContent: performSearchHistory(reader, currentSessionId, clock, input) }; }, }); @@ -68,14 +46,7 @@ export function createHistoryTools(reader: IHistoryReader, currentSessionId: () }, ], handler: async (input) => { - const citations = input.citations.map((citation) => ({ conversationId: citation.session, turnId: citation.turnId })); - const windows = reader.read({ citations, window: input.window }); - const out = windows.map((window) => ({ - session: window.conversationId, - turnId: window.turnId, - events: window.events.map((event) => ({ turnId: event.turnId, timestamp: event.timestamp, role: event.role, type: event.type as EventType, text: event.text })), - })); - return { textContent: out satisfies ReadHistoryOutput }; + return { textContent: performReadHistory(reader, input) }; }, }); diff --git a/packages/claude-sdk-tools/src/History/performReadHistory.ts b/packages/claude-sdk-tools/src/History/performReadHistory.ts new file mode 100644 index 00000000..0ee69a6f --- /dev/null +++ b/packages/claude-sdk-tools/src/History/performReadHistory.ts @@ -0,0 +1,16 @@ +import type { IHistoryReader } from '@shellicar/claude-core/history/interfaces'; +import type { ReadHistoryInput, ReadHistoryOutput } from './types.js'; + +type EventType = ReadHistoryOutput[number]['events'][number]['type']; + +/** Shared between V1's `ReadHistory` and V2's — the citation mapping and output shaping is real + * logic, not a bare store call, so it lives here once rather than being copied into each tool. */ +export function performReadHistory(reader: IHistoryReader, input: ReadHistoryInput): ReadHistoryOutput { + const citations = input.citations.map((citation) => ({ conversationId: citation.session, turnId: citation.turnId })); + const windows = reader.read({ citations, window: input.window }); + return windows.map((window) => ({ + session: window.conversationId, + turnId: window.turnId, + events: window.events.map((event) => ({ turnId: event.turnId, timestamp: event.timestamp, role: event.role, type: event.type as EventType, text: event.text })), + })); +} diff --git a/packages/claude-sdk-tools/src/History/performSearchHistory.ts b/packages/claude-sdk-tools/src/History/performSearchHistory.ts new file mode 100644 index 00000000..ccd69374 --- /dev/null +++ b/packages/claude-sdk-tools/src/History/performSearchHistory.ts @@ -0,0 +1,32 @@ +import type { Clock } from '@js-joda/core'; +import type { IHistoryReader } from '@shellicar/claude-core/history/interfaces'; +import { parseTimeBound, resolveTimeBound, type TimeBoundEdge } from './timeBound.js'; +import type { SearchHistoryInput, SearchHistoryOutput } from './types.js'; + +// The store types a block's `type` as the raw string it stored, but only the four searchable block types +// (text, thinking, tool_use, tool_result) ever reach the FTS index — historyBlocks maps every other block to +// null text, and a null-text block is never indexed. So a hit's or event's type is always one of the four; this +// narrows the store's `string` to the enum spec.md's output declares. +type EventType = SearchHistoryOutput[number]['type']; + +// Turn a schema-validated `since`/`until` string into the ISO instant the store compares against, or `undefined` +// when the field is absent. The schema already rejected a malformed bound, so parseTimeBound never returns null +// here; the branch is how the nullable oracle is consumed, not a guard against input the schema lets through. +function resolveBound(value: string | undefined, edge: TimeBoundEdge, clock: Clock): string | undefined { + if (value === undefined) { + return undefined; + } + const parsed = parseTimeBound(value); + return parsed === null ? undefined : resolveTimeBound(parsed, edge, clock); +} + +/** Shared between V1's `SearchHistory` and V2's — the query building, time-bound resolution, and + * output shaping is real logic, not a bare store call, so it lives here once rather than being + * copied into each tool. */ +export function performSearchHistory(reader: IHistoryReader, currentSessionId: () => string, clock: Clock, input: SearchHistoryInput): SearchHistoryOutput { + const since = resolveBound(input.since, 'since', clock); + const until = resolveBound(input.until, 'until', clock); + const excludeConversationId = input.includeCurrentSession ? undefined : currentSessionId(); + const hits = reader.search({ query: input.query, role: input.role, type: input.type, since, until, excludeConversationId, limit: input.limit }); + return hits.map((hit) => ({ session: hit.conversationId, turnId: hit.turnId, timestamp: hit.timestamp, role: hit.role, type: hit.type as EventType, snippet: hit.snippet })); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/OrchestrateEngine.ts b/packages/claude-sdk-tools/src/Orchestrate/OrchestrateEngine.ts new file mode 100644 index 00000000..8249bc33 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/OrchestrateEngine.ts @@ -0,0 +1,125 @@ +import type { Clock } from '@js-joda/core'; +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import type { OrchestrateApprovalContext, OrchestrateBatchItem, SdkMessage, ToolAttachmentBlock, ToolOutcome } from '@shellicar/claude-sdk'; +import { type ApprovalCoordinator, IOrchestrateEngine, type ISdkMessagePublisher } from '@shellicar/claude-sdk'; +import type { IScopedProvider, IServiceProvider } from '@shellicar/core-di'; +import type { PolicyStore } from '../Policy/PolicyStore.js'; +import { createPolicyGatedApproval } from './policyGatedApproval.js'; +import type { ToolsV2Registry } from './registry.js'; +import { runToolV2Call } from './runToolV2Call.js'; + +/** The concrete `IOrchestrateEngine` `QueryRunner` dispatches to. Owns exactly the names the + * registry knows about, plus `Orchestrate` itself — everything else falls through to V1 + * untouched. Maps `runToolV2Call`'s `{ ok, content } | { ok, error }` onto the shared + * `ToolOutcome` taxonomy so `QueryRunner` doesn't need a second result shape for V2. + * + * Approval is genuinely decided here, not by QueryRunner: every gated stage is checked against + * `policyStore.current` first (see `createPolicyGatedApproval`) — `allow`/`deny` never reach + * a human at all, and the human-ask callback QueryRunner supplies is only invoked for whatever + * Policy itself leaves as `ask`. + * + * Takes `logger`/`provider`/`approval`/`publisher` as explicit constructor arguments, not + * `@dependsOn` — this class is built by a manual factory in container.ts (and constructed + * directly with `new` in tests), never resolved purely through the DI container, so a + * `@dependsOn` field would never be populated in either of those call sites. + * + * Owns every V2 approval concern outright: `runBatch` decides whether a gated stage needs + * asking, mints/keys its own requestId per tool_use, and sends the `tool_approval_request` + * wire message itself, using the same `ApprovalCoordinator`/publisher QueryRunner's V1 phase + * uses. QueryRunner supplies nothing but the raw batch and a `requireApproval` flag — no + * coordination of any kind lives on QueryRunner for V2. */ +export class OrchestrateEngine extends IOrchestrateEngine { + readonly #registry: ToolsV2Registry; + readonly #policyStore: PolicyStore; + readonly #logger: ILogger; + readonly #provider: IServiceProvider; + readonly #approval: ApprovalCoordinator; + readonly #publisher: ISdkMessagePublisher; + readonly #fs: IFileSystem; + readonly #clock: Clock; + + public constructor(registry: ToolsV2Registry, policyStore: PolicyStore, logger: ILogger, provider: IServiceProvider, approval: ApprovalCoordinator, publisher: ISdkMessagePublisher, fs: IFileSystem, clock: Clock) { + super(); + this.#registry = registry; + this.#policyStore = policyStore; + this.#logger = logger; + this.#provider = provider; + this.#approval = approval; + this.#publisher = publisher; + this.#fs = fs; + this.#clock = clock; + } + + public owns(name: string): boolean { + return name === 'Orchestrate' || this.#registry.get(name) != null; + } + + /** Opens exactly one DI scope for the whole batch, runs every item against it, and lets it go + * out of scope (disposing whatever it resolved) only once every item has settled — so a + * batch of several V2 tool_uses in the same round shares one instance of a per-batch-scoped + * resource instead of each call opening (and tearing down) its own. + * + * All approval coordination for the batch lives here: a per-item stage-index counter mints + * each gated stage's own `${toolUseId}:${stageIndex}` requestId, `#approval.cancelled` is + * checked before ever asking, and the `tool_approval_request` wire message is sent directly + * through `#publisher` — the same mechanism V1 already uses, reused here rather than handed + * back to the caller to reconstruct. */ + public async runBatch(items: OrchestrateBatchItem[], requireApproval: boolean, signal?: AbortSignal): Promise> { + await using scope = this.#provider.createScope(); + const entries = await Promise.all( + items.map(async (item): Promise<[string, ToolOutcome]> => { + const requestApproval = requireApproval + ? async (ctx: OrchestrateApprovalContext): Promise => { + if (this.#approval.cancelled) { + return false; + } + // The stage's real position in the pipeline, straight from `execute()`'s own loop + // — never a count of how many stages have asked so far. A stage that asks is + // reported where it actually sits, so "3 of 3" means the last step of three, even + // when the first two were auto-allowed and never asked at all. + const requestId = `${item.id}:${ctx.stagePosition - 1}`; + // Drained here because a person is about to be shown it, which is the only reason + // anything drains: a verdict Policy reached on its own never touches the stream. + const piped = await ctx.batch(); + const response = await this.#approval.request(requestId, () => { + // The stage as the caller wrote it, variables unresolved. This request is published + // whether or not it is granted, so a value resolved into it would be exposed by the + // asking rather than by the answer. The decision itself is made on `ctx.input`, + // which is fully resolved. `ctx.batch` (whatever was piped in) is secondary + // context, only worth showing when non-empty: a bare `piped: []` for an ordinary + // producer stage would just be noise. + const approvalInput = { ...(ctx.asWritten as Record), ...(piped.length > 0 ? { piped } : {}) }; + this.#publisher.send({ type: 'tool_approval_request', requestId, toolUseId: item.id, name: ctx.name, input: approvalInput, v2: true, stageIndex: ctx.stagePosition, stageCount: ctx.stageCount } satisfies SdkMessage); + }); + return response.approved; + } + : undefined; + const outcome = await this.#runOne(item.name, item.input, requestApproval, signal, scope); + return [item.id, outcome]; + }), + ); + return new Map(entries); + } + + async #runOne(name: string, input: unknown, requestApproval: ((ctx: OrchestrateApprovalContext) => Promise) | undefined, signal: AbortSignal | undefined, scope: IScopedProvider | undefined): Promise { + // The same cwd the tools themselves resolve relative paths against (Program.cwd defaults to + // it), so a $PWD-scoped rule judges the directory the call actually runs in. + const approve = createPolicyGatedApproval(this.#policyStore, this.#registry, this.#fs, this.#logger, requestApproval); + const startedAt = this.#clock.millis(); + try { + const result = await runToolV2Call(name, input, this.#registry, approve, signal, scope); + // A cancel that arrived mid-run is reported by the caller's own signal, not by anything + // execute() itself distinguishes internally — orchestrate only stops advancing to further + // stages once aborted (see execute.ts), it never labels a stage's own outcome as "cancelled". + // This is the one place that reads the signal back to decide the *call's* outcome. + if (signal?.aborted) { + return { kind: 'cancelled', elapsedMs: this.#clock.millis() - startedAt }; + } + return result.ok ? { kind: 'ok', content: result.content, ...(result.attachments.length > 0 ? { blocks: result.attachments as ToolAttachmentBlock[] } : {}) } : { kind: 'failed', error: result.error }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + return { kind: 'failed', error }; + } + } +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/defineToolV2.ts b/packages/claude-sdk-tools/src/Orchestrate/defineToolV2.ts new file mode 100644 index 00000000..c8eb156d --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/defineToolV2.ts @@ -0,0 +1,17 @@ +import type { Tool } from '@shellicar/orchestrate-core'; +import type { z } from 'zod'; + +/** A tool, and what the model has to be told about it. The engine needs none of the latter: it runs + * a tool, it never describes one. */ +export type ToolV2Definition = Tool & { + description: string; + /** The tool's own shape, and the only one. The wire entry the model is given and the parse of a + * stage's input are both built from this, never from a second hand-kept copy. */ + model: z.ZodType; +}; + +/** Every tool is written through this, so the contract is checked where the tool is defined rather + * than wherever something first tries to use it. An annotation on each factory would check exactly + * the same thing, but a missing annotation looks like nothing at all, whereas a tool that does not + * call this stands out against every other one. */ +export const defineToolV2 = (definition: ToolV2Definition): ToolV2Definition => definition; diff --git a/packages/claude-sdk-tools/src/Orchestrate/lines.ts b/packages/claude-sdk-tools/src/Orchestrate/lines.ts new file mode 100644 index 00000000..7f3f9597 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/lines.ts @@ -0,0 +1,4 @@ +/** What separates one item from the next in what a tool writes, and what whoever reads them back + * splits on. Deliberately not the platform's: these bytes are a protocol between two ends that are + * both ours, so a machine whose convention is CRLF must still write what the other end reads. */ +export const NEWLINE = '\n'; diff --git a/packages/claude-sdk-tools/src/Orchestrate/policyGatedApproval.ts b/packages/claude-sdk-tools/src/Orchestrate/policyGatedApproval.ts new file mode 100644 index 00000000..5c5332b0 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/policyGatedApproval.ts @@ -0,0 +1,47 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { collectPaths } from '@shellicar/claude-sdk'; +import type { ApprovalContext, ApprovalDecision } from '@shellicar/orchestrate-core'; +import type { z } from 'zod'; +import { canonicalPath } from '../Policy/canonicalPath.js'; +import type { PolicyStore } from '../Policy/PolicyStore.js'; +import { resolve, strictest } from '../Policy/resolve.js'; + +/** Asking a person. Boolean only: a refusal from a person carries no message. */ +export type HumanApprove = (ctx: ApprovalContext) => Promise; + +/** Enough of a registry to reach a tool's model. */ +export type ToolSchemaLookup = { get: (name: string) => { model: z.ZodType } | undefined }; + +/** Decides a stage by Policy, asking a person only for what Policy leaves as `ask`. + * + * Every decision is logged as `policy_resolution` with the verdict, the tool, what the call does + * and the paths it resolved to. */ +export function createPolicyGatedApproval(policyStore: PolicyStore, registry: ToolSchemaLookup, fs: IFileSystem, logger: ILogger, humanApprove?: HumanApprove): ApprovalDecision { + return async (ctx) => { + const model = registry.get(ctx.name)?.model; + // Resolved to the object the kernel will act on, so a symlink inside the project cannot present + // a file outside it as one within. + const paths = await Promise.all((model ? collectPaths(model, ctx.input) : []).map((path) => canonicalPath(fs, path))); + // A call that both executes and writes is judged on each, and the strictest governs: the same + // rule as a call naming several paths, for the same reason. Allowing it because one of the + // things it does is permitted would let the other travel through on its back. + const { verdict, message } = strictest(ctx.operations.map((operation) => resolve(policyStore.current, { tool: ctx.name, input: ctx.input, paths, operation, cwd: fs.cwd(), home: fs.homedir(), platform: fs.platform() }))); + // The verdict is about the resolved command; the line records the stage as written, so a value + // that resolved into it is not persisted to a log file. + logger.info('policy_resolution', { tool: ctx.name, operations: ctx.operations, verdict, paths, input: ctx.asWritten, message }); + if (verdict === 'allow') { + return { approved: true }; + } + if (verdict === 'deny') { + return { approved: false, message }; + } + if (!humanApprove) { + logger.info('policy_resolution_ask_auto_approved', { tool: ctx.name, reason: 'no human-ask callback configured' }); + return { approved: true }; + } + const approved = await humanApprove(ctx); + logger.info('policy_resolution_ask_answered', { tool: ctx.name, approved }); + return { approved }; + }; +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/registry.ts b/packages/claude-sdk-tools/src/Orchestrate/registry.ts new file mode 100644 index 00000000..ce5e1518 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/registry.ts @@ -0,0 +1,277 @@ +import type { BetaTool } from '@anthropic-ai/sdk/resources/beta.mjs'; +import type { Clock } from '@js-joda/core'; +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import type { IHistoryReader } from '@shellicar/claude-core/history/interfaces'; +import type { SipsBridge } from '@shellicar/claude-core/image/SipsBridge'; +import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import type { IMemoryStore } from '@shellicar/claude-core/memory/interfaces'; +import { withResolvedPaths } from '@shellicar/claude-sdk'; +import type { IExecutor } from '@shellicar/exec-core'; +import type { Operation, Stage, ToolV2 } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import type { AzSessionCache } from '../Az/AzSessionCache.js'; +import type { AzDeps } from '../Az/runAz.js'; +import type { AzAccountsConfig } from '../Az/tools.js'; +import type { AdoEscalatedDeps } from '../AzureDevOps/runAdoEscalated.js'; +import type { IEnvProvider } from '../exec-shared.js'; +import { PROTECTED_ENV_NAMES } from '../exec-shared.js'; +import type { GhEscalatedDeps } from '../GitHub/runGhEscalated.js'; +import type { RefStore } from '../RefStore/RefStore.js'; +import { type ToolV2Definition, xargsTargetKeys } from './defineToolV2.js'; +import { planStages, type ToolFactsLookup, type WireStage, type WireToolStage } from './stagePlan.js'; +import { createAppendFileToolV2 } from './tools/AppendFile.js'; +import { createAzToolsV2 } from './tools/Az.js'; +import { createAdoPrToolsV2 } from './tools/AzureDevOps.js'; +import { createCreateFileToolV2 } from './tools/CreateFile.js'; +import { createDeleteToolV2 } from './tools/Delete.js'; +import { createDeleteMemoryToolV2 } from './tools/DeleteMemory.js'; +import { createEditFileToolV2 } from './tools/EditFile.js'; +import { createFindToolV2 } from './tools/Find.js'; +import { createGhPrToolsV2 } from './tools/GitHub.js'; +import { createHeadToolV2 } from './tools/Head.js'; +import { createMatchToolV2 } from './tools/Match.js'; +import { createMemoryTypesToolV2 } from './tools/MemoryTypes.js'; +import { createPathsToolV2 } from './tools/Paths.js'; +import { createProgramToolV2 } from './tools/Program.js'; +import { createRangeToolV2 } from './tools/Range.js'; +import { createReadToolV2 } from './tools/Read.js'; +import { createReadBinaryFileToolV2 } from './tools/ReadBinaryFile.js'; +import { createReadHistoryToolV2 } from './tools/ReadHistory.js'; +import { createReadMemoryToolV2 } from './tools/ReadMemory.js'; +import { createRefToolV2 } from './tools/Ref.js'; +import { createSearchHistoryToolV2 } from './tools/SearchHistory.js'; +import { createSearchMemoryToolV2 } from './tools/SearchMemory.js'; +import { createSkillToolV2 } from './tools/Skill.js'; +import { createTailToolV2 } from './tools/Tail.js'; +import { createTsToolsV2 } from './tools/TypeScript.js'; +import { createWriteMemoryToolV2 } from './tools/WriteMemory.js'; + +export type ToolsV2RegistryDeps = { + fs: IFileSystem; + executor: IExecutor; + refStore: RefStore; + sips: SipsBridge; + logger: ILogger; + memoryStore: IMemoryStore; + historyReader: IHistoryReader; + currentSessionId: () => string; + clock: Clock; + skillDirs: readonly string[]; + ghDeps: GhEscalatedDeps; + adoDeps: AdoEscalatedDeps; + azDeps: AzDeps; + azSessionCache: AzSessionCache; + getAzAccounts: () => AzAccountsConfig; + /** The environment every `Program` call runs under — the same provider ExecV3 uses, so a V2 exec + * gets the same credential stripping, and the same variables are available to expand in `args`. */ + envProvider: IEnvProvider; + /** Resolves a marked path field to a single absolute form (expand `~`/`$VAR`, then resolve + * against cwd) — the same contract V1's `ToolRegistry` takes, so both consumers share one + * real implementation. Defaults to identity when omitted. */ + expand?: (p: string) => string; +}; + +// Forward-pointing join to the NEXT stage — absent means sequential (`;`), matching +// orchestrate-core's `Op` and ExecV3's own convention. +const OpSchema = z.enum(['|', '&&', '||']); + +const XargsStageSchema = z.object({ xargs: z.literal(true).describe("Collect everything piped in and append it to the NEXT stage's own argument field, the way `find | xargs rm -v` appends paths after the fixed arguments. Which field that is belongs to the tool, so it is never named here.") }); + +export type { WireStage } from './stagePlan.js'; + +/** Whether the tool itself demands this field, as opposed to accepting it. */ +function isRequiredField(model: z.ZodType, key: string): boolean { + if (!(model instanceof z.ZodObject)) { + return false; + } + const field = (model.shape as Record)[key]; + return field != null && !field.safeParse(undefined).success; +} + +/** The same model with its xargs target made optional, for the wire parse only. */ +function relaxXargsTarget(model: z.ZodType): z.ZodType { + const [key] = xargsTargetKeys(model); + if (key == null || !(model instanceof z.ZodObject)) { + return model; + } + const field = (model.shape as Record)[key] as z.ZodType; + return model.extend({ [key]: field.optional() }); +} + +/** Every V2 tool Orchestrate can run, and the one place the wire tools array and Orchestrate's + * own stage validation both come from. Each tool is self-describing (its own `model`), so + * there is exactly one schema per tool, never a second hand-copied one — the bug the previous + * pass of this file had, where the stage schema was a separate, manually-kept-in-lockstep + * list. This is Orchestrate's own registry, not V1's `ToolRegistry` — a genuinely separate + * system, per the Tools V2 decision. */ +export class ToolsV2Registry { + readonly #defs: Map>; + readonly #stageSchema: z.ZodType; + readonly #expand: (p: string) => string; + /** The ambient environment a run clones its own variable overlay from (see `runToolV2Call`). */ + public readonly envProvider: IEnvProvider; + + /** `envProvider` has no default on purpose: every process this registry spawns runs under it, and + * a default would silently hand an unstripped environment to a caller who simply forgot to wire + * one — the credential stripping would be absent and nothing would say so. `expand` does default + * to identity, since an unexpanded path fails visibly rather than quietly widening access. */ + public constructor(defs: ToolV2Definition[], envProvider: IEnvProvider, expand: (p: string) => string = (p) => p) { + this.#defs = new Map(defs.map((d) => [d.name, d])); + this.#expand = expand; + this.envProvider = envProvider; + const stageVariants = defs + .filter((d) => !d.excludeFromStages) + .map((d) => + z.object({ + tool: z.literal(d.name), + // The xargs target is relaxed here and enforced by the sequence check below: whether it + // is required depends on whether an `Xargs` precedes this stage, which a per-stage + // schema cannot see. Every other field stays strict, and the target is still checked + // for real once injection has happened (see `toStage`'s wrapped `run`). + input: relaxXargsTarget(d.model), + op: OpSchema.optional(), + showStderr: z.boolean().optional(), + captureAs: z + .string() + .regex(/^\w+$/) + .refine((name) => !PROTECTED_ENV_NAMES.includes(name as (typeof PROTECTED_ENV_NAMES)[number]), { + message: `a capture becomes an environment variable for every later stage, so it cannot take one of these names: ${PROTECTED_ENV_NAMES.join(', ')}`, + }) + .optional() + .describe("Store this stage's output in a variable of this name, instead of only piping it. A spawned process sees it as a real environment variable. The variable lives for this call only."), + }), + ); + this.#stageSchema = z.union([z.discriminatedUnion('tool', stageVariants as unknown as [z.ZodObject, ...z.ZodObject[]]), XargsStageSchema]) as z.ZodType; + } + + /** Every registered tool gets its own wire entry, same as V1's `Find`/`Paths` sources are + * both a pipe step and standalone-callable — a V2 tool is genuinely a tool, callable + * directly, not merely a shape hidden inside Orchestrate's own schema. */ + public get wireTools(): BetaTool[] { + return Array.from(this.#defs.values()).map((d) => ({ + name: d.name, + description: d.description, + input_schema: d.model.toJSONSchema({ target: 'draft-07', io: 'input' }) as BetaTool['input_schema'], + })); + } + + /** The `stages` array shape Orchestrate's own wire tool takes — a discriminated union built + * from every registered tool's own `model`, plus `Xargs`. Generated, not hand-authored. + * Rejects a trailing `op` on the last stage — there is nothing after it to join to, so it + * can only be a mistake, same as ExecV3's own dangling-operator validation. */ + public get stageSchema(): z.ZodType<{ stages: WireStage[] }> { + return z.object({ stages: z.array(this.#stageSchema).min(1) }) as unknown as z.ZodType<{ stages: WireStage[] }>; + } + + /** What the sequence rules need to know about a tool, drawn from its own declarations. */ + readonly #facts: ToolFactsLookup = (name) => { + const def = this.#defs.get(name); + if (def == null) { + return undefined; + } + const target = xargsTargetKeys(def.model)[0]; + return { xargsTarget: target, xargsTargetRequired: target != null && isRequiredField(def.model, target), readsUpstream: def.readsUpstream === true }; + }; + + /** A whole call, checked and built in one pass: the shape, then the sequence, then the stages. */ + public planCall(input: unknown): { ok: true; stages: Stage[] } | { ok: false; error: string } { + const parsed = this.stageSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.message }; + } + const plan = planStages(parsed.data.stages, this.#facts); + if (!plan.ok) { + return { ok: false, error: plan.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('\n') }; + } + return { ok: true, stages: plan.stages.map((stage) => (stage.kind === 'xargs' ? ({ kind: 'xargs', parameter: stage.parameter } satisfies Stage) : this.toStage(stage.wire))) }; + } + + public get(name: string): ToolV2Definition | undefined { + return this.#defs.get(name); + } + + /** Resolves one already-parsed wire stage into a real `orchestrate-core` `Stage`, validating + * the stage's own `input` against its tool's `model` (not a second schema), then giving the + * tool's own `resolveDefaults` a chance to fill in anything it knows from its own injected + * dependency (e.g. `Program.cwd` defaulting to `fs.cwd()`) — the resolved value this + * produces is what Policy sees, the same as an explicitly-supplied one. Throws on a name + * outside the registry — the discriminated union already makes that a parse error before + * this is ever reached, so reaching it with an unknown name is a real bug, not user input. */ + public toStage(wire: WireToolStage): Stage { + const def = this.#defs.get(wire.tool); + if (def == null) { + throw new Error(`Orchestrate: "${wire.tool}" is not in the Tools V2 registry`); + } + // Relaxed, because an Xargs-fed stage legitimately arrives without its target field; the + // strict parse happens in `run` below, once injection has actually filled it in. + const parsedInput = relaxXargsTarget(def.model).parse(wire.input); + const resolvedInput = def.resolveDefaults ? def.resolveDefaults(parsedInput) : parsedInput; + const captureAs = wire.captureAs; + const model = def.model; + const expand = this.#expand; + // Everything `execute()` assembled, including whatever an Xargs appended, checked against the + // tool's real schema and with every marked path resolved to the file it names. This is what + // Policy judges and what a human is shown, because it is what the tool will act on: judging + // the unexpanded form let `$HOME/.ssh/id_rsa` read as a path inside the working directory. + // Variables first, then paths: a path may itself be written as `$SOMEWHERE`, and resolving it + // before the variable is resolved would settle the wrong thing. + const prepare = (input: unknown, env?: unknown): unknown => { + const parsed = model.parse(input); + const settled = def.settleInput ? def.settleInput(parsed, env as IEnvProvider) : parsed; + return withResolvedPaths(model, settled, expand); + }; + const run: ToolV2['run'] = (input, upstream, stderr, signal, scope, env) => def.run(input, upstream, stderr, signal, scope as Parameters[4], env as Parameters[5]) as ReturnType['run']>; + const operations = def.operations ?? ((): Operation[] => (def.operation != null ? [def.operation] : ['none'])); + const tool: ToolV2 = { name: def.name, operations: operations as (input: unknown) => Operation[], run }; + return { kind: 'tool', tool, input: resolvedInput as Record, op: wire.op, showStderr: wire.showStderr, captureAs, prepare }; + } +} + +/** Builds the registry with every real V2 tool wired to its dependencies. */ +export function createToolsV2Registry(deps: ToolsV2RegistryDeps): ToolsV2Registry { + return new ToolsV2Registry( + [ + createFindToolV2(deps.fs), + createPathsToolV2(deps.fs), + createMatchToolV2(), + createHeadToolV2(), + createTailToolV2(), + createRangeToolV2(), + createReadToolV2(deps.fs), + createReadBinaryFileToolV2(deps.fs, deps.sips, deps.logger), + createProgramToolV2(deps.executor, deps.fs, deps.envProvider), + createDeleteToolV2(deps.fs), + createRefToolV2(deps.refStore), + createCreateFileToolV2(deps.fs), + createAppendFileToolV2(deps.fs), + createEditFileToolV2(deps.fs), + createWriteMemoryToolV2(deps.memoryStore), + createReadMemoryToolV2(deps.memoryStore), + createSearchMemoryToolV2(deps.memoryStore), + createDeleteMemoryToolV2(deps.memoryStore), + createMemoryTypesToolV2(deps.memoryStore), + createSearchHistoryToolV2(deps.historyReader, deps.currentSessionId, deps.clock), + createReadHistoryToolV2(deps.historyReader), + createSkillToolV2(deps.fs, deps.skillDirs, deps.logger), + ...createGhPrToolsV2(deps.ghDeps), + ...createAdoPrToolsV2(deps.adoDeps, deps.getAzAccounts, deps.azSessionCache), + ...createAzToolsV2(deps.azDeps, deps.getAzAccounts, deps.azSessionCache), + ...createTsToolsV2(), + ], + deps.envProvider, + deps.expand, + ); +} + +/** Every wire entry Tools V2 contributes to the model's tools array: every registered tool + * individually (so `Find` is directly callable, same as V1's `Find`/`Paths` pipe sources are), + * plus `Orchestrate` itself, whose `stages` schema is generated from those same tools — + * nothing here is a second, hand-authored copy of any tool's shape. */ +export function toolsV2WireTools(registry: ToolsV2Registry): BetaTool[] { + const orchestrate: BetaTool = { + name: 'Orchestrate', + description: 'Runs a sequence of Tools V2 tools, joined by | (pipe stdout into the next stage) / && / || (gate on success/failure) / absent (sequential). Composes any registered tool with any other.', + input_schema: registry.stageSchema.toJSONSchema({ target: 'draft-07', io: 'input' }) as BetaTool['input_schema'], + }; + return [...registry.wireTools, orchestrate]; +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/runToolV2Call.ts b/packages/claude-sdk-tools/src/Orchestrate/runToolV2Call.ts new file mode 100644 index 00000000..63f51044 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/runToolV2Call.ts @@ -0,0 +1,84 @@ +import type { IScopedProvider } from '@shellicar/core-di'; +import type { ApprovalDecision, Stage, VarStore } from '@shellicar/orchestrate-core'; +import { execute } from '@shellicar/orchestrate-core'; +import { OverlayEnvProvider } from '../exec-shared.js'; +import type { ToolsV2Registry } from './registry.js'; + +export type OrchestrateCallResult = { ok: true; content: string; attachments: unknown[] } | { ok: false; error: string }; + +/** `seq 1 100000 | head -3` is a success in any shell: the producer is killed by SIGPIPE the moment + * its reader walks away, and nobody calls that a failed command. */ +function stoppedByPipe(report: { signal: string | null }): boolean { + return report.signal === 'SIGPIPE'; +} + +function summarise(reports: Awaited>['reports'], result: unknown[], attachments: unknown[]): OrchestrateCallResult { + const reportLines = reports.map((r) => { + // A stage carries its own explanation whatever became of it: a refusal, a stage stopped for + // outgrowing what could be held, or the stage after it that therefore never ran. + const because = r.message != null ? ` — ${r.message}` : ''; + if (r.outcome === 'skipped') { + return `${r.name}: skipped${because}`; + } + if (r.outcome === 'denied') { + return `${r.name}: denied${because}`; + } + // A producer whose consumer stopped reading is killed by SIGPIPE. That is how a pipeline ends, + // not a tool going wrong, so it reads as itself rather than as a failure. + const status = r.success ? 'ok' : stoppedByPipe(r) ? 'stopped (SIGPIPE)' : 'failed'; + // What each stage produced, so an empty result says which stage found nothing, and a stage in + // the middle of a pipe is not invisible. + const emitted = r.emitted != null ? `, ${r.emitted} ${r.emitted === 1 ? 'line' : 'lines'}` : ''; + const stderr = r.stderrShown != null && r.stderrShown.length > 0 ? `\n${r.stderrShown.map((l) => ` stderr: ${l}`).join('\n')}` : ''; + return `${r.name}: ${status}${emitted}${because}${stderr}`; + }); + + const anyFailed = reports.some((r) => r.outcome === 'denied' || (r.outcome === 'ran' && r.success === false && !stoppedByPipe(r))); + const content = [...reportLines, '', ...result.map(String)].join('\n'); + return anyFailed ? { ok: false, error: content } : { ok: true, content, attachments }; +} + +/** The one function Tools V2 dispatch needs to call: a raw `tool_use.name`/`.input` pair in, + * plain text out — the same `{ ok, content } | { ok, error }` shape a V1 handler's `run` + * closure reduces to, so the dispatch fork doesn't need a second result shape to reason + * about. Covers both wire-call shapes: `name === 'Orchestrate'` takes `{ stages: [...] }` and + * composes several tools; any other registered name is a direct single-tool call, wrapped as + * a one-stage sequence — both reduce to the same `execute()` call over a `Stage[]`, so a + * direct `Find` call still goes through the identical gating/approval path a composed one + * does. Parses the wire schema itself rather than trusting a pre-parsed value, mirroring + * `ToolRegistry.resolve`'s own single-parse discipline for V1. */ +export async function runToolV2Call(name: string, input: unknown, registry: ToolsV2Registry, approve?: ApprovalDecision, signal?: AbortSignal, scope?: IScopedProvider): Promise { + let stages: Stage[]; + if (name === 'Orchestrate') { + const planned = registry.planCall(input); + if (!planned.ok) { + return { ok: false, error: planned.error }; + } + stages = planned.stages; + } else { + const def = registry.get(name); + if (def == null) { + return { ok: false, error: `Orchestrate: "${name}" is not a registered V2 tool` }; + } + const parsedInput = def.model.safeParse(input); + if (!parsedInput.success) { + return { ok: false, error: parsedInput.error.message }; + } + stages = [registry.toStage({ tool: name, input: parsedInput.data as Record })]; + } + + // One environment per call, cloned from the ambient provider so a `captureAs` writes into this + // run alone: the next call starts from the ambient environment again, and nothing a pipeline + // captured outlives it or reaches the CLI's own environment. + // + // A capture goes nowhere else. It is never substituted into a stage's input, because a stage's + // input is what Policy judges, what an approval request carries over the wire, and what the log + // records — a token put there would be all three. `$TOKEN` stays as written and is resolved by + // the process that runs, out of this environment, exactly as a shell resolves it from the child's + // environment rather than rewriting the command. + const runEnv = new OverlayEnvProvider(registry.envProvider); + const vars: VarStore = { set: (name, value) => runEnv.set(name, value) }; + + const { result, reports, attachments } = await execute(stages, { approve, signal, scope, vars, env: runEnv }); + return summarise(reports, result, attachments); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/stagePlan.ts b/packages/claude-sdk-tools/src/Orchestrate/stagePlan.ts new file mode 100644 index 00000000..3c1c0afb --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/stagePlan.ts @@ -0,0 +1,74 @@ +import type { Op } from '@shellicar/orchestrate-core'; + +/** What a wire stage says before anything is known about the tools it names. */ +export type WireToolStage = { tool: string; input: unknown; op?: Op; showStderr?: boolean; captureAs?: string }; +export type WireXargsStage = { xargs: true }; +export type WireStage = WireToolStage | WireXargsStage; + +export const isXargsStage = (stage: WireStage): stage is WireXargsStage => 'xargs' in stage; + +/** Everything the sequence needs to know about one tool: the field an `Xargs` fills, whether the + * tool's own schema demands that field, and whether the tool reads what a `|` pipes into it. + * Deliberately not the tool itself, so the sequence can be reasoned about (and tested) without + * building a registry or a schema. + * + * Requiredness is the tool's own business: `Read` cannot act without paths, while `Program`'s + * arguments are optional because `Program { cat }` reading a pipe is a whole command. */ +export type ToolFacts = { xargsTarget?: string; xargsTargetRequired?: boolean; readsUpstream: boolean }; +export type ToolFactsLookup = (name: string) => ToolFacts | undefined; + +export type StageIssue = { message: string; path: (string | number)[] }; + +/** One stage, with what the sequence around it settled: which field the preceding `Xargs` fills, + * so nothing downstream has to work it out from the schema a second time. */ +export type PlannedStage = { kind: 'tool'; wire: WireToolStage; fedBy?: string } | { kind: 'xargs'; parameter: string }; + +export type StagePlan = { ok: true; stages: PlannedStage[] } | { ok: false; issues: StageIssue[] }; + +/** Whether a sequence holds together, and what each stage receives. Every rule is about a stage's + * neighbours, which is why none can live in a stage's own schema. */ +export function planStages(stages: WireStage[], lookup: ToolFactsLookup): StagePlan { + const issues: StageIssue[] = []; + const planned: PlannedStage[] = []; + + const last = stages[stages.length - 1]; + if (last != null && !isXargsStage(last) && last.op != null) { + issues.push({ message: 'The last stage must not have an op set — there is nothing after it to join to.', path: ['stages'] }); + } + + stages.forEach((stage, index) => { + const previous = index > 0 ? (stages[index - 1] as WireStage) : undefined; + + if (isXargsStage(stage)) { + const next = stages[index + 1]; + if (next == null || isXargsStage(next)) { + issues.push({ message: 'Xargs must be followed by the tool stage it feeds.', path: ['stages', index] }); + return; + } + const target = lookup(next.tool)?.xargsTarget; + if (target == null) { + issues.push({ message: `Xargs cannot feed ${next.tool}: it takes no argument list. Pipe into it directly if it reads a pipe, or drop the Xargs.`, path: ['stages', index] }); + return; + } + planned.push({ kind: 'xargs', parameter: target }); + return; + } + + const facts = lookup(stage.tool) as ToolFacts; + + const fedByXargs = previous != null && isXargsStage(previous); + if (facts.xargsTarget != null && facts.xargsTargetRequired === true && !fedByXargs && (stage.input as Record | undefined)?.[facts.xargsTarget] == null) { + issues.push({ message: `${stage.tool} needs ${facts.xargsTarget}, either supplied here or fed by an Xargs stage before it.`, path: ['stages', index, 'input', facts.xargsTarget] }); + } + + // `op` is written on the producing stage, so the issue is reported there. + if (previous != null && !isXargsStage(previous) && previous.op === '|' && !facts.readsUpstream) { + const fix = facts.xargsTarget != null ? `Put an Xargs stage between them to append the piped values to ${stage.tool}'s ${facts.xargsTarget}.` : `${stage.tool} cannot take piped input at all.`; + issues.push({ message: `${previous.tool} pipes into ${stage.tool}, which does not read a pipe, so its output would be discarded. ${fix}`, path: ['stages', index - 1, 'op'] }); + } + + planned.push({ kind: 'tool', wire: stage, fedBy: fedByXargs ? facts.xargsTarget : undefined }); + }); + + return issues.length > 0 ? { ok: false, issues } : { ok: true, stages: planned }; +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/AppendFile.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/AppendFile.ts new file mode 100644 index 00000000..abc83656 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/AppendFile.ts @@ -0,0 +1,30 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { pathSchema } from '@shellicar/claude-sdk'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const AppendFileToolV2Model = z.object({ + path: pathSchema.describe('Path to the file to append to. Supports absolute, relative, ~ and $HOME.'), + content: z.string().describe('Text to append to the end of the file. Written verbatim; no separator is inserted at the seam.'), +}); + +/** The V2 tool equivalent of V1's `AppendFile` \u2014 creates the file (and missing parent + * directories) if it doesn't exist, otherwise appends verbatim. `fs.write` tier. */ +export function createAppendFileToolV2(fs: IFileSystem) { + return defineToolV2({ + name: 'AppendFile', + description: 'Appends text to the end of a file, creating the file (and any missing parent directories) if it does not exist. Content is written verbatim.', + operation: 'fs.write', + model: AppendFileToolV2Model, + run: (input): ToolV2Result => { + async function* run(): AsyncGenerator { + await fs.appendFile(input.path, input.content); + yield `appended: ${input.path}`; + } + + return { stdout: fromLines(run()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Az.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Az.ts new file mode 100644 index 00000000..1753b729 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Az.ts @@ -0,0 +1,56 @@ +import type { Operation, ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import type { AzSessionCache } from '../../Az/AzSessionCache.js'; +import { resolveAzAccount } from '../../Az/createAzTool.js'; +import type { AzDeps } from '../../Az/runAz.js'; +import { runAz } from '../../Az/runAz.js'; +import { AZ_CLI_TOOL_NAME, type AzAccountsConfig, ESCALATED_AZ_CLI_TOOL_NAME } from '../../Az/tools.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const AzToolV2Model = z.object({ + account: z.string().optional().describe('Which configured Azure account to run this command against. Optional when exactly one account is configured for this identity; required when more than one is configured.'), + args: z.array(z.string()).min(1).describe('Arguments to `az`, e.g. ["group", "list"] for `az group list`. No shell — no quoting, no globbing, no operators'), +}); + +/** One of `AzCli`/`EscalatedAzCli` V2, differing only in identity/operation — same shape as V1's + * `createAzTool`, reusing the same `resolveAzAccount`/`runAz`. `AzCli` (reader) maps onto + * `fs.write`, the closest V2 tier to V1's generic `'write'` tag; `EscalatedAzCli` (holder) is + * `escalate` — always asks, never subject to Policy's ordinary fs.* tiers. */ +function createAzToolV2(name: string, operation: Operation, description: string, identity: 'reader' | 'holder', deps: AzDeps, getAccounts: () => AzAccountsConfig, cache: AzSessionCache) { + return defineToolV2({ + name, + description, + operation, + model: AzToolV2Model, + run: (input, _upstream, stderr): ToolV2Result => { + let ok = true; + + async function* run(): AsyncGenerator { + const account = resolveAzAccount(getAccounts, identity, input.account); + const result = await runAz(deps, cache, identity, account, input.args, process.cwd()); + ok = result.exitCode === 0; + const stdout = result.stdout.trim(); + if (stdout.length > 0) { + yield* stdout.split('\n'); + } + const stderrText = result.stderr.trim(); + if (stderrText.length > 0) { + stderr.push(...stderrText.split('\n')); + } + } + + return { stdout: fromLines(run()), success: () => ok }; + }, + }); +} + +/** `AzCli`/`EscalatedAzCli` V2, sharing `deps`/`cache` with V1's `createAzTools` and with the + * AzureDevOps.PullRequest.* V2 tools (see `AzureDevOps.ts`) — one `AzSessionCache` for every + * escalated `az` surface, V1 and V2 alike. */ +export function createAzToolsV2(deps: AzDeps, getAccounts: () => AzAccountsConfig, cache: AzSessionCache) { + return [ + createAzToolV2(AZ_CLI_TOOL_NAME, 'fs.write', 'Run an Azure CLI (`az`) command under the unprivileged reader identity of a configured account.', 'reader', deps, getAccounts, cache), + createAzToolV2(ESCALATED_AZ_CLI_TOOL_NAME, 'escalate', 'Run an Azure CLI (`az`) command under the privileged holder identity of a configured account. Always asks for approval first.', 'holder', deps, getAccounts, cache), + ] as const; +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/AzureDevOps.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/AzureDevOps.ts new file mode 100644 index 00000000..b0b02ba5 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/AzureDevOps.ts @@ -0,0 +1,140 @@ +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import type { z } from 'zod'; +import type { AzSessionCache } from '../../Az/AzSessionCache.js'; +import { resolveAzAccount } from '../../Az/createAzTool.js'; +import type { AzAccountsConfig } from '../../Az/tools.js'; +import { buildMergeCommitMessage } from '../../AzureDevOps/createAdoAutoMergeTool.js'; +import type { AdoPrToolSpec } from '../../AzureDevOps/createAdoPrTool.js'; +import { getGitRemoteUrl } from '../../AzureDevOps/gitRemote.js'; +import { orgArgs } from '../../AzureDevOps/orgArgs.js'; +import { orgNameFromRemote, parseAdoRemote } from '../../AzureDevOps/parseAdoRemote.js'; +import type { AdoEscalatedDeps } from '../../AzureDevOps/runAdoEscalated.js'; +import { runAdoEscalated } from '../../AzureDevOps/runAdoEscalated.js'; +import { AdoPrAutoMergeInputSchema } from '../../AzureDevOps/schema.js'; +import { adoPrCreateSpec, adoPrEditSpec, adoPrReadySpec, adoPrReviewerAddSpec, adoPrReviewerRemoveSpec, adoPrVoteSpec } from '../../AzureDevOps/specs.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +/** One named AzureDevOps.PullRequest.* V2 tool from an `AdoPrToolSpec` — same spec, same + * `runAdoEscalated`/`resolveAzAccount`/remote-parsing V1's `createAdoPrTool` uses, so the arg + * building and account/remote resolution are identical between V1 and V2. `escalate`: see + * `GitHub.ts`'s equivalent for the reasoning. */ +function createAdoPrToolV2>(spec: AdoPrToolSpec, deps: AdoEscalatedDeps, getAccounts: () => AzAccountsConfig, cache: AzSessionCache) { + return defineToolV2({ + name: spec.name, + description: spec.description, + operation: 'escalate', + model: spec.input_schema, + run: (input, _upstream, stderr): ToolV2Result => { + let ok = true; + + async function* run(): AsyncGenerator { + const cwd = input.cwd ?? process.cwd(); + const remoteUrl = await getGitRemoteUrl(cwd); + const remote = remoteUrl != null ? parseAdoRemote(remoteUrl) : null; + const account = resolveAzAccount(getAccounts, 'holder', input.account, orgNameFromRemote(remote)); + const result = await runAdoEscalated(deps, cache, account, spec.subcommand, spec.buildArgs(input, remote), cwd); + ok = result.exitCode === 0; + const stdout = result.stdout.trim(); + if (stdout.length > 0) { + yield* stdout.split('\n'); + } + const stderrText = result.stderr.trim(); + if (stderrText.length > 0) { + stderr.push(...stderrText.split('\n')); + } + } + + return { stdout: fromLines(run()), success: () => ok }; + }, + }); +} + +/** AzureDevOps_PullRequest_AutoMerge V2 — same two-call (show, then update) shape as V1's + * `createAdoAutoMergeTool`, reusing its `buildMergeCommitMessage` verbatim so the merge commit + * message stays byte-identical between V1 and V2. */ +function createAdoAutoMergeToolV2(deps: AdoEscalatedDeps, getAccounts: () => AzAccountsConfig, cache: AzSessionCache) { + return defineToolV2({ + name: 'AzureDevOps_PullRequest_AutoMerge', + description: + "Enable or disable auto-complete on a pull request. Never performs an immediate merge — only queues one via --auto-complete true, or clears it via --auto-complete false. The merge commit message is generated from the pull request's own title and description, matching what the Azure DevOps web UI would produce; it cannot be set by the caller.", + operation: 'escalate', + model: AdoPrAutoMergeInputSchema, + run: (input, _upstream, stderr): ToolV2Result => { + let ok = true; + + async function* run(): AsyncGenerator { + const cwd = input.cwd ?? process.cwd(); + const remoteUrl = await getGitRemoteUrl(cwd); + const remote = remoteUrl != null ? parseAdoRemote(remoteUrl) : null; + const account = resolveAzAccount(getAccounts, 'holder', input.account, orgNameFromRemote(remote)); + const orgArgsResolved = orgArgs(input.org, remote); + + if (!input.enable) { + const result = await runAdoEscalated(deps, cache, account, ['update'], ['--id', String(input.id), '--auto-complete', 'false', ...orgArgsResolved], cwd); + ok = result.exitCode === 0; + const stdout = result.stdout.trim(); + if (stdout.length > 0) { + yield* stdout.split('\n'); + } + const stderrText = result.stderr.trim(); + if (stderrText.length > 0) { + stderr.push(...stderrText.split('\n')); + } + return; + } + + const show = await runAdoEscalated(deps, cache, account, ['show'], ['--id', String(input.id), '--query', '{title:title,description:description}', '-o', 'json', ...orgArgsResolved], cwd); + if (show.exitCode !== 0) { + ok = false; + const stdout = show.stdout.trim(); + if (stdout.length > 0) { + yield* stdout.split('\n'); + } + const stderrText = show.stderr.trim(); + if (stderrText.length > 0) { + stderr.push(...stderrText.split('\n')); + } + return; + } + const pr = JSON.parse(show.stdout) as { title: string; description?: string }; + const message = buildMergeCommitMessage(input.id, pr.title, pr.description ?? ''); + + const args = ['--id', String(input.id), '--auto-complete', 'true', '--merge-commit-message', message, ...orgArgsResolved]; + if (input.squash != null) { + args.push('--squash', String(input.squash)); + } + if (input.deleteSourceBranch != null) { + args.push('--delete-source-branch', String(input.deleteSourceBranch)); + } + const result = await runAdoEscalated(deps, cache, account, ['update'], args, cwd); + ok = result.exitCode === 0; + const stdout = result.stdout.trim(); + if (stdout.length > 0) { + yield* stdout.split('\n'); + } + const stderrText = result.stderr.trim(); + if (stderrText.length > 0) { + stderr.push(...stderrText.split('\n')); + } + } + + return { stdout: fromLines(run()), success: () => ok }; + }, + }); +} + +/** The seven named AzureDevOps.PullRequest.* V2 tools, sharing `deps`/`cache` with V1's + * `createAdoPrTools` — the same `AzSessionCache` instance, so a warm holder session is reused + * across V1 and V2 calls alike, and with `AzCli`/`EscalatedAzCli` V2 (see `Az.ts`). */ +export function createAdoPrToolsV2(deps: AdoEscalatedDeps, getAccounts: () => AzAccountsConfig, cache: AzSessionCache) { + return [ + createAdoPrToolV2(adoPrCreateSpec, deps, getAccounts, cache), + createAdoPrToolV2(adoPrReadySpec, deps, getAccounts, cache), + createAdoPrToolV2(adoPrEditSpec, deps, getAccounts, cache), + createAdoAutoMergeToolV2(deps, getAccounts, cache), + createAdoPrToolV2(adoPrReviewerAddSpec, deps, getAccounts, cache), + createAdoPrToolV2(adoPrReviewerRemoveSpec, deps, getAccounts, cache), + createAdoPrToolV2(adoPrVoteSpec, deps, getAccounts, cache), + ] as const; +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/CreateFile.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/CreateFile.ts new file mode 100644 index 00000000..c06a6c4b --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/CreateFile.ts @@ -0,0 +1,41 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { pathSchema } from '@shellicar/claude-sdk'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { performCreateFile } from '../../CreateFile/performCreateFile.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const CreateFileToolV2Model = z.object({ + path: pathSchema.describe('Path to the file to create. Supports absolute, relative, ~ and $HOME.'), + content: z.string().optional().describe('Initial file content. Defaults to empty.'), + overwrite: z.boolean().optional().describe('If false (default), error if file already exists. If true, error if file does not exist.'), +}); + +/** The V2 tool equivalent of V1's `CreateFile` \u2014 same overwrite semantics (default: error if + * the file already exists; `overwrite: true` instead requires it already exist). `fs.write` + * tier. `path` is its own marked field, same as every write-shaped V2 tool \u2014 a create target + * is always named explicitly, never implicit from a pipe. */ +export function createCreateFileToolV2(fs: IFileSystem) { + return defineToolV2({ + name: 'CreateFile', + description: 'Create a new file with optional content. Creates parent directories automatically. By default errors if the file already exists. Set overwrite: true to replace an existing file (errors if file does not exist).', + operation: 'fs.write', + model: CreateFileToolV2Model, + run: (input, _upstream, stderr): ToolV2Result => { + let ok = true; + + async function* run(): AsyncGenerator { + const result = await performCreateFile(fs, input.path, input.content ?? '', input.overwrite ?? false); + if (!result.ok) { + ok = false; + stderr.push(result.message); + return; + } + yield `created: ${input.path}`; + } + + return { stdout: fromLines(run()), success: () => ok }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Delete.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Delete.ts new file mode 100644 index 00000000..0af38898 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Delete.ts @@ -0,0 +1,70 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { pathSchema } from '@shellicar/claude-sdk'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { deleteBatch } from '../../deleteBatch.js'; +import { isNodeError } from '../../isNodeError.js'; +import { defineToolV2, xargsTarget } from '../defineToolV2.js'; + +export const DeleteToolV2Model = z.object({ + // The xargs target: required of a call that stands alone, and filled by an `Xargs` stage when + // one precedes it. + files: xargsTarget(z.array(pathSchema)).describe('Paths to delete — files or directories. Feed from Find via Xargs, not a direct pipe.'), +}); + +/** The V2 tool equivalent of V1's `DeleteFile` and `DeleteDirectory`, unified into one \u2014 same + * principle as `Match` losing its `input.kind` branch: in the plain-text, piped world there's + * no reliable place to pre-sort "these are files, these are directories" (`Find` yields both + * uniformly), so the tool itself checks each target and deletes it the right way, rather than + * the caller having to split a batch across two tools first. Reuses `deleteBatch` verbatim, + * same as V1 did \u2014 only the per-path operation and error mapping change. `fs.delete` tier, + * covering both cases; V1 never split fs.delete by target type either. + * + * Takes `files` as its own marked field only, never an implicit upstream-as-paths read \u2014 + * same reasoning as `Read`: real Unix has no `find | rm`, only `find | xargs rm`. Taking paths + * only through a real field is also what lets `collectPaths` see them for Policy; a value + * smuggled through `upstream` was invisible to any path-scoped policy rule. */ +export function createDeleteToolV2(fs: IFileSystem) { + return defineToolV2({ + name: 'Delete', + description: 'Delete files or directories by path. A directory must be empty.', + operation: 'fs.delete', + model: DeleteToolV2Model, + run: (input, _upstream, stderr): ToolV2Result => { + let ok = true; + + async function* run(): AsyncGenerator { + const result = await deleteBatch( + input.files ?? [], + async (path) => { + const stat = await fs.stat(path); + if (stat.isDirectory()) { + await fs.deleteDirectory(path); + } else { + await fs.deleteFile(path); + } + }, + (err) => { + if (isNodeError(err, 'ENOENT')) { + return 'Path not found'; + } + if (isNodeError(err, 'ENOTEMPTY')) { + return 'Directory is not empty. Delete the files inside first.'; + } + return undefined; + }, + ); + for (const path of result.deleted) { + yield `deleted: ${path}`; + } + for (const e of result.errors) { + ok = false; + stderr.push(`${e.path}: ${e.error}`); + } + } + + return { stdout: fromLines(run()), success: () => ok }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/DeleteMemory.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/DeleteMemory.ts new file mode 100644 index 00000000..54351a35 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/DeleteMemory.ts @@ -0,0 +1,22 @@ +import type { IMemoryStore } from '@shellicar/claude-core/memory/interfaces'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { DeleteMemoryInputSchema } from '../../Memory/schema.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +/** The V2 tool equivalent of V1's `DeleteMemory` — same `IMemoryStore.delete`, same input schema. */ +export function createDeleteMemoryToolV2(store: IMemoryStore) { + return defineToolV2({ + name: 'DeleteMemory', + description: 'Retire a memory by id so it stops surfacing in search — use when rewriting a memory that is wrong. Idempotent: deleting an unknown or already-retired id still succeeds.', + operation: 'none', + model: DeleteMemoryInputSchema, + run: (input): ToolV2Result => { + async function* run(): AsyncGenerator { + await store.delete(input.id); + yield JSON.stringify({ deleted: true, id: input.id }); + } + return { stdout: fromLines(run()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/EditFile.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/EditFile.ts new file mode 100644 index 00000000..2245f085 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/EditFile.ts @@ -0,0 +1,49 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { pathSchema } from '@shellicar/claude-sdk'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { performEdit } from '../../EditFile/performEdit.js'; +import { EditFileLineOperationSchema, EditFileTextOperationSchema } from '../../EditFile/schema.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const EditFileToolV2Model = z + .object({ + file: pathSchema, + lineEdits: z + .array(EditFileLineOperationSchema) + .optional() + .default([]) + .describe('Structural edits by line number (insert / replace / delete). Applied bottom-to-top so all line numbers refer to the file as it exists before this call \u2014 no offset calculation needed. If two edits target the same lines, an error is thrown.'), + textEdits: z.array(EditFileTextOperationSchema).optional().default([]).describe('Text-search edits (replace_text / regex_text). Applied in order after all lineEdits.'), + }) + .refine((input) => input.lineEdits.length > 0 || input.textEdits.length > 0, { + message: 'At least one edit must be provided (lineEdits or textEdits)', + }); + +/** The V2 tool equivalent of V1's `EditFile` \u2014 identical logic, reusing the same + * `applyEdits`/`generateDiff`/`resolveAfterLine`/`validateLineEdits` modules verbatim (pure, + * file-agnostic functions, nothing V1-specific about them). The only real difference is the + * output shape: V1 returns the diff as one JSON string; V2 splits it into lines, the same + * plain-text convention every V2 tool follows, so a huge diff can still be piped into + * Head/Tail/Range/Match like any other tool's output. A thrown validation error (out of + * bounds, overlapping edits, a replace_text not found) propagates as a stream rejection, + * same as `Program`'s own failsafe termination \u2014 no separate error channel needed. */ +export function createEditFileToolV2(fs: IFileSystem) { + return defineToolV2({ + name: 'EditFile', + description: 'Edit a file: apply line and text edits, write the result to disk, and return a line-numbered diff.', + operation: 'fs.write', + model: EditFileToolV2Model, + run: (input): ToolV2Result => { + async function* run(): AsyncGenerator { + const diff = await performEdit(fs, input.file, input.lineEdits, input.textEdits); + for (const line of diff.split('\n')) { + yield line; + } + } + + return { stdout: fromLines(run()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Find.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Find.ts new file mode 100644 index 00000000..98c5134b --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Find.ts @@ -0,0 +1,69 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { pathSchema } from '@shellicar/claude-sdk'; +import type { Ended, Operation, Running, Writer } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { regexPattern } from '../../regexPattern.js'; +import { defineToolV2 } from '../defineToolV2.js'; +import { NEWLINE } from '../lines.js'; +import { walkLazy } from '../walkLazy.js'; + +export const FindModel = z.object({ + path: pathSchema.describe('Directory to search. Supports absolute, relative, ~ and $HOME.'), + pattern: regexPattern('Match against file paths', ['\\.ts$', '\\.(ts|js)$']).optional(), + type: z.enum(['file', 'directory', 'both']).optional(), + exclude: z.array(z.string()).optional(), + maxDepth: z.number().int().min(1).optional(), + followSymlinks: z.boolean().optional(), +}); + +type FindInput = z.infer; + +/** Walks a directory, writing a path at a time. Directory entries, not file content, which is the + * same distinction Unix draws between `r` on a directory and `r` on a file. */ +export function createFindTool(fs: IFileSystem) { + return defineToolV2({ + name: 'Find', + description: 'Find files or directories under a directory. A source: starts a pipe.', + model: FindModel, + operations: (): Operation[] => ['fs.list'], + + run: (raw: Record, _upstream: unknown, out: Writer, say: (line: string) => void): Running => { + const input = raw as FindInput; + const re = input.pattern != null ? new RegExp(input.pattern) : undefined; + const options = { pattern: input.pattern, type: input.type, exclude: input.exclude, maxDepth: input.maxDepth, followSymlinks: input.followSymlinks }; + let ended: Ended = { kind: 'finished' }; + let unreadable = 0; + + const walking = (async () => { + try { + for await (const record of walkLazy(fs, input.path, options, 1, re, new Set(), () => void unreadable++)) { + // A write that is not accepted means the reader has gone, which is the only thing that + // stops the walk. There is no process here to take a signal. + if (!(await out.write(Buffer.from(`${record.path}${NEWLINE}`, 'utf8')))) { + return; + } + } + } catch (err) { + // Nothing was walked at all, so the answer is not incomplete, it is absent. `find` itself + // exits 1 when it cannot read what it was pointed at. + ended = { kind: 'failed', code: 1 }; + say(err instanceof Error ? err.message : String(err)); + } + })().finally(() => { + // A count rather than a line each: what a stage says is bounded, and lines past the bound + // are dropped where nobody sees them, taking the number with them. + if (unreadable > 0) { + say(`${unreadable} ${unreadable === 1 ? 'directory' : 'directories'} could not be read`); + } + out.end(); + }); + + return { + ended: () => ended, + stop: async () => { + await walking; + }, + }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/GitHub.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/GitHub.ts new file mode 100644 index 00000000..f7f3470e --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/GitHub.ts @@ -0,0 +1,47 @@ +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import type { z } from 'zod'; +import type { GhPrToolSpec } from '../../GitHub/createGhPrTool.js'; +import { type GhEscalatedDeps, runGhEscalated } from '../../GitHub/runGhEscalated.js'; +import { ghPrAutoMergeSpec, ghPrCommentSpec, ghPrCreateSpec, ghPrEditSpec, ghPrReadySpec, ghPrReviewSpec } from '../../GitHub/specs.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +/** One named GitHub.PullRequest.* V2 tool from a `GhPrToolSpec` — the exact same spec (name, + * schema, subcommand, buildArgs) V1's `createGhPrTool` builds from, and the exact same + * `runGhEscalated`, so the arg-building logic and the run mechanics are identical between V1 + * and V2; only the tool-definition wrapper differs. `escalate`: a privilege-boundary crossing + * (the holder token) that must always ask, never subject to Policy's ordinary fs.* tiers — see + * the `Operation` type in orchestrate-core. */ +function createGhPrToolV2>(spec: GhPrToolSpec, deps: GhEscalatedDeps) { + return defineToolV2({ + name: spec.name, + description: spec.description, + operation: 'escalate', + model: spec.input_schema, + run: (input, _upstream, stderr): ToolV2Result => { + let ok = true; + + async function* run(): AsyncGenerator { + const cwd = input.cwd ?? process.cwd(); + const result = await runGhEscalated(deps, spec.subcommand, spec.buildArgs(input), cwd); + ok = result.exitCode === 0; + const stdout = result.stdout.trim(); + if (stdout.length > 0) { + yield* stdout.split('\n'); + } + const stderrText = result.stderr.trim(); + if (stderrText.length > 0) { + stderr.push(...stderrText.split('\n')); + } + } + + return { stdout: fromLines(run()), success: () => ok }; + }, + }); +} + +/** The six named GitHub.PullRequest.* V2 tools, sharing deps with V1's `createGhPrTools` — same + * holder credential, same executor. */ +export function createGhPrToolsV2(deps: GhEscalatedDeps) { + return [createGhPrToolV2(ghPrCreateSpec, deps), createGhPrToolV2(ghPrReadySpec, deps), createGhPrToolV2(ghPrEditSpec, deps), createGhPrToolV2(ghPrCommentSpec, deps), createGhPrToolV2(ghPrAutoMergeSpec, deps), createGhPrToolV2(ghPrReviewSpec, deps)] as const; +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Head.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Head.ts new file mode 100644 index 00000000..0a35d8fc --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Head.ts @@ -0,0 +1,42 @@ +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines, lines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const HeadToolV2Model = z.object({ count: z.number().int().min(1).optional() }); + +/** First N lines of the upstream, then stops pulling — the tool that proves the whole + * streaming requirement (see the design doc and orchestrate-core's tests): a short-circuiting + * consumer here must cut an expensive or unbounded producer short, not force it to finish. */ +export function createHeadToolV2() { + return defineToolV2({ + name: 'Head', + readsUpstream: true, + description: 'First N of the piped stream. Stage.', + operation: 'none', + model: HeadToolV2Model, + run: (input, upstream): ToolV2Result => { + const count = input.count ?? 10; + + async function* take(): AsyncGenerator { + if (upstream == null) { + return; + } + let taken = 0; + for await (const value of lines(upstream)) { + yield String(value); + taken++; + // Stop the instant the Nth item is yielded — checking after a break would already + // have pulled one item too many, the exact bug the design doc's Program tool tests + // exist to catch (an over-pull that a real process would have paid real work for). + if (taken >= count) { + upstream.destroy(); + return; + } + } + } + + return { stdout: fromLines(take()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Match.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Match.ts new file mode 100644 index 00000000..5cac27ae --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Match.ts @@ -0,0 +1,72 @@ +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines, lines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { regexPattern } from '../../regexPattern.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const MatchToolV2Model = z.object({ + pattern: regexPattern('Keep matching lines', ['TODO', '(?\\w+)']), + caseInsensitive: z.boolean().optional(), + before: z.number().int().min(0).optional(), + after: z.number().int().min(0).optional(), +}); + +/** Tests every incoming string against the pattern, the way `grep` does, whether what was piped in + * is paths or content. */ +export function createMatchToolV2() { + return defineToolV2({ + name: 'Match', + readsUpstream: true, + description: 'Keep matching lines from the piped stream. Stage.', + operation: 'none', + model: MatchToolV2Model, + run: (input, upstream): ToolV2Result => { + const re = new RegExp(input.pattern, input.caseInsensitive ? 'i' : ''); + const before = input.before ?? 0; + const after = input.after ?? 0; + + async function* filter(): AsyncGenerator { + if (upstream == null) { + return; + } + + type Buffered = { lineNo: number; text: string }; + const beforeBuffer: Buffered[] = []; + let windowEnd = -1; + let lastEmittedLineNo = -1; + let lineNo = 0; + + for await (const value of lines(upstream)) { + const text = String(value); + const currentLineNo = lineNo; + lineNo++; + + if (re.test(text)) { + for (const buffered of beforeBuffer) { + if (buffered.lineNo > lastEmittedLineNo) { + yield buffered.text; + lastEmittedLineNo = buffered.lineNo; + } + } + if (currentLineNo > lastEmittedLineNo) { + yield text; + lastEmittedLineNo = currentLineNo; + } + windowEnd = Math.max(windowEnd, currentLineNo + after); + beforeBuffer.length = 0; + } else if (currentLineNo <= windowEnd) { + yield text; + lastEmittedLineNo = currentLineNo; + } else { + beforeBuffer.push({ lineNo: currentLineNo, text }); + if (beforeBuffer.length > before) { + beforeBuffer.shift(); + } + } + } + } + + return { stdout: fromLines(filter()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/MemoryTypes.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/MemoryTypes.ts new file mode 100644 index 00000000..df27fc54 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/MemoryTypes.ts @@ -0,0 +1,24 @@ +import type { IMemoryStore } from '@shellicar/claude-core/memory/interfaces'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { MemoryTypesInputSchema } from '../../Memory/schema.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +/** The V2 tool equivalent of V1's `MemoryTypes` — same `IMemoryStore.types`, same input schema. */ +export function createMemoryTypesToolV2(store: IMemoryStore) { + return defineToolV2({ + name: 'MemoryTypes', + description: 'List the distinct memory types in use with their counts, so you reuse an established word rather than coin a near-duplicate.', + operation: 'none', + model: MemoryTypesInputSchema, + run: (): ToolV2Result => { + async function* run(): AsyncGenerator { + const types = await store.types(); + for (const t of types) { + yield `${t.type}: ${t.count}`; + } + } + return { stdout: fromLines(run()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Paths.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Paths.ts new file mode 100644 index 00000000..51153a1b --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Paths.ts @@ -0,0 +1,44 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { pathSchema } from '@shellicar/claude-sdk'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const PathsToolV2Model = z.object({ + paths: z.array(pathSchema).min(1).describe('Explicit file or directory paths to start an Orchestrate sequence from.'), +}); + +/** The V2 tool equivalent of V1's `Paths` \u2014 the other Pipe source alongside `Find`: use when + * the caller already knows the paths, rather than discovering them. `fs.list` tier, same as + * `Find`: this only confirms each path exists (stat), it doesn't read file content. Fails the + * whole call on the first missing path \u2014 same fatal-on-first-miss behaviour V1 has, since a + * caller who names an explicit path expects it to exist. */ +export function createPathsToolV2(fs: IFileSystem) { + return defineToolV2({ + name: 'Paths', + description: 'Start an Orchestrate sequence from explicit, already-known paths. Source: use when you name the files, rather than discovering them with Find.', + operation: 'fs.list', + model: PathsToolV2Model, + run: (input, _upstream, stderr): ToolV2Result => { + let ok = true; + return { + stdout: fromLines( + (async function* () { + for (const path of input.paths) { + try { + await fs.stat(path); + } catch { + ok = false; + stderr.push(`Path not found: ${path}`); + return; + } + yield path; + } + })(), + ), + success: () => ok, + }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Program.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Program.ts new file mode 100644 index 00000000..ed10df64 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Program.ts @@ -0,0 +1,177 @@ +import { resolve } from 'node:path'; +import { PassThrough, Readable, type Writable } from 'node:stream'; +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { pathSchema } from '@shellicar/claude-sdk'; +import { type CommandSpec, type IExecutor, PipeConsumerGone } from '@shellicar/exec-core'; +import type { Ended, Operation, Reader, Running, Writer } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { stripAnsi } from '../../Exec/stripAnsi.js'; +import { type IEnvProvider, PROTECTED_ENV_NAMES } from '../../exec-shared.js'; + +/** How much of a process's output is held before the process is made to wait. */ +export const PIPE_BUFFER_BYTES = 64 * 1024; + +export const ProgramModel = z.object({ + // Taken literally, unlike `args` and `cwd`: expanding it would mean a rule about which program + // may run had to police a name decided later. + program: z.string().min(1).describe('The program to execute. Taken literally: no ~ or $VAR expansion, unlike args and cwd. Must be on the PATH or an absolute path.'), + args: z.array(z.string()).optional(), + cwd: pathSchema.optional().describe('Working directory for this command. Defaults to the current working directory when omitted.'), + env: z + .record(z.string(), z.string()) + .optional() + .refine((env) => env == null || Object.keys(env).every((name) => !PROTECTED_ENV_NAMES.includes(name as (typeof PROTECTED_ENV_NAMES)[number])), { + message: `these environment variables cannot be set for a command, because the engine will not honour them and the command would differ from the one asked for: ${PROTECTED_ENV_NAMES.join(', ')}`, + }), + /** A literal here-string, used only when nothing is piped in. */ + stdin: z.string().optional(), + /** Writes a stream to a file instead of sending it on. A relative path resolves against this + * call's own `cwd`. */ + redirect: z.object({ stdout: pathSchema.optional(), stderr: pathSchema.optional() }).optional(), + /** Kills this command after this many milliseconds, whatever the run's own limit. */ + timeout: z.number().int().positive().optional(), + /** Keeps escape codes, for a command whose colour is the point. */ + stripAnsi: z.boolean().optional(), +}); + +type ProgramInput = z.infer; + +type Deps = { sleep?: (ms: number, signal: AbortSignal) => Promise }; + +/** Reads a stream a line at a time, so a filter applies to a line rather than to whatever a chunk + * boundary happened to cut. A line longer than the buffer is passed on as it stands. */ +function onEachLine(from: NodeJS.ReadableStream, take: (line: string, terminated: boolean) => void): Promise { + // Latin-1 throughout: one byte in, one byte out, so what a process wrote arrives as it wrote it + // even when it is not text at all. Escape sequences are ASCII, so stripping still works. + let partial = ''; + return new Promise((resolve) => { + from.on('data', (chunk: Buffer) => { + partial += chunk.toString('binary'); + let index = partial.indexOf('\n'); + while (index >= 0) { + take(partial.slice(0, index), true); + partial = partial.slice(index + 1); + index = partial.indexOf('\n'); + } + if (partial.length >= PIPE_BUFFER_BYTES) { + take(partial, false); + partial = ''; + } + }); + from.on('end', () => { + if (partial.length > 0) { + take(partial, false); + } + resolve(); + }); + }); +} + +function readerAsStream(from: Reader): Readable { + return Readable.from( + (async function* () { + for (let chunk = await from.read(); chunk != null; chunk = await from.read()) { + yield chunk; + } + })(), + { objectMode: false }, + ); +} + +/** How the process ended, in the run's own terms. */ +function endedAs(exitCode: number | null, signal: string | null): Ended { + if (signal != null) { + return { kind: 'signalled', signal }; + } + return exitCode === 0 ? { kind: 'finished' } : { kind: 'failed', code: exitCode ?? 1 }; +} + +/** Spawns one process. Its bytes go down, its stderr is captured, its exit is how the stage ended, + * and closing its output kills it the way a departing reader kills a process in a pipeline. */ +export function createProgramTool(executor: IExecutor, fs: IFileSystem, envProvider: IEnvProvider, deps: Deps = {}) { + return { + name: 'Program', + // A redirect writes a file, so a call that has one is a write as well as an execution. + operations: (input: Record): Operation[] => { + const redirect = (input as ProgramInput).redirect; + return redirect?.stdout != null || redirect?.stderr != null ? ['fs.exec', 'fs.write'] : ['fs.exec']; + }, + takesListIn: 'args', + + run: (raw: Record, upstream: Reader | undefined, out: Writer, say: (line: string, options?: { captured?: boolean }) => void): Running => { + const input = raw as ProgramInput; + const cwd = input.cwd ?? fs.cwd(); + const clean = input.stripAnsi === false ? (line: string) => line : stripAnsi; + const env = envProvider.buildEnv(input.env); + const controller = new AbortController(); + + const stdout = new PassThrough({ highWaterMark: PIPE_BUFFER_BYTES }); + const stderr = new PassThrough({ highWaterMark: PIPE_BUFFER_BYTES }); + const toFile = (path: string): Writable => fs.createWriteStream(resolve(cwd, path), { flags: 'w' }); + const stdoutFile = input.redirect?.stdout != null ? toFile(input.redirect.stdout) : undefined; + const stderrFile = input.redirect?.stderr != null ? toFile(input.redirect.stderr) : undefined; + + // Redirected output belongs to the file, so the stage sends nothing on, the way a redirected + // command shows nothing on a terminal. + const wrote: Promise[] = []; + const sentOn = onEachLine(stdout, (line, terminated) => { + const text = terminated ? `${clean(line)}\n` : clean(line); + if (stdoutFile != null) { + stdoutFile.write(text, 'binary'); + return; + } + wrote.push(out.write(Buffer.from(text, 'binary'))); + }); + const captured = onEachLine(stderr, (line) => { + const text = clean(line); + if (stderrFile != null) { + stderrFile.write(`${text}\n`, 'binary'); + return; + } + say(text, { captured: true }); + }); + + let exit: Ended = { kind: 'finished' }; + let finished = false; + + const running = executor + .run({ program: input.program, args: input.args, cwd, env } satisfies CommandSpec, { stdout, stderr, stdin: upstream != null ? readerAsStream(upstream) : input.stdin != null ? Readable.from(input.stdin) : undefined, signal: controller.signal }) + .then((status) => { + exit = endedAs(status.exitCode, status.signal); + }) + .catch((err: unknown) => { + out.fail(err); + }) + .finally(async () => { + finished = true; + // Everything the process wrote has to have been read and passed on before the output is + // ended: a last line with no newline on it arrives after the process itself is gone. + await Promise.all([sentOn, captured]); + await Promise.all(wrote); + stdoutFile?.end(); + stderrFile?.end(); + out.end(); + }); + + if (input.timeout != null && deps.sleep != null) { + void deps.sleep(input.timeout, controller.signal).then(() => { + if (!finished) { + say(`timed out after ${input.timeout}ms`); + controller.abort(new Error('timed out')); + } + }); + } + + return { + ended: () => exit, + stop: async () => { + // A relayed pipe gives a spawned process no SIGPIPE of its own, so it is sent here. + if (!finished) { + controller.abort(PipeConsumerGone); + } + await running; + }, + }; + }, + }; +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Range.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Range.ts new file mode 100644 index 00000000..4ad0318c --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Range.ts @@ -0,0 +1,47 @@ +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines, lines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const RangeToolV2Model = z + .object({ + start: z.number().int().min(1).describe('1-based start position (inclusive)'), + end: z.number().int().min(1).describe('1-based end position (inclusive)'), + }) + .refine((v) => v.start <= v.end, { message: 'Range start must not be after end', path: ['start'] }); + +/** A 1-based inclusive window [start, end] of the upstream. Lazy in both directions: items + * before `start` are skipped without being buffered, and pulling stops the instant the item + * at `end` is yielded — same "no extra pull" discipline as Head, for the same reason. */ +export function createRangeToolV2() { + return defineToolV2({ + name: 'Range', + readsUpstream: true, + description: 'A 1-based inclusive window of the piped stream. Stage.', + operation: 'none', + model: RangeToolV2Model, + run: (input, upstream): ToolV2Result => { + const { start, end } = input; + + async function* window(): AsyncGenerator { + if (upstream == null) { + return; + } + let pos = 0; + for await (const value of lines(upstream)) { + pos++; + if (pos < start) { + continue; + } + yield String(value); + if (pos >= end) { + upstream.destroy(); + return; + } + } + } + + return { stdout: fromLines(window()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Read.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Read.ts new file mode 100644 index 00000000..75d6738b --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Read.ts @@ -0,0 +1,75 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { pathSchema } from '@shellicar/claude-sdk'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { fileTypeFromBuffer } from 'file-type'; +import { z } from 'zod'; +import { defineToolV2, xargsTarget } from '../defineToolV2.js'; + +const HEADER_BYTES = 4100; // file-type needs ~4100 bytes for detection (mirrors ReadFile/V1 Read) + +export const ReadToolV2Model = z.object({ + // The xargs target: required of a call that stands alone, and filled by an `Xargs` stage when + // one precedes it. The registry knows the difference from the stages around it, so the schema + // states the real requirement rather than being loosened to accommodate injection. + paths: xargsTarget(z.array(pathSchema)).describe('File paths to read. Feed from Find/Paths via Xargs, not a direct pipe.'), +}); + +/** Reads the content of each named path, skipping directories and binary files (same rule as + * V1's `Read` \u2014 `grep -I`-style: a binary file has no text lines to contribute). Each line is + * emitted as `path:lineNumber:text` \u2014 the `grep -Hn` convention. + * + * Takes `paths` as its own marked field, never an implicit upstream-as-paths read: real Unix + * has no tool that treats piped lines as filenames to open on its own (`cat` doesn't; that + * behaviour is always `xargs` converting the list into arguments for the reader). Taking + * paths only through a real field is also what lets `collectPaths` see them for Policy \u2014 + * a value smuggled through `upstream` was invisible to any path-scoped policy rule. */ +export function createReadToolV2(fs: IFileSystem) { + return defineToolV2({ + name: 'Read', + description: 'Reads the content of each named path, as path:lineNumber:text.', + operation: 'fs.read', + model: ReadToolV2Model, + run: (input, _upstream, stderr): ToolV2Result => { + let ok = true; + + async function* readAll(): AsyncGenerator { + for (const path of input.paths ?? []) { + let stat: Awaited>; + try { + stat = await fs.stat(path); + } catch (err) { + ok = false; + stderr.push(err instanceof Error ? err.message : String(err)); + continue; + } + if (stat.isDirectory()) { + continue; // a directory has no contents to read + } + + let data: string; + try { + data = await fs.readFile(path, 'base64'); + } catch (err) { + ok = false; + stderr.push(err instanceof Error ? err.message : String(err)); + continue; + } + + const buf = Buffer.from(data, 'base64'); + const sniff = await fileTypeFromBuffer(buf.subarray(0, HEADER_BYTES)); + if (sniff) { + continue; // binary file: read it with ReadBinaryFile outside a pipe instead + } + + const lines = buf.toString('utf8').split('\n'); + for (let i = 0; i < lines.length; i++) { + yield `${path}:${i + 1}:${lines[i]}`; + } + } + } + + return { stdout: fromLines(readAll()), success: () => ok }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/ReadBinaryFile.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/ReadBinaryFile.ts new file mode 100644 index 00000000..aabf495c --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/ReadBinaryFile.ts @@ -0,0 +1,99 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { conditionImage } from '@shellicar/claude-core/image/conditionImage'; +import type { SipsBridge } from '@shellicar/claude-core/image/SipsBridge'; +import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { pathSchema } from '@shellicar/claude-sdk'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { fileTypeFromBuffer } from 'file-type'; +import { z } from 'zod'; +import { isNodeError } from '../../isNodeError.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +const MAX_BINARY_BYTES = 32 * 1024 * 1024; +const IMAGE_BASE64_MAX_BYTES = 5 * 1024 * 1024; // Anthropic API per-image cap +const HEADER_BASE64_CHARS = 5600; // file-type needs up to ~4100 bytes for accurate detection + +export const ReadBinaryFileModel = z.object({ + path: pathSchema.describe('Path to the file. Supports absolute, relative, ~ and $HOME.'), +}); + +/** The always-single-target binary reader V2's `Read` deliberately skips (a directory of PDFs/ + * images piped through a batch reader would decode all of them into context at once \u2014 expensive + * and irreversible, so `Read` only ever handles text). Auto-detects PDF/image content from the + * file itself rather than trusting a caller-declared MIME type \u2014 once a tool's whole purpose is + * "read binary content", there's nothing left to disambiguate by declaring one up front; V1's + * `ReadFile` needed the declared/validated `mimeType` only because one tool had to tell text and + * binary apart. + * + * `excludeFromStages`: its real output is a native attachment, not `Stream` \u2014 piping a + * PDF into another Orchestrate stage is meaningless, so it stays individually callable but is + * never offered as a pipe stage. */ +export function createReadBinaryFileToolV2(fs: IFileSystem, sips: SipsBridge, logger: ILogger) { + return defineToolV2({ + name: 'ReadBinaryFile', + description: 'Read a single PDF or image file (png, jpeg, gif, webp) as a native document/image attachment. Never fed by a pipe \u2014 use Read for text files.', + operation: 'fs.read', + model: ReadBinaryFileModel, + excludeFromStages: true, + run: (input, _upstream, stderr): ToolV2Result => { + let ok = true; + let attachment: unknown | undefined; + + async function* run(): AsyncGenerator { + const filePath = input.path; + + let size: number; + try { + ({ size } = await fs.stat(filePath)); + } catch (err) { + ok = false; + stderr.push(isNodeError(err, 'ENOENT') ? `File not found: ${filePath}` : String(err)); + return; + } + + if (size > MAX_BINARY_BYTES) { + ok = false; + stderr.push(`File is too large (${Math.round(size / (1024 * 1024))}MB, max ${MAX_BINARY_BYTES / (1024 * 1024)}MB).`); + return; + } + + let data: string; + try { + data = await fs.readFile(filePath, 'base64'); + } catch (err) { + ok = false; + stderr.push(isNodeError(err, 'ENOENT') ? `File not found: ${filePath}` : String(err)); + return; + } + + const header = Buffer.from(data.slice(0, HEADER_BASE64_CHARS), 'base64'); + const type = await fileTypeFromBuffer(header); + + if (type?.mime === 'application/pdf') { + attachment = { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data } }; + yield `${filePath} (application/pdf, ${Math.round(size / 1024)}KB)`; + return; + } + + if (type?.mime === 'image/jpeg' || type?.mime === 'image/png' || type?.mime === 'image/gif' || type?.mime === 'image/webp') { + const conditioned = await conditionImage(Buffer.from(data, 'base64'), type.mime, sips, logger); + const outData = conditioned.data.toString('base64'); + if (outData.length > IMAGE_BASE64_MAX_BYTES) { + ok = false; + stderr.push(`Image base64 payload too large (${Math.round(outData.length / 1024)}KB, max ${IMAGE_BASE64_MAX_BYTES / 1024}KB).`); + return; + } + attachment = { type: 'image', source: { type: 'base64', media_type: conditioned.mediaType, data: outData } }; + yield `${filePath} (${conditioned.mediaType}, ${Math.round(outData.length / 1024)}KB)`; + return; + } + + ok = false; + stderr.push(`${filePath} is not a PDF or image \u2014 use Read for text files.`); + } + + return { stdout: fromLines(run()), success: () => ok, attachments: () => (attachment ? [attachment] : []) }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/ReadHistory.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/ReadHistory.ts new file mode 100644 index 00000000..286bf90c --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/ReadHistory.ts @@ -0,0 +1,25 @@ +import type { IHistoryReader } from '@shellicar/claude-core/history/interfaces'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { performReadHistory } from '../../History/performReadHistory.js'; +import { ReadHistoryInputSchema } from '../../History/schema.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +/** The V2 tool equivalent of V1's `ReadHistory` — same `performReadHistory`, same input schema. */ +export function createReadHistoryToolV2(reader: IHistoryReader) { + return defineToolV2({ + name: 'ReadHistory', + description: 'Open the full exchange around one or more search citations. Each citation is a { session, turnId } from a SearchHistory hit; the shared `window` sets how many turns either side of each centre to include. Each event text is capped so one giant tool_result cannot flood context.', + operation: 'none', + model: ReadHistoryInputSchema, + run: (input): ToolV2Result => { + async function* run(): AsyncGenerator { + const windows = performReadHistory(reader, input); + for (const line of JSON.stringify(windows, null, 2).split('\n')) { + yield line; + } + } + return { stdout: fromLines(run()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/ReadMemory.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/ReadMemory.ts new file mode 100644 index 00000000..bc6d4907 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/ReadMemory.ts @@ -0,0 +1,26 @@ +import type { IMemoryStore } from '@shellicar/claude-core/memory/interfaces'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { ReadMemoryInputSchema } from '../../Memory/schema.js'; +import type { ReadMemoryOutput } from '../../Memory/types.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +/** The V2 tool equivalent of V1's `ReadMemory` — same `IMemoryStore.read`, same input schema. */ +export function createReadMemoryToolV2(store: IMemoryStore) { + return defineToolV2({ + name: 'ReadMemory', + description: 'Fetch one memory by its id. Returns not-found if the id is unknown or has been retired.', + operation: 'none', + model: ReadMemoryInputSchema, + run: (input): ToolV2Result => { + async function* run(): AsyncGenerator { + const memory = await store.read(input.id); + const out: ReadMemoryOutput = memory === undefined ? { found: false, id: input.id } : { found: true, memory }; + for (const line of JSON.stringify(out, null, 2).split('\n')) { + yield line; + } + } + return { stdout: fromLines(run()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Ref.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Ref.ts new file mode 100644 index 00000000..819e5af8 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Ref.ts @@ -0,0 +1,44 @@ +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import type { RefStore } from '../../RefStore/RefStore.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const RefToolV2Model = z.object({ + id: z.string().describe('The ref ID returned in a { ref, size, hint } token.'), + start: z.number().int().min(0).default(0).describe('Start character offset (inclusive). Default 0.'), + limit: z.number().int().min(1).max(100_000).default(10_000).describe('Maximum number of characters to return. Max 100000, default 10000. Use start+limit to page through large refs.'), +}); + +/** The V2 tool equivalent of V1's `Ref` \u2014 same character-range paging (`start`/`limit`), but + * emits its slice split into lines instead of one JSON blob, so it composes directly with + * `Match`/`Head`/`Tail`/`Range` (`Ref | Match` filters a huge stored ref without ever pulling + * the whole thing into context first). `id` names no path \u2014 it addresses the same in-memory + * `RefStore` V1's automatic ref-swap (`transformToolResult`) already writes to, so a ref + * produced by any tool's oversized output is fetchable here. `none` tier: an in-memory + * lookup, not a filesystem or process operation. */ +export function createRefToolV2(store: RefStore) { + return defineToolV2({ + name: 'Ref', + description: 'Fetch the content of a stored ref, split into lines. When a tool result contains { ref, size, hint } instead of the full value, use this tool to retrieve it \u2014 pipe into Match/Head/Tail/Range to filter without pulling the whole thing into context.', + operation: 'none', + model: RefToolV2Model, + run: (input, _upstream, stderr): ToolV2Result => { + let ok = true; + + async function* run(): AsyncGenerator { + const slice = store.getSlice(input.id, input.start, input.limit); + if (slice === undefined) { + ok = false; + stderr.push(`Ref not found: ${input.id}`); + return; + } + for (const line of slice.content.split('\n')) { + yield line; + } + } + + return { stdout: fromLines(run()), success: () => ok }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/SearchHistory.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/SearchHistory.ts new file mode 100644 index 00000000..7aac7e4b --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/SearchHistory.ts @@ -0,0 +1,29 @@ +import type { Clock } from '@js-joda/core'; +import type { IHistoryReader } from '@shellicar/claude-core/history/interfaces'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { performSearchHistory } from '../../History/performSearchHistory.js'; +import { SearchHistoryInputSchema } from '../../History/schema.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +/** The V2 tool equivalent of V1's `SearchHistory` — same `performSearchHistory`, same input + * schema, reused verbatim; the only difference is the output shape (one hit per line). */ +export function createSearchHistoryToolV2(reader: IHistoryReader, currentSessionId: () => string, clock: Clock) { + return defineToolV2({ + name: 'SearchHistory', + description: + 'Search your past conversations by relevance and get back ranked, cited snippets. A citation is a session id plus a turn id; pass one (or several) to ReadHistory to open the full exchange around it. Thinking is indexed and ranks on par with prose — the reasoning in a thinking block is often the most descriptive account of what a piece of work was.', + operation: 'none', + model: SearchHistoryInputSchema, + run: (input): ToolV2Result => { + async function* run(): AsyncGenerator { + const hits = performSearchHistory(reader, currentSessionId, clock, input); + yield `${hits.length} hit(s)`; + for (const hit of hits) { + yield JSON.stringify(hit); + } + } + return { stdout: fromLines(run()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/SearchMemory.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/SearchMemory.ts new file mode 100644 index 00000000..24c4761c --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/SearchMemory.ts @@ -0,0 +1,27 @@ +import type { IMemoryStore } from '@shellicar/claude-core/memory/interfaces'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { SearchMemoryInputSchema } from '../../Memory/schema.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +/** The V2 tool equivalent of V1's `SearchMemory` — same `IMemoryStore.search`, same input schema. + * One hit per line makes the result pipeable into Head/Match like any other V2 tool's output. */ +export function createSearchMemoryToolV2(store: IMemoryStore) { + return defineToolV2({ + name: 'SearchMemory', + description: + 'Search every memory by relevance. Describe what you need in plain words; the most relevant memories come back ranked, best first. Optionally narrow to one type. Results are NOT scoped to the current repository — search spans every memory in the store. Each hit carries the environment (host/org/repo) it was written in; that is there to help you judge whether a memory is relevant to what you are doing now, not to filter results. The only isolation is the tenantId in CLI config, which selects a separate store.', + operation: 'none', + model: SearchMemoryInputSchema, + run: (input): ToolV2Result => { + async function* run(): AsyncGenerator { + const results = await store.search({ query: input.query, type: input.type, limit: input.limit }); + yield `${results.length} result(s)`; + for (const hit of results) { + yield JSON.stringify(hit); + } + } + return { stdout: fromLines(run()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Skill.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Skill.ts new file mode 100644 index 00000000..4187c03d --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Skill.ts @@ -0,0 +1,49 @@ +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { splitFrontmatter } from '../../Skill/frontmatter.js'; +import { resolveSkills } from '../../Skill/resolve.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const SkillToolV2Model = z.object({ + skill: z.string().min(1).describe('The name of a skill from the available-skills list.'), +}); + +/** The V2 tool equivalent of V1's `Skill` — same `resolveSkills`/`splitFrontmatter`, same + * load-only design (see V1's own doc comment: discovery is the launcher's catalogue, not this + * tool's job). `fs.read` tier: loading a skill body is a filesystem read like any other. */ +export function createSkillToolV2(fs: IFileSystem, skillDirs: readonly string[], logger?: ILogger) { + return defineToolV2({ + name: 'Skill', + description: "Load a skill's instructions into the conversation. Available skills are listed in the injected skills catalogue; invoke only names from that list, never guessed ones. When a skill matches the task, invoke it before responding.", + operation: 'fs.read', + model: SkillToolV2Model, + run: (input): ToolV2Result => { + let found = false; + + async function* run(): AsyncGenerator { + const resolved = await resolveSkills(fs, skillDirs, logger); + const target = resolved.get(input.skill); + if (target === undefined) { + const available = [...resolved.keys()].sort((a, b) => a.localeCompare(b)); + logger?.info('Skill load: not found', { skill: input.skill, available }); + yield `Skill not found: ${input.skill}`; + for (const name of available) { + yield `- ${name}`; + } + return; + } + found = true; + const body = splitFrontmatter(await fs.readFile(target.file)).body.trimStart(); + logger?.info('Skill load', { skill: input.skill, file: target.file, chars: body.length }); + for (const line of body.split('\n')) { + yield line; + } + } + + return { stdout: fromLines(run()), success: () => found }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Tail.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Tail.ts new file mode 100644 index 00000000..833c524b --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Tail.ts @@ -0,0 +1,41 @@ +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines, lines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { defineToolV2 } from '../defineToolV2.js'; + +export const TailToolV2Model = z.object({ count: z.number().int().min(1).optional() }); + +/** Last N lines of the upstream. Deliberately NOT lazy in the way Head is — there is no way + * to know whether an item belongs in the final N without having seen everything after it, so + * this must drain the whole upstream before it can yield anything. That's inherent to what + * "tail" means (same as real `tail` on a non-seekable stream), not a shortcut taken here. */ +export function createTailToolV2() { + return defineToolV2({ + name: 'Tail', + readsUpstream: true, + description: 'Last N of the piped stream. Stage.', + operation: 'none', + model: TailToolV2Model, + run: (input, upstream): ToolV2Result => { + const count = input.count ?? 10; + + async function* takeLast(): AsyncGenerator { + if (upstream == null) { + return; + } + const window: string[] = []; + for await (const value of lines(upstream)) { + window.push(String(value)); + if (window.length > count) { + window.shift(); + } + } + for (const value of window) { + yield value; + } + } + + return { stdout: fromLines(takeLast()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/TypeScript.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/TypeScript.ts new file mode 100644 index 00000000..ec3fc978 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/TypeScript.ts @@ -0,0 +1,111 @@ +import { pathSchema } from '@shellicar/claude-sdk'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { z } from 'zod'; +import { ITypeScriptService } from '../../typescript/ITypeScriptService.js'; +import { positionInputSchema } from '../../typescript/positionInputSchema.js'; +import { defineToolV2, type ToolV2Definition, xargsTarget } from '../defineToolV2.js'; + +// Paths, and one severity for the whole call. V1 carries a severity per file; a command line has +// no way to say that, and neither does a stage fed by `Find | Xargs files`, which can only ever +// hand over paths. The filter belongs to the invocation, the way `grep -i` does. +const TsDiagnosticsToolV2Model = z.object({ + files: xargsTarget(z.array(pathSchema).min(1)).describe('Files to check. One call checks the whole batch on a single tsserver spawn.'), + severity: z.enum(['error', 'warning', 'suggestion', 'all']).default('error').describe('Filter diagnostics by severity. Defaults to error.'), +}); + +/** Resolves `scope`'s shared `ITypeScriptService` \u2014 present for every V2 tool call in a batch + * since `OrchestrateEngine.runBatch` always opens one; the null check exists only for a caller + * that reaches a TS tool's `run` outside a batch (e.g. a unit test calling it directly). */ +function resolveTypeScriptService(name: string, scope: Parameters['run']>[4]) { + if (scope == null) { + throw new Error(`${name} requires a batch scope to resolve ITypeScriptService`); + } + return scope.resolve(ITypeScriptService); +} + +/** V2 equivalents of V1's TsDiagnostics/TsHover/TsReferences/TsDefinition \u2014 same + * `ITypeScriptService`, reduced to plain-text `path:line:character: text` lines (the + * convention `Read`'s V2 leaf already uses) instead of V1's JSON output, per Orchestrate's + * plain-text-stdout design. `fs.read` tier: reading type information is a filesystem read + * like any other. All four share one shared tsserver per batch via + * `scope.resolve(ITypeScriptService)` \u2014 the same per-batch DI scope + * `OrchestrateEngine.runBatch` opens once and passes to every V2 tool call in the round. */ +export function createTsToolsV2(): ToolV2Definition[] { + return [ + defineToolV2({ + name: 'TsDiagnostics', + description: 'Get TypeScript diagnostics (type errors, syntax errors) for one or more files. Returns diagnostics grouped by file path, each entry including line, character, message, and error code.', + operation: 'fs.read', + model: TsDiagnosticsToolV2Model, + run: (input, _upstream, _stderr, _signal, scope): ToolV2Result => { + async function* run(): AsyncGenerator { + const ts = resolveTypeScriptService('TsDiagnostics', scope); + const { files, severity } = input as z.infer; + for (const file of files) { + const diagnostics = await ts.getDiagnostics({ file, severity }); + for (const d of diagnostics) { + yield `${d.file}:${d.line}:${d.character}: [${d.severity}] ${d.message} (${d.code})`; + } + } + } + return { stdout: fromLines(run()), success: () => true }; + }, + }), + defineToolV2({ + name: 'TsHover', + description: 'Get type information and documentation for a symbol at a specific position in a TypeScript file. Returns the type signature, symbol kind, and any JSDoc documentation.', + operation: 'fs.read', + model: positionInputSchema, + run: (input, _upstream, _stderr, _signal, scope): ToolV2Result => { + let found = false; + async function* run(): AsyncGenerator { + const ts = resolveTypeScriptService('TsHover', scope); + const info = await ts.getHoverInfo({ file: input.file, line: input.line, character: input.character }); + if (info == null) { + yield 'No symbol at that position'; + return; + } + found = true; + yield `${info.kind}: ${info.text}`; + if (info.documentation) { + yield info.documentation; + } + } + return { stdout: fromLines(run()), success: () => found }; + }, + }), + defineToolV2({ + name: 'TsReferences', + description: 'Find all references to a symbol at a specific position in a TypeScript file. Returns every location where the symbol is used across the project, grouped by file path, including the definition site.', + operation: 'fs.read', + model: positionInputSchema, + run: (input, _upstream, _stderr, _signal, scope): ToolV2Result => { + async function* run(): AsyncGenerator { + const ts = resolveTypeScriptService('TsReferences', scope); + const references = await ts.getReferences({ file: input.file, line: input.line, character: input.character }); + for (const r of references) { + yield `${r.file}:${r.line}:${r.character}: ${r.text}`; + } + } + return { stdout: fromLines(run()), success: () => true }; + }, + }), + defineToolV2({ + name: 'TsDefinition', + description: 'Go to the definition of a symbol at a specific position in a TypeScript file. Returns the definition positions grouped by file path. May return multiple locations for overloaded functions or declaration merging.', + operation: 'fs.read', + model: positionInputSchema, + run: (input, _upstream, _stderr, _signal, scope): ToolV2Result => { + async function* run(): AsyncGenerator { + const ts = resolveTypeScriptService('TsDefinition', scope); + const definitions = await ts.getDefinition({ file: input.file, line: input.line, character: input.character }); + for (const d of definitions) { + yield `${d.file}:${d.line}:${d.character}`; + } + } + return { stdout: fromLines(run()), success: () => true }; + }, + }), + ]; +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/WriteMemory.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/WriteMemory.ts new file mode 100644 index 00000000..601ef356 --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/WriteMemory.ts @@ -0,0 +1,26 @@ +import type { IMemoryStore } from '@shellicar/claude-core/memory/interfaces'; +import type { ToolV2Result } from '@shellicar/orchestrate-core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { WriteMemoryInputSchema } from '../../Memory/schema.js'; +import { defineToolV2 } from '../defineToolV2.js'; + +/** The V2 tool equivalent of V1's `WriteMemory` — same `IMemoryStore.write`, same input schema, + * reused verbatim. The only difference is the output shape: V1 returns the stored entry as one + * JSON value; V2 splits it into lines, the plain-text convention every V2 tool follows. */ +export function createWriteMemoryToolV2(store: IMemoryStore) { + return defineToolV2({ + name: 'WriteMemory', + description: 'Write a memory for any later Claude to find. Records what you learned — a trap, a decision and its reasoning, a correction — so it survives this session. Title is the handle that ranks; body is the memory; type classifies it.', + operation: 'none', + model: WriteMemoryInputSchema, + run: (input): ToolV2Result => { + async function* run(): AsyncGenerator { + const memory = await store.write({ title: input.title, body: input.body, type: input.type, keywords: input.keywords }); + for (const line of JSON.stringify(memory, null, 2).split('\n')) { + yield line; + } + } + return { stdout: fromLines(run()), success: () => true }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/tools/Xargs.ts b/packages/claude-sdk-tools/src/Orchestrate/tools/Xargs.ts new file mode 100644 index 00000000..97bef18c --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/tools/Xargs.ts @@ -0,0 +1,12 @@ +const SEPARATOR = '\n'; + +/** Where one argument ends and the next begins: at a newline, and nowhere else, because a path may + * contain a space. A carriage return before the separator came from a producer using CRLF and is + * not part of the argument. */ +export function splitArguments(bytes: Buffer): string[] { + return bytes + .toString('utf8') + .split(SEPARATOR) + .map((argument) => (argument.endsWith('\r') ? argument.slice(0, -1) : argument)) + .filter((argument) => argument.length > 0); +} diff --git a/packages/claude-sdk-tools/src/Orchestrate/walkLazy.ts b/packages/claude-sdk-tools/src/Orchestrate/walkLazy.ts new file mode 100644 index 00000000..07cd7e7d --- /dev/null +++ b/packages/claude-sdk-tools/src/Orchestrate/walkLazy.ts @@ -0,0 +1,86 @@ +import { join } from 'node:path'; +import type { FileRecord } from '@shellicar/claude-core/fs/records'; +import type { FindOptions, IFileEntry, StatResult } from '@shellicar/claude-core/fs/types'; + +interface WalkFs { + readdir(path: string): Promise; + realpath(path: string): Promise; + readlink(path: string): Promise; + stat(path: string): Promise; +} + +/** A lazy sibling of `@shellicar/claude-core`'s `walk` — same traversal rules (exclude, + * maxDepth, type, followSymlinks, cycle detection via realpath), but yields each record as + * it's discovered instead of collecting the whole tree into an array first. This is what + * lets a downstream consumer (e.g. `Head`) short-circuit an unbounded or expensive walk, + * which the buffered version structurally cannot do. Deliberately a separate function, not a + * change to the shared `walk` V1 tools already depend on — see the design doc's "Tools V2 as + * a separate system" decision. */ +export async function* walkLazy(fs: WalkFs, dir: string, options: FindOptions, depth: number, re: RegExp | undefined, visited: Set = new Set(), unreadable?: (path: string) => void): AsyncGenerator { + const { maxDepth, exclude = [], type = 'file', followSymlinks = true } = options; + + if (maxDepth !== undefined && depth > maxDepth) { + return; + } + + const realDir = await fs.realpath(dir); + if (visited.has(realDir)) { + return; + } + visited.add(realDir); + + // The top-level call lets a missing/non-directory start point throw (surfaced as fatal by + // the caller). A recursive descent that cannot enter a directory is swallowed below. + const entries = await fs.readdir(dir); + + for (const entry of entries) { + if (exclude.includes(entry.name)) { + continue; + } + + const fullPath = join(dir, entry.name); + const nameMatches = !re || re.test(entry.name); + + if (entry.isDirectory()) { + if ((type === 'directory' || type === 'both') && nameMatches) { + yield { path: fullPath, type: 'dir' }; + } + try { + yield* walkLazy(fs, fullPath, options, depth + 1, re, visited, unreadable); + } catch { + // Not fatal: the rest of the tree is still worth having. Reported rather than swallowed, + // because an answer missing a whole subtree and saying nothing reads as a complete one. + unreadable?.(fullPath); + } + } else if (entry.isFile()) { + if ((type === 'file' || type === 'both') && nameMatches) { + const { size } = await fs.stat(fullPath); + yield { path: fullPath, type: 'file', size }; + } + } else if (entry.isSymbolicLink()) { + let targetStat: StatResult; + try { + targetStat = await fs.stat(fullPath); + } catch { + continue; // broken symlink — skip + } + const target = await fs.readlink(fullPath); + if (targetStat.isDirectory()) { + if ((type === 'directory' || type === 'both') && nameMatches) { + yield { path: fullPath, type: 'link', target: `${target}/` }; + } + if (followSymlinks) { + try { + yield* walkLazy(fs, fullPath, options, depth + 1, re, visited, unreadable); + } catch { + unreadable?.(fullPath); + } + } + } else if (targetStat.isFile()) { + if ((type === 'file' || type === 'both') && nameMatches) { + yield { path: fullPath, type: 'link', size: targetStat.size, target }; + } + } + } + } +} diff --git a/packages/claude-sdk-tools/src/Policy/PolicyStore.ts b/packages/claude-sdk-tools/src/Policy/PolicyStore.ts new file mode 100644 index 00000000..99e88da4 --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/PolicyStore.ts @@ -0,0 +1,39 @@ +import type { PolicySet } from './types.js'; +import type { ToolLookup, ValidationResult } from './validatePolicy.js'; +import { validatePolicy } from './validatePolicy.js'; + +/** The maximally conservative fallback \u2014 ask for everything. Used only when there is + * otherwise no valid policy to fall back to (construction-time only): never allow, never + * silently run with no policy at all. */ +const SAFE_DEFAULT: PolicySet = [{ default: 'ask' }]; + +export type UpdateResult = { accepted: true; warnings: string[] } | { accepted: false; errors: string[] }; + +/** Holds the currently-active policy, and only ever replaces it with a new one that validates. + * An update that fails case 1 or case 2 leaves the previous policy in place untouched \u2014 a + * reload never leaves the store without SOME policy, and never silently degrades to a worse + * one. Case 3 (a rule for a tool that isn't loaded yet) is accepted, with its warning + * surfaced, since it isn't a provable mistake. */ +export class PolicyStore { + #current: PolicySet; + readonly #registry: ToolLookup; + + public constructor(initial: unknown, registry: ToolLookup) { + this.#registry = registry; + const result = validatePolicy(initial, registry); + this.#current = result.valid ? (initial as PolicySet) : SAFE_DEFAULT; + } + + public get current(): PolicySet { + return this.#current; + } + + public update(candidate: unknown): UpdateResult { + const result: ValidationResult = validatePolicy(candidate, this.#registry); + if (!result.valid) { + return { accepted: false, errors: result.errors }; + } + this.#current = candidate as PolicySet; + return { accepted: true, warnings: result.warnings }; + } +} diff --git a/packages/claude-sdk-tools/src/Policy/canonicalPath.ts b/packages/claude-sdk-tools/src/Policy/canonicalPath.ts new file mode 100644 index 00000000..789e825a --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/canonicalPath.ts @@ -0,0 +1,29 @@ +import { basename, dirname, join } from 'node:path'; +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; + +/** + * The path as the kernel will understand it, with every symlink resolved. + * + * A decision has to be about the object the operation will act on, and the kernel's notion of that + * object is the resolved one: permissions on a symlink are ignored, `open()` walks the link and + * checks the target. So `$PWD/link/id_rsa`, where `link` points at `~/.ssh`, is a read of + * `~/.ssh/id_rsa` however it was spelled, and a rule scoped to the project has to see that. + * + * A path that does not exist yet is the ordinary case for a write, so the nearest existing ancestor + * is resolved and the rest is kept as written: the directory being written into is real, and that + * is where a link would be. + * + * This does not close the gap between deciding and acting — the link can be replaced in between, + * and only the OS can prevent that. It makes the decision correct about the object as it stands. + */ +export async function canonicalPath(fs: IFileSystem, path: string): Promise { + try { + return await fs.realpath(path); + } catch { + const parent = dirname(path); + if (parent === path) { + return path; + } + return join(await canonicalPath(fs, parent), basename(path)); + } +} diff --git a/packages/claude-sdk-tools/src/Policy/defaultPolicy.ts b/packages/claude-sdk-tools/src/Policy/defaultPolicy.ts new file mode 100644 index 00000000..9b07840d --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/defaultPolicy.ts @@ -0,0 +1,25 @@ +import type { PolicySet } from './types.js'; + +/** + * The shipped default: reads and listings inside the working directory, and nothing else. + * + * Everything not named here falls to the engine's own `ask` — never a silent allow — so the + * default decides as little as possible while still being usable. Without this one rule every + * file read would prompt, which in practice drives an operator to paste in a blanket allow; + * with it, working inside the project is quiet and anything reaching beyond it is seen. + * + * Deliberately free of opinions. An earlier version shipped a list of denied commands + * (`rm`, `git reset`, `sudo`, inline `-c`), a frictionless carve-out for the Memory tools, and a + * `~/.ssh/**` deny. Those existed because there was no way to express them in config; now there + * is, so they belong to whoever is running the thing, not to the product. Two reasons that + * matters beyond taste: + * + * - A shipped deny list is a guess about someone else's work. Editing your own ssh config or an + * env file with help is a legitimate task, and a default that forbids it is wrong for that + * operator while looking authoritative. + * - A carve-out in front of a permissive rule only protects while the ordering holds. The old + * default allowed reads everywhere (`path: '*'`) and relied on the ssh deny sitting above it. + * Scoping the allow to the working directory means anything outside it is asked about on its + * own merits, rather than depending on someone having predicted which paths were sensitive. + */ +export const defaultPolicy: PolicySet = [{ path: '$PWD', operations: { 'fs.read': 'allow', 'fs.list': 'allow' } }]; diff --git a/packages/claude-sdk-tools/src/Policy/matchInput.ts b/packages/claude-sdk-tools/src/Policy/matchInput.ts new file mode 100644 index 00000000..a7ee83fb --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/matchInput.ts @@ -0,0 +1,22 @@ +import type { ValuePattern } from './matchValue.js'; +import { matchesValue } from './matchValue.js'; + +/** Names real fields of a tool's own input, verbatim \u2014 `program` names `input.program`, + * `args` names `input.args`, whatever the tool actually calls them. No translation layer: + * the engine never maps a rule vocabulary onto a tool's schema, it reads the tool's own + * field names straight out of the rule. */ +export type InputMatcher = Record; + +/** Concern 2, isolated: every named field must be present in the real input AND match its + * pattern. Operates on the tool's raw input directly \u2014 genuinely generic, since it never + * hardcodes a field name anywhere in code, only reads whichever keys the rule itself names. */ +export function matchesInput(matcher: InputMatcher | undefined, input: unknown): boolean { + if (matcher == null) { + return true; + } + if (typeof input !== 'object' || input == null) { + return false; + } + const record = input as Record; + return Object.entries(matcher).every(([key, pattern]) => matchesValue(pattern, record[key])); +} diff --git a/packages/claude-sdk-tools/src/Policy/matchPath.ts b/packages/claude-sdk-tools/src/Policy/matchPath.ts new file mode 100644 index 00000000..31dc3893 --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/matchPath.ts @@ -0,0 +1,117 @@ +import { compilePathPattern, pathSegments } from './pathPattern.js'; + +/** + * One segment's pattern against one segment, where `*` matches any run of characters that does not + * cross a `/` — and it cannot cross one, because a segment has none. + * + * The walk is the same shape as the segment walk below, one level down: advance while the two + * agree, and on a disagreement return to the most recent `*` and let it swallow one more character. + * Since each retry gives the wildcard exactly one more character and never revisits an earlier + * choice, the work is bounded by pattern length times segment length. Compiling this to + * `[^/]*a[^/]*a[^/]*` instead would put unbounded quantifiers next to each other and make a + * near-miss exponential. + */ +function segmentMatches(pattern: string, segment: string): boolean { + let patternIndex = 0; + let charIndex = 0; + let lastWildcard = -1; + let charAfterWildcard = 0; + + while (charIndex < segment.length) { + const patternChar = pattern[patternIndex]; + + if (patternChar === '*') { + lastWildcard = patternIndex; + charAfterWildcard = charIndex; + patternIndex++; + continue; + } + + if (patternIndex < pattern.length && patternChar === segment[charIndex]) { + patternIndex++; + charIndex++; + continue; + } + + if (lastWildcard === -1) { + return false; + } + + // Hand the wildcard one more character and resume from just after it. + patternIndex = lastWildcard + 1; + charAfterWildcard++; + charIndex = charAfterWildcard; + } + + // Any wildcards left over match nothing, which is fine; anything else is unmatched pattern. + while (pattern[patternIndex] === '*') { + patternIndex++; + } + return patternIndex === pattern.length; +} + +/** + * Does this path fall under this pattern? + * + * Both sides are reduced to segments first (see `compilePathPattern`), so the only thing that can + * consume a variable number of them is `**`. Everything else matches one segment against one + * segment, which either holds or does not. + * + * That leaves `**` as the single place the walk can go wrong, and it is handled the standard way: + * remember where the most recent `**` was, and when a later segment fails to match, go back and let + * that `**` absorb one more segment. Every retry consumes one more of the path and never + * reconsiders an earlier `**`, so the work is bounded by pattern segments times path segments + * however many `**` a pattern contains — no engine's backtracking behaviour to reason about. + */ +/** macOS and Windows are case-insensitive by default, so `/Users/x/.ssh` and `/users/x/.ssh` are + * one file and a rule about one has to cover the other. Every other platform Node runs on is + * case-sensitive, where `src` and `SRC` really are different directories. */ +const foldsCase = (platform: NodeJS.Platform): boolean => platform === 'darwin' || platform === 'win32'; + +export function matchesPath(pattern: string, path: string, cwd: string, home: string, platform: NodeJS.Platform): boolean { + if (pattern === '*') { + return true; + } + + const fold = (segment: string): string => (foldsCase(platform) ? segment.toLowerCase() : segment); + const patternSegments = compilePathPattern(pattern, cwd, home).map(fold); + const actualSegments = pathSegments(path, cwd, home).map(fold); + + let patternIndex = 0; + let pathIndex = 0; + let lastDoubleStar = -1; + let pathIndexAfterDoubleStar = 0; + + while (pathIndex < actualSegments.length) { + const patternSegment = patternSegments[patternIndex]; + + if (patternSegment === '**') { + lastDoubleStar = patternIndex; + pathIndexAfterDoubleStar = pathIndex; + patternIndex++; + continue; + } + + if (patternSegment !== undefined && segmentMatches(patternSegment, actualSegments[pathIndex] as string)) { + patternIndex++; + pathIndex++; + continue; + } + + if (lastDoubleStar === -1) { + return false; + } + + // Hand the `**` one more segment and resume from just after it. + patternIndex = lastDoubleStar + 1; + pathIndexAfterDoubleStar++; + pathIndex = pathIndexAfterDoubleStar; + } + + // A trailing `**` is allowed to absorb nothing at all, which is what makes `$PWD/**` match + // `$PWD` itself. + while (patternSegments[patternIndex] === '**') { + patternIndex++; + } + return patternIndex === patternSegments.length; +} diff --git a/packages/claude-sdk-tools/src/Policy/matchTool.ts b/packages/claude-sdk-tools/src/Policy/matchTool.ts new file mode 100644 index 00000000..54f8b9c5 --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/matchTool.ts @@ -0,0 +1,13 @@ +import type { ToolMatch } from './types.js'; + +/** Concern 1, isolated: does this rule's `tool` field cover the named tool? Absent or `'*'` + * covers everything \u2014 the engine never needs to know what tools exist to answer this. */ +export function matchesTool(match: ToolMatch | undefined, toolName: string): boolean { + if (match == null || match === '*') { + return true; + } + if (Array.isArray(match)) { + return match.includes(toolName); + } + return match === toolName; +} diff --git a/packages/claude-sdk-tools/src/Policy/matchValue.ts b/packages/claude-sdk-tools/src/Policy/matchValue.ts new file mode 100644 index 00000000..9f803fe1 --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/matchValue.ts @@ -0,0 +1,64 @@ +import { normaliseArgs } from '../normaliseArgs.js'; + +/** A generic value pattern. Which comparison applies is decided purely by the PATTERN's own + * shape - never by which field it's checking or which tool it came from. A plain list is a + * shorthand: against a scalar actual value it's membership (the actual equals one of these); + * against an array actual value it's equivalent to anyOf (the actual contains one of these). + * allOf/anyOf/suffix/maxLength can combine freely in one object - every one that's present + * must hold, not just whichever is checked first. basename strips any path prefix off a + * scalar before comparing - deliberately its own opt-in shape, not applied unconditionally: a + * field that happens to contain a '/' for unrelated reasons (a branch name, a URL) must never + * have it silently stripped, only a field the rule author has decided is path-shaped. + * allOf/anyOf normalise an array of string tokens the same way ruleConfigMatches already does + * (--foo=bar -> --foo, a bundled short flag -ni also matches -i) - a real, load-bearing + * CLI-argument convention, not a simplification. Reused identically whether the field happens + * to be called program, args, or anything else. */ +export type ValuePattern = string[] | { allOf?: string[]; anyOf?: string[]; suffix?: string; basename?: string[]; maxLength?: number }; + +function basename(value: string): string { + const idx = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')); + return idx === -1 ? value : value.slice(idx + 1); +} + +/** A set of name/value pairs is matched by its names, so `Program.env` is reachable by the same + * patterns everything else uses: `{ anyOf: ['GIT_SSH_COMMAND'] }` denies a call that sets it. + * Values are deliberately not matched — a variable of that kind is worth refusing whatever it is + * set to, and the ones worth allowing are harmless whatever they are set to. */ +function asMatchable(actual: unknown): unknown { + return typeof actual === 'object' && actual != null && !Array.isArray(actual) ? Object.keys(actual as Record) : actual; +} + +export function matchesValue(pattern: ValuePattern, value: unknown): boolean { + const actual = asMatchable(value); + if (Array.isArray(pattern)) { + if (typeof actual === 'string') { + return pattern.includes(actual); + } + // Normalised the same way `anyOf` is: the shorthand is documented as meaning `anyOf`, and a + // shorthand that quietly matched less than the form it stands for would be the trap the + // documentation prevents. + return Array.isArray(actual) && pattern.some((v) => normaliseArgs(actual as string[]).includes(v)); + } + if (pattern.allOf || pattern.anyOf) { + if (!Array.isArray(actual)) { + return false; + } + const flags = normaliseArgs(actual as string[]); + if (pattern.allOf && !pattern.allOf.every((v) => flags.includes(v))) { + return false; + } + if (pattern.anyOf && !pattern.anyOf.some((v) => flags.includes(v))) { + return false; + } + } + if (pattern.maxLength != null && !(Array.isArray(actual) && actual.length <= pattern.maxLength)) { + return false; + } + if (pattern.suffix && !(typeof actual === 'string' && actual.endsWith(pattern.suffix))) { + return false; + } + if (pattern.basename && !(typeof actual === 'string' && pattern.basename.includes(basename(actual)))) { + return false; + } + return pattern.allOf != null || pattern.anyOf != null || pattern.suffix != null || pattern.basename != null || pattern.maxLength != null; +} diff --git a/packages/claude-sdk-tools/src/Policy/pathPattern.ts b/packages/claude-sdk-tools/src/Policy/pathPattern.ts new file mode 100644 index 00000000..9689fcbb --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/pathPattern.ts @@ -0,0 +1,73 @@ +import { resolve } from 'node:path'; + +/** Turns whatever a caller actually wrote — relative, `~/`-prefixed, or already absolute — into + * a real absolute path, purely for one comparison. Never mutates anything the caller holds. */ +export function resolvePath(path: string, cwd: string, home: string): string { + const tilded = path === '~' ? home : path.startsWith('~/') ? `${home}/${path.slice(2)}` : path; + return resolve(cwd, tilded); +} + +/** + * A pattern reduced to its segments, with `$PWD`/`$HOME`/`~` already resolved and adjacent `**` + * merged. + * + * The merge matters beyond tidiness: every `**` a matcher keeps is another place it can be forced + * to reconsider, and two of them side by side describe exactly the same set of paths as one. A + * pattern that says `**\/**` costs twice for nothing. Non-adjacent `**` are left alone — they are + * separated by something that must match, so they say different things and cannot be merged. + * + * A trailing `/**` is kept as a segment rather than stripped: it means "this directory and + * everything under it", and both matchers express that by letting a final `**` absorb the rest, + * including nothing at all. + */ +export function compilePathPattern(pattern: string, cwd: string, home: string): string[] { + const expanded = pattern.replaceAll('$PWD', cwd).replaceAll('$HOME', home); + const absolute = resolvePathPreservingGlobs(expanded, cwd, home); + const segments = absolute.split('/').filter((s) => s.length > 0); + + const merged: string[] = []; + for (const segment of segments) { + if (segment === '**' && merged[merged.length - 1] === '**') { + continue; + } + merged.push(segment); + } + + // A pattern naming a place and nothing else means that place and everything under it: `$PWD` + // has always covered the files in the project, not the directory entry alone, and the shipped + // default depends on it. Adding the `**` here keeps that meaning while letting the matchers + // below know only one set of rules. A pattern that already globs says what it means and is left + // exactly as written — `$PWD/*.env` is deliberately not `$PWD/*.env/**`. + if (!merged.some((segment) => segment.includes('*'))) { + merged.push('**'); + } + return merged; +} + +/** + * A pattern names a place, absolutely. `$PWD`, `$HOME` and `~` expand to one; anything else starts + * at the root, so `**` means everywhere rather than everywhere under the working directory. + * + * Nothing is resolved against the working directory: local is spelled `$PWD`, and `validatePolicy` + * refuses a pattern beginning with anything else ambiguous. + * + * `resolve()` would mangle a `**` segment, so the glob tail is set aside, the concrete prefix is + * resolved, and the two are rejoined. + */ +function resolvePathPreservingGlobs(pattern: string, cwd: string, home: string): string { + const firstGlob = pattern.search(/[*]/); + if (firstGlob === -1) { + return resolvePath(pattern, cwd, home); + } + const cut = pattern.lastIndexOf('/', firstGlob); + const prefix = cut <= 0 ? pattern.slice(0, firstGlob) : pattern.slice(0, cut); + const tail = cut <= 0 ? pattern.slice(firstGlob) : pattern.slice(cut + 1); + return prefix === '' ? `/${tail}` : `${resolvePath(prefix, cwd, home)}/${tail}`; +} + +/** The path being judged, as segments. */ +export function pathSegments(path: string, cwd: string, home: string): string[] { + return resolvePath(path, cwd, home) + .split('/') + .filter((s) => s.length > 0); +} diff --git a/packages/claude-sdk-tools/src/Policy/resolve.ts b/packages/claude-sdk-tools/src/Policy/resolve.ts new file mode 100644 index 00000000..09dafd28 --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/resolve.ts @@ -0,0 +1,108 @@ +import { matchesInput } from './matchInput.js'; +import { matchesPath } from './matchPath.js'; +import { matchesTool } from './matchTool.js'; +import type { PolicySet, Resolution, Verdict } from './types.js'; + +export type ResolveInput = { + tool: string; + /** The tool's own raw input, untouched \u2014 `resolve` reads named fields out of it generically + * (via `matchesInput`), it never assumes a shape of its own. */ + input: unknown; + /** Every path this call resolves to (already normalised, already extracted by the caller via + * the existing isPath/collectPaths mechanism). Each is judged on its own; see `resolve`. */ + paths: string[]; + operation: string; + cwd: string; + home: string; + /** Decides whether path matching folds case, since that is a property of the machine rather than + * of the rule. Read through the filesystem abstraction, never from the process directly. */ + platform: NodeJS.Platform; +}; + +/** `{key}` \u2192 `input[key]`, for whichever fields the real input happens to carry \u2014 generic + * the same way `matchesInput` is: no field name is ever known ahead of time. */ +function interpolateMessage(message: string | undefined, input: unknown): string | undefined { + if (message == null) { + return undefined; + } + const record = typeof input === 'object' && input != null ? (input as Record) : {}; + return message.replace(/\{(\w+)\}/g, (whole, key: string) => { + const value = record[key]; + return typeof value === 'string' ? value : whole; + }); +} + +/** One path's verdict: the first rule in the ordered list for which every matcher it names + * holds (tool AND input AND this path) AND that actually covers this operation (an operations + * entry for it, or its own default) governs completely. A rule that matches but is silent on + * this specific operation -- no operations entry for it, no default -- does NOT stop the + * search: it falls through to the next matching rule, the same way a rule that doesn't match + * at all does. Being silent on an operation is different from deciding ask for it; treating + * the two the same would let an earlier, narrower rule (e.g. a path zone that only ever talks + * about read/write) silently block a later, more general rule from ever being consulted for an + * operation the earlier rule never mentioned. No matching rule anywhere in the list also falls + * to Ask -- never a silent Allow. + * + * `path` is undefined for a call that names no paths at all. A rule with a real pattern + * ($PWD, ~/.ssh/**) then cannot match: there is nothing to test containment against. The + * wildcard (path: '*') is different -- it imposes no real constraint, so it matches anyway, + * the same way tool: '*' matches regardless of the tool name. */ +function resolveOne(policy: PolicySet, args: ResolveInput, path: string | undefined): Resolution { + for (const rule of policy) { + if (!matchesTool(rule.tool, args.tool)) { + continue; + } + if (!matchesInput(rule.input, args.input)) { + continue; + } + if (rule.path != null && rule.path !== '*') { + if (path === undefined || !matchesPath(rule.path, path, args.cwd, args.home, args.platform)) { + continue; + } + } + const verdict = rule.operations?.[args.operation] ?? rule.default; + if (verdict == null) { + continue; + } + const message = interpolateMessage(rule.message, args.input); + return message != null ? { verdict, message } : { verdict }; + } + return { verdict: 'ask' }; +} + +const SEVERITY: Record = { allow: 0, ask: 1, deny: 2 }; + +/** The least permissive of several verdicts, carrying its own message. A call is judged more than + * once whenever it touches more than one thing: several paths, or an execution that also writes. + * Nothing to judge is not the same as judged and permitted. */ +export function strictest(resolutions: Resolution[]): Resolution { + let worst: Resolution | undefined; + for (const resolution of resolutions) { + if (worst === undefined || SEVERITY[resolution.verdict] > SEVERITY[worst.verdict]) { + worst = resolution; + } + } + return worst ?? { verdict: 'ask' }; +} + +/** + * A call naming several paths is several calls. Filesystem permissions belong to the objects, + * not to the request, so each path is resolved on its own -- and the operation is one + * indivisible act over all of them, so what the caller is asking for is the conjunction: + * `Delete{a, b}` is not "approve a" and separately "approve b", it is "approve deleting a AND + * b". A conjunction is only as permissive as its weakest term, so any deny denies the call, + * else any ask asks, else allow. + * + * That is what stops a permitted path from carrying a forbidden one through with it: judging + * the paths as a set instead would mean adding one innocuous path could change which rules a + * call matches at all, and a deny carve-out could be escaped by naming a second file beside it. + * + * The message returned is the one belonging to the path that actually decided the call, not an + * arbitrary one -- so a refusal tells the model which target it was refused for. + */ +export function resolve(policy: PolicySet, args: ResolveInput): Resolution { + if (args.paths.length === 0) { + return resolveOne(policy, args, undefined); + } + return strictest(args.paths.map((path) => resolveOne(policy, args, path))); +} diff --git a/packages/claude-sdk-tools/src/Policy/types.ts b/packages/claude-sdk-tools/src/Policy/types.ts new file mode 100644 index 00000000..4957b40b --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/types.ts @@ -0,0 +1,44 @@ +import type { InputMatcher } from './matchInput.js'; + +export type Verdict = 'allow' | 'ask' | 'deny'; + +/** A tool name, a list of names, or absent \u2014 absent (or `'*'`) matches any tool. Never a + * fixed enum: any name a registry actually has is valid here, V1 or V2, with no code change + * needed to cover a new one. */ +export type ToolMatch = string | string[]; + +/** One line of the policy, same discipline as a firewall rule chain: whatever it names must + * ALL hold for it to match (`tool` AND `input` AND `path`, whichever are present), and the + * first rule that both matches and has something to say about this operation governs + * completely. A rule that matches but is silent on the operation, with no `operations` + * entry for it and no `default`, decides nothing and the search continues past it, so an + * early narrow rule can't block a later one from being consulted for an operation it never + * mentioned. A call no rule speaks to is `ask`, never a silent allow. + * + * `input` names the tool's own real fields verbatim (`program`, `args`, whatever the tool + * actually calls them) \u2014 structural matching against the real input, never a translated or + * invented vocabulary, so the engine needs no per-tool knowledge to apply it. + * + * `path` is a location glob (`$PWD`, `$HOME`, `~/`, `/**`, `*`), tested against whatever a + * tool's schema has marked as a path field \u2014 the same `isPath`/`collectPaths` mechanism + * already in use, just freed from a fixed two-zone grid into an ordered rule list. + * + * `operation` is an open string key throughout \u2014 V1's `read`/`write`/`delete`, V2's + * `fs.list`/`fs.read`/`fs.write`/`fs.delete`/`fs.exec`, or anything a future tool introduces, + * all coexist without the resolver hardcoding any of them. */ +export type Rule = { + tool?: ToolMatch; + input?: InputMatcher; + path?: string; + /** The verdict for any operation this rule doesn't name explicitly in `operations`. */ + default?: Verdict; + operations?: Record; + /** Shown to the model when this rule governs \u2014 the reason a `deny`/`ask` isn't a silent or + * unexplained refusal. `{key}` is replaced with `input[key]` for whichever field of the + * real input actually exists, generic the same way `input` matching itself is. */ + message?: string; +}; + +export type Resolution = { verdict: Verdict; message?: string }; + +export type PolicySet = Rule[]; diff --git a/packages/claude-sdk-tools/src/Policy/validatePolicy.ts b/packages/claude-sdk-tools/src/Policy/validatePolicy.ts new file mode 100644 index 00000000..680e1741 --- /dev/null +++ b/packages/claude-sdk-tools/src/Policy/validatePolicy.ts @@ -0,0 +1,105 @@ +import { z } from 'zod'; + +const ToolMatchSchema = z.union([z.string(), z.array(z.string())]); + +const ValuePatternSchema = z.union([ + z.array(z.string()), + z.object({ + allOf: z.array(z.string()).optional(), + anyOf: z.array(z.string()).optional(), + suffix: z.string().optional(), + basename: z.array(z.string()).optional(), + maxLength: z.number().optional(), + }), +]); + +const VerdictSchema = z.enum(['allow', 'ask', 'deny']); + +/** Case 1's schema \u2014 shape only. Deliberately says nothing about whether `tool`/`input` refer + * to anything real; that needs a live tool registry (cases 2 and 3), which a shape check alone + * can never have. */ +/** A pattern names where it applies, and says so: `$PWD`, `$HOME`, `~` or `/` for a place, a + * leading glob for anywhere. `src/**` is refused because it reads as "anywhere called src" and + * there is no way to tell that from "this project's src", which is written `$PWD/src/**`. */ +const PathPatternSchema = z.string().refine((pattern) => /^(\$PWD|\$HOME|~|\/|\*)/.test(pattern), { + message: 'a path pattern must start with $PWD, $HOME, ~, / or a glob. Any other leading text is ambiguous: "src/**" reads as anywhere called src, and "$FOO/**" names a variable nothing expands, so write "$PWD/src/**" for this project or "/**" for anywhere', +}); + +export const RuleSchema = z.object({ + tool: ToolMatchSchema.optional(), + input: z.record(z.string(), ValuePatternSchema).optional(), + path: PathPatternSchema.optional(), + default: VerdictSchema.optional(), + operations: z.record(z.string(), VerdictSchema).optional(), + message: z.string().optional(), +}); + +export const PolicySetSchema = z.array(RuleSchema); + +/** What case 2/3 checking needs from a tool registry \u2014 structural, not the concrete + * `ToolsV2Registry` class, so this module never depends on Orchestrate's registry directly. */ +export type ToolLookup = { get: (name: string) => { model: z.ZodType } | undefined }; + +export type ValidationResult = { valid: true; warnings: string[] } | { valid: false; errors: string[] }; + +function hasField(model: z.ZodType, key: string): boolean { + return model instanceof z.ZodObject && key in model.shape; +} + +/** Validates a policy against the three cases decided for it: + * 1. Wrong shape \u2014 a rule that doesn't parse at all. Invalid, whole policy, every issue + * collected (not just the first), since one bad rule can hide a second. + * 2. A rule scoped to a specific, currently-loaded tool (or list of tools) whose `input` + * names a field none of them actually have. There's no legitimate reason to write this + * against a tool you can check right now, so it's treated exactly like case 1: invalid, + * whole policy. Valid as soon as AT LEAST ONE named tool has the field \u2014 only provably + * dead when NONE of the checkable tools do. A wildcard scope (`tool: '*'` or absent) is + * never an instance of this case: it has no single schema to be wrong against. + * 3. A rule scoped to a tool that isn't currently loaded at all. Can't be checked against a + * real schema, and might be legitimate forward-looking config (a disabled tool, one from + * an unmerged branch) \u2014 so it's a warning, not a rejection; the rest of the policy still + * loads. */ +export function validatePolicy(policy: unknown, registry: ToolLookup): ValidationResult { + const parsed = PolicySetSchema.safeParse(policy); + if (!parsed.success) { + return { valid: false, errors: parsed.error.issues.map((issue) => `rule${issue.path.length > 0 ? `[${issue.path.join('.')}]` : ''}: ${issue.message}`) }; + } + + const errors: string[] = []; + const warnings: string[] = []; + + parsed.data.forEach((rule, index) => { + if (rule.tool == null) { + return; + } + const toolNames = Array.isArray(rule.tool) ? rule.tool : [rule.tool]; + if (toolNames.includes('*')) { + return; + } + + for (const name of toolNames) { + if (registry.get(name) == null) { + warnings.push(`rule[${index}]: tool "${name}" is not currently registered \u2014 this rule is inert until it is`); + } + } + + if (rule.input == null) { + return; + } + const registeredModels = toolNames.map((name) => registry.get(name)).filter((def): def is { model: z.ZodType } => def != null); + if (registeredModels.length === 0) { + return; // nothing checkable yet; already warned above + } + for (const key of Object.keys(rule.input)) { + const anyHasField = registeredModels.some((def) => hasField(def.model, key)); + if (!anyHasField) { + errors.push(`rule[${index}]: input.${key} does not exist on any of [${toolNames.join(', ')}]`); + } + } + }); + + if (errors.length > 0) { + return { valid: false, errors }; + } + return { valid: true, warnings }; +} diff --git a/packages/claude-sdk-tools/src/Ref/Ref.ts b/packages/claude-sdk-tools/src/Ref/Ref.ts index b7a44d87..7b9147e0 100644 --- a/packages/claude-sdk-tools/src/Ref/Ref.ts +++ b/packages/claude-sdk-tools/src/Ref/Ref.ts @@ -18,23 +18,19 @@ export function createRef(store: RefStore, threshold: number): CreateRefResult { output_schema: RefOutputSchema, input_examples: [{ id: 'uuid-...' }, { id: 'uuid-...', start: 1000, limit: 1000 }], handler: async (input) => { - const content = store.get(input.id); - if (content === undefined) { + const slice = store.getSlice(input.id, input.start, input.limit); + if (slice === undefined) { return { textContent: { found: false, id: input.id } satisfies RefOutput }; } - const start = input.start; - const end = Math.min(start + input.limit, content.length); - const slice = content.slice(start, end); - return { textContent: { found: true, hint: store.getHint(input.id), - content: slice, - totalSize: content.length, - start, - end, + content: slice.content, + totalSize: slice.totalSize, + start: slice.start, + end: slice.end, } satisfies RefOutput, }; }, diff --git a/packages/claude-sdk-tools/src/RefStore/RefStore.ts b/packages/claude-sdk-tools/src/RefStore/RefStore.ts index 77665615..e50277c1 100644 --- a/packages/claude-sdk-tools/src/RefStore/RefStore.ts +++ b/packages/claude-sdk-tools/src/RefStore/RefStore.ts @@ -32,6 +32,18 @@ export class RefStore { return raw === undefined ? undefined : (JSON.parse(raw) as { hint: string }).hint; } + /** The character-range paging both V1's `Ref` tool (returns `{content, totalSize, start, end}` + * as one structured value) and V2's `Ref` tool (splits `content` into lines) need — shared + * here rather than each computing the same `Math.min`/`.slice()` independently. */ + public getSlice(id: string, start: number, limit: number): { content: string; totalSize: number; start: number; end: number } | undefined { + const content = this.get(id); + if (content === undefined) { + return undefined; + } + const end = Math.min(start + limit, content.length); + return { content: content.slice(start, end), totalSize: content.length, start, end }; + } + /** * Walk a JSON-compatible value tree. Any string value whose length exceeds * `threshold` chars is stored in the ref store and replaced with a RefToken. diff --git a/packages/claude-sdk-tools/src/TsDefinition/TsDefinition.ts b/packages/claude-sdk-tools/src/TsDefinition/TsDefinition.ts index af4029eb..4300e916 100644 --- a/packages/claude-sdk-tools/src/TsDefinition/TsDefinition.ts +++ b/packages/claude-sdk-tools/src/TsDefinition/TsDefinition.ts @@ -1,12 +1,12 @@ import { defineTool } from '@shellicar/claude-sdk'; import type { z } from 'zod'; import { groupByFile } from '../typescript/groupByFile'; -import type { ITypeScriptService } from '../typescript/ITypeScriptService'; +import { ITypeScriptService } from '../typescript/ITypeScriptService'; import { TsDefinitionInputSchema, TsDefinitionOutputSchema } from './schema'; export type TsDefinitionOutput = z.output; -export function createTsDefinition(ts: ITypeScriptService) { +export function createTsDefinition() { return defineTool({ operation: 'read', name: 'TsDefinition', @@ -14,7 +14,11 @@ export function createTsDefinition(ts: ITypeScriptService) { input_schema: TsDefinitionInputSchema, output_schema: TsDefinitionOutputSchema, input_examples: [{ file: 'src/index.ts', line: 3, character: 20 }], - handler: async (input) => { + handler: async (input, _signal, scope) => { + if (scope == null) { + throw new Error('TsDefinition requires a block scope to resolve ITypeScriptService'); + } + const ts = scope.resolve(ITypeScriptService); const definitions = await ts.getDefinition({ file: input.file, line: input.line, diff --git a/packages/claude-sdk-tools/src/TsDiagnostics/TsDiagnostics.ts b/packages/claude-sdk-tools/src/TsDiagnostics/TsDiagnostics.ts index d7a27012..e057f2c8 100644 --- a/packages/claude-sdk-tools/src/TsDiagnostics/TsDiagnostics.ts +++ b/packages/claude-sdk-tools/src/TsDiagnostics/TsDiagnostics.ts @@ -1,12 +1,13 @@ import { defineTool } from '@shellicar/claude-sdk'; import type { z } from 'zod'; import { groupByFile } from '../typescript/groupByFile'; -import type { Diagnostic, ITypeScriptService } from '../typescript/ITypeScriptService'; +import type { Diagnostic } from '../typescript/ITypeScriptService'; +import { ITypeScriptService } from '../typescript/ITypeScriptService'; import { TsDiagnosticsInputSchema, TsDiagnosticsOutputSchema } from './schema'; export type TsDiagnosticsOutput = z.output; -export function createTsDiagnostics(ts: ITypeScriptService) { +export function createTsDiagnostics() { return defineTool({ operation: 'read', name: 'TsDiagnostics', @@ -14,7 +15,13 @@ export function createTsDiagnostics(ts: ITypeScriptService) { input_schema: TsDiagnosticsInputSchema, output_schema: TsDiagnosticsOutputSchema, input_examples: [{ files: [{ file: 'src/index.ts' }] }, { files: [{ file: 'src/runAgent.ts', severity: 'error' }, { file: 'src/index.ts' }] }], - handler: async (input) => { + handler: async (input, _signal, scope) => { + if (scope == null) { + throw new Error('TsDiagnostics requires a block scope to resolve ITypeScriptService'); + } + // Resolved fresh from this block's scope: one tsserver per block, shared by every + // TS tool called within it, torn down when the scope disposes at the block's end. + const ts = scope.resolve(ITypeScriptService); // Each file runs on the same per-block server, so a batch is one spawn. const diagnostics: Diagnostic[] = []; for (const target of input.files) { diff --git a/packages/claude-sdk-tools/src/TsHover/TsHover.ts b/packages/claude-sdk-tools/src/TsHover/TsHover.ts index 2b85ac35..d29f4e2b 100644 --- a/packages/claude-sdk-tools/src/TsHover/TsHover.ts +++ b/packages/claude-sdk-tools/src/TsHover/TsHover.ts @@ -1,8 +1,8 @@ import { defineTool } from '@shellicar/claude-sdk'; -import type { ITypeScriptService } from '../typescript/ITypeScriptService'; +import { ITypeScriptService } from '../typescript/ITypeScriptService'; import { TsHoverInputSchema, TsHoverOutputSchema } from './schema'; -export function createTsHover(ts: ITypeScriptService) { +export function createTsHover() { return defineTool({ operation: 'read', name: 'TsHover', @@ -10,7 +10,11 @@ export function createTsHover(ts: ITypeScriptService) { input_schema: TsHoverInputSchema, output_schema: TsHoverOutputSchema, input_examples: [{ file: 'src/index.ts', line: 12, character: 8 }], - handler: async (input) => { + handler: async (input, _signal, scope) => { + if (scope == null) { + throw new Error('TsHover requires a block scope to resolve ITypeScriptService'); + } + const ts = scope.resolve(ITypeScriptService); const result = await ts.getHoverInfo({ file: input.file, line: input.line, diff --git a/packages/claude-sdk-tools/src/TsReferences/TsReferences.ts b/packages/claude-sdk-tools/src/TsReferences/TsReferences.ts index 2eaef6f0..e9e034cf 100644 --- a/packages/claude-sdk-tools/src/TsReferences/TsReferences.ts +++ b/packages/claude-sdk-tools/src/TsReferences/TsReferences.ts @@ -1,12 +1,12 @@ import { defineTool } from '@shellicar/claude-sdk'; import type { z } from 'zod'; import { groupByFile } from '../typescript/groupByFile'; -import type { ITypeScriptService } from '../typescript/ITypeScriptService'; +import { ITypeScriptService } from '../typescript/ITypeScriptService'; import { TsReferencesInputSchema, TsReferencesOutputSchema } from './schema'; export type TsReferencesOutput = z.output; -export function createTsReferences(ts: ITypeScriptService) { +export function createTsReferences() { return defineTool({ operation: 'read', name: 'TsReferences', @@ -14,7 +14,11 @@ export function createTsReferences(ts: ITypeScriptService) { input_schema: TsReferencesInputSchema, output_schema: TsReferencesOutputSchema, input_examples: [{ file: 'src/index.ts', line: 5, character: 13 }], - handler: async (input) => { + handler: async (input, _signal, scope) => { + if (scope == null) { + throw new Error('TsReferences requires a block scope to resolve ITypeScriptService'); + } + const ts = scope.resolve(ITypeScriptService); const references = await ts.getReferences({ file: input.file, line: input.line, diff --git a/packages/claude-sdk-tools/src/composable.ts b/packages/claude-sdk-tools/src/composable.ts index 1e55d215..a47c7474 100644 --- a/packages/claude-sdk-tools/src/composable.ts +++ b/packages/claude-sdk-tools/src/composable.ts @@ -32,6 +32,11 @@ export type ComposableTool, TIn>) => Promise>; + + /** The tool's own one-line rendering of its resolved model — same contract as + * `AnyToolDefinition.summarize`. Runs on the model alone (never the reconciled canonical), + * since that's what a standalone call or a pipe step's own summary shows. */ + summarize?: (model: z.output) => string; }; /** The reconciler — the sole place model and stream meet. A stage grafts the upstream stream under a @@ -62,6 +67,7 @@ export function toStandalone(t: ComposableTool): AnyToolDefinition { input_schema: t.model, output_schema: z.union([z.string(), z.object({ tool: z.string(), error: z.string() })]), input_examples: t.input_examples as Record[], + summarize: t.summarize as ((input: unknown) => string) | undefined, handler: async (model: unknown) => { try { const out = await t.run(reconcile(model, undefined) as never); diff --git a/packages/claude-sdk-tools/src/entry/Orchestrate.ts b/packages/claude-sdk-tools/src/entry/Orchestrate.ts new file mode 100644 index 00000000..d1161ec7 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Orchestrate.ts @@ -0,0 +1,14 @@ +import { executor } from '../exec-shared'; +import { OrchestrateEngine } from '../Orchestrate/OrchestrateEngine'; +import type { ToolsV2RegistryDeps, WireStage } from '../Orchestrate/registry'; +import { createToolsV2Registry, ToolsV2Registry, toolsV2WireTools } from '../Orchestrate/registry'; +import { runToolV2Call } from '../Orchestrate/runToolV2Call'; + +export type { ToolsV2RegistryDeps, WireStage }; +// Shares the process-wide Executor with ExecV3/Az/GitHub/AzureDevOps (see their entry files), +// so a Program call is tracked and reaped by the same exit-sweep handler as every other exec child. +// ToolsV2Registry is a real value export, not just a type: a caller needing an empty registry +// (e.g. a test double for anything that only depends on ToolsV2Service, never on any real V2 +// tool) constructs `new ToolsV2Registry([])` directly rather than routing through +// createToolsV2Registry with a pile of unused fake dependencies. +export { createToolsV2Registry, executor as orchestrateExecutor, OrchestrateEngine, runToolV2Call, ToolsV2Registry, toolsV2WireTools }; diff --git a/packages/claude-sdk-tools/src/entry/Policy.ts b/packages/claude-sdk-tools/src/entry/Policy.ts new file mode 100644 index 00000000..512a11c4 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Policy.ts @@ -0,0 +1,17 @@ +import { defaultPolicy } from '../Policy/defaultPolicy.js'; +import type { InputMatcher } from '../Policy/matchInput.js'; +import { matchesInput } from '../Policy/matchInput.js'; +import { matchesPath } from '../Policy/matchPath.js'; +import { matchesTool } from '../Policy/matchTool.js'; +import type { ValuePattern } from '../Policy/matchValue.js'; +import { matchesValue } from '../Policy/matchValue.js'; +import type { UpdateResult } from '../Policy/PolicyStore.js'; +import { PolicyStore } from '../Policy/PolicyStore.js'; +import type { ResolveInput } from '../Policy/resolve.js'; +import { resolve } from '../Policy/resolve.js'; +import type { PolicySet, Resolution, Rule, ToolMatch, Verdict } from '../Policy/types.js'; +import type { ToolLookup, ValidationResult } from '../Policy/validatePolicy.js'; +import { PolicySetSchema, RuleSchema, validatePolicy } from '../Policy/validatePolicy.js'; + +export type { InputMatcher, PolicySet, Resolution, ResolveInput, Rule, ToolLookup, ToolMatch, UpdateResult, ValidationResult, ValuePattern, Verdict }; +export { defaultPolicy, matchesInput, matchesPath, matchesTool, matchesValue, PolicySetSchema, PolicyStore, RuleSchema, resolve, validatePolicy }; diff --git a/packages/claude-sdk-tools/src/exec-shared.ts b/packages/claude-sdk-tools/src/exec-shared.ts index de47ed41..7a68f0fe 100644 --- a/packages/claude-sdk-tools/src/exec-shared.ts +++ b/packages/claude-sdk-tools/src/exec-shared.ts @@ -15,6 +15,35 @@ export abstract class IEnvProvider { public abstract buildEnv(cmdEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv; } +/** + * A provider carrying its own variable overlay on top of a base one. Variables set here win over + * everything the base builds, and exist only for as long as this instance does. + * + * This is what gives one Orchestrate run its own variable namespace: the run clones the ambient + * provider, a `captureAs` stage writes its output into the clone, later stages read it — as a + * `$NAME` substitution and as a real environment variable in any child process they spawn — and + * the whole namespace dies with the run. Nothing a pipeline captures can leak into the next one, + * or into the ambient environment, because the base is never written to. + */ +export class OverlayEnvProvider extends IEnvProvider { + readonly #base: IEnvProvider; + readonly #vars: Map; + + public constructor(base: IEnvProvider, vars: Map = new Map()) { + super(); + this.#base = base; + this.#vars = vars; + } + + public buildEnv(cmdEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...this.#base.buildEnv(cmdEnv), ...Object.fromEntries(this.#vars) }; + } + + public set(name: string, value: string): void { + this.#vars.set(name, value); + } +} + /** A strip+provide env transform. `cmdEnv` (the tool call's own per-command env, model-controlled) * is merged FIRST, over `process.env`. `strip` then deletes its keys from that merged result, so a * caller-supplied value cannot survive by riding in through cmdEnv. `provide` is applied LAST, @@ -33,6 +62,38 @@ export abstract class IEnvProvider { * knows it, and doesn't rot when a new provider is added elsewhere without updating a shared list. */ export type EnvProviderConfig = { strip: string[]; provide: Record string> }; +/** + * Names a call may not set for a process it spawns, because the engine will not honour them and a + * command that runs with them quietly ignored is not the command that was asked for. + * + * Two groups. `PATH` and the loader and interpreter variables decide which file a program name + * refers to and what code is loaded into it, so they change what a decision was even about: a rule + * that allowed `git` means nothing if `git` is whatever the call put on the path. The credential + * names are stripped from the ambient environment anyway, so a call setting one is asking for + * something it will not get. + * + * A variable that redirects one specific program (`GIT_SSH_COMMAND` and its relatives) is not here: + * that is the program doing what the program does, which is what a policy rule is for, and `env` is + * matchable by name so a rule can name it. + */ +export const PROTECTED_ENV_NAMES = [ + 'PATH', + 'LD_PRELOAD', + 'LD_LIBRARY_PATH', + 'DYLD_INSERT_LIBRARIES', + 'DYLD_LIBRARY_PATH', + 'NODE_OPTIONS', + 'GH_TOKEN', + 'GITHUB_TOKEN', + 'SSH_AUTH_SOCK', + 'AZURE_CONFIG_DIR', + 'AZURE_EXTENSION_DIR', + 'AZURE_DEVOPS_EXT_PAT', + 'AZURE_CLIENT_SECRET', + 'AZURE_PASSWORD', + 'AZURE_CLIENT_CERTIFICATE_PATH', +] as const; + export function buildEnvFrom(config: EnvProviderConfig, cmdEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, ...cmdEnv }; for (const key of config.strip) { diff --git a/packages/claude-sdk-tools/src/normaliseArgs.ts b/packages/claude-sdk-tools/src/normaliseArgs.ts new file mode 100644 index 00000000..ebb7eb11 --- /dev/null +++ b/packages/claude-sdk-tools/src/normaliseArgs.ts @@ -0,0 +1,29 @@ +/** `--foo=bar` -> `--foo` (the value is never matched on). A single-dash multi-character token is + * ambiguous on shape alone \u2014 `-ni` is bundled short flags (POSIX getopt: `-n -i`), but `-exec` is + * one word-flag (find's convention) \u2014 so both readings are kept rather than choosing: the token + * normalises to itself *plus* its exploded per-character short flags. `argsAnyOf: ['-exec']` still + * matches the literal token; `argsAnyOf: ['-i']` still matches `-ni` via the exploded form. + * + * A neutral, domain-general utility (CLI argument conventions, not any specific tool's schema) \u2014 + * shared by `Exec/ruleConfig.ts` and `Policy/matchValue.ts` rather than either depending on the + * other. */ +export function normaliseArg(arg: string): string[] { + if (arg.startsWith('--')) { + const eq = arg.indexOf('='); + return [eq === -1 ? arg : arg.slice(0, eq)]; + } + if (arg.startsWith('-') && arg.length > 2) { + return [ + arg, + ...arg + .slice(1) + .split('') + .map((c) => `-${c}`), + ]; + } + return [arg]; +} + +export function normaliseArgs(args: string[]): string[] { + return args.flatMap(normaliseArg); +} diff --git a/packages/claude-sdk-tools/src/typescript/FrameReader.ts b/packages/claude-sdk-tools/src/typescript/FrameReader.ts new file mode 100644 index 00000000..2d55bf40 --- /dev/null +++ b/packages/claude-sdk-tools/src/typescript/FrameReader.ts @@ -0,0 +1,67 @@ +const HEADER_END = Buffer.from('\r\n\r\n', 'utf8'); + +/** A header is a couple of short lines. Anything approaching this is not a header, and waiting for + * its terminator would grow the buffer without limit. */ +const MAX_HEADER_BYTES = 8 * 1024; + +/** A tsserver reply is a JSON document; this is far above any real one. A declared length beyond it + * is a corrupt frame, and honouring it would mean holding that much before discovering so. */ +const MAX_BODY_BYTES = 64 * 1024 * 1024; + +/** + * tsserver frames every message as `Content-Length: N\r\n\r\n{json}`, where N counts bytes. + * + * So the buffer is bytes throughout, and only a complete body is ever decoded. Accumulating the + * stream as a string instead is subtly wrong and was: a JavaScript string's length counts UTF-16 + * units, so one em dash in a symbol's documentation made the declared length exceed everything + * the reader could see, and it waited for bytes it was already holding until the caller timed out. + * + * Kept apart from the client that spawns the process so the framing can be exercised on its own, + * without a real tsserver. + */ +export class FrameReader { + #buffer: Buffer = Buffer.alloc(0); + + public reset(): void { + this.#buffer = Buffer.alloc(0); + } + + /** Everything that has become a complete message with this chunk. */ + public push(chunk: Buffer): unknown[] { + this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]); + const messages: unknown[] = []; + while (true) { + const headerEnd = this.#buffer.indexOf(HEADER_END); + if (headerEnd === -1) { + if (this.#buffer.length > MAX_HEADER_BYTES) { + // Not a header. Keeping it would grow forever waiting for a terminator that isn't coming. + this.reset(); + } + break; + } + const header = this.#buffer.subarray(0, headerEnd).toString('utf8'); + const match = header.match(/Content-Length:\s*(\d+)/); + if (match?.[1] == null) { + this.#buffer = this.#buffer.subarray(headerEnd + HEADER_END.length); + continue; + } + const contentLength = Number.parseInt(match[1], 10); + if (contentLength > MAX_BODY_BYTES) { + this.reset(); + break; + } + const bodyStart = headerEnd + HEADER_END.length; + if (this.#buffer.length < bodyStart + contentLength) { + break; + } + const body = this.#buffer.subarray(bodyStart, bodyStart + contentLength).toString('utf8'); + this.#buffer = this.#buffer.subarray(bodyStart + contentLength); + try { + messages.push(JSON.parse(body)); + } catch { + // A body that isn't JSON is not something a caller can act on; the next frame still parses. + } + } + return messages; + } +} diff --git a/packages/claude-sdk-tools/src/typescript/ITypeScriptService.ts b/packages/claude-sdk-tools/src/typescript/ITypeScriptService.ts index a7dc63f5..a9fd8c7e 100644 --- a/packages/claude-sdk-tools/src/typescript/ITypeScriptService.ts +++ b/packages/claude-sdk-tools/src/typescript/ITypeScriptService.ts @@ -50,9 +50,4 @@ export abstract class ITypeScriptService { public abstract getHoverInfo(options: HoverOptions): Promise; public abstract getReferences(options: ReferencesOptions): Promise; public abstract getDefinition(options: DefinitionOptions): Promise; - /** Ends the per-block server lifecycle the implementation owns. Declared here - * so the live DI contract is this interface, not the concrete bridge; it also - * satisfies the SDK's structural `ToolBlockLifetime` for the tools' block - * lifetime declaration. */ - public abstract blockEnded(): Promise; } diff --git a/packages/claude-sdk-tools/src/typescript/TsServerBridge.ts b/packages/claude-sdk-tools/src/typescript/TsServerBridge.ts index f649a77f..be847a4c 100644 --- a/packages/claude-sdk-tools/src/typescript/TsServerBridge.ts +++ b/packages/claude-sdk-tools/src/typescript/TsServerBridge.ts @@ -12,24 +12,25 @@ import { ITypeScriptService } from './ITypeScriptService'; * * Owns the on-demand, per-block server lifecycle: the client is started lazily on * the first TS-tool call of a block (a single shared promise every parallel TS - * tool in the block awaits) and stopped on `blockEnded()`. A fresh spawn per block - * reads the file from disk, so diagnostics are never stale across turns; and the - * spawn location is $HOME, so relative-path resolution tracks the live cwd here - * rather than a cwd frozen into the process. + * tool in the block awaits) and stopped when this instance is disposed. A fresh + * spawn per block reads the file from disk, so diagnostics are never stale across + * turns; and the spawn location is $HOME, so relative-path resolution tracks the + * live cwd here rather than a cwd frozen into the process. * - * `blockEnded` matches the SDK's `ToolBlockLifetime` structurally, so each TS - * tool declares this one instance as its `blockLifetime`; the build-tools step - * collects it (deduped) and the block notifier drives `blockEnded` per block. + * Registered `Lifetime.Scoped`: a fresh instance per tool-execution block, resolved + * from that block's own DI scope (see `QueryRunner`'s `scope` argument, threaded into + * every tool handler) and torn down automatically when the scope disposes at the end + * of the block — `[Symbol.asyncDispose]` is this class's own teardown, not a separate + * notifier fanning an edge out to a list of subscribed tools. */ export class TsServerBridge extends ITypeScriptService { @dependsOn(ITsServerClient) private readonly client!: ITsServerClient; @dependsOn(IFileSystem) private readonly fs!: IFileSystem; #startPromise: Promise | null = null; - /** Called once per tool block by the block notifier. Stops the block's - * server (if one was started) and clears the memo so the next block spawns - * fresh. No-op when no TS tool ran in the block. */ - public async blockEnded(): Promise { + /** Called by the DI scope when it disposes at the end of this instance's block. + * Stops the block's server (if one was started). No-op when no TS tool ran. */ + public async [Symbol.asyncDispose](): Promise { const pending = this.#startPromise; this.#startPromise = null; if (pending == null) { diff --git a/packages/claude-sdk-tools/src/typescript/TsServerClient.ts b/packages/claude-sdk-tools/src/typescript/TsServerClient.ts index 7efdcc23..0c80c26e 100644 --- a/packages/claude-sdk-tools/src/typescript/TsServerClient.ts +++ b/packages/claude-sdk-tools/src/typescript/TsServerClient.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; import { ILogger } from '@shellicar/claude-core/logging/ILogger'; import { dependsOn } from '@shellicar/core-di'; +import { FrameReader } from './FrameReader'; import { ITsServerClient, type TsServerDefinition, type TsServerDiagnostic, type TsServerQuickInfo, type TsServerReference } from './ITsServerClient'; import { ITsServerOptions } from './ITsServerOptions'; import { TsServerError } from './TsServerError'; @@ -15,6 +16,11 @@ type TsServerResponse = { command: string; request_seq: number; success: boolean; + /** tsserver's own reason for `success: false` (the wire protocol's `message` field) — + * present whenever it has one, absent for some failures. Every throw on the failure path + * must fold this in, or the caller sees only a generic "X failed for Y" with no way to + * tell why. */ + message?: string; body?: unknown; }; @@ -54,13 +60,20 @@ export function resolveTsServerPath(): string | null { } } +/** Folds tsserver's own failure reason (the wire protocol's `message` field, when it sent one) + * into the error text — the difference between a generic "references failed for View.ts" and + * actually knowing why. */ +export function tsServerFailureMessage(command: string, file: string, message: string | undefined): string { + return `tsserver ${command} failed for ${file}${message ? `: ${message}` : ''}`; +} + export class TsServerClient extends ITsServerClient { @dependsOn(ITsServerOptions) private readonly options!: ITsServerOptions; @dependsOn(IFileSystem) private readonly fs!: IFileSystem; @dependsOn(ILogger) private readonly logger!: ILogger; #proc: ChildProcess | null = null; #seq = 0; - #buffer = ''; + #frames = new FrameReader(); #pending = new Map(); #openFiles = new Set(); #started = false; @@ -86,7 +99,7 @@ export class TsServerClient extends ITsServerClient { return; } - this.#buffer = ''; + this.#frames.reset(); this.#seq = 0; this.#openFiles.clear(); @@ -107,8 +120,7 @@ export class TsServerClient extends ITsServerClient { if (this.#proc !== proc) { return; } - this.#buffer += chunk.toString(); - this.#processBuffer(); + this.#deliver(chunk); }); proc.stdin?.on('error', (err) => { @@ -176,7 +188,7 @@ export class TsServerClient extends ITsServerClient { public async getSyntacticDiagnostics(file: string): Promise { const res = await this.#send('syntacticDiagnosticsSync', { file }); if (!res.success) { - throw new TsServerError(`tsserver syntacticDiagnosticsSync failed for ${file}`); + throw new TsServerError(tsServerFailureMessage('syntacticDiagnosticsSync', file, res.message)); } return (res.body as TsServerDiagnostic[]) ?? []; } @@ -184,7 +196,7 @@ export class TsServerClient extends ITsServerClient { public async getSemanticDiagnostics(file: string): Promise { const res = await this.#send('semanticDiagnosticsSync', { file }); if (!res.success) { - throw new TsServerError(`tsserver semanticDiagnosticsSync failed for ${file}`); + throw new TsServerError(tsServerFailureMessage('semanticDiagnosticsSync', file, res.message)); } return (res.body as TsServerDiagnostic[]) ?? []; } @@ -204,7 +216,7 @@ export class TsServerClient extends ITsServerClient { public async references(file: string, line: number, offset: number): Promise { const res = await this.#send('references', { file, line, offset }); if (!res.success) { - throw new TsServerError(`tsserver references failed for ${file}`); + throw new TsServerError(tsServerFailureMessage('references', file, res.message)); } const body = res.body as { refs?: TsServerReference[] } | undefined; return body?.refs ?? []; @@ -213,7 +225,7 @@ export class TsServerClient extends ITsServerClient { public async definition(file: string, line: number, offset: number): Promise { const res = await this.#send('definition', { file, line, offset }); if (!res.success) { - throw new TsServerError(`tsserver definition failed for ${file}`); + throw new TsServerError(tsServerFailureMessage('definition', file, res.message)); } return (res.body as TsServerDefinition[]) ?? []; } @@ -258,44 +270,18 @@ export class TsServerClient extends ITsServerClient { }); } - #processBuffer(): void { - // tsserver frames: Content-Length: N\r\n\r\n{json} - while (true) { - const headerEnd = this.#buffer.indexOf('\r\n\r\n'); - if (headerEnd === -1) { - break; - } - - const header = this.#buffer.slice(0, headerEnd); - const match = header.match(/Content-Length:\s*(\d+)/); - if (!match) { - this.#buffer = this.#buffer.slice(headerEnd + 4); + #deliver(chunk: Buffer): void { + for (const parsed of this.#frames.push(chunk)) { + const msg = parsed as { type: string; request_seq?: number }; + if (msg.type !== 'response' || msg.request_seq == null) { + // Ignore events (sync commands only; the exclusive geterr channel is not used). continue; } - - const contentLength = Number.parseInt(match[1], 10); - const bodyStart = headerEnd + 4; - - if (this.#buffer.length < bodyStart + contentLength) { - break; - } - - const body = this.#buffer.slice(bodyStart, bodyStart + contentLength); - this.#buffer = this.#buffer.slice(bodyStart + contentLength); - - try { - const msg = JSON.parse(body) as { type: string; request_seq?: number }; - if (msg.type === 'response' && msg.request_seq != null && this.#pending.has(msg.request_seq)) { - const pending = this.#pending.get(msg.request_seq); - if (pending) { - clearTimeout(pending.timer); - this.#pending.delete(msg.request_seq); - pending.resolve(msg as TsServerResponse); - } - } - // Ignore events (sync commands only; the exclusive geterr channel is not used). - } catch { - // skip unparseable + const pending = this.#pending.get(msg.request_seq); + if (pending) { + clearTimeout(pending.timer); + this.#pending.delete(msg.request_seq); + pending.resolve(msg as TsServerResponse); } } } diff --git a/packages/claude-sdk-tools/test/Az/createAzTool.spec.ts b/packages/claude-sdk-tools/test/Az/createAzTool.spec.ts index 19293b6f..6e70d1fb 100644 --- a/packages/claude-sdk-tools/test/Az/createAzTool.spec.ts +++ b/packages/claude-sdk-tools/test/Az/createAzTool.spec.ts @@ -29,6 +29,20 @@ describe('resolveAzAccount', () => { expect(() => resolveAzAccount(() => multiple, 'reader', undefined)).toThrow(expected); }); + // Without the names, the only way to find out what to pass is to go and read the config file. + it('names the accounts that could have been chosen', () => { + const expected = 'shellicar, hopeventures'; + const actual = (() => { + try { + resolveAzAccount(() => multiple, 'reader', undefined); + return ''; + } catch (err) { + return err instanceof Error ? err.message : ''; + } + })(); + expect(actual).toContain(expected); + }); + it('resolves to the requested account when it matches', () => { const expected = 'hopeventures'; const actual = resolveAzAccount(() => multiple, 'holder', 'hopeventures'); diff --git a/packages/claude-sdk-tools/test/CreateFile/performCreateFile.spec.ts b/packages/claude-sdk-tools/test/CreateFile/performCreateFile.spec.ts new file mode 100644 index 00000000..8c364d84 --- /dev/null +++ b/packages/claude-sdk-tools/test/CreateFile/performCreateFile.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { performCreateFile } from '../../src/CreateFile/performCreateFile.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +describe('performCreateFile', () => { + it('creates a new file with the given content', async () => { + const fs = new MemoryFileSystem(); + + await performCreateFile(fs, '/a.txt', 'hello', false); + + const expected = 'hello'; + const actual = await fs.readFile('/a.txt'); + expect(actual).toBe(expected); + }); + + it('reports ok on a successful create', async () => { + const fs = new MemoryFileSystem(); + + const result = await performCreateFile(fs, '/a.txt', 'hello', false); + + const expected = true; + const actual = result.ok; + expect(actual).toBe(expected); + }); + + it('fails when the file already exists and overwrite is false', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'existing' }); + + const result = await performCreateFile(fs, '/a.txt', 'new', false); + + const expected = false; + const actual = result.ok; + expect(actual).toBe(expected); + }); + + it('names the reason when the file already exists', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'existing' }); + + const result = await performCreateFile(fs, '/a.txt', 'new', false); + + const expected = 'File already exists. Set overwrite: true to replace it.'; + const actual = !result.ok ? result.message : ''; + expect(actual).toBe(expected); + }); + + it('overwrites an existing file when overwrite is true', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'old' }); + + await performCreateFile(fs, '/a.txt', 'new', true); + + const expected = 'new'; + const actual = await fs.readFile('/a.txt'); + expect(actual).toBe(expected); + }); + + it('fails when overwrite is true but the file does not exist', async () => { + const fs = new MemoryFileSystem(); + + const result = await performCreateFile(fs, '/missing.txt', 'x', true); + + const expected = false; + const actual = result.ok; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/EditFile/applyTextEdits.spec.ts b/packages/claude-sdk-tools/test/EditFile/applyTextEdits.spec.ts new file mode 100644 index 00000000..ef8490e0 --- /dev/null +++ b/packages/claude-sdk-tools/test/EditFile/applyTextEdits.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { applyTextEdits, sortBottomToTop } from '../../src/EditFile/applyTextEdits.js'; + +describe('sortBottomToTop', () => { + it('sorts a replace edit after a later delete edit, so lines shift bottom-to-top', () => { + const edits = [ + { action: 'delete' as const, startLine: 1, endLine: 1 }, + { action: 'replace' as const, startLine: 3, endLine: 3, content: 'x' }, + ]; + + const expected = [3, 1]; + const actual = sortBottomToTop(4, edits).map((e) => (e.action === 'insert' ? -1 : e.startLine)); + expect(actual).toEqual(expected); + }); +}); + +describe('applyTextEdits \u2014 replace_text', () => { + it('replaces a literal string', () => { + const expected = 'let x = 1;'; + const actual = applyTextEdits('const x = 1;', [{ action: 'replace_text', oldString: 'const x', replacement: 'let x', replaceMultiple: false }]); + expect(actual).toBe(expected); + }); + + it('throws when the string is not found', () => { + expect(() => applyTextEdits('foo', [{ action: 'replace_text', oldString: 'missing', replacement: 'x', replaceMultiple: false }])).toThrow('not found'); + }); + + it('throws when the string matches more than once without replaceMultiple', () => { + expect(() => applyTextEdits('foo foo', [{ action: 'replace_text', oldString: 'foo', replacement: 'x', replaceMultiple: false }])).toThrow('matched 2 times'); + }); + + it('replaces every match when replaceMultiple is true', () => { + const expected = 'x x'; + const actual = applyTextEdits('foo foo', [{ action: 'replace_text', oldString: 'foo', replacement: 'x', replaceMultiple: true }]); + expect(actual).toBe(expected); + }); +}); + +describe('applyTextEdits \u2014 regex_text', () => { + it('replaces using a regex pattern', () => { + const expected = 'import { Foo }'; + const actual = applyTextEdits('import type { Foo }', [{ action: 'regex_text', pattern: 'import type \\{ (\\w+) \\}', replacement: 'import { $1 }', replaceMultiple: false }]); + expect(actual).toBe(expected); + }); + + it('throws when the pattern is not found', () => { + expect(() => applyTextEdits('foo', [{ action: 'regex_text', pattern: 'missing', replacement: 'x', replaceMultiple: false }])).toThrow('not found'); + }); +}); diff --git a/packages/claude-sdk-tools/test/EditFile/performEdit.spec.ts b/packages/claude-sdk-tools/test/EditFile/performEdit.spec.ts new file mode 100644 index 00000000..12cfc1d3 --- /dev/null +++ b/packages/claude-sdk-tools/test/EditFile/performEdit.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { performEdit } from '../../src/EditFile/performEdit.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +describe('performEdit', () => { + it('writes the edited content to disk', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'one\ntwo\nthree' }); + + await performEdit(fs, '/a.ts', [{ action: 'replace', startLine: 2, endLine: 2, content: 'TWO' }], []); + + const expected = 'one\nTWO\nthree'; + const actual = await fs.readFile('/a.ts'); + expect(actual).toBe(expected); + }); + + it('returns a line-numbered diff', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'one\ntwo\nthree' }); + + const diff = await performEdit(fs, '/a.ts', [{ action: 'replace', startLine: 2, endLine: 2, content: 'TWO' }], []); + + const expected = true; + const actual = diff.includes('+2:TWO'); + expect(actual).toBe(expected); + }); + + it('applies lineEdits before textEdits', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'oldCall()\nkeep' }); + + await performEdit(fs, '/a.ts', [{ action: 'insert', after_line: -1, content: 'function helper() {}' }], [{ action: 'replace_text', oldString: 'oldCall()', replacement: 'helper()', replaceMultiple: false }]); + + const expected = 'helper()\nkeep\nfunction helper() {}'; + const actual = await fs.readFile('/a.ts'); + expect(actual).toBe(expected); + }); + + it('throws when a line edit is out of bounds, and does not write to disk', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'one\ntwo' }); + + await expect(performEdit(fs, '/a.ts', [{ action: 'delete', startLine: 5, endLine: 5 }], [])).rejects.toThrow('out of bounds'); + + const expected = 'one\ntwo'; + const actual = await fs.readFile('/a.ts'); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/FakeExecutor.ts b/packages/claude-sdk-tools/test/FakeExecutor.ts index cc4e4a20..690da8c5 100644 --- a/packages/claude-sdk-tools/test/FakeExecutor.ts +++ b/packages/claude-sdk-tools/test/FakeExecutor.ts @@ -1,4 +1,4 @@ -import type { CommandSpec, ExitStatus, IExecutor, SpawnOpts } from '@shellicar/exec-core'; +import { type CommandSpec, type ExitStatus, type IExecutor, PipeConsumerGone, type SpawnOpts } from '@shellicar/exec-core'; export type FakeResponse = { stdout?: string; @@ -27,11 +27,21 @@ async function drain(stdin: SpawnOpts['stdin']): Promise { * on what would have run. */ export class FakeExecutor implements IExecutor { public readonly calls: CommandSpec[] = []; + /** The signal the process was killed with, for a run that did not end on its own. */ + public killedWith: string | undefined; public constructor(private readonly respond: FakeResponder = () => ({ exitCode: 0 })) {} public async run(cmd: CommandSpec, opts: SpawnOpts = {}): Promise { this.calls.push(cmd); + opts.signal?.addEventListener( + 'abort', + () => { + // The executor maps a departing reader onto SIGPIPE and anything else onto a hard kill. + this.killedWith = opts.signal?.reason === PipeConsumerGone ? 'SIGPIPE' : 'SIGKILL'; + }, + { once: true }, + ); const stdin = await drain(opts.stdin); const response = this.respond(cmd, stdin); diff --git a/packages/claude-sdk-tools/test/Find.spec.ts b/packages/claude-sdk-tools/test/Find.spec.ts index 10fc90a1..74137989 100644 --- a/packages/claude-sdk-tools/test/Find.spec.ts +++ b/packages/claude-sdk-tools/test/Find.spec.ts @@ -79,3 +79,23 @@ describe('createFind — error handling', () => { expect(actual).toBe(expected); }); }); + +describe('createFind — summarize', () => { + it('shows the path resolved relative to cwd, and the pattern when one is given', () => { + const fs = new MemoryFileSystem({}, '/home/user', '/repo'); + const tool = createFind(fs); + + const expected = 'Find(src \\.ts$)'; + const actual = tool.summarize?.(FindModel.parse({ path: '/repo/src', pattern: '\\.ts$' })); + expect(actual).toBe(expected); + }); + + it('omits the pattern from the summary when none was given', () => { + const fs = new MemoryFileSystem({}, '/home/user', '/repo'); + const tool = createFind(fs); + + const expected = 'Find(src)'; + const actual = tool.summarize?.(FindModel.parse({ path: '/repo/src' })); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/FrameReader.spec.ts b/packages/claude-sdk-tools/test/FrameReader.spec.ts new file mode 100644 index 00000000..3475a9f4 --- /dev/null +++ b/packages/claude-sdk-tools/test/FrameReader.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { FrameReader } from '../src/typescript/FrameReader.js'; + +/** A frame exactly as tsserver writes one: the length counts bytes, not characters. */ +function frame(payload: unknown): Buffer { + const body = Buffer.from(JSON.stringify(payload), 'utf8'); + return Buffer.concat([Buffer.from(`Content-Length: ${body.byteLength}\r\n\r\n`, 'utf8'), body]); +} + +describe('reading tsserver frames', () => { + it('reads a message whose body is plain ASCII', () => { + const reader = new FrameReader(); + + const expected = [{ seq: 1, body: 'plain' }]; + const actual = reader.push(frame({ seq: 1, body: 'plain' })); + expect(actual).toEqual(expected); + }); + + // A hover answer carries the symbol's own documentation, so any non-ASCII a codebase writes in a + // comment ends up here. An em dash is one character and three bytes. + it('reads a message whose body contains a multi-byte character', () => { + const reader = new FrameReader(); + + const expected = [{ seq: 2, body: 'a — b' }]; + const actual = reader.push(frame({ seq: 2, body: 'a — b' })); + expect(actual).toEqual(expected); + }); + + it('reads the message following a multi-byte one, rather than losing the boundary', () => { + const reader = new FrameReader(); + + const expected = [ + { seq: 3, body: '— first' }, + { seq: 4, body: 'second' }, + ]; + const actual = reader.push(Buffer.concat([frame({ seq: 3, body: '— first' }), frame({ seq: 4, body: 'second' })])); + expect(actual).toEqual(expected); + }); + + it('waits for the rest of a message split across chunks', () => { + const reader = new FrameReader(); + const whole = frame({ seq: 5, body: 'split — here' }); + + reader.push(whole.subarray(0, 20)); + const expected = [{ seq: 5, body: 'split — here' }]; + const actual = reader.push(whole.subarray(20)); + expect(actual).toEqual(expected); + }); + + it('yields nothing until a message is complete', () => { + const reader = new FrameReader(); + const whole = frame({ seq: 6, body: 'incomplete' }); + + const expected: unknown[] = []; + const actual = reader.push(whole.subarray(0, whole.byteLength - 5)); + expect(actual).toEqual(expected); + }); +}); + +// tsserver is a child process, but a reader that trusts what it is told without limit turns any +// corrupt or truncated frame into unbounded memory. +describe('reading frames that never resolve', () => { + it('gives up on a header with no terminator rather than holding it forever', () => { + const reader = new FrameReader(); + + reader.push(Buffer.alloc(9 * 1024, 0x41)); + const expected = [{ seq: 1, body: 'after' }]; + const actual = reader.push(frame({ seq: 1, body: 'after' })); + expect(actual).toEqual(expected); + }); + + it('gives up on a declared length no real reply could have', () => { + const reader = new FrameReader(); + + reader.push(Buffer.from('Content-Length: 999999999999\r\n\r\n', 'utf8')); + const expected = [{ seq: 2, body: 'after' }]; + const actual = reader.push(frame({ seq: 2, body: 'after' })); + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/MemoryFileSystem.ts b/packages/claude-sdk-tools/test/MemoryFileSystem.ts index 02f8e635..06022e5b 100644 --- a/packages/claude-sdk-tools/test/MemoryFileSystem.ts +++ b/packages/claude-sdk-tools/test/MemoryFileSystem.ts @@ -152,7 +152,19 @@ export class MemoryFileSystem extends IFileSystem { })); } + /** Links, as a plain mapping from the path someone names to the path it really is. Enough to + * model what matters here: a name that resolves to somewhere else. */ + public readonly links = new Map(); + public async realpath(path: string): Promise { + for (const [from, to] of this.links) { + if (path === from) { + return to; + } + if (path.startsWith(`${from}/`)) { + return `${to}${path.slice(from.length)}`; + } + } return path; } diff --git a/packages/claude-sdk-tools/test/Orchestrate/AppendFile.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/AppendFile.spec.ts new file mode 100644 index 00000000..b694c2d8 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/AppendFile.spec.ts @@ -0,0 +1,71 @@ +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createAppendFileToolV2 } from '../../src/Orchestrate/tools/AppendFile.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +describe('AppendFile tool', () => { + it('is fs.write tier', () => { + const tool = createAppendFileToolV2(new MemoryFileSystem()); + + const expected = 'fs.write'; + const actual = tool.operation; + expect(actual).toBe(expected); + }); + + it('creates the file when it does not exist yet', async () => { + const fs = new MemoryFileSystem(); + const tool = createAppendFileToolV2(fs); + + const { stdout } = tool.run({ path: '/a.txt', content: 'first line\n' }, undefined, []); + for await (const _line of toLines(stdout)) { + // drain + } + + const expected = 'first line\n'; + const actual = await fs.readFile('/a.txt'); + expect(actual).toBe(expected); + }); + + it('appends verbatim to the end of an existing file', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'first line\n' }); + const tool = createAppendFileToolV2(fs); + + const { stdout } = tool.run({ path: '/a.txt', content: 'second line\n' }, undefined, []); + for await (const _line of toLines(stdout)) { + // drain + } + + const expected = 'first line\nsecond line\n'; + const actual = await fs.readFile('/a.txt'); + expect(actual).toBe(expected); + }); + + it('reports success', async () => { + const fs = new MemoryFileSystem(); + const tool = createAppendFileToolV2(fs); + + const { stdout, success } = tool.run({ path: '/a.txt', content: 'x' }, undefined, []); + for await (const _line of toLines(stdout)) { + // drain + } + + const expected = true; + const actual = success(); + expect(actual).toBe(expected); + }); + + it('yields the appended path', async () => { + const fs = new MemoryFileSystem(); + const tool = createAppendFileToolV2(fs); + + const { stdout } = tool.run({ path: '/a.txt', content: 'x' }, undefined, []); + const out: string[] = []; + for await (const line of toLines(stdout)) { + out.push(line); + } + + const expected = ['appended: /a.txt']; + const actual = out; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Az.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Az.spec.ts new file mode 100644 index 00000000..d73304bc --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Az.spec.ts @@ -0,0 +1,48 @@ +import { Clock } from '@js-joda/core'; +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { AzSessionCache } from '../../src/Az/AzSessionCache.js'; +import type { AzDeps } from '../../src/Az/runAz.js'; +import { createAzToolsV2 } from '../../src/Orchestrate/tools/Az.js'; +import { FakeExecutor } from '../FakeExecutor.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +function makeDeps(executor: FakeExecutor): AzDeps { + return { executor, getCert: () => 'cert', getIdentity: () => ({ type: 'cert', clientId: 'client-id', subscriptionIds: [] }), getTenantId: () => 'tenant-id' }; +} + +describe('Az V2', () => { + it('AzCli (reader) maps onto fs.write, the closest tier to V1s generic write tag', () => { + const [AzCli] = createAzToolsV2(makeDeps(new FakeExecutor()), () => ({}), new AzSessionCache(Clock.systemUTC())); + + expect(AzCli.name).toBe('AzCli'); + expect(AzCli.operation).toBe('fs.write'); + }); + + it('EscalatedAzCli is escalate — always gated, never a pre-trustable fs.* tier', () => { + const [, EscalatedAzCli] = createAzToolsV2(makeDeps(new FakeExecutor()), () => ({}), new AzSessionCache(Clock.systemUTC())); + + expect(EscalatedAzCli.name).toBe('EscalatedAzCli'); + expect(EscalatedAzCli.operation).toBe('escalate'); + }); + + it('runs the given args against the sole configured reader account', async () => { + const executor = new FakeExecutor(() => ({ stdout: '[]\n', exitCode: 0 })); + const cache = new AzSessionCache(Clock.systemUTC()); + const [AzCli] = createAzToolsV2(makeDeps(executor), () => ({ acct: { tenantId: 't', reader: { type: 'cert', clientId: 'c', subscriptionIds: [] }, holder: null } }), cache); + + const result = AzCli.run({ args: ['group', 'list'] }, undefined, []); + const lines = await drain(result.stdout); + + expect(result.success()).toBe(true); + expect(lines).toEqual(['[]']); + expect(executor.calls.find((c) => c.args?.[0] === 'group')?.args).toEqual(['group', 'list']); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/AzureDevOps.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/AzureDevOps.spec.ts new file mode 100644 index 00000000..7bfc5703 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/AzureDevOps.spec.ts @@ -0,0 +1,45 @@ +import { tmpdir } from 'node:os'; +import { Clock } from '@js-joda/core'; +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { AzSessionCache } from '../../src/Az/AzSessionCache.js'; +import type { AzDeps } from '../../src/Az/runAz.js'; +import { createAdoPrToolsV2 } from '../../src/Orchestrate/tools/AzureDevOps.js'; +import { FakeExecutor } from '../FakeExecutor.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +function makeDeps(executor: FakeExecutor): AzDeps { + return { executor, getCert: () => 'cert', getIdentity: () => ({ type: 'cert', clientId: 'client-id', subscriptionIds: [] }), getTenantId: () => 'tenant-id' }; +} + +describe('AzureDevOps V2', () => { + it('every tool is registered as escalate — always gated, never a pre-trustable fs.* tier', () => { + const executor = new FakeExecutor(); + const tools = createAdoPrToolsV2(makeDeps(executor), () => ({ acct: { tenantId: 't', reader: null, holder: { type: 'cert', clientId: 'c', subscriptionIds: [] } } }), new AzSessionCache(Clock.systemUTC())); + + expect(tools.map((t) => t.operation)).toEqual(tools.map(() => 'escalate')); + expect(tools.map((t) => t.name)).toEqual(['AzureDevOps_PullRequest_Create', 'AzureDevOps_PullRequest_Ready', 'AzureDevOps_PullRequest_Edit', 'AzureDevOps_PullRequest_AutoMerge', 'AzureDevOps_PullRequest_ReviewerAdd', 'AzureDevOps_PullRequest_ReviewerRemove', 'AzureDevOps_PullRequest_Vote']); + }); + + it('runs the Ready tool against the sole configured holder account, in a directory with no git remote', async () => { + const executor = new FakeExecutor(() => ({ stdout: 'ok\n', exitCode: 0 })); + const cache = new AzSessionCache(Clock.systemUTC()); + const [Create, Ready] = createAdoPrToolsV2(makeDeps(executor), () => ({ acct: { tenantId: 't', reader: null, holder: { type: 'cert', clientId: 'c', subscriptionIds: [] } } }), cache); + void Create; + + const result = Ready.run({ id: 42, cwd: tmpdir() }, undefined, []); + const lines = await drain(result.stdout); + + expect(result.success()).toBe(true); + expect(lines).toEqual(['ok']); + const prCall = executor.calls.find((c) => c.args?.[0] === 'repos'); + expect(prCall?.args).toEqual(['repos', 'pr', 'update', '--id', '42', '--draft', 'false']); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/CreateFile.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/CreateFile.spec.ts new file mode 100644 index 00000000..5d364b06 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/CreateFile.spec.ts @@ -0,0 +1,127 @@ +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createCreateFileToolV2 } from '../../src/Orchestrate/tools/CreateFile.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +describe('CreateFile tool', () => { + it('is fs.write tier', () => { + const tool = createCreateFileToolV2(new MemoryFileSystem()); + + const expected = 'fs.write'; + const actual = tool.operation; + expect(actual).toBe(expected); + }); + + it('creates a new file with the given content', async () => { + const fs = new MemoryFileSystem(); + const tool = createCreateFileToolV2(fs); + + const { success } = tool.run({ path: '/a.txt', content: 'hello' }, undefined, []); + + const expected = true; + const actual = success(); + expect(actual).toBe(expected); + }); + + it('actually writes the content to the filesystem', async () => { + const fs = new MemoryFileSystem(); + const tool = createCreateFileToolV2(fs); + + const { stdout } = tool.run({ path: '/a.txt', content: 'hello' }, undefined, []); + for await (const _line of toLines(stdout)) { + // drain + } + + const expected = 'hello'; + const actual = await fs.readFile('/a.txt'); + expect(actual).toBe(expected); + }); + + it('defaults content to an empty string when omitted', async () => { + const fs = new MemoryFileSystem(); + const tool = createCreateFileToolV2(fs); + + const { stdout } = tool.run({ path: '/a.txt' }, undefined, []); + for await (const _line of toLines(stdout)) { + // drain + } + + const expected = ''; + const actual = await fs.readFile('/a.txt'); + expect(actual).toBe(expected); + }); + + it('fails when the file already exists and overwrite is not set', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'existing' }); + const tool = createCreateFileToolV2(fs); + const stderr: string[] = []; + + const { stdout, success } = tool.run({ path: '/a.txt' }, undefined, stderr); + for await (const _line of toLines(stdout)) { + // drain + } + + const expected = false; + const actual = success(); + expect(actual).toBe(expected); + }); + + it('names the reason in stderr when the file already exists', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'existing' }); + const tool = createCreateFileToolV2(fs); + const stderr: string[] = []; + + const { stdout } = tool.run({ path: '/a.txt' }, undefined, stderr); + for await (const _line of toLines(stdout)) { + // drain + } + + const expected = ['File already exists. Set overwrite: true to replace it.']; + const actual = stderr; + expect(actual).toEqual(expected); + }); + + it('overwrites an existing file when overwrite is true', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'old' }); + const tool = createCreateFileToolV2(fs); + + const { stdout } = tool.run({ path: '/a.txt', content: 'new', overwrite: true }, undefined, []); + for await (const _line of toLines(stdout)) { + // drain + } + + const expected = 'new'; + const actual = await fs.readFile('/a.txt'); + expect(actual).toBe(expected); + }); + + it('fails when overwrite is true but the file does not exist', async () => { + const fs = new MemoryFileSystem(); + const tool = createCreateFileToolV2(fs); + const stderr: string[] = []; + + const { stdout, success } = tool.run({ path: '/missing.txt', overwrite: true }, undefined, stderr); + for await (const _line of toLines(stdout)) { + // drain + } + + const expected = false; + const actual = success(); + expect(actual).toBe(expected); + }); + + it('yields the created path on success', async () => { + const fs = new MemoryFileSystem(); + const tool = createCreateFileToolV2(fs); + + const { stdout } = tool.run({ path: '/a.txt' }, undefined, []); + const out: string[] = []; + for await (const line of toLines(stdout)) { + out.push(line); + } + + const expected = ['created: /a.txt']; + const actual = out; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Delete.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Delete.spec.ts new file mode 100644 index 00000000..6a223adf --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Delete.spec.ts @@ -0,0 +1,70 @@ +import { fromLines, lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createDeleteToolV2 } from '../../src/Orchestrate/tools/Delete.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +describe('Delete tool', () => { + it('is fs.delete tier', () => { + const tool = createDeleteToolV2(new MemoryFileSystem()); + + const expected = 'fs.delete'; + const actual = tool.operation; + expect(actual).toBe(expected); + }); + + it('deletes every file named in files', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'x', '/b.txt': 'x' }); + const tool = createDeleteToolV2(fs); + + const { stdout, success } = tool.run({ files: ['/a.txt', '/b.txt'] }, undefined, []); + const out = await drain(stdout); + + expect(success()).toBe(true); + expect(out.sort()).toEqual(['deleted: /a.txt', 'deleted: /b.txt']); + }); + + it('yields nothing and reports success when the file list is empty', async () => { + const tool = createDeleteToolV2(new MemoryFileSystem()); + + const { stdout, success } = tool.run({ files: [] }, undefined, []); + const out = await drain(stdout); + + expect(out).toEqual([]); + expect(success()).toBe(true); + }); + + it('ignores anything piped in \u2014 files must be fed via Xargs into its own field, never an implicit upstream read', async () => { + const fs = new MemoryFileSystem({ '/direct.txt': 'x', '/piped.txt': 'x' }); + const tool = createDeleteToolV2(fs); + + async function* upstream(): AsyncGenerator { + yield '/piped.txt'; + } + + const { stdout } = tool.run({ files: ['/direct.txt'] }, fromLines(upstream()), []); + await drain(stdout); + + expect(await fs.exists('/direct.txt')).toBe(false); + expect(await fs.exists('/piped.txt')).toBe(true); + }); + + it('reports failure and a stderr message for a path that does not exist', async () => { + const fs = new MemoryFileSystem(); + const tool = createDeleteToolV2(fs); + const stderr: string[] = []; + + const { stdout, success } = tool.run({ files: ['/missing.txt'] }, undefined, stderr); + await drain(stdout); + + expect(success()).toBe(false); + expect(stderr).toEqual(['/missing.txt: Path not found']); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/EditFile.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/EditFile.spec.ts new file mode 100644 index 00000000..0593f465 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/EditFile.spec.ts @@ -0,0 +1,119 @@ +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createEditFileToolV2 } from '../../src/Orchestrate/tools/EditFile.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +describe('EditFile tool', () => { + it('is fs.write tier', () => { + const tool = createEditFileToolV2(new MemoryFileSystem()); + + const expected = 'fs.write'; + const actual = tool.operation; + expect(actual).toBe(expected); + }); + + it('writes the edited content to disk', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'one\ntwo\nthree' }); + const tool = createEditFileToolV2(fs); + + const { stdout } = tool.run({ file: '/a.ts', lineEdits: [{ action: 'replace', startLine: 2, endLine: 2, content: 'TWO' }], textEdits: [] }, undefined, []); + await drain(stdout); + + const expected = 'one\nTWO\nthree'; + const actual = await fs.readFile('/a.ts'); + expect(actual).toBe(expected); + }); + + it('yields a line-numbered diff, one line at a time', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'one\ntwo\nthree' }); + const tool = createEditFileToolV2(fs); + + const { stdout } = tool.run({ file: '/a.ts', lineEdits: [{ action: 'replace', startLine: 2, endLine: 2, content: 'TWO' }], textEdits: [] }, undefined, []); + const actual = await drain(stdout); + + const expected = true; + expect(actual.some((line) => line.includes('+2:TWO'))).toBe(expected); + }); + + it('applies a textEdits replace_text', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'const x = 1;' }); + const tool = createEditFileToolV2(fs); + + const { stdout } = tool.run({ file: '/a.ts', lineEdits: [], textEdits: [{ action: 'replace_text', oldString: 'const x', replacement: 'let x', replaceMultiple: false }] }, undefined, []); + await drain(stdout); + + const expected = 'let x = 1;'; + const actual = await fs.readFile('/a.ts'); + expect(actual).toBe(expected); + }); + + it('applies lineEdits before textEdits', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'oldCall()\nkeep' }); + const tool = createEditFileToolV2(fs); + + const { stdout } = tool.run( + { + file: '/a.ts', + lineEdits: [{ action: 'insert', after_line: -1, content: 'function helper() {}' }], + textEdits: [{ action: 'replace_text', oldString: 'oldCall()', replacement: 'helper()', replaceMultiple: false }], + }, + undefined, + [], + ); + await drain(stdout); + + const expected = 'helper()\nkeep\nfunction helper() {}'; + const actual = await fs.readFile('/a.ts'); + expect(actual).toBe(expected); + }); + + it('rejects the stream with the real error when a line edit is out of bounds, reusing V1\u2019s own validation', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'one\ntwo' }); + const tool = createEditFileToolV2(fs); + + const { stdout } = tool.run({ file: '/a.ts', lineEdits: [{ action: 'delete', startLine: 5, endLine: 5 }], textEdits: [] }, undefined, []); + + await expect(drain(stdout)).rejects.toThrow('out of bounds'); + }); + + it('rejects the stream when a replace_text string is not found', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'foo' }); + const tool = createEditFileToolV2(fs); + + const { stdout } = tool.run({ file: '/a.ts', lineEdits: [], textEdits: [{ action: 'replace_text', oldString: 'missing', replacement: 'x', replaceMultiple: false }] }, undefined, []); + + await expect(drain(stdout)).rejects.toThrow('not found'); + }); + + it('does not write to disk when an edit throws', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'foo' }); + const tool = createEditFileToolV2(fs); + + const { stdout } = tool.run({ file: '/a.ts', lineEdits: [], textEdits: [{ action: 'replace_text', oldString: 'missing', replacement: 'x', replaceMultiple: false }] }, undefined, []); + await drain(stdout).catch(() => {}); + + const expected = 'foo'; + const actual = await fs.readFile('/a.ts'); + expect(actual).toBe(expected); + }); + + it('reports success when the edit applies cleanly', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'one' }); + const tool = createEditFileToolV2(fs); + + const { stdout, success } = tool.run({ file: '/a.ts', lineEdits: [{ action: 'replace', startLine: 1, endLine: 1, content: 'ONE' }], textEdits: [] }, undefined, []); + await drain(stdout); + + const expected = true; + const actual = success(); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Find.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Find.spec.ts new file mode 100644 index 00000000..0c741219 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Find.spec.ts @@ -0,0 +1,247 @@ +import { channel, type Ended } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createFindTool } from '../../src/Orchestrate/tools/Find.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +// A source: nothing above it, so it has three of the four ports. It writes a path at a time, says +// what it could not do, and answers for how the walk went. What tells it to stop is its reader +// going away, which it learns from a write that was not accepted. There is no process here to take +// a signal, so an unaccepted write is the whole of it. + +/** Counts the directories actually read, so a walk that stopped early is proven by absence. One + * named as unreadable throws when read, the way a directory without permission on it does. */ +class CountingFileSystem extends MemoryFileSystem { + public readdirCalls: string[] = []; + public readonly unreadable = new Set(); + public override async readdir(path: string) { + this.readdirCalls.push(path); + if (this.unreadable.has(path)) { + const err = new Error(`EACCES: permission denied, scandir '${path}'`) as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return super.readdir(path); + } +} + +type Ran = { + output: string; + said: string[]; + ended: Ended; + readdirCalls: string[]; +}; + +type Given = { + /** What is on disk. */ + files?: Record; + /** How far it may run ahead of whoever reads it. */ + ahead?: number; + /** Whoever is reading walks away before the walk is finished. */ + readerLeaves?: boolean; + /** Directories that cannot be read at all. */ + unreadable?: string[]; +}; + +/** Walks once and reports everything observable about it. */ +async function ran(input: Record, given: Given = {}): Promise { + const fs = new CountingFileSystem(given.files ?? {}); + for (const path of given.unreadable ?? []) { + fs.unreadable.add(path); + } + const tool = createFindTool(fs); + const out = channel(given.ahead ?? 64 * 1024); + const said: string[] = []; + + const running = tool.run( + input, + undefined, + out, + (line) => void said.push(line), + () => {}, + ); + + const chunks: Buffer[] = []; + if (given.readerLeaves === true) { + out.close(); + } else { + for (let chunk = await out.read(); chunk != null; chunk = await out.read()) { + chunks.push(chunk); + } + } + await running.stop(); + return { output: Buffer.concat(chunks).toString('utf8'), said, ended: running.ended(), readdirCalls: fs.readdirCalls }; +} + +describe('what it writes', () => { + it('writes a path at a time, one to a line', async () => { + const { output } = await ran({ path: '/root' }, { files: { '/root/a.ts': 'x', '/root/b.ts': 'x' } }); + + const expected = '/root/a.ts\n/root/b.ts\n'; + const actual = output; + expect(actual).toBe(expected); + }); + + it('writes nothing at all when it found nothing', async () => { + const { output } = await ran({ path: '/root', pattern: '\\.rs$' }, { files: { '/root/a.ts': 'x' } }); + + const expected = ''; + const actual = output; + expect(actual).toBe(expected); + }); + + // A path may contain a space, which is why the separator is the newline and nothing else. Whoever + // reads this back splits on the same byte, and on that byte alone. + it('writes a path with a space in it as one line', async () => { + const { output } = await ran({ path: '/root' }, { files: { '/root/two words.ts': 'x' } }); + + const expected = '/root/two words.ts\n'; + const actual = output; + expect(actual).toBe(expected); + }); +}); + +describe('what it looks for', () => { + it('finds only what matches the pattern it was given', async () => { + const { output } = await ran({ path: '/root', pattern: '\\.ts$' }, { files: { '/root/a.ts': 'x', '/root/b.md': 'x' } }); + + const expected = '/root/a.ts\n'; + const actual = output; + expect(actual).toBe(expected); + }); + + it('finds directories when it was asked for directories', async () => { + const { output } = await ran({ path: '/root', type: 'directory' }, { files: { '/root/src/a.ts': 'x' } }); + + const expected = '/root/src\n'; + const actual = output; + expect(actual).toBe(expected); + }); + + it('finds nothing under a directory it was told to leave out', async () => { + const { output } = await ran({ path: '/root', exclude: ['skip'] }, { files: { '/root/a.ts': 'x', '/root/skip/b.ts': 'x' } }); + + const expected = '/root/a.ts\n'; + const actual = output; + expect(actual).toBe(expected); + }); + + it('finds nothing deeper than it was told to go', async () => { + const { output } = await ran({ path: '/root', maxDepth: 1 }, { files: { '/root/a.ts': 'x', '/root/deep/b.ts': 'x' } }); + + const expected = '/root/a.ts\n'; + const actual = output; + expect(actual).toBe(expected); + }); +}); + +// The whole reason the walk is lazy. A reader that has gone stops the work, rather than the work +// finishing into a channel nobody is reading. +describe('a reader that goes away', () => { + it('stops the walk where it stood, rather than reading the rest of the tree', async () => { + const files = { '/root/dir1/a.ts': 'x', '/root/dir2/b.ts': 'x', '/root/dir3/c.ts': 'x' }; + + const { readdirCalls } = await ran({ path: '/root' }, { files, readerLeaves: true }); + + const expected = ['/root', '/root/dir1']; + const actual = readdirCalls; + expect(actual).toEqual(expected); + }); + + // Being told to stop is not the walk going wrong. A shell says the same thing: the producer of + // `find | head` is killed, and nobody calls that a failed command. + it('is not reported as a failure', async () => { + const files = { '/root/dir1/a.ts': 'x', '/root/dir2/b.ts': 'x' }; + + const { ended } = await ran({ path: '/root' }, { files, readerLeaves: true }); + + const expected = { kind: 'finished' }; + const actual = ended; + expect(actual).toEqual(expected); + }); +}); + +describe('how the walk went', () => { + it('is finished when it reached the end of the tree', async () => { + const { ended } = await ran({ path: '/root' }, { files: { '/root/a.ts': 'x' } }); + + const expected = { kind: 'finished' }; + const actual = ended; + expect(actual).toEqual(expected); + }); + + // `find` itself exits 1 when it could not read what it was pointed at. + it('is a failure when the directory it was given cannot be read', async () => { + const { ended } = await ran({ path: '/missing' }); + + const expected = { kind: 'failed', code: 1 }; + const actual = ended; + expect(actual).toEqual(expected); + }); + + it('says why when the directory it was given cannot be read', async () => { + const { said } = await ran({ path: '/missing' }); + + const expected = ["ENOENT: no such file or directory, scandir '/missing'"]; + const actual = said; + expect(actual).toEqual(expected); + }); +}); + +// A directory that cannot be read leaves the answer incomplete, and an incomplete answer that says +// nothing is how a search for a file that does exist comes back empty and is believed. +describe('a directory it cannot enter', () => { + it('still hands back everything it could reach', async () => { + const files = { '/root/a.ts': 'x', '/root/locked/b.ts': 'x' }; + + const { output } = await ran({ path: '/root' }, { files, unreadable: ['/root/locked'] }); + + const expected = '/root/a.ts\n'; + const actual = output; + expect(actual).toBe(expected); + }); + + // A count and not a list, because what a stage says is bounded and lines past the bound are + // dropped where nobody sees them. A tree with three hundred unreadable directories would lose + // both the names and the number; a count survives whole, and the number is the part that is + // acted on. + it('says how many it could not read', async () => { + const files = { '/root/locked1/a.ts': 'x', '/root/locked2/b.ts': 'x' }; + + const { said } = await ran({ path: '/root' }, { files, unreadable: ['/root/locked1', '/root/locked2'] }); + + const expected = ['2 directories could not be read']; + const actual = said; + expect(actual).toEqual(expected); + }); + + it('says a single one as one directory', async () => { + const files = { '/root/locked/a.ts': 'x' }; + + const { said } = await ran({ path: '/root' }, { files, unreadable: ['/root/locked'] }); + + const expected = ['1 directory could not be read']; + const actual = said; + expect(actual).toEqual(expected); + }); + + // The answer is qualified, not wrong. The walk did everything it was allowed to do, and calling + // that a failure sends whoever reads the report looking for a defect that is not there, while + // stopping anything joined to it by &&. + it('is not a failure', async () => { + const files = { '/root/locked/a.ts': 'x' }; + + const { ended } = await ran({ path: '/root' }, { files, unreadable: ['/root/locked'] }); + + const expected = { kind: 'finished' }; + const actual = ended; + expect(actual).toEqual(expected); + }); + + it('says nothing at all when every directory could be read', async () => { + const { said } = await ran({ path: '/root' }, { files: { '/root/a.ts': 'x' } }); + + const expected: string[] = []; + const actual = said; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/GitHub.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/GitHub.spec.ts new file mode 100644 index 00000000..01b77ab3 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/GitHub.spec.ts @@ -0,0 +1,44 @@ +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createGhPrToolsV2 } from '../../src/Orchestrate/tools/GitHub.js'; +import { FakeExecutor } from '../FakeExecutor.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +describe('GitHub V2', () => { + it('is registered as escalate — always gated, never a pre-trustable fs.* tier', () => { + const [Create] = createGhPrToolsV2({ executor: new FakeExecutor(), getHolderToken: () => 'token' }); + + expect(Create.operation).toBe('escalate'); + }); + + it('runs the Create tool subcommand with the built args and the holder token env', async () => { + const executor = new FakeExecutor(() => ({ stdout: 'https://github.com/x/y/pull/1\n', exitCode: 0 })); + const [Create] = createGhPrToolsV2({ executor, getHolderToken: () => 'holder-token' }); + + const result = Create.run({ title: 'Fix it', body: 'Body', base: 'main', cwd: '/repo' }, undefined, []); + const lines = await drain(result.stdout); + + expect(executor.calls[0]).toEqual({ program: 'gh', args: ['pr', 'create', '--title', 'Fix it', '--body', 'Body', '--base', 'main', '--draft'], cwd: '/repo', env: expect.objectContaining({ GH_TOKEN: 'holder-token' }) }); + expect(lines).toEqual(['https://github.com/x/y/pull/1']); + expect(result.success()).toBe(true); + }); + + it('reports failure when gh exits non-zero', async () => { + const executor = new FakeExecutor(() => ({ stderr: 'not found', exitCode: 1 })); + const [Create] = createGhPrToolsV2({ executor, getHolderToken: () => 'token' }); + + const stderr: string[] = []; + const result = Create.run({ title: 'x', body: 'y', base: 'main' }, undefined, stderr); + await drain(result.stdout); + + expect(result.success()).toBe(false); + expect(stderr).toEqual(['not found']); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Head.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Head.spec.ts new file mode 100644 index 00000000..fdf00f63 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Head.spec.ts @@ -0,0 +1,48 @@ +import { fromLines, lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createHeadToolV2 } from '../../src/Orchestrate/tools/Head.js'; + +describe('Head tool', () => { + it('yields only the first N items', async () => { + async function* source(): AsyncGenerator { + yield 'a'; + yield 'b'; + yield 'c'; + } + const tool = createHeadToolV2(); + const { stdout } = tool.run({ count: 2 }, fromLines(source()), []); + + const out: string[] = []; + for await (const value of toLines(stdout)) { + out.push(String(value)); + } + + const expected = ['a', 'b']; + const actual = out; + expect(actual).toEqual(expected); + }); + + it('stops an unbounded upstream once it has what it asked for', async () => { + let pulls = 0; + async function* infinite(): AsyncGenerator { + while (true) { + pulls++; + yield `line${pulls}`; + } + } + + const tool = createHeadToolV2(); + const { stdout } = tool.run({ count: 3 }, fromLines(infinite()), []); + + const out: string[] = []; + for await (const value of toLines(stdout)) { + out.push(String(value)); + } + + // Stopped, not drained. Not an exact count: the medium between stages is bytes, so a producer + // fills a chunk ahead of its reader the way it would against a real pipe. + const expected = true; + const actual = pulls > 0 && pulls <= 5; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/History.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/History.spec.ts new file mode 100644 index 00000000..1cec2d26 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/History.spec.ts @@ -0,0 +1,55 @@ +import { Clock } from '@js-joda/core'; +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createReadHistoryToolV2 } from '../../src/Orchestrate/tools/ReadHistory.js'; +import { createSearchHistoryToolV2 } from '../../src/Orchestrate/tools/SearchHistory.js'; +import { RecordingHistoryReader } from '../RecordingHistoryReader.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +describe('SearchHistory V2', () => { + it('excludes the current session unless includeCurrentSession is set', async () => { + const reader = new RecordingHistoryReader(); + const tool = createSearchHistoryToolV2(reader, () => 'session-1', Clock.systemUTC()); + + await drain(tool.run({ query: 'q', limit: 10, includeCurrentSession: false }, undefined, []).stdout); + + expect(reader.searchArg?.excludeConversationId).toBe('session-1'); + }); + + it('includes the current session when requested', async () => { + const reader = new RecordingHistoryReader(); + const tool = createSearchHistoryToolV2(reader, () => 'session-1', Clock.systemUTC()); + + await drain(tool.run({ query: 'q', limit: 10, includeCurrentSession: true }, undefined, []).stdout); + + expect(reader.searchArg?.excludeConversationId).toBeUndefined(); + }); + + it('yields a count line followed by one line per hit', async () => { + const reader = new RecordingHistoryReader(); + reader.searchResult = [{ conversationId: 's', turnId: 't', timestamp: 'now', role: 'user', type: 'text', snippet: 'hi', score: 1 }]; + const tool = createSearchHistoryToolV2(reader, () => 'session-1', Clock.systemUTC()); + + const lines = await drain(tool.run({ query: 'q', limit: 10, includeCurrentSession: false }, undefined, []).stdout); + + expect(lines[0]).toBe('1 hit(s)'); + }); +}); + +describe('ReadHistory V2', () => { + it('maps session/turnId citations to the reader request', async () => { + const reader = new RecordingHistoryReader(); + const tool = createReadHistoryToolV2(reader); + + await drain(tool.run({ citations: [{ session: 's1', turnId: 't1' }], window: 3 }, undefined, []).stdout); + + expect(reader.readArg).toEqual({ citations: [{ conversationId: 's1', turnId: 't1' }], window: 3 }); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Match.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Match.spec.ts new file mode 100644 index 00000000..38191cd7 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Match.spec.ts @@ -0,0 +1,113 @@ +import { fromLines, lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createMatchToolV2 } from '../../src/Orchestrate/tools/Match.js'; + +/** What a stage upstream of this one would hand it: bytes. */ +function streamOf(values: string[]) { + return fromLines(values); +} + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +describe('Match tool — uniform matching, no kind branch', () => { + it('matches against paths exactly the same way it matches against content, unaware of provenance', async () => { + const tool = createMatchToolV2(); + const { stdout } = tool.run({ pattern: 'TODO' }, streamOf(['src/TODO.txt', 'src/other.txt']), []); + + const expected = ['src/TODO.txt']; + const actual = await drain(stdout); + expect(actual).toEqual(expected); + }); + + it('is case insensitive when asked', async () => { + const tool = createMatchToolV2(); + const { stdout } = tool.run({ pattern: 'todo', caseInsensitive: true }, streamOf(['TODO', 'nope']), []); + + const expected = ['TODO']; + const actual = await drain(stdout); + expect(actual).toEqual(expected); + }); + + it('yields nothing when there is no upstream at all', async () => { + const tool = createMatchToolV2(); + const { stdout } = tool.run({ pattern: 'x' }, undefined, []); + + const expected: string[] = []; + const actual = await drain(stdout); + expect(actual).toEqual(expected); + }); +}); + +describe('Match tool — before/after context', () => { + it('includes the requested number of lines before a match', async () => { + const tool = createMatchToolV2(); + const { stdout } = tool.run({ pattern: 'MATCH', before: 1 }, streamOf(['a', 'b', 'MATCH', 'c']), []); + + const expected = ['b', 'MATCH']; + const actual = await drain(stdout); + expect(actual).toEqual(expected); + }); + + it('includes the requested number of lines after a match', async () => { + const tool = createMatchToolV2(); + const { stdout } = tool.run({ pattern: 'MATCH', after: 1 }, streamOf(['a', 'MATCH', 'b', 'c']), []); + + const expected = ['MATCH', 'b']; + const actual = await drain(stdout); + expect(actual).toEqual(expected); + }); + + it('does not duplicate a line shared by two overlapping match windows', async () => { + const tool = createMatchToolV2(); + // MATCH at index 1 (after=2 covers indices 1-3), MATCH at index 3 (before=2 covers 1-3) — index + // 2 and 3 are shared by both windows; each line must still appear exactly once, in order. + const { stdout } = tool.run({ pattern: 'MATCH', before: 2, after: 2 }, streamOf(['x', 'MATCH', 'y', 'MATCH', 'z']), []); + + const expected = ['x', 'MATCH', 'y', 'MATCH', 'z']; + const actual = await drain(stdout); + expect(actual).toEqual(expected); + }); +}); + +describe('Match tool — laziness', () => { + it('does not pull the whole upstream when the caller stops early', async () => { + const pulled: string[] = []; + async function* infinite(): AsyncGenerator { + let i = 0; + try { + while (true) { + pulled.push(`line${i}`); + yield `line${i}`; + i++; + } + } finally { + pulled.push('cleaned-up'); + } + } + + const tool = createMatchToolV2(); + const { stdout } = tool.run({ pattern: 'line' }, fromLines(infinite()), []); + + // One line taken, then the reader walks away: closing the stream is what reaches back to the + // stage feeding it. + const reader = toLines(stdout); + const first = await reader.next(); + stdout.destroy(); + await reader.return(undefined); + // Closing travels back through the chain: this stage's stream, the splitter reading it, and the + // stage feeding that. Each hop is a turn of the loop. + for (let turn = 0; turn < 10; turn++) { + await new Promise((resolve) => setImmediate(resolve)); + } + + const expected = true; + const actual = !first.done && pulled.includes('cleaned-up'); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Memory.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Memory.spec.ts new file mode 100644 index 00000000..4f912f40 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Memory.spec.ts @@ -0,0 +1,101 @@ +import type { MemoryEntry } from '@shellicar/claude-core/memory/types'; +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createDeleteMemoryToolV2 } from '../../src/Orchestrate/tools/DeleteMemory.js'; +import { createMemoryTypesToolV2 } from '../../src/Orchestrate/tools/MemoryTypes.js'; +import { createReadMemoryToolV2 } from '../../src/Orchestrate/tools/ReadMemory.js'; +import { createSearchMemoryToolV2 } from '../../src/Orchestrate/tools/SearchMemory.js'; +import { createWriteMemoryToolV2 } from '../../src/Orchestrate/tools/WriteMemory.js'; +import { RecordingMemoryStore } from '../RecordingMemoryStore.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +const ENTRY: MemoryEntry = { id: 'id-1', title: 't', body: 'b', type: 'trap', keywords: [], environment: {}, createdAt: 'now' }; + +describe('WriteMemory V2', () => { + it('calls store.write with the given fields', async () => { + const store = new RecordingMemoryStore(); + const tool = createWriteMemoryToolV2(store); + + await drain(tool.run({ title: 't', body: 'b', type: 'trap', keywords: [], intent: 'x' }, undefined, []).stdout); + + expect(store.writeArg).toEqual({ title: 't', body: 'b', type: 'trap', keywords: [] }); + }); + + it('reports success', () => { + const tool = createWriteMemoryToolV2(new RecordingMemoryStore()); + const { success } = tool.run({ title: 't', body: 'b', type: 'trap', keywords: [], intent: 'x' }, undefined, []); + expect(success()).toBe(true); + }); +}); + +describe('ReadMemory V2', () => { + it('reports found: false for an unknown id', async () => { + const store = new RecordingMemoryStore(); + const tool = createReadMemoryToolV2(store); + + const lines = await drain(tool.run({ id: 'missing', intent: 'x' }, undefined, []).stdout); + + expect(JSON.parse(lines.join('\n'))).toEqual({ found: false, id: 'missing' }); + }); + + it('reports the entry for a known id', async () => { + const store = new RecordingMemoryStore(); + store.readResult = ENTRY; + const tool = createReadMemoryToolV2(store); + + const lines = await drain(tool.run({ id: 'id-1', intent: 'x' }, undefined, []).stdout); + + expect(JSON.parse(lines.join('\n'))).toEqual({ found: true, memory: ENTRY }); + }); +}); + +describe('SearchMemory V2', () => { + it('passes query/type/limit to the store', async () => { + const store = new RecordingMemoryStore(); + const tool = createSearchMemoryToolV2(store); + + await drain(tool.run({ query: 'sqlite', type: 'trap', limit: 5, intent: 'x' }, undefined, []).stdout); + + expect(store.searchArg).toEqual({ query: 'sqlite', type: 'trap', limit: 5 }); + }); + + it('yields one line per hit after a count line', async () => { + const store = new RecordingMemoryStore(); + store.searchResult = [{ ...ENTRY, score: 0.9 }]; + const tool = createSearchMemoryToolV2(store); + + const lines = await drain(tool.run({ query: 'sqlite', limit: 10, intent: 'x' }, undefined, []).stdout); + + expect(lines[0]).toBe('1 result(s)'); + }); +}); + +describe('DeleteMemory V2', () => { + it('calls store.delete with the id', async () => { + const store = new RecordingMemoryStore(); + const tool = createDeleteMemoryToolV2(store); + + await drain(tool.run({ id: 'id-1', intent: 'x' }, undefined, []).stdout); + + expect(store.deleteArg).toBe('id-1'); + }); +}); + +describe('MemoryTypes V2', () => { + it('yields one line per type', async () => { + const store = new RecordingMemoryStore(); + store.typesResult = [{ type: 'trap', count: 3 }]; + const tool = createMemoryTypesToolV2(store); + + const lines = await drain(tool.run({}, undefined, []).stdout); + + expect(lines).toEqual(['trap: 3']); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/OrchestrateEngine.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/OrchestrateEngine.spec.ts new file mode 100644 index 00000000..e4236111 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/OrchestrateEngine.spec.ts @@ -0,0 +1,307 @@ +import { Clock } from '@js-joda/core'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { ApprovalCoordinator, ISdkMessagePublisher } from '@shellicar/claude-sdk'; +import { createServiceCollection } from '@shellicar/core-di'; +import { describe, expect, it } from 'vitest'; +import { OrchestrateEngine } from '../../src/Orchestrate/OrchestrateEngine.js'; +import { createToolsV2Registry } from '../../src/Orchestrate/registry.js'; +import { PolicyStore } from '../../src/Policy/PolicyStore.js'; +import { RefStore } from '../../src/RefStore/RefStore.js'; +import { FakeExecutor } from '../FakeExecutor.js'; +import { fakeEscalatedRegistryDeps } from '../fakeEscalatedRegistryDeps.js'; +import { passthroughSips } from '../helpers.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; +import { MemoryObjectStore } from '../MemoryObjectStore.js'; +import { RecordingHistoryReader } from '../RecordingHistoryReader.js'; +import { RecordingMemoryStore } from '../RecordingMemoryStore.js'; + +class NoopLogger extends ILogger { + public trace(): void {} + public debug(): void {} + public info(): void {} + public warn(): void {} + public error(): void {} +} + +class NoopPublisher extends ISdkMessagePublisher { + public send(): void {} + public close(): void {} + public async drain(): Promise {} +} + +class RecordingPublisher extends ISdkMessagePublisher { + public readonly messages: Parameters[0][] = []; + public send(msg: Parameters[0]): void { + this.messages.push(msg); + } + public close(): void {} + public async drain(): Promise {} +} + +function makeEngineWithApproval(rules: ConstructorParameters[0] = [{ default: 'ask' }]) { + const fs = new MemoryFileSystem({ '/root/a.txt': 'x' }); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: new RefStore(new MemoryObjectStore()), + sips: passthroughSips, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + const policyStore = new PolicyStore(rules, registry); + const provider = createServiceCollection().buildProvider(); + const approval = new ApprovalCoordinator(); + const publisher = new RecordingPublisher(); + const engine = new OrchestrateEngine(registry, policyStore, new NoopLogger(), provider, approval, publisher, fs, Clock.systemUTC()); + return { engine, approval, publisher }; +} + +function makeEngine() { + const fs = new MemoryFileSystem({ '/root/a.txt': 'x' }); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: new RefStore(new MemoryObjectStore()), + sips: passthroughSips, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + // No requestApproval is passed by these tests, so an 'ask' verdict auto-approves (matching + // the existing "no human-ask configured" contract) — these tests are about owns()/outcome + // mapping, not policy specifics. + const policyStore = new PolicyStore([{ default: 'ask' }], registry); + const provider = createServiceCollection().buildProvider(); + return new OrchestrateEngine(registry, policyStore, new NoopLogger(), provider, new ApprovalCoordinator(), new NoopPublisher(), fs, Clock.systemUTC()); +} + +describe('OrchestrateEngine.owns', () => { + it('owns Orchestrate itself', () => { + const engine = makeEngine(); + + const expected = true; + const actual = engine.owns('Orchestrate'); + expect(actual).toBe(expected); + }); + + it('owns every individually registered tool', () => { + const engine = makeEngine(); + + const expected = true; + const actual = engine.owns('Find'); + expect(actual).toBe(expected); + }); + + it('does not own a name outside the registry', () => { + const engine = makeEngine(); + + const expected = false; + const actual = engine.owns('DeleteFile'); + expect(actual).toBe(expected); + }); +}); + +describe('OrchestrateEngine.runBatch — one call', () => { + it('maps a successful call onto an ok ToolOutcome', async () => { + const engine = makeEngine(); + + const outcomes = await engine.runBatch([{ id: 'tu_1', name: 'Find', input: { path: '/root' } }], false); + + const expected = 'ok'; + const actual = outcomes.get('tu_1')?.kind; + expect(actual).toBe(expected); + }); + + it('maps a failed call onto a failed ToolOutcome', async () => { + const engine = makeEngine(); + + const outcomes = await engine.runBatch([{ id: 'tu_1', name: 'Find', input: { path: '/missing' } }], false); + + const expected = 'failed'; + const actual = outcomes.get('tu_1')?.kind; + expect(actual).toBe(expected); + }); +}); + +describe('OrchestrateEngine.runBatch', () => { + it("maps each item's outcome back onto its own id", async () => { + const engine = makeEngine(); + + const outcomes = await engine.runBatch([{ id: 'tu_1', name: 'Find', input: { path: '/root' } }], false); + + const expected = 'ok'; + const actual = outcomes.get('tu_1')?.kind; + expect(actual).toBe(expected); + }); + + it('auto-approves without asking when requireApproval is false', async () => { + const { engine, publisher } = makeEngineWithApproval(); + + await engine.runBatch([{ id: 'tu_1', name: 'Find', input: { path: '/root' } }], false); + + const expected = 0; + const actual = publisher.messages.filter((m) => m.type === 'tool_approval_request').length; + expect(actual).toBe(expected); + }); + + it('sends a tool_approval_request naming the gated stage and its resolved input when requireApproval is true', async () => { + const { engine, approval, publisher } = makeEngineWithApproval(); + + const runPromise = engine.runBatch([{ id: 'tu_1', name: 'Find', input: { path: '/root' } }], true); + await new Promise((resolve) => setImmediate(resolve)); + const request = publisher.messages.find((m) => m.type === 'tool_approval_request'); + if (request?.type !== 'tool_approval_request') { + throw new Error('unreachable'); + } + approval.handle({ type: 'tool_approval_response', requestId: request.requestId, approved: true }); + await runPromise; + + const expected = { name: 'Find', input: { path: '/root' } }; + const actual = { name: request.name, input: request.input }; + expect(actual).toEqual(expected); + }); + + it('keys the requestId as toolUseId:stageIndex', async () => { + const { engine, approval, publisher } = makeEngineWithApproval(); + + const runPromise = engine.runBatch([{ id: 'tu_1', name: 'Find', input: { path: '/root' } }], true); + await new Promise((resolve) => setImmediate(resolve)); + const request = publisher.messages.find((m) => m.type === 'tool_approval_request'); + if (request?.type !== 'tool_approval_request') { + throw new Error('unreachable'); + } + approval.handle({ type: 'tool_approval_response', requestId: request.requestId, approved: true }); + await runPromise; + + const expected = 'tu_1:0'; + const actual = request.requestId; + expect(actual).toBe(expected); + }); + + it('resolves to a failed outcome when the human approval is rejected', async () => { + const { engine, approval, publisher } = makeEngineWithApproval(); + + const runPromise = engine.runBatch([{ id: 'tu_1', name: 'Find', input: { path: '/root' } }], true); + await new Promise((resolve) => setImmediate(resolve)); + const request = publisher.messages.find((m) => m.type === 'tool_approval_request'); + if (request?.type !== 'tool_approval_request') { + throw new Error('unreachable'); + } + approval.handle({ type: 'tool_approval_response', requestId: request.requestId, approved: false }); + const outcomes = await runPromise; + + const expected = 'failed'; + const actual = outcomes.get('tu_1')?.kind; + expect(actual).toBe(expected); + }); + + it('never asks when the coordinator is already cancelled', async () => { + const { engine, approval, publisher } = makeEngineWithApproval(); + approval.handle({ type: 'cancel' }); + + await engine.runBatch([{ id: 'tu_1', name: 'Find', input: { path: '/root' } }], true); + + const expected = 0; + const actual = publisher.messages.filter((m) => m.type === 'tool_approval_request').length; + expect(actual).toBe(expected); + }); + + it('omits a stage position for a direct single-tool call', async () => { + const { engine, approval, publisher } = makeEngineWithApproval(); + + const runPromise = engine.runBatch([{ id: 'tu_1', name: 'Find', input: { path: '/root' } }], true); + await new Promise((resolve) => setImmediate(resolve)); + const request = publisher.messages.find((m) => m.type === 'tool_approval_request'); + if (request?.type !== 'tool_approval_request') { + throw new Error('unreachable'); + } + approval.handle({ type: 'tool_approval_response', requestId: request.requestId, approved: true }); + await runPromise; + + const expected = { stageIndex: 1, stageCount: 1 }; + const actual = { stageIndex: request.stageIndex, stageCount: request.stageCount }; + expect(actual).toEqual(expected); + }); + + it('labels each gated stage of a multi-stage Orchestrate call with its position and the pipeline length', async () => { + const { engine, approval, publisher } = makeEngineWithApproval(); + + const runPromise = engine.runBatch( + [ + { + id: 'tu_1', + name: 'Orchestrate', + input: { + stages: [ + { tool: 'Find', input: { path: '/root' }, op: '|' }, + { tool: 'Head', input: { count: 1 } }, + ], + }, + }, + ], + true, + ); + // Every stage is asked about, so every request has to be answered or the run never finishes. + const answered = new Set(); + for (let turn = 0; turn < 10; turn++) { + await new Promise((resolve) => setImmediate(resolve)); + for (const message of publisher.messages) { + if (message.type === 'tool_approval_request' && !answered.has(message.requestId)) { + answered.add(message.requestId); + approval.handle({ type: 'tool_approval_response', requestId: message.requestId, approved: true }); + } + } + } + await runPromise; + + const request = publisher.messages.find((m) => m.type === 'tool_approval_request'); + if (request?.type !== 'tool_approval_request') { + throw new Error('unreachable'); + } + + const expected = { stageIndex: 1, stageCount: 2 }; + const actual = { stageIndex: request.stageIndex, stageCount: request.stageCount }; + expect(actual).toEqual(expected); + }); + + // The label answers "where in this pipeline am I?" — so both numbers count the same thing: + // every stage in the call, gated or not. Counting only the stages that happen to ask makes + // the 3rd step of a 3-step pipeline read as "1 of 3" purely because the first two were + // auto-allowed, which tells a human nothing about where the run actually is. + it('labels a gated stage with its real position in the pipeline, not its position among the stages that happened to ask', async () => { + const { engine, approval, publisher } = makeEngineWithApproval([{ operations: { 'fs.list': 'allow' }, default: 'ask' }]); + + const runPromise = engine.runBatch( + [ + { + id: 'tu_1', + name: 'Orchestrate', + input: { + stages: [{ tool: 'Find', input: { path: '/root' }, op: '|' }, { xargs: true }, { tool: 'Delete', input: {} }], + }, + }, + ], + true, + ); + await new Promise((resolve) => setImmediate(resolve)); + const request = publisher.messages.find((m) => m.type === 'tool_approval_request'); + if (request?.type !== 'tool_approval_request') { + throw new Error('unreachable'); + } + approval.handle({ type: 'tool_approval_response', requestId: request.requestId, approved: true }); + await runPromise; + + const expected = { name: 'Delete', stageIndex: 3, stageCount: 3 }; + const actual = { name: request.name, stageIndex: request.stageIndex, stageCount: request.stageCount }; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Paths.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Paths.spec.ts new file mode 100644 index 00000000..7b7f3e2f --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Paths.spec.ts @@ -0,0 +1,60 @@ +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createPathsToolV2 } from '../../src/Orchestrate/tools/Paths.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +describe('Paths tool', () => { + it('is fs.list tier \u2014 confirms existence, does not read file content', () => { + const tool = createPathsToolV2(new MemoryFileSystem()); + + const expected = 'fs.list'; + const actual = tool.operation; + expect(actual).toBe(expected); + }); + + it('yields each explicit path that exists', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'x', '/b.txt': 'x' }); + const tool = createPathsToolV2(fs); + const stderr: string[] = []; + + const { stdout } = tool.run({ paths: ['/a.txt', '/b.txt'] }, undefined, stderr); + const out: string[] = []; + for await (const path of toLines(stdout)) { + out.push(path); + } + + const expected = ['/a.txt', '/b.txt']; + const actual = out; + expect(actual).toEqual(expected); + }); + + it('reports failure when a named path does not exist', async () => { + const fs = new MemoryFileSystem(); + const tool = createPathsToolV2(fs); + const stderr: string[] = []; + + const { stdout, success } = tool.run({ paths: ['/missing.txt'] }, undefined, stderr); + for await (const _path of toLines(stdout)) { + // drain + } + + const expected = false; + const actual = success(); + expect(actual).toBe(expected); + }); + + it('records the error message on stderr when a named path does not exist', async () => { + const fs = new MemoryFileSystem(); + const tool = createPathsToolV2(fs); + const stderr: string[] = []; + + const { stdout } = tool.run({ paths: ['/missing.txt'] }, undefined, stderr); + for await (const _path of toLines(stdout)) { + // drain + } + + const expected = ['Path not found: /missing.txt']; + const actual = stderr; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Program.backpressure.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Program.backpressure.spec.ts new file mode 100644 index 00000000..4d679a38 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Program.backpressure.spec.ts @@ -0,0 +1,79 @@ +import { once } from 'node:events'; +import type { CommandSpec, ExitStatus, IExecutor, SpawnOpts } from '@shellicar/exec-core'; +import { lines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createProgramToolV2 } from '../../src/Orchestrate/tools/Program.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +const BUFFER_BYTES = 100; +const LINES_AVAILABLE = 1000; + +const env = { buildEnv: () => ({}) as NodeJS.ProcessEnv, get: () => undefined } as never; + +/** + * A process writing to a pipe: it keeps writing while there is room, and waits when there is not, + * exactly as `write(2)` blocks on a full pipe. `written` is how far it actually got, which is what + * says whether anything throttled it. + */ +class BlockingWriter implements IExecutor { + public written = 0; + + public async run(_cmd: CommandSpec, opts: SpawnOpts = {}): Promise { + const stdout = opts.stdout; + if (stdout == null) { + return { exitCode: 0, signal: null }; + } + for (let index = 0; index < LINES_AVAILABLE; index++) { + if (opts.signal?.aborted) { + break; + } + const room = stdout.write(`line ${index}\n`); + this.written++; + if (!room) { + // Waiting for room, or for the process to be told to stop. Either way the wait ends when the + // stream is torn down, and that ending is not an error to report: it is the reader leaving. + await Promise.race([once(stdout, 'drain').catch(() => undefined), once(opts.signal as AbortSignal, 'abort').catch(() => undefined)]); + } + } + opts.stdout?.end(); + opts.stderr?.end(); + return { exitCode: 0, signal: null }; + } +} + +async function takeLines(stream: AsyncIterable, count: number): Promise { + const taken: string[] = []; + for await (const line of lines(stream)) { + taken.push(line); + if (taken.length >= count) { + break; + } + } + return taken; +} + +// A pipe holds a fixed amount and then makes the writer wait, which is what keeps `seq | head -3` +// from producing a hundred thousand lines nobody reads. +describe('Program — a consumer that stops reading', () => { + it('still gets the lines it asked for when the buffer is smaller than the output', async () => { + const tool = createProgramToolV2(new BlockingWriter(), new MemoryFileSystem(), env, BUFFER_BYTES); + + const result = tool.run({ program: 'produce', cwd: '/' }, undefined, [], undefined, undefined, env); + + const expected = ['line 0', 'line 1', 'line 2']; + const actual = await takeLines(result.stdout, 3); + expect(actual).toEqual(expected); + }); + + it('leaves the writer waiting on a full buffer rather than letting it run to the end', async () => { + const writer = new BlockingWriter(); + const tool = createProgramToolV2(writer, new MemoryFileSystem(), env, BUFFER_BYTES); + + const result = tool.run({ program: 'produce', cwd: '/' }, undefined, [], undefined, undefined, env); + await takeLines(result.stdout, 3); + + const expected = true; + const actual = writer.written < LINES_AVAILABLE; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Program.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Program.spec.ts new file mode 100644 index 00000000..1f33bcbb --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Program.spec.ts @@ -0,0 +1,265 @@ +import type { CommandSpec } from '@shellicar/exec-core'; +import { channel, type Ended } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createProgramTool } from '../../src/Orchestrate/tools/Program.js'; +import { fakeEnvProvider } from '../fakeEnvProvider.js'; +import { FakeExecutor, type FakeResponse } from '../FakeExecutor.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +// The one tool with a real process behind it. Everything about that process reaches the run through +// the four ports and nowhere else: its output goes down, its stderr is captured, its exit is how +// the stage ended, and closing its output is how it is told to stop. + +type Ran = { + output: string; + said: string[]; + captured: string[]; + ended: Ended; + executor: FakeExecutor; + fs: MemoryFileSystem; +}; + +type Given = { + /** What the process would do. */ + response?: FakeResponse | ((cmd: CommandSpec, stdin: string) => FakeResponse); + /** What was piped into the stage. */ + upstream?: string; + /** The filesystem it writes any redirect to. */ + fs?: MemoryFileSystem; + /** A delay that has already elapsed, for a command with its own limit. */ + elapsed?: boolean; + /** Whoever is reading walks away before the process is finished. */ + readerLeaves?: boolean; +}; + +/** Runs one command and reports everything observable about it. */ +async function ran(input: Record, given: Given = {}): Promise { + const respond = typeof given.response === 'function' ? given.response : () => given.response ?? { exitCode: 0 }; + const executor = new FakeExecutor(respond); + const fs = given.fs ?? new MemoryFileSystem(); + const tool = createProgramTool(executor, fs, fakeEnvProvider({}), { sleep: given.elapsed === true ? async () => {} : () => new Promise(() => {}) }); + const out = channel(64 * 1024); + const said: string[] = []; + const captured: string[] = []; + const from = given.upstream == null ? undefined : readerOver(Buffer.from(given.upstream, 'utf8')); + + const running = tool.run({ cwd: '/', ...input }, from, out, (line, options) => void (options?.captured === true ? captured : said).push(line), () => {}); + + const chunks: Buffer[] = []; + if (given.readerLeaves === true) { + out.close(); + } else { + for (let chunk = await out.read(); chunk != null; chunk = await out.read()) { + chunks.push(chunk); + } + } + await running.stop(); + return { output: Buffer.concat(chunks).toString('utf8'), said, captured, ended: running.ended(), executor, fs }; +} + +function readerOver(bytes: Buffer) { + let taken = false; + return { + read: async () => { + if (taken) { + return undefined; + } + taken = true; + return bytes; + }, + }; +} + +describe('what a process writes', () => { + it('goes down, exactly as the process wrote it', async () => { + const { output } = await ran({ program: 'echo', args: ['hello'] }, { response: { stdout: 'hello\n', exitCode: 0 } }); + + const expected = 'hello\n'; + const actual = output; + expect(actual).toBe(expected); + }); + + it('goes down unchanged when it is not text', async () => { + const bytes = '\u0000\u00ff\u0080'; + const { output } = await ran({ program: 'cat' }, { response: { stdout: bytes, exitCode: 0 } }); + + const expected = Buffer.from(bytes, 'binary').toString('hex'); + const actual = Buffer.from(output, 'binary').toString('hex'); + expect(actual).toBe(expected); + }); + + it('goes down whole when it has no separator in it', async () => { + const { output } = await ran({ program: 'head' }, { response: { stdout: 'no separator at all', exitCode: 0 } }); + + const expected = 'no separator at all'; + const actual = output; + expect(actual).toBe(expected); + }); +}); + +describe('what a process writes to its stderr', () => { + it('is captured rather than said, so it is shown only when it is worth reading', async () => { + const { captured, said } = await ran({ program: 'curl' }, { response: { stderr: 'downloading: 10%\n', exitCode: 0 } }); + + const expected = { captured: ['downloading: 10%'], said: [] }; + const actual = { captured, said }; + expect(actual).toEqual(expected); + }); + + it('does not go down with the output', async () => { + const { output } = await ran({ program: 'curl' }, { response: { stdout: 'result\n', stderr: 'noise\n', exitCode: 0 } }); + + const expected = 'result\n'; + const actual = output; + expect(actual).toBe(expected); + }); +}); + +describe('how a process ended', () => { + it('is finished when it exited zero', async () => { + const { ended } = await ran({ program: 'true' }, { response: { exitCode: 0 } }); + + const expected = { kind: 'finished' }; + const actual = ended; + expect(actual).toEqual(expected); + }); + + it('is a failure carrying the exit code when it exited non-zero', async () => { + const { ended } = await ran({ program: 'false' }, { response: { exitCode: 3 } }); + + const expected = { kind: 'failed', code: 3 }; + const actual = ended; + expect(actual).toEqual(expected); + }); + + it('is the signal it died of when a signal killed it', async () => { + const { ended } = await ran({ program: 'sleep' }, { response: { exitCode: null, signal: 'SIGKILL' } }); + + const expected = { kind: 'signalled', signal: 'SIGKILL' }; + const actual = ended; + expect(actual).toEqual(expected); + }); +}); + +describe('what the process is given', () => { + it('runs the program with the arguments it was told to', async () => { + const { executor } = await ran({ program: 'git', args: ['status', '--short'] }); + + const expected = { program: 'git', args: ['status', '--short'] }; + const actual = { program: executor.calls[0]?.program, args: executor.calls[0]?.args }; + expect(actual).toEqual(expected); + }); + + it('runs it where it was told to', async () => { + const { executor } = await ran({ program: 'git', cwd: '/somewhere' }); + + const expected = '/somewhere'; + const actual = executor.calls[0]?.cwd; + expect(actual).toBe(expected); + }); + + it('gives it what was piped in, as its input', async () => { + const { output } = await ran({ program: 'cat' }, { response: (_cmd, stdin) => ({ stdout: stdin, exitCode: 0 }), upstream: 'piped in\n' }); + + const expected = 'piped in\n'; + const actual = output; + expect(actual).toBe(expected); + }); + + it('gives it literal input when the call wrote some', async () => { + const { output } = await ran({ program: 'cat', stdin: 'written here' }, { response: (_cmd, stdin) => ({ stdout: stdin, exitCode: 0 }) }); + + const expected = 'written here'; + const actual = output; + expect(actual).toBe(expected); + }); +}); + +// A spawned process writes to a pipe, so a well-behaved one already writes plain. What arrives with +// escape codes in it came from a program told to colour anyway, and stripping is the common want: +// the exception is being shown what a command really emits. +describe('escape codes in what a process wrote', () => { + it('are stripped, so what goes down is what the text says', async () => { + const { output } = await ran({ program: 'git', args: ['diff'] }, { response: { stdout: '\u001b[31mdeleted\u001b[0m\n', exitCode: 0 } }); + + const expected = 'deleted\n'; + const actual = output; + expect(actual).toBe(expected); + }); + + it('are kept when the call said to keep them', async () => { + const { output } = await ran({ program: 'git', args: ['diff', '--color'], stripAnsi: false }, { response: { stdout: '\u001b[31mdeleted\u001b[0m\n', exitCode: 0 } }); + + const expected = '\u001b[31mdeleted\u001b[0m\n'; + const actual = output; + expect(actual).toBe(expected); + }); + + it('are stripped from what it captured too', async () => { + const { captured } = await ran({ program: 'git' }, { response: { stderr: '\u001b[33mwarning\u001b[0m\n', exitCode: 1 } }); + + const expected = ['warning']; + const actual = captured; + expect(actual).toEqual(expected); + }); +}); + +// `command > file` is how a command's output becomes a file rather than something to read, and it +// is why a call that redirects counts as writing as far as a decision is concerned. +describe('a command told to write its output to a file', () => { + it('writes the file', async () => { + const { fs } = await ran({ program: 'echo', redirect: { stdout: '/out.txt' } }, { response: { stdout: 'result\n', exitCode: 0 } }); + + const expected = 'result\n'; + const actual = await fs.readFile('/out.txt'); + expect(actual).toBe(expected); + }); + + it('sends nothing down, the way a redirected command shows nothing on a terminal', async () => { + const { output } = await ran({ program: 'echo', redirect: { stdout: '/out.txt' } }, { response: { stdout: 'result\n', exitCode: 0 } }); + + const expected = ''; + const actual = output; + expect(actual).toBe(expected); + }); + + it('says how it ended as it would have anyway', async () => { + const { ended } = await ran({ program: 'echo', redirect: { stdout: '/out.txt' } }, { response: { stdout: 'x', exitCode: 4 } }); + + const expected = { kind: 'failed', code: 4 }; + const actual = ended; + expect(actual).toEqual(expected); + }); +}); + +// A run has a limit covering the whole pipeline; a command may have its own, for the one step known +// to be slow. +describe('a command that outlives its own limit', () => { + it('is killed', async () => { + const { executor } = await ran({ program: 'sleep', args: ['600'], timeout: 5000 }, { elapsed: true }); + + const expected = 'SIGKILL'; + const actual = executor.killedWith; + expect(actual).toBe(expected); + }); + + it('ends as having been signalled, not as having finished', async () => { + const { ended } = await ran({ program: 'sleep', args: ['600'], timeout: 5000 }, { elapsed: true, response: { exitCode: null, signal: 'SIGKILL' } }); + + const expected = { kind: 'signalled', signal: 'SIGKILL' }; + const actual = ended; + expect(actual).toEqual(expected); + }); +}); + +// A reader walking away is what SIGPIPE means, and a relayed pipe gives a spawned process no such +// signal of its own. +describe('a reader that stops', () => { + it('kills the process with SIGPIPE rather than letting it run on', async () => { + const { executor } = await ran({ program: 'yes' }, { readerLeaves: true }); + + const expected = 'SIGPIPE'; + const actual = executor.killedWith; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Range.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Range.spec.ts new file mode 100644 index 00000000..21a9468a --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Range.spec.ts @@ -0,0 +1,48 @@ +import { fromLines, lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createRangeToolV2 } from '../../src/Orchestrate/tools/Range.js'; + +async function* source(values: string[]): AsyncGenerator { + for (const v of values) { + yield v; + } +} + +describe('Range tool', () => { + it('yields the 1-based inclusive window', async () => { + const tool = createRangeToolV2(); + const { stdout } = tool.run({ start: 2, end: 4 }, fromLines(source(['a', 'b', 'c', 'd', 'e'])), []); + + const out: string[] = []; + for await (const value of toLines(stdout)) { + out.push(String(value)); + } + + const expected = ['b', 'c', 'd']; + const actual = out; + expect(actual).toEqual(expected); + }); + + it('stops pulling once the end position is reached', async () => { + let pulls = 0; + async function* infinite(): AsyncGenerator { + while (true) { + pulls++; + yield `line${pulls}`; + } + } + + const tool = createRangeToolV2(); + const { stdout } = tool.run({ start: 2, end: 4 }, fromLines(infinite()), []); + + for await (const _value of toLines(stdout)) { + // drain + } + + // Stopped, not drained. Not an exact count: bytes flow a chunk at a time, so the producer runs + // slightly ahead of the reader, as it would against a real pipe. + const expected = true; + const actual = pulls > 0 && pulls <= 6; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Read.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Read.spec.ts new file mode 100644 index 00000000..4735567c --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Read.spec.ts @@ -0,0 +1,72 @@ +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createReadToolV2 } from '../../src/Orchestrate/tools/Read.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +describe('Read tool', () => { + it('is fs.read tier \u2014 reading file content, not a directory listing', () => { + const tool = createReadToolV2(new MemoryFileSystem()); + + const expected = 'fs.read'; + const actual = tool.operation; + expect(actual).toBe(expected); + }); + + it('emits each line prefixed with path:lineNumber:, the grep -Hn convention', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'first\nsecond' }); + const tool = createReadToolV2(fs); + + const { stdout } = tool.run({ paths: ['/a.txt'] }, undefined, []); + const out: string[] = []; + for await (const line of toLines(stdout)) { + out.push(line); + } + + const expected = ['/a.txt:1:first', '/a.txt:2:second']; + const actual = out; + expect(actual).toEqual(expected); + }); + + it('reads content from multiple named files in order', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'a-content', '/b.txt': 'b-content' }); + const tool = createReadToolV2(fs); + + const { stdout } = tool.run({ paths: ['/a.txt', '/b.txt'] }, undefined, []); + const out: string[] = []; + for await (const line of toLines(stdout)) { + out.push(line); + } + + const expected = ['/a.txt:1:a-content', '/b.txt:1:b-content']; + const actual = out; + expect(actual).toEqual(expected); + }); + + it('yields nothing and reports success when the path list is empty', async () => { + const tool = createReadToolV2(new MemoryFileSystem()); + + const { stdout, success } = tool.run({ paths: [] }, undefined, []); + const out: string[] = []; + for await (const line of toLines(stdout)) { + out.push(line); + } + + expect(out).toEqual([]); + expect(success()).toBe(true); + }); + + it('reports failure when a named path does not exist', async () => { + const fs = new MemoryFileSystem(); + const tool = createReadToolV2(fs); + const stderr: string[] = []; + + const { stdout, success } = tool.run({ paths: ['/missing.txt'] }, undefined, stderr); + for await (const _line of toLines(stdout)) { + // drain + } + + const expected = false; + const actual = success(); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/ReadBinaryFile.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/ReadBinaryFile.spec.ts new file mode 100644 index 00000000..6765ca1e --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/ReadBinaryFile.spec.ts @@ -0,0 +1,97 @@ +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createReadBinaryFileToolV2 } from '../../src/Orchestrate/tools/ReadBinaryFile.js'; +import { noopLogger, passthroughSips } from '../helpers.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +// A full PNG header (signature + IHDR) so file-type can sniff it; 8 bytes alone is too short. +const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex'); +const PDF = Buffer.from('%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<< >>\nendobj\n'); + +describe('ReadBinaryFile tool — shape', () => { + it('is fs.read tier', () => { + const tool = createReadBinaryFileToolV2(new MemoryFileSystem(), passthroughSips, noopLogger); + + const expected = 'fs.read'; + const actual = tool.operation; + expect(actual).toBe(expected); + }); + + it('is excluded from Orchestrate stages — its attachment output has nowhere to go mid-pipe', () => { + const tool = createReadBinaryFileToolV2(new MemoryFileSystem(), passthroughSips, noopLogger); + + const expected = true; + const actual = tool.excludeFromStages; + expect(actual).toBe(expected); + }); +}); + +describe('ReadBinaryFile tool — PDF', () => { + it('reports success and attaches a document block', async () => { + const fs = new MemoryFileSystem({ '/doc.pdf': PDF }); + const tool = createReadBinaryFileToolV2(fs, passthroughSips, noopLogger); + + const { stdout, success, attachments } = tool.run({ path: '/doc.pdf' }, undefined, []); + await drain(stdout); + + expect(success()).toBe(true); + expect(attachments?.()).toEqual([{ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: PDF.toString('base64') } }]); + }); +}); + +describe('ReadBinaryFile tool — image', () => { + it('reports success and attaches an image block', async () => { + const fs = new MemoryFileSystem({ '/img.png': PNG }); + const tool = createReadBinaryFileToolV2(fs, passthroughSips, noopLogger); + + const { stdout, success, attachments } = tool.run({ path: '/img.png' }, undefined, []); + await drain(stdout); + + expect(success()).toBe(true); + expect(attachments?.()).toEqual([{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: PNG.toString('base64') } }]); + }); +}); + +describe('ReadBinaryFile tool — validation', () => { + it('reports failure and a stderr message for a text file — use Read instead', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'plain text content' }); + const tool = createReadBinaryFileToolV2(fs, passthroughSips, noopLogger); + const stderr: string[] = []; + + const { stdout, success } = tool.run({ path: '/a.txt' }, undefined, stderr); + await drain(stdout); + + expect(success()).toBe(false); + expect(stderr[0]).toContain('use Read for text files'); + }); + + it('reports failure for a missing file', async () => { + const fs = new MemoryFileSystem(); + const tool = createReadBinaryFileToolV2(fs, passthroughSips, noopLogger); + const stderr: string[] = []; + + const { stdout, success } = tool.run({ path: '/missing.pdf' }, undefined, stderr); + await drain(stdout); + + expect(success()).toBe(false); + expect(stderr[0]).toContain('File not found'); + }); + + it('produces no attachment on failure', async () => { + const fs = new MemoryFileSystem({ '/a.txt': 'plain text content' }); + const tool = createReadBinaryFileToolV2(fs, passthroughSips, noopLogger); + + const { stdout, attachments } = tool.run({ path: '/a.txt' }, undefined, []); + await drain(stdout); + + expect(attachments?.()).toEqual([]); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Ref.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Ref.spec.ts new file mode 100644 index 00000000..3f54a6aa --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Ref.spec.ts @@ -0,0 +1,81 @@ +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createRefToolV2, RefToolV2Model } from '../../src/Orchestrate/tools/Ref.js'; +import { RefStore } from '../../src/RefStore/RefStore.js'; +import { MemoryObjectStore } from '../MemoryObjectStore.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +describe('Ref tool', () => { + it('is none tier \u2014 an in-memory lookup, not a filesystem operation', () => { + const tool = createRefToolV2(new RefStore(new MemoryObjectStore())); + + const expected = 'none'; + const actual = tool.operation; + expect(actual).toBe(expected); + }); + + it('emits the stored content split into lines, so it composes with Match/Head/Tail/Range', async () => { + const store = new RefStore(new MemoryObjectStore()); + const id = store.store('first\nsecond\nthird'); + const tool = createRefToolV2(store); + + const { stdout } = tool.run({ id, start: 0, limit: 10_000 }, undefined, []); + const actual = await drain(stdout); + + const expected = ['first', 'second', 'third']; + expect(actual).toEqual(expected); + }); + + it('slices by start/limit before splitting into lines, same character-paging as V1', async () => { + const store = new RefStore(new MemoryObjectStore()); + const id = store.store('0123456789'); + const tool = createRefToolV2(store); + + const { stdout } = tool.run({ id, start: 2, limit: 4 }, undefined, []); + const actual = await drain(stdout); + + const expected = ['2345']; + expect(actual).toEqual(expected); + }); + + it('defaults start to 0 and limit to 10000 when omitted from the wire input', () => { + const parsed = RefToolV2Model.parse({ id: 'some-id' }); + + const expected = { id: 'some-id', start: 0, limit: 10_000 }; + const actual = parsed; + expect(actual).toEqual(expected); + }); + + it('reports failure and a stderr message when the id is not found', async () => { + const store = new RefStore(new MemoryObjectStore()); + const tool = createRefToolV2(store); + const stderr: string[] = []; + + const { stdout, success } = tool.run({ id: 'missing', start: 0, limit: 10_000 }, undefined, stderr); + await drain(stdout); + + const expected = false; + const actual = success(); + expect(actual).toBe(expected); + }); + + it('names the missing id in the stderr message', async () => { + const store = new RefStore(new MemoryObjectStore()); + const tool = createRefToolV2(store); + const stderr: string[] = []; + + const { stdout } = tool.run({ id: 'missing-id', start: 0, limit: 10_000 }, undefined, stderr); + await drain(stdout); + + const expected = ['Ref not found: missing-id']; + const actual = stderr; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Skill.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Skill.spec.ts new file mode 100644 index 00000000..2d9747fe --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Skill.spec.ts @@ -0,0 +1,36 @@ +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createSkillToolV2 } from '../../src/Orchestrate/tools/Skill.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +describe('Skill V2', () => { + it('yields the frontmatter-stripped body of a found skill', async () => { + const fs = new MemoryFileSystem({ '/skills/git/SKILL.md': '---\ndescription: git skill\n---\nUse git carefully.' }); + const tool = createSkillToolV2(fs, ['/skills']); + + const result = tool.run({ skill: 'git' }, undefined, []); + const lines = await drain(result.stdout); + + expect(lines).toEqual(['Use git carefully.']); + expect(result.success()).toBe(true); + }); + + it('lists available skill names when the requested one is not found', async () => { + const fs = new MemoryFileSystem({ '/skills/git/SKILL.md': 'Use git carefully.' }); + const tool = createSkillToolV2(fs, ['/skills']); + + const result = tool.run({ skill: 'missing' }, undefined, []); + const lines = await drain(result.stdout); + + expect(lines).toEqual(['Skill not found: missing', '- git']); + expect(result.success()).toBe(false); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Tail.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Tail.spec.ts new file mode 100644 index 00000000..b2bb3c81 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Tail.spec.ts @@ -0,0 +1,39 @@ +import { fromLines, lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createTailToolV2 } from '../../src/Orchestrate/tools/Tail.js'; + +async function* source(values: string[]): AsyncGenerator { + for (const v of values) { + yield v; + } +} + +describe('Tail tool', () => { + it('yields only the last N items, in order', async () => { + const tool = createTailToolV2(); + const { stdout } = tool.run({ count: 2 }, fromLines(source(['a', 'b', 'c'])), []); + + const out: string[] = []; + for await (const value of toLines(stdout)) { + out.push(String(value)); + } + + const expected = ['b', 'c']; + const actual = out; + expect(actual).toEqual(expected); + }); + + it('yields the whole stream when count exceeds its length', async () => { + const tool = createTailToolV2(); + const { stdout } = tool.run({ count: 10 }, fromLines(source(['a', 'b'])), []); + + const out: string[] = []; + for await (const value of toLines(stdout)) { + out.push(String(value)); + } + + const expected = ['a', 'b']; + const actual = out; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/TypeScript.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/TypeScript.spec.ts new file mode 100644 index 00000000..35e5b8f4 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/TypeScript.spec.ts @@ -0,0 +1,151 @@ +import { lines as toLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createTsToolsV2 } from '../../src/Orchestrate/tools/TypeScript.js'; +import type { Diagnostic, ITypeScriptService } from '../../src/typescript/ITypeScriptService.js'; +import { fakeScope } from '../helpers.js'; + +async function drain(stream: AsyncIterable): Promise { + const out: string[] = []; + for await (const value of toLines(stream)) { + out.push(String(value)); + } + return out; +} + +const stubService = (overrides: Partial): ITypeScriptService => ({ + getDiagnostics: async () => [], + getHoverInfo: async () => null, + getReferences: async () => [], + getDefinition: async () => [], + ...overrides, +}); + +function findTool(name: string) { + const tool = createTsToolsV2().find((t) => t.name === name); + if (tool == null) { + throw new Error(`no such tool: ${name}`); + } + return tool; +} + +describe('TypeScript V2 tools', () => { + describe('TsDiagnostics', () => { + it('yields one grep-style line per diagnostic', async () => { + const diagnostics: Diagnostic[] = [{ file: '/abs/View.ts', line: 1, character: 5, message: 'boom', code: 2322, severity: 'error' }]; + const tool = findTool('TsDiagnostics'); + + const result = tool.run({ files: ['/abs/View.ts'], severity: 'error' }, undefined, [], undefined, fakeScope(stubService({ getDiagnostics: async () => diagnostics }))); + const lines = await drain(result.stdout); + + expect(lines).toEqual(['/abs/View.ts:1:5: [error] boom (2322)']); + }); + + // The shape a `Find | Xargs files | TsDiagnostics` pipeline can actually produce: paths, and + // one filter for the call. + it('checks every file it was given', async () => { + const checked: string[] = []; + const tool = findTool('TsDiagnostics'); + + const result = tool.run( + { files: ['/abs/One.ts', '/abs/Two.ts'], severity: 'error' }, + undefined, + [], + undefined, + fakeScope( + stubService({ + getDiagnostics: async ({ file }) => { + checked.push(file); + return []; + }, + }), + ), + ); + await drain(result.stdout); + + const expected = ['/abs/One.ts', '/abs/Two.ts']; + const actual = checked; + expect(actual).toEqual(expected); + }); + + it('applies the call severity to every file, rather than one per file', async () => { + const applied: string[] = []; + const tool = findTool('TsDiagnostics'); + + const result = tool.run( + { files: ['/abs/One.ts', '/abs/Two.ts'], severity: 'warning' }, + undefined, + [], + undefined, + fakeScope( + stubService({ + getDiagnostics: async ({ severity }) => { + applied.push(String(severity)); + return []; + }, + }), + ), + ); + await drain(result.stdout); + + const expected = ['warning', 'warning']; + const actual = applied; + expect(actual).toEqual(expected); + }); + + it('rejects when no scope is supplied', async () => { + const tool = findTool('TsDiagnostics'); + + const result = tool.run({ files: ['/abs/View.ts'], severity: 'error' }, undefined, []); + + await expect(drain(result.stdout)).rejects.toThrow('TsDiagnostics requires a batch scope to resolve ITypeScriptService'); + }); + }); + + describe('TsHover', () => { + it('yields the symbol kind and text, then any documentation', async () => { + const tool = findTool('TsHover'); + const scope = fakeScope(stubService({ getHoverInfo: async () => ({ kind: 'const', text: 'const x: number', documentation: 'A number.' }) })); + + const result = tool.run({ file: '/abs/View.ts', line: 12, character: 8 }, undefined, [], undefined, scope); + const lines = await drain(result.stdout); + + expect(lines).toEqual(['const: const x: number', 'A number.']); + expect(result.success()).toBe(true); + }); + + it('reports no symbol as an unsuccessful result', async () => { + const tool = findTool('TsHover'); + const scope = fakeScope(stubService({ getHoverInfo: async () => null })); + + const result = tool.run({ file: '/abs/View.ts', line: 12, character: 8 }, undefined, [], undefined, scope); + const lines = await drain(result.stdout); + + expect(lines).toEqual(['No symbol at that position']); + expect(result.success()).toBe(false); + }); + }); + + describe('TsReferences', () => { + it('yields one grep-style line per reference', async () => { + const tool = findTool('TsReferences'); + const scope = fakeScope(stubService({ getReferences: async () => [{ file: '/abs/View.ts', line: 5, character: 13, text: 'useThing()' }] })); + + const result = tool.run({ file: '/abs/View.ts', line: 5, character: 13 }, undefined, [], undefined, scope); + const lines = await drain(result.stdout); + + expect(lines).toEqual(['/abs/View.ts:5:13: useThing()']); + }); + }); + + describe('TsDefinition', () => { + it('yields one grep-style line per definition', async () => { + const tool = findTool('TsDefinition'); + const scope = fakeScope(stubService({ getDefinition: async () => [{ file: '/abs/index.ts', line: 3, character: 20 }] })); + + const result = tool.run({ file: '/abs/View.ts', line: 3, character: 20 }, undefined, [], undefined, scope); + const lines = await drain(result.stdout); + + expect(lines).toEqual(['/abs/index.ts:3:20']); + }); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/Xargs.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/Xargs.spec.ts new file mode 100644 index 00000000..697a19c3 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/Xargs.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { splitArguments } from '../../src/Orchestrate/tools/Xargs.js'; + +// Bytes in, arguments out. One per line, because our producers emit a path per line and a path may +// contain spaces — `xargs -d '\n'`, always. + +describe('splitting bytes into arguments', () => { + it('takes one argument per line', () => { + const expected = ['a.ts', 'b.ts']; + const actual = splitArguments(Buffer.from('a.ts\nb.ts\n')); + expect(actual).toEqual(expected); + }); + + it('does not split on a space', () => { + const expected = ['my file.ts']; + const actual = splitArguments(Buffer.from('my file.ts\n')); + expect(actual).toEqual(expected); + }); + + it('treats a final separator as terminating the last argument, not starting another', () => { + const expected = ['a.ts']; + const actual = splitArguments(Buffer.from('a.ts\n')); + expect(actual).toEqual(expected); + }); + + it('takes bytes with no separator at all as one argument', () => { + const expected = ['a.ts']; + const actual = splitArguments(Buffer.from('a.ts')); + expect(actual).toEqual(expected); + }); + + it('drops an empty line rather than passing an empty argument', () => { + const expected = ['a.ts', 'b.ts']; + const actual = splitArguments(Buffer.from('a.ts\n\nb.ts\n')); + expect(actual).toEqual(expected); + }); + + it('gives nothing for no bytes at all', () => { + const expected: string[] = []; + const actual = splitArguments(Buffer.from('')); + expect(actual).toEqual(expected); + }); +}); + +// The bytes are split here and nowhere else, so being lenient cannot make two readers disagree +// about where an argument ended. What it avoids is a path arriving with a carriage return on it, +// which fails at the point of use with a message that never mentions it. +describe('splitting bytes that came from a producer using CRLF', () => { + it('leaves no carriage return on an argument', () => { + const expected = ['a.ts', 'b.ts']; + const actual = splitArguments(Buffer.from('a.ts\r\nb.ts\r\n')); + expect(actual).toEqual(expected); + }); + + it('strips only the one at the end', () => { + const expected = ['we\rird.ts']; + const actual = splitArguments(Buffer.from('we\rird.ts\r\n')); + expect(actual).toEqual(expected); + }); + + it('keeps a trailing space, which a filename may really have', () => { + const expected = ['spacey.ts ']; + const actual = splitArguments(Buffer.from('spacey.ts \n')); + expect(actual).toEqual(expected); + }); +}); + +describe('splitting bytes that are not text', () => { + it('keeps every byte of an argument as it arrived', () => { + const expected = 'caf\u00e9.ts'; + const actual = splitArguments(Buffer.from('café.ts\n', 'utf8'))[0]; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/cancel.integration.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/cancel.integration.spec.ts new file mode 100644 index 00000000..ee26fe52 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/cancel.integration.spec.ts @@ -0,0 +1,215 @@ +import { Clock } from '@js-joda/core'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import type { ConsumerMessage, DurableConfig, SdkMessage, ThinkingEffort } from '@shellicar/claude-sdk'; +import { ApprovalCoordinator, Conversation, IConversation, IDurableConfigProvider, IOrchestrateEngine, ISdkMessagePublisher, IToolRegistry, IToolsClockListener, ITurnRunner, QueryRunner, ToolRegistry } from '@shellicar/claude-sdk'; +import { createServiceCollection, IServiceProvider, Lifetime } from '@shellicar/core-di'; +import type { CommandSpec, ExitStatus, IExecutor, SpawnOpts } from '@shellicar/exec-core'; +import { describe, expect, it } from 'vitest'; +import { OrchestrateEngine } from '../../src/Orchestrate/OrchestrateEngine.js'; +import { createToolsV2Registry } from '../../src/Orchestrate/registry.js'; +import { PolicyStore } from '../../src/Policy/PolicyStore.js'; +import { RefStore } from '../../src/RefStore/RefStore.js'; +import { fakeEscalatedRegistryDeps } from '../fakeEscalatedRegistryDeps.js'; +import { passthroughSips } from '../helpers.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; +import { MemoryObjectStore } from '../MemoryObjectStore.js'; +import { RecordingHistoryReader } from '../RecordingHistoryReader.js'; +import { RecordingMemoryStore } from '../RecordingMemoryStore.js'; + +// --------------------------------------------------------------------------- +// Full-stack proof that ESC-cancel reaches a running Orchestrate/Program call: +// real QueryRunner, real ApprovalCoordinator, real OrchestrateEngine, real +// ToolsV2Registry, real orchestrate-core execute(), real Program tool. The one +// faked seam is the OS process itself (FakeExecutor never spawns a real one) — +// everything above that boundary is production code. +// --------------------------------------------------------------------------- + +type RunParams = Parameters['run']>; +type RunResult = Awaited['run']>>; + +class FakeTurnRunner extends ITurnRunner { + readonly #responses: RunResult[]; + public constructor(responses: RunResult[]) { + super(); + this.#responses = [...responses]; + } + public async run(conversation: RunParams[0], _durable: RunParams[1], _turnInput: RunParams[2]): Promise { + const next = this.#responses.shift(); + if (next == null) { + throw new Error('FakeTurnRunner: no more scripted results'); + } + const content = next.blocks.map((b) => (b.type === 'tool_use' ? { type: 'tool_use' as const, id: b.id, name: b.name, input: b.input } : { type: 'text' as const, text: (b as { text: string }).text })); + conversation.push({ role: 'assistant', content }); + return next; + } +} + +class FakeSdkPublisher extends ISdkMessagePublisher { + public readonly messages: SdkMessage[] = []; + public send(msg: SdkMessage): void { + this.messages.push(msg); + } + public close(): void {} + public drain(): Promise { + return Promise.resolve(); + } +} + +class NoopToolsClock extends IToolsClockListener { + public toolsStarted(): void {} + public toolsStopped(): void {} +} + +class NoopLogger extends ILogger { + public trace(): void {} + public debug(): void {} + public info(): void {} + public warn(): void {} + public error(): void {} +} + +class FakeDurableConfigProvider extends IDurableConfigProvider { + readonly #config: DurableConfig; + public constructor(config: DurableConfig) { + super(); + this.#config = config; + } + public get config(): DurableConfig { + return this.#config; + } + public update(): void {} + public updateIdentityBody(): void {} + public async resolveSystemPromptsFor(): Promise {} + public async resolveSkillCatalogue(): Promise {} + public needsSystemPromptResolve(): boolean { + return false; + } + public getEffectiveModel(): string { + return this.#config.model; + } + public getEffectiveThinkingEnabled(): boolean { + return false; + } + public getEffectiveEffort(): ThinkingEffort | undefined { + return undefined; + } +} + +/** Behaves like a real spawned process that never finishes on its own: `run` only settles once + * the caller's `signal` aborts, at which point it reports the same shape a real killed process + * would (`exitCode: null`). Records whether it was ever actually asked to run. */ +function hangingExecutor(): { executor: IExecutor; started: Promise } { + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const executor: IExecutor = { + async run(_cmd: CommandSpec, opts: SpawnOpts = {}): Promise { + markStarted(); + return new Promise((resolvePromise) => { + opts.signal?.addEventListener('abort', () => resolvePromise({ exitCode: null, signal: 'SIGTERM' })); + }); + }, + }; + return { executor, started }; +} + +function toolUseResult(id: string, name: string, input: Record): RunResult { + return { blocks: [{ type: 'tool_use', id, name, input }], stopReason: 'tool_use', contextManagementOccurred: false, usage: { inputTokens: 1, cacheCreationTokens: 0, cacheCreation5mTokens: 0, cacheCreation1hTokens: 0, cacheReadTokens: 0, outputTokens: 1 } }; +} + +function endTurnResult(): RunResult { + return { blocks: [{ type: 'text', text: 'done' }], stopReason: 'end_turn', contextManagementOccurred: false, usage: { inputTokens: 1, cacheCreationTokens: 0, cacheCreation5mTokens: 0, cacheCreation1hTokens: 0, cacheReadTokens: 0, outputTokens: 1 } }; +} + +function makeStack(responses: RunResult[], executor: IExecutor) { + const fs = new MemoryFileSystem(); + const registry = createToolsV2Registry({ + fs, + executor, + refStore: new RefStore(new MemoryObjectStore()), + sips: passthroughSips, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + const policyStore = new PolicyStore([{ default: 'allow' }], registry); + const conversation = new Conversation(); + const approval = new ApprovalCoordinator(); + const channel = new FakeSdkPublisher(); + const durableProvider = new FakeDurableConfigProvider({ model: 'claude-opus-4-5' as DurableConfig['model'], maxTokens: 1024, tools: [] }); + + const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); + services + .register(ITurnRunner) + .using(() => new FakeTurnRunner(responses)) + .asSelf(); + services + .register(Conversation) + .using(() => conversation) + .asSelf() + .as(IConversation); + services + .register(IToolRegistry) + .using(() => new ToolRegistry([], new NoopLogger())) + .asSelf(); + services + .register(IOrchestrateEngine) + .using((x) => new OrchestrateEngine(registry, policyStore, new NoopLogger(), x.resolve(IServiceProvider), approval, channel, fs, Clock.systemUTC())) + .asSelf(); + services + .register(ApprovalCoordinator) + .using(() => approval) + .asSelf(); + services + .register(ISdkMessagePublisher) + .using(() => channel) + .asSelf(); + services + .register(IDurableConfigProvider) + .using(() => durableProvider) + .asSelf(); + services + .register(ILogger) + .using(() => new NoopLogger()) + .asSelf(); + services + .register(IToolsClockListener) + .using(() => new NoopToolsClock()) + .asSelf(); + services.register(QueryRunner).asSelf(); + const queryRunner = services.buildProvider().resolve(QueryRunner); + return { queryRunner, approval, channel, conversation }; +} + +describe('ESC-cancel — full stack, one Program call away from real process control', () => { + it('kills the fake process when a cancel arrives mid-run', async () => { + const { executor, started } = hangingExecutor(); + const stack = makeStack([toolUseResult('tu_1', 'Program', { program: 'sleep', args: ['5'] }), endTurnResult()], executor); + + const runPromise = stack.queryRunner.run({ messages: ['run it'], abortController: new AbortController() }); + await started; + stack.approval.handle({ type: 'cancel' } as ConsumerMessage); + await runPromise; + + const actual = stack.channel.messages.find((m) => m.type === 'tool_result'); + expect(actual).toMatchObject({ isError: true, cancelled: true }); + }); + + it('does not cancel the query itself on a single ESC', async () => { + const { executor, started } = hangingExecutor(); + const stack = makeStack([toolUseResult('tu_1', 'Program', { program: 'sleep', args: ['5'] }), endTurnResult()], executor); + + const runPromise = stack.queryRunner.run({ messages: ['run it'], abortController: new AbortController() }); + await started; + stack.approval.handle({ type: 'cancel' } as ConsumerMessage); + await runPromise; + + const actual = stack.approval.cancelled; + expect(actual).toBe(false); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/policyGatedApproval.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/policyGatedApproval.spec.ts new file mode 100644 index 00000000..9f097896 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/policyGatedApproval.spec.ts @@ -0,0 +1,444 @@ +import { Clock } from '@js-joda/core'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { describe, expect, it } from 'vitest'; +import { createPolicyGatedApproval } from '../../src/Orchestrate/policyGatedApproval.js'; +import { createToolsV2Registry } from '../../src/Orchestrate/registry.js'; +import { runToolV2Call } from '../../src/Orchestrate/runToolV2Call.js'; +import { createFindToolV2 } from '../../src/Orchestrate/tools/Find.js'; +import { PolicyStore } from '../../src/Policy/PolicyStore.js'; +import { RefStore } from '../../src/RefStore/RefStore.js'; +import { FakeExecutor } from '../FakeExecutor.js'; +import { fakeEscalatedRegistryDeps } from '../fakeEscalatedRegistryDeps.js'; +import { passthroughSips } from '../helpers.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; +import { MemoryObjectStore } from '../MemoryObjectStore.js'; +import { RecordingHistoryReader } from '../RecordingHistoryReader.js'; +import { RecordingMemoryStore } from '../RecordingMemoryStore.js'; + +function makeRefStore(): RefStore { + return new RefStore(new MemoryObjectStore()); +} + +const lookup = { get: () => undefined }; + +/** The machine the decision is made on: where it is, whose home it is, and whether its filesystem + * distinguishes case. */ +const fs = new MemoryFileSystem({}, '/home/u', '/repo'); + +class NoopLogger extends ILogger { + public trace(_message: string, ..._meta: unknown[]): void {} + public debug(_message: string, ..._meta: unknown[]): void {} + public info(_message: string, ..._meta: unknown[]): void {} + public warn(_message: string, ..._meta: unknown[]): void {} + public error(_message: string, ..._meta: unknown[]): void {} +} + +describe('createPolicyGatedApproval \u2014 an allow verdict', () => { + it('approves the call', async () => { + const policyStore = new PolicyStore([{ tool: 'Program', default: 'allow' }], lookup); + const approve = createPolicyGatedApproval(policyStore, lookup, fs, new NoopLogger(), async () => false); + + const expected = true; + const actual = (await approve({ name: 'Program', operations: ['fs.exec'], input: {}, asWritten: {}, batch: async () => [], stagePosition: 1, stageCount: 1 })).approved; + expect(actual).toBe(expected); + }); + + it('never asks a human', async () => { + const policyStore = new PolicyStore([{ tool: 'Program', default: 'allow' }], lookup); + let humanAsked = false; + const approve = createPolicyGatedApproval(policyStore, lookup, fs, new NoopLogger(), async () => { + humanAsked = true; + return false; + }); + + await approve({ name: 'Program', operations: ['fs.exec'], input: {}, asWritten: {}, batch: async () => [], stagePosition: 1, stageCount: 1 }); + + const expected = false; + const actual = humanAsked; + expect(actual).toBe(expected); + }); +}); + +describe('createPolicyGatedApproval \u2014 a deny verdict', () => { + it('denies the call', async () => { + const policyStore = new PolicyStore([{ tool: 'Program', default: 'deny' }], lookup); + const approve = createPolicyGatedApproval(policyStore, lookup, fs, new NoopLogger(), async () => true); + + const expected = false; + const actual = (await approve({ name: 'Program', operations: ['fs.exec'], input: {}, asWritten: {}, batch: async () => [], stagePosition: 1, stageCount: 1 })).approved; + expect(actual).toBe(expected); + }); + + it('never asks a human', async () => { + const policyStore = new PolicyStore([{ tool: 'Program', default: 'deny' }], lookup); + let humanAsked = false; + const approve = createPolicyGatedApproval(policyStore, lookup, fs, new NoopLogger(), async () => { + humanAsked = true; + return true; + }); + + await approve({ name: 'Program', operations: ['fs.exec'], input: {}, asWritten: {}, batch: async () => [], stagePosition: 1, stageCount: 1 }); + + const expected = false; + const actual = humanAsked; + expect(actual).toBe(expected); + }); + + it('carries the policy message through', async () => { + const policyStore = new PolicyStore([{ tool: 'Program', default: 'deny', message: 'blocked by policy' }], lookup); + const approve = createPolicyGatedApproval(policyStore, lookup, fs, new NoopLogger()); + + const outcome = await approve({ name: 'Program', operations: ['fs.exec'], input: {}, asWritten: {}, batch: async () => [], stagePosition: 1, stageCount: 1 }); + + const expected = 'blocked by policy'; + const actual = !outcome.approved ? outcome.message : undefined; + expect(actual).toBe(expected); + }); +}); + +describe('createPolicyGatedApproval \u2014 an ask verdict', () => { + it('falls through to the human-ask callback', async () => { + const policyStore = new PolicyStore([{ tool: 'Program', default: 'ask' }], lookup); + const approve = createPolicyGatedApproval(policyStore, lookup, fs, new NoopLogger(), async () => true); + + const expected = true; + const actual = (await approve({ name: 'Program', operations: ['fs.exec'], input: {}, asWritten: {}, batch: async () => [], stagePosition: 1, stageCount: 1 })).approved; + expect(actual).toBe(expected); + }); + + it('auto-approves when no human-ask callback was supplied at all', async () => { + const policyStore = new PolicyStore([{ tool: 'Program', default: 'ask' }], lookup); + const approve = createPolicyGatedApproval(policyStore, lookup, fs, new NoopLogger()); + + const expected = true; + const actual = (await approve({ name: 'Program', operations: ['fs.exec'], input: {}, asWritten: {}, batch: async () => [], stagePosition: 1, stageCount: 1 })).approved; + expect(actual).toBe(expected); + }); +}); + +describe('createPolicyGatedApproval \u2014 logging', () => { + it('logs every resolution under one grep-able message name', async () => { + const policyStore = new PolicyStore([{ tool: 'Program', default: 'deny', message: 'blocked' }], lookup); + const logs: unknown[] = []; + const logger = new NoopLogger(); + logger.info = (message: string, ...meta: unknown[]) => { + logs.push({ message, meta }); + }; + const approve = createPolicyGatedApproval(policyStore, lookup, fs, logger); + + await approve({ name: 'Program', operations: ['fs.exec'], input: {}, asWritten: {}, batch: async () => [], stagePosition: 1, stageCount: 1 }); + + const expected = true; + const actual = logs.some((l) => (l as { message: string }).message === 'policy_resolution'); + expect(actual).toBe(expected); + }); +}); + +describe('createPolicyGatedApproval \u2014 path extraction', () => { + it('extracts the tool\u2019s own marked path field so a path-scoped rule can actually match', async () => { + const findTool = createFindToolV2(new MemoryFileSystem()); + const registry = { get: (name: string) => (name === 'Find' ? findTool : undefined) }; + const policyStore = new PolicyStore([{ path: '/inside/**', default: 'deny' }], registry); + const approve = createPolicyGatedApproval(policyStore, registry, fs, new NoopLogger()); + + const expected = false; + const actual = (await approve({ name: 'Find', operations: ['fs.list'], input: { path: '/inside/dir' }, asWritten: { path: '/inside/dir' }, batch: async () => [], stagePosition: 1, stageCount: 1 })).approved; + expect(actual).toBe(expected); + }); + + it('a path-scoped rule does not match when the tool\u2019s path is outside the rule\u2019s pattern', async () => { + const findTool = createFindToolV2(new MemoryFileSystem()); + const registry = { get: (name: string) => (name === 'Find' ? findTool : undefined) }; + const policyStore = new PolicyStore( + [ + { path: '/inside/**', default: 'deny' }, + { tool: '*', default: 'allow' }, + ], + registry, + ); + const approve = createPolicyGatedApproval(policyStore, registry, fs, new NoopLogger()); + + const expected = true; + const actual = (await approve({ name: 'Find', operations: ['fs.list'], input: { path: '/outside/dir' }, asWritten: { path: '/outside/dir' }, batch: async () => [], stagePosition: 1, stageCount: 1 })).approved; + expect(actual).toBe(expected); + }); + + it('a tool with no registered schema extracts no paths, so a real (non-wildcard) path-scoped rule cannot match it', async () => { + const policyStore = new PolicyStore( + [ + { path: '$PWD', default: 'deny' }, + { tool: '*', default: 'allow' }, + ], + lookup, + ); + const approve = createPolicyGatedApproval(policyStore, lookup, fs, new NoopLogger()); + + const expected = true; + const actual = (await approve({ name: 'UnknownTool', operations: ['fs.exec'], input: { path: '/anything' }, asWritten: { path: '/anything' }, batch: async () => [], stagePosition: 1, stageCount: 1 })).approved; + expect(actual).toBe(expected); + }); +}); + +describe('Program with no cwd \u2014 the default must come from the injected IFileSystem, never process.cwd() baked into the schema', () => { + it('still runs, defaulting to the injected filesystem\u2019s own cwd \u2014 not rejected by the schema', async () => { + const fs = new MemoryFileSystem({}, '/home/user', '/memory-cwd'); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + // Allow everything: this test only proves the call actually reaches and runs Program at + // all with a real, correct cwd \u2014 not that Policy denies it for an unrelated reason. + const policyStore = new PolicyStore([{ tool: '*', default: 'allow' }], registry); + const approve = createPolicyGatedApproval(policyStore, registry, fs, new NoopLogger()); + + const expected = true; + const actual = (await runToolV2Call('Program', { program: 'echo', args: ['hi'] }, registry, approve)).ok; + expect(actual).toBe(expected); + }); + + it('the resolved cwd Policy sees for an omitted cwd is the injected filesystem\u2019s cwd, so a $PWD rule genuinely matches it', async () => { + const fs = new MemoryFileSystem({}, '/home/user', '/memory-cwd'); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + const policyStore = new PolicyStore( + [ + { path: '$PWD', default: 'deny' }, + { tool: '*', default: 'allow' }, + ], + registry, + ); + const approve = createPolicyGatedApproval(policyStore, registry, fs, new NoopLogger()); + + const expected = false; + const actual = (await runToolV2Call('Program', { program: 'echo', args: ['hi'] }, registry, approve)).ok; + expect(actual).toBe(expected); + }); + + it('is denied because the $PWD rule genuinely matched, not because the schema rejected the call before any stage ever ran', async () => { + const fs = new MemoryFileSystem({}, '/home/user', '/memory-cwd'); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + const policyStore = new PolicyStore( + [ + { path: '$PWD', default: 'deny' }, + { tool: '*', default: 'allow' }, + ], + registry, + ); + const approve = createPolicyGatedApproval(policyStore, registry, fs, new NoopLogger()); + + const result = await runToolV2Call('Program', { program: 'echo', args: ['hi'] }, registry, approve); + + // A schema rejection never produces "denied" text at all (it fails before any stage runs). + const expected = 'denied'; + const actual = !result.ok ? result.error : ''; + expect(actual).toContain(expected); + }); +}); + +// A path written with a variable in it used to be judged as the characters it was typed with: +// `$HOME/.ssh/id_rsa` read as a relative path under the working directory, passed a rule scoped to +// $PWD, and was then expanded and opened. The decision and the action have to be about the same +// file. +describe('judging a path written with a variable in it', () => { + function registryExpanding(fs: MemoryFileSystem) { + return createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + expand: (p) => p.replace('$HOME', '/home/user'), + ...fakeEscalatedRegistryDeps(), + }); + } + + const readsInsideTheProject = [ + { path: '$PWD', operations: { 'fs.read': 'allow' as const } }, + { path: '*', default: 'deny' as const }, + ]; + + it('refuses a home path that a $PWD rule would have allowed as written', async () => { + const fs = new MemoryFileSystem({ '/home/user/.ssh/id_rsa': 'secret' }, '/home/user', '/project'); + const registry = registryExpanding(fs); + const approve = createPolicyGatedApproval(new PolicyStore(readsInsideTheProject, registry), registry, fs, new NoopLogger()); + + const expected = false; + const actual = (await runToolV2Call('Read', { paths: ['$HOME/.ssh/id_rsa'] }, registry, approve)).ok; + expect(actual).toBe(expected); + }); + + it('still allows a path that really is inside the project', async () => { + const fs = new MemoryFileSystem({ '/project/a.txt': 'hello' }, '/home/user', '/project'); + const registry = registryExpanding(fs); + const approve = createPolicyGatedApproval(new PolicyStore(readsInsideTheProject, registry), registry, fs, new NoopLogger()); + + const expected = true; + const actual = (await runToolV2Call('Read', { paths: ['/project/a.txt'] }, registry, approve)).ok; + expect(actual).toBe(expected); + }); +}); + +// A rule that matches on arguments is only worth anything if it sees the arguments the process +// will actually receive. A call can carry its own environment, and `Program` expands `$NAME` in its +// arguments from it, so a flag put there never appears in what Policy matched against. +describe('a rule matching on arguments, against a flag that arrives through a variable', () => { + const noForcedRemoval = [ + { tool: 'Program', input: { program: { basename: ['rm'] }, args: { anyOf: ['-rf'] } }, default: 'deny' as const, message: 'no forced removal' }, + { tool: '*', default: 'allow' as const }, + ]; + + function wiring() { + const fs = new MemoryFileSystem({}, '/home/user', '/project'); + const executor = new FakeExecutor(() => ({ exitCode: 0 })); + const registry = createToolsV2Registry({ + fs, + executor, + refStore: makeRefStore(), + sips: passthroughSips, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + const approve = createPolicyGatedApproval(new PolicyStore(noForcedRemoval, registry), registry, fs, new NoopLogger()); + return { registry, approve, executor }; + } + + it('denies it, though the flag is not in the arguments as written', async () => { + const { registry, approve } = wiring(); + + const expected = false; + const actual = (await runToolV2Call('Program', { program: 'rm', args: ['$MYARGS'], env: { MYARGS: '-rf' }, cwd: '/project' }, registry, approve)).ok; + expect(actual).toBe(expected); + }); + + it('never runs it', async () => { + const { registry, approve, executor } = wiring(); + + await runToolV2Call('Program', { program: 'rm', args: ['$MYARGS'], env: { MYARGS: '-rf' }, cwd: '/project' }, registry, approve); + + const expected = 0; + const actual = executor.calls.length; + expect(actual).toBe(expected); + }); + + it('denies it when the flag arrives through a capture from an earlier stage', async () => { + const fs = new MemoryFileSystem({}, '/home/user', '/project'); + const executor = new FakeExecutor((cmd) => (cmd.program === 'print-flag' ? { stdout: '-rf\n', exitCode: 0 } : { exitCode: 0 })); + const registry = createToolsV2Registry({ + fs, + executor, + refStore: makeRefStore(), + sips: passthroughSips, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + const approve = createPolicyGatedApproval(new PolicyStore(noForcedRemoval, registry), registry, fs, new NoopLogger()); + + const result = await runToolV2Call( + 'Orchestrate', + { + stages: [ + { tool: 'Program', input: { program: 'print-flag', cwd: '/project' }, captureAs: 'FLAG', op: '&&' }, + { tool: 'Program', input: { program: 'rm', args: ['$FLAG'], cwd: '/project' } }, + ], + }, + registry, + approve, + ); + + const expected = false; + const actual = result.ok; + expect(actual).toBe(expected); + }); +}); + +// The kernel ignores permissions on a symlink and checks the target, so a name inside the project +// that points outside it is a read of the file outside. A decision made on the name as written was +// about a file that isn't the one being opened. +describe('judging a path that reaches outside through a link', () => { + const insideTheProject = [ + { path: '$PWD/**', operations: { 'fs.read': 'allow' as const } }, + { path: '*', default: 'deny' as const }, + ]; + + function wiringWithLink() { + const linked = new MemoryFileSystem({ '/home/u/.ssh/id_rsa': 'secret', '/repo/src/a.ts': 'ordinary' }, '/home/u', '/repo'); + linked.links.set('/repo/shortcut', '/home/u/.ssh'); + const registry = createToolsV2Registry({ + fs: linked, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: new NoopLogger(), + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + return { registry, approve: createPolicyGatedApproval(new PolicyStore(insideTheProject, registry), registry, linked, new NoopLogger()) }; + } + + it('refuses a read through a link that leaves the project', async () => { + const { registry, approve } = wiringWithLink(); + + const expected = false; + const actual = (await runToolV2Call('Read', { paths: ['/repo/shortcut/id_rsa'] }, registry, approve)).ok; + expect(actual).toBe(expected); + }); + + it('still allows a read of a file genuinely inside the project', async () => { + const { registry, approve } = wiringWithLink(); + + const expected = true; + const actual = (await runToolV2Call('Read', { paths: ['/repo/src/a.ts'] }, registry, approve)).ok; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/programEnv.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/programEnv.spec.ts new file mode 100644 index 00000000..063cda8c --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/programEnv.spec.ts @@ -0,0 +1,159 @@ +import { Clock } from '@js-joda/core'; +import { collectPaths } from '@shellicar/claude-sdk'; +import { describe, expect, it } from 'vitest'; +import { createToolsV2Registry } from '../../src/Orchestrate/registry.js'; +import { createProgramToolV2, ProgramToolV2Model } from '../../src/Orchestrate/tools/Program.js'; +import { matchesValue } from '../../src/Policy/matchValue.js'; +import { RefStore } from '../../src/RefStore/RefStore.js'; +import { FakeExecutor } from '../FakeExecutor.js'; +import { fakeEnvProvider } from '../fakeEnvProvider.js'; +import { fakeEscalatedRegistryDeps } from '../fakeEscalatedRegistryDeps.js'; +import { noopLogger, passthroughSips } from '../helpers.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; +import { MemoryObjectStore } from '../MemoryObjectStore.js'; +import { RecordingHistoryReader } from '../RecordingHistoryReader.js'; +import { RecordingMemoryStore } from '../RecordingMemoryStore.js'; + +function makeRefStore(): RefStore { + return new RefStore(new MemoryObjectStore()); +} + +// `PATH` decides which file a program name refers to, and the loader variables decide what code is +// loaded into it, so a call that sets them changes what a decision was even about. A rule allowing +// `git` means nothing if `git` is whatever the call put on the path. +describe('a call that sets an environment variable the engine will not honour', () => { + it('is refused when it sets PATH', () => { + const expected = false; + const actual = ProgramToolV2Model.safeParse({ program: 'git', args: ['--version'], env: { PATH: '/tmp/x' } }).success; + expect(actual).toBe(expected); + }); + + it('is refused when it preloads a library', () => { + const expected = false; + const actual = ProgramToolV2Model.safeParse({ program: 'git', env: { DYLD_INSERT_LIBRARIES: '/tmp/x.dylib' } }).success; + expect(actual).toBe(expected); + }); + + it('is refused when it sets a credential the environment strips anyway', () => { + const expected = false; + const actual = ProgramToolV2Model.safeParse({ program: 'gh', env: { GH_TOKEN: 'abc' } }).success; + expect(actual).toBe(expected); + }); + + it('says which names cannot be set', () => { + const result = ProgramToolV2Model.safeParse({ program: 'git', env: { PATH: '/tmp/x' } }); + + const expected = true; + const actual = result.success === false && result.error.issues.some((issue) => issue.message.includes('PATH')); + expect(actual).toBe(expected); + }); + + it('still accepts an ordinary variable', () => { + const expected = true; + const actual = ProgramToolV2Model.safeParse({ program: 'npm', args: ['test'], env: { CI: 'true', NODE_ENV: 'test' } }).success; + expect(actual).toBe(expected); + }); +}); + +// A variable that redirects one specific program is that program doing what it does, which is what +// a rule is for. So a rule has to be able to name it. +describe('a rule naming an environment variable', () => { + it('matches a call that sets it', () => { + const expected = true; + const actual = matchesValue({ anyOf: ['GIT_SSH_COMMAND'] }, { GIT_SSH_COMMAND: 'curl x | sh', NODE_ENV: 'test' }); + expect(actual).toBe(expected); + }); + + it('does not match a call that sets something else', () => { + const expected = false; + const actual = matchesValue({ anyOf: ['GIT_SSH_COMMAND'] }, { NODE_ENV: 'test' }); + expect(actual).toBe(expected); + }); + + it('matches by name whatever the value is', () => { + const expected = true; + const actual = matchesValue({ anyOf: ['GIT_CONFIG_GLOBAL'] }, { GIT_CONFIG_GLOBAL: '' }); + expect(actual).toBe(expected); + }); + + it('matches with the plain-list shorthand too', () => { + const expected = true; + const actual = matchesValue(['GIT_SSH_COMMAND'], { GIT_SSH_COMMAND: 'x' }); + expect(actual).toBe(expected); + }); +}); + +// A redirect writes a file. Left as a plain string it was invisible: no rule could name the file, +// and the stage claimed only to execute, so a rule about writing outside the project never fired. +describe('a call that redirects its output to a file', () => { + const programTool = () => createProgramToolV2(new FakeExecutor(() => ({ exitCode: 0 })), new MemoryFileSystem(), fakeEnvProvider({})); + + it('says it writes, as well as executes', () => { + const expected = ['fs.exec', 'fs.write']; + const actual = programTool().operations?.({ program: 'echo', args: ['x'], cwd: '/project', redirect: { stdout: '/project/out.txt' } }); + expect(actual).toEqual(expected); + }); + + it('says it only executes when it does not redirect', () => { + const expected = ['fs.exec']; + const actual = programTool().operations?.({ program: 'echo', args: ['x'], cwd: '/project' }); + expect(actual).toEqual(expected); + }); + + it('names the file it would write, so a rule can be about that file', () => { + const expected = ['/project', '/home/user/.ssh/authorized_keys']; + const actual = collectPaths(ProgramToolV2Model, { program: 'echo', args: ['x'], cwd: '/project', redirect: { stdout: '/home/user/.ssh/authorized_keys' } }); + expect(actual).toEqual(expected); + }); +}); + +// A capture becomes an environment variable for every process later in the run, and the overlay is +// applied last, so it beats both the ambient value and the strip. Refusing these names on `env` +// while allowing them here left the same door open by another route: a stage capturing a directory +// as PATH decides what the next stage's program name resolves to. +describe('a capture that names an environment variable the engine will not honour', () => { + function registry() { + return createToolsV2Registry({ + fs: new MemoryFileSystem(), + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + } + + it('is refused when it captures as PATH', () => { + const result = registry().stageSchema.safeParse({ + stages: [ + { tool: 'Program', input: { program: 'echo', args: ['/tmp/planted'], cwd: '/' }, captureAs: 'PATH', op: '&&' }, + { tool: 'Program', input: { program: 'git', args: ['--version'], cwd: '/' } }, + ], + }); + + const expected = false; + const actual = result.success; + expect(actual).toBe(expected); + }); + + it('is refused when it captures as a credential name', () => { + const result = registry().stageSchema.safeParse({ stages: [{ tool: 'Program', input: { program: 'echo', args: ['x'], cwd: '/' }, captureAs: 'GH_TOKEN' }] }); + + const expected = false; + const actual = result.success; + expect(actual).toBe(expected); + }); + + it('still allows an ordinary capture name', () => { + const result = registry().stageSchema.safeParse({ stages: [{ tool: 'Program', input: { program: 'echo', args: ['x'], cwd: '/' }, captureAs: 'TOKEN' }] }); + + const expected = true; + const actual = result.success; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/registry.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/registry.spec.ts new file mode 100644 index 00000000..59caf623 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/registry.spec.ts @@ -0,0 +1,305 @@ +import { Clock } from '@js-joda/core'; +import { lines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { createToolsV2Registry, toolsV2WireTools } from '../../src/Orchestrate/registry.js'; +import { RefStore } from '../../src/RefStore/RefStore.js'; +import { FakeExecutor } from '../FakeExecutor.js'; +import { fakeEnvProvider } from '../fakeEnvProvider.js'; +import { fakeEscalatedRegistryDeps } from '../fakeEscalatedRegistryDeps.js'; +import { noopLogger, passthroughSips } from '../helpers.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; +import { MemoryObjectStore } from '../MemoryObjectStore.js'; +import { RecordingHistoryReader } from '../RecordingHistoryReader.js'; +import { RecordingMemoryStore } from '../RecordingMemoryStore.js'; + +function makeRegistry() { + return createToolsV2Registry({ + fs: new MemoryFileSystem(), + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: new RefStore(new MemoryObjectStore()), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); +} + +describe('createToolsV2Registry', () => { + it('gives every registered tool its own wire entry', () => { + const registry = makeRegistry(); + + const expected = [ + 'Find', + 'Paths', + 'Match', + 'Head', + 'Tail', + 'Range', + 'Read', + 'ReadBinaryFile', + 'Program', + 'Delete', + 'Ref', + 'CreateFile', + 'AppendFile', + 'EditFile', + 'WriteMemory', + 'ReadMemory', + 'SearchMemory', + 'DeleteMemory', + 'MemoryTypes', + 'SearchHistory', + 'ReadHistory', + 'Skill', + 'TsDiagnostics', + 'TsHover', + 'TsReferences', + 'TsDefinition', + 'GitHub_PullRequest_Create', + 'GitHub_PullRequest_Ready', + 'GitHub_PullRequest_Edit', + 'GitHub_PullRequest_Comment', + 'GitHub_PullRequest_AutoMerge', + 'GitHub_PullRequest_Review', + 'AzureDevOps_PullRequest_Create', + 'AzureDevOps_PullRequest_Ready', + 'AzureDevOps_PullRequest_Edit', + 'AzureDevOps_PullRequest_AutoMerge', + 'AzureDevOps_PullRequest_ReviewerAdd', + 'AzureDevOps_PullRequest_ReviewerRemove', + 'AzureDevOps_PullRequest_Vote', + 'AzCli', + 'EscalatedAzCli', + ].sort(); + const actual = registry.wireTools.map((t) => t.name).sort(); + expect(actual).toEqual(expected); + }); + + it('looks a registered tool up by name', () => { + const registry = makeRegistry(); + + const expected = 'Find'; + const actual = registry.get('Find')?.name; + expect(actual).toBe(expected); + }); +}); + +describe('toolsV2WireTools', () => { + it('includes Orchestrate alongside every individually registered tool', () => { + const registry = makeRegistry(); + + const expected = [ + 'Find', + 'Paths', + 'Match', + 'Head', + 'Tail', + 'Range', + 'Read', + 'ReadBinaryFile', + 'Program', + 'Delete', + 'Ref', + 'CreateFile', + 'AppendFile', + 'EditFile', + 'WriteMemory', + 'ReadMemory', + 'SearchMemory', + 'DeleteMemory', + 'MemoryTypes', + 'SearchHistory', + 'ReadHistory', + 'Skill', + 'TsDiagnostics', + 'TsHover', + 'TsReferences', + 'TsDefinition', + 'GitHub_PullRequest_Create', + 'GitHub_PullRequest_Ready', + 'GitHub_PullRequest_Edit', + 'GitHub_PullRequest_Comment', + 'GitHub_PullRequest_AutoMerge', + 'GitHub_PullRequest_Review', + 'AzureDevOps_PullRequest_Create', + 'AzureDevOps_PullRequest_Ready', + 'AzureDevOps_PullRequest_Edit', + 'AzureDevOps_PullRequest_AutoMerge', + 'AzureDevOps_PullRequest_ReviewerAdd', + 'AzureDevOps_PullRequest_ReviewerRemove', + 'AzureDevOps_PullRequest_Vote', + 'AzCli', + 'EscalatedAzCli', + 'Orchestrate', + ].sort(); + const actual = toolsV2WireTools(registry) + .map((t) => t.name) + .sort(); + expect(actual).toEqual(expected); + }); +}); + +describe('ToolsV2Registry.stageSchema', () => { + it('accepts a Find piped into Head, validated against each tool own model', () => { + const registry = makeRegistry(); + const input = { + stages: [ + { tool: 'Find', input: { path: '/root' }, op: '|' }, + { tool: 'Head', input: { count: 1 } }, + ], + }; + + const expected = true; + const actual = registry.stageSchema.safeParse(input).success; + expect(actual).toBe(expected); + }); + + it('accepts showStderr on any stage, not just Program — it is per-stage, not per-tool', () => { + const registry = makeRegistry(); + const input = { stages: [{ tool: 'Find', input: { path: '/root' }, showStderr: true }] }; + + const expected = true; + const actual = registry.stageSchema.safeParse(input).success; + expect(actual).toBe(expected); + }); + + it('accepts an Xargs stage bridging into the next stage', () => { + const registry = makeRegistry(); + const input = { stages: [{ tool: 'Find', input: { path: '/root' }, op: '|' }, { xargs: true }, { tool: 'Program', input: { program: 'rm', cwd: '/root' } }] }; + + const expected = true; + const actual = registry.stageSchema.safeParse(input).success; + expect(actual).toBe(expected); + }); + + it('rejects a tool name outside the registry', () => { + const registry = makeRegistry(); + const input = { stages: [{ tool: 'DeleteFile', input: {} }] }; + + const expected = false; + const actual = registry.stageSchema.safeParse(input).success; + expect(actual).toBe(expected); + }); + + it('rejects a Range stage whose start is after its end, via Range own model', () => { + const registry = makeRegistry(); + const input = { stages: [{ tool: 'Range', input: { start: 10, end: 1 } }] }; + + const expected = false; + const actual = registry.stageSchema.safeParse(input).success; + expect(actual).toBe(expected); + }); + + it('rejects a tool marked excludeFromStages — it stays individually callable but cannot be dropped into a pipe', () => { + const registry = makeRegistry(); + const input = { stages: [{ tool: 'ReadBinaryFile', input: { path: '/doc.pdf' } }] }; + + const expected = false; + const actual = registry.stageSchema.safeParse(input).success; + expect(actual).toBe(expected); + }); + + it('still gives a tool marked excludeFromStages its own wire entry', () => { + const registry = makeRegistry(); + + const expected = true; + const actual = registry.wireTools.some((t) => t.name === 'ReadBinaryFile'); + expect(actual).toBe(expected); + }); + + it('rejects a dangling op on the last stage — there is nothing after it to join to', () => { + const registry = makeRegistry(); + const input = { stages: [{ tool: 'Head', input: { count: 1 }, op: '|' }] }; + + const expected = false; + const actual = registry.planCall(input).ok; + expect(actual).toBe(expected); + }); + + it('accepts an op on an earlier stage as long as the last stage has none', () => { + const registry = makeRegistry(); + const input = { + stages: [ + { tool: 'Find', input: { path: '/root' }, op: '|' }, + { tool: 'Head', input: { count: 1 } }, + ], + }; + + const expected = true; + const actual = registry.stageSchema.safeParse(input).success; + expect(actual).toBe(expected); + }); +}); + +describe('ToolsV2Registry.toStage', () => { + it('resolves a tool-shaped wire stage into a real orchestrate-core Stage', () => { + const registry = makeRegistry(); + + const stage = registry.toStage({ tool: 'Head', input: { count: 5 } }); + + const expected = 'tool'; + const actual = stage.kind; + expect(actual).toBe(expected); + }); + + it('carries showStderr from the wire stage onto the resolved Stage, not onto the tool', () => { + const registry = makeRegistry(); + + const stage = registry.toStage({ tool: 'Head', input: { count: 5 }, showStderr: true }); + + const expected = true; + const actual = stage.kind === 'tool' ? stage.showStderr : undefined; + expect(actual).toBe(expected); + }); + + it('resolves an Xargs wire stage to the field the next tool declares as its target', () => { + const registry = makeRegistry(); + + const planned = registry.planCall({ stages: [{ tool: 'Paths', input: { paths: ['/a'] }, op: '|' }, { xargs: true }, { tool: 'Read', input: {} }] }); + + const expected = 'paths'; + const actual = planned.ok && planned.stages[1]?.kind === 'xargs' ? planned.stages[1].parameter : undefined; + expect(actual).toBe(expected); + }); + + it("resolves a tool's own isPath field via the injected expand before the stage is judged, leaving the Stage's own input untouched", async () => { + const executor = new FakeExecutor(() => ({ exitCode: 0 })); + const registry = createToolsV2Registry({ + fs: new MemoryFileSystem(), + executor, + refStore: new RefStore(new MemoryObjectStore()), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + expand: (p) => (p === '~/project' ? '/resolved/project' : p), + ...fakeEscalatedRegistryDeps(), + }); + + const stage = registry.toStage({ tool: 'Program', input: { program: 'ls', cwd: '~/project' } }); + if (stage.kind !== 'tool') { + throw new Error('unreachable'); + } + // What `execute()` does: settle the input against the run's environment, judge that, then run. + const prepared = stage.prepare?.(stage.input, fakeEnvProvider({})) as { cwd: string }; + const result = stage.tool.run(prepared, undefined, []); + for await (const _ of lines(result.stdout)) { + // drain + } + + const expectedCwd = '/resolved/project'; + const actualCwd = executor.calls[0]?.cwd; + expect(actualCwd).toBe(expectedCwd); + + const expectedStageInput = '~/project'; + const actualStageInput = (stage.input as { cwd: string }).cwd; + expect(actualStageInput).toBe(expectedStageInput); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/runToolV2Call.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/runToolV2Call.spec.ts new file mode 100644 index 00000000..b6fef3c9 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/runToolV2Call.spec.ts @@ -0,0 +1,430 @@ +import { Clock } from '@js-joda/core'; +import { describe, expect, it } from 'vitest'; +import { createToolsV2Registry } from '../../src/Orchestrate/registry.js'; +import { runToolV2Call } from '../../src/Orchestrate/runToolV2Call.js'; +import { RefStore } from '../../src/RefStore/RefStore.js'; +import { FakeExecutor } from '../FakeExecutor.js'; +import { fakeEnvProvider } from '../fakeEnvProvider.js'; +import { fakeEscalatedRegistryDeps } from '../fakeEscalatedRegistryDeps.js'; +import { noopLogger, passthroughSips } from '../helpers.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; +import { MemoryObjectStore } from '../MemoryObjectStore.js'; +import { RecordingHistoryReader } from '../RecordingHistoryReader.js'; +import { RecordingMemoryStore } from '../RecordingMemoryStore.js'; + +function makeRefStore(): RefStore { + return new RefStore(new MemoryObjectStore()); +} + +describe('runToolV2Call — Orchestrate composing several tools', () => { + it('returns ok with the piped result as content on success', async () => { + const fs = new MemoryFileSystem({ '/root/a.txt': 'x', '/root/b.txt': 'x' }); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + + const result = await runToolV2Call( + 'Orchestrate', + { + stages: [ + { tool: 'Find', input: { path: '/root', pattern: '\\.txt$' }, op: '|' }, + { tool: 'Head', input: { count: 1 } }, + ], + }, + registry, + ); + + const expected = true; + const actual = result.ok; + expect(actual).toBe(expected); + }); + + it('rejects invalid input without running any stage', async () => { + const registry = createToolsV2Registry({ + fs: new MemoryFileSystem(), + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + + const result = await runToolV2Call('Orchestrate', { stages: [{ tool: 'NotARealTool', input: {} }] }, registry); + + const expected = false; + const actual = result.ok; + expect(actual).toBe(expected); + }); + + it('calls the provided approve callback for a gated stage', async () => { + const fs = new MemoryFileSystem({ '/root/a.txt': 'x' }); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + let approveCalled = false; + + await runToolV2Call('Orchestrate', { stages: [{ tool: 'Find', input: { path: '/root' } }] }, registry, async () => { + approveCalled = true; + return { approved: true }; + }); + + const expected = true; + const actual = approveCalled; + expect(actual).toBe(expected); + }); +}); + +describe('runToolV2Call — a direct call to one registered tool, not through Orchestrate', () => { + it('runs Find directly by name, wrapped as a single-stage sequence', async () => { + const fs = new MemoryFileSystem({ '/root/a.txt': 'x' }); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + + const result = await runToolV2Call('Find', { path: '/root' }, registry); + + const expected = true; + const actual = result.ok; + expect(actual).toBe(expected); + }); + + it('rejects a name outside the registry', async () => { + const registry = createToolsV2Registry({ + fs: new MemoryFileSystem(), + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + + const result = await runToolV2Call('NotARealTool', {}, registry); + + const expected = false; + const actual = result.ok; + expect(actual).toBe(expected); + }); + + it('rejects input that fails the tool own model', async () => { + const registry = createToolsV2Registry({ + fs: new MemoryFileSystem(), + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + + const result = await runToolV2Call('Range', { start: 10, end: 1 }, registry); + + const expected = false; + const actual = result.ok; + expect(actual).toBe(expected); + }); + + it('still gates a direct call the same way a composed call would', async () => { + const fs = new MemoryFileSystem({ '/root/a.txt': 'x' }); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + let approveCalled = false; + + await runToolV2Call('Find', { path: '/root' }, registry, async () => { + approveCalled = true; + return { approved: true }; + }); + + const expected = true; + const actual = approveCalled; + expect(actual).toBe(expected); + }); +}); + +// A `$NAME` reference resolves against what this run captured, and nothing else. Backing it with +// the environment instead would substitute any ambient variable into any string field of any tool +// — `$HOME` inside a file's content, for instance, which references nothing the run captured. +// Environment variables expand on a command line, in `Program`, against the env it spawns under. +describe('runToolV2Call — references resolve against captures, not the environment', () => { + it('leaves an ambient environment variable untouched in a tool input', async () => { + process.env.ORCHESTRATE_PROBE_VAR = 'leaked'; + try { + const fs = new MemoryFileSystem(); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + + await runToolV2Call('Orchestrate', { stages: [{ tool: 'CreateFile', input: { path: '/probe.txt', content: 'value: $ORCHESTRATE_PROBE_VAR' } }] }, registry); + + const expected = 'value: $ORCHESTRATE_PROBE_VAR'; + const actual = await fs.readFile('/probe.txt'); + expect(actual).toBe(expected); + } finally { + delete process.env.ORCHESTRATE_PROBE_VAR; + } + }); +}); + +// `seq 1 100000 | head -3` is a success in any shell. The producer is killed by SIGPIPE when its +// reader walks away, and the call is judged on what it was asked to do, not on that kill. +describe('runToolV2Call — a producer stopped by its consumer', () => { + function registryWithSignallingProgram() { + return createToolsV2Registry({ + fs: new MemoryFileSystem(), + executor: new FakeExecutor(() => ({ stdout: 'one\ntwo\nthree\n', exitCode: null, signal: 'SIGPIPE' })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + } + + it('does not report the call as failed', async () => { + const result = await runToolV2Call( + 'Orchestrate', + { + stages: [ + { tool: 'Program', input: { program: 'seq', args: ['1', '100'], cwd: '/' }, op: '|' }, + { tool: 'Head', input: { count: 1 } }, + ], + }, + registryWithSignallingProgram(), + ); + + const expected = true; + const actual = result.ok; + expect(actual).toBe(expected); + }); + + it('says the stage was stopped rather than that it failed', async () => { + const result = await runToolV2Call( + 'Orchestrate', + { + stages: [ + { tool: 'Program', input: { program: 'seq', args: ['1', '100'], cwd: '/' }, op: '|' }, + { tool: 'Head', input: { count: 1 } }, + ], + }, + registryWithSignallingProgram(), + ); + + const expected = true; + const actual = result.ok === true && result.content.includes('Program: stopped (SIGPIPE)'); + expect(actual).toBe(expected); + }); +}); + +// A stage that never ran, or one stopped for outgrowing what could be held, carries its reason on +// its own report. The summary is where a reader sees it, so it prints for every outcome, not only +// for a refusal. +describe('runToolV2Call — the reason a stage did not run', () => { + it('appears in the summary', async () => { + const fs = new MemoryFileSystem({ '/root/a.txt': 'x' }); + const registry = createToolsV2Registry({ + fs, + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); + + const result = await runToolV2Call( + 'Orchestrate', + { + stages: [ + { tool: 'Find', input: { path: '/root' }, op: '&&' }, + { tool: 'Read', input: { paths: ['/root/a.txt'] } }, + ], + }, + registry, + async () => ({ approved: false, message: 'not allowed here' }), + ); + + const expected = true; + const actual = result.ok === false && result.error.includes('not allowed here'); + expect(actual).toBe(expected); + }); +}); + +// An approval request is published so it can be answered, so whatever it carries is stored whether +// it is approved or refused. A variable's value therefore never goes into the arguments: they carry +// the reference as written, and the value reaches the command through the environment it runs +// under, which happens only once the call was approved. +describe('runToolV2Call — what an approval is shown versus what runs', () => { + function registryWith(executor: FakeExecutor, vars: NodeJS.ProcessEnv) { + return createToolsV2Registry({ + fs: new MemoryFileSystem(), + executor, + refStore: makeRefStore(), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + // After the shared fakes, which bring an env provider of their own. + envProvider: fakeEnvProvider(vars), + }); + } + + it('shows an ambient variable as written rather than its value', async () => { + const seen: unknown[] = []; + const registry = registryWith(new FakeExecutor(() => ({ exitCode: 0 })), { SOME_PATH: '/etc/ssl/cert.pem' }); + + await runToolV2Call('Program', { program: 'echo', args: ['$SOME_PATH'], cwd: '/' }, registry, async (ctx) => { + seen.push(ctx.asWritten); + return { approved: true }; + }); + + const expected = ['$SOME_PATH']; + const actual = (seen[0] as { args: string[] }).args; + expect(actual).toEqual(expected); + }); + + it('decides on the value, so a rule about the real command line can match it', async () => { + const seen: unknown[] = []; + const registry = registryWith(new FakeExecutor(() => ({ exitCode: 0 })), { SOME_PATH: '/etc/ssl/cert.pem' }); + + await runToolV2Call('Program', { program: 'echo', args: ['$SOME_PATH'], cwd: '/' }, registry, async (ctx) => { + seen.push(ctx.input); + return { approved: true }; + }); + + const expected = ['/etc/ssl/cert.pem']; + const actual = (seen[0] as { args: string[] }).args; + expect(actual).toEqual(expected); + }); + + it('runs the command with the value', async () => { + const executor = new FakeExecutor(() => ({ exitCode: 0 })); + const registry = registryWith(executor, { SOME_PATH: '/etc/ssl/cert.pem' }); + + await runToolV2Call('Program', { program: 'echo', args: ['$SOME_PATH'], cwd: '/' }, registry, async () => ({ approved: true })); + + const expected = ['/etc/ssl/cert.pem']; + const actual = executor.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('shows a captured value as written rather than its value', async () => { + const seen: unknown[] = []; + const registry = registryWith(new FakeExecutor(() => ({ stdout: 'secret-token\n', exitCode: 0 })), {}); + + await runToolV2Call( + 'Orchestrate', + { + stages: [ + { tool: 'Program', input: { program: 'get-token', cwd: '/' }, captureAs: 'TOKEN', op: '&&' }, + { tool: 'Program', input: { program: 'curl', args: ['-H', 'Bearer $TOKEN'], cwd: '/' } }, + ], + }, + registry, + async (ctx) => { + seen.push(ctx.asWritten); + return { approved: true }; + }, + ); + + const expected = true; + const actual = seen.every((input) => JSON.stringify(input).includes('secret-token') === false); + expect(actual).toBe(expected); + }); + + it('runs the command with the captured value', async () => { + const executor = new FakeExecutor((cmd) => (cmd.program === 'get-token' ? { stdout: 'secret-token\n', exitCode: 0 } : { exitCode: 0 })); + const registry = registryWith(executor, {}); + + await runToolV2Call( + 'Orchestrate', + { + stages: [ + { tool: 'Program', input: { program: 'get-token', cwd: '/' }, captureAs: 'TOKEN', op: '&&' }, + { tool: 'Program', input: { program: 'curl', args: ['-H', 'Bearer $TOKEN'], cwd: '/' } }, + ], + }, + registry, + async () => ({ approved: true }), + ); + + const expected = ['-H', 'Bearer secret-token']; + const actual = executor.calls[1]?.args; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/stagePlan.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/stagePlan.spec.ts new file mode 100644 index 00000000..743283a0 --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/stagePlan.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { planStages, type ToolFacts, type WireStage } from '../../src/Orchestrate/stagePlan.js'; + +// The whole point of the facts lookup: the sequence rules can be stated against tools that don't +// exist, with no registry, no schemas and no zod. +const tools: Record = { + Producer: { readsUpstream: false }, + Filter: { readsUpstream: true }, + Consumer: { xargsTarget: 'files', xargsTargetRequired: true, readsUpstream: false }, + Both: { xargsTarget: 'args', readsUpstream: true }, +}; + +function plan(stages: WireStage[]) { + return planStages(stages, (name) => tools[name]); +} + +function issues(stages: WireStage[]): string[] { + const result = plan(stages); + return result.ok ? [] : result.issues.map((issue) => issue.message); +} + +describe('planning a sequence that holds together', () => { + it('accepts a single stage that supplies its own argument list', () => { + const expected = true; + const actual = plan([{ tool: 'Consumer', input: { files: ['a'] } }]).ok; + expect(actual).toBe(expected); + }); + + it('tells the following stage which of its fields the Xargs fills', () => { + const result = plan([{ tool: 'Producer', input: {}, op: '|' }, { xargs: true }, { tool: 'Consumer', input: {} }]); + + const expected = 'files'; + const actual = result.ok && result.stages[1]?.kind === 'xargs' ? result.stages[1].parameter : undefined; + expect(actual).toBe(expected); + }); + + it('marks the fed stage as fed, so nothing downstream rediscovers it', () => { + const result = plan([{ tool: 'Producer', input: {}, op: '|' }, { xargs: true }, { tool: 'Consumer', input: {} }]); + + const expected = 'files'; + const actual = result.ok && result.stages[2]?.kind === 'tool' ? result.stages[2].fedBy : undefined; + expect(actual).toBe(expected); + }); + + it('accepts a pipe into a tool that reads one', () => { + const expected = true; + const actual = plan([ + { tool: 'Producer', input: {}, op: '|' }, + { tool: 'Filter', input: {} }, + ]).ok; + expect(actual).toBe(expected); + }); + + it('accepts a fed stage that also names arguments of its own', () => { + const expected = true; + const actual = plan([{ tool: 'Producer', input: {}, op: '|' }, { xargs: true }, { tool: 'Consumer', input: { files: ['own'] } }]).ok; + expect(actual).toBe(expected); + }); +}); + +describe('planning a sequence that does not', () => { + it('reports a stage missing the argument list nothing will fill', () => { + const expected = ['Consumer needs files, either supplied here or fed by an Xargs stage before it.']; + const actual = issues([{ tool: 'Consumer', input: {} }]); + expect(actual).toEqual(expected); + }); + + it('reports an Xargs aimed at a tool with no argument list', () => { + const expected = ['Xargs cannot feed Filter: it takes no argument list. Pipe into it directly if it reads a pipe, or drop the Xargs.']; + const actual = issues([{ tool: 'Producer', input: {}, op: '|' }, { xargs: true }, { tool: 'Filter', input: {} }]); + expect(actual).toEqual(expected); + }); + + it('reports an Xargs with nothing following it', () => { + const expected = ['Xargs must be followed by the tool stage it feeds.']; + const actual = issues([{ tool: 'Producer', input: {}, op: '|' }, { xargs: true }]); + expect(actual).toEqual(expected); + }); + + it('reports a pipe whose output the next stage cannot read', () => { + const expected = ["Producer pipes into Consumer, which does not read a pipe, so its output would be discarded. Put an Xargs stage between them to append the piped values to Consumer's files."]; + const actual = issues([ + { tool: 'Producer', input: {}, op: '|' }, + { tool: 'Consumer', input: { files: ['a'] } }, + ]); + expect(actual).toEqual(expected); + }); + + it('blames the stage carrying the op, since that is where the mistake is written', () => { + const result = plan([ + { tool: 'Producer', input: {}, op: '|' }, + { tool: 'Consumer', input: { files: ['a'] } }, + ]); + + const expected = ['stages', 0, 'op']; + const actual = result.ok ? undefined : result.issues[0]?.path; + expect(actual).toEqual(expected); + }); + + it('reports a trailing op with nothing after it', () => { + const expected = ['The last stage must not have an op set — there is nothing after it to join to.']; + const actual = issues([ + { tool: 'Producer', input: {}, op: '|' }, + { tool: 'Filter', input: {}, op: '&&' }, + ]); + expect(actual).toEqual(expected); + }); + + it('reports every problem in the sequence, not only the first', () => { + const expected = 2; + const actual = issues([ + { tool: 'Producer', input: {}, op: '|' }, + { tool: 'Consumer', input: {} }, + ]).length; + expect(actual).toBe(expected); + }); +}); + +// A tool can both read a pipe and take an argument list; which it gets is the caller's choice, +// expressed by whether an Xargs sits between them. +describe('planning around a tool that could take either', () => { + it('lets a pipe reach it directly', () => { + const expected = true; + const actual = plan([ + { tool: 'Producer', input: {}, op: '|' }, + { tool: 'Both', input: {} }, + ]).ok; + expect(actual).toBe(expected); + }); + + it('lets an Xargs fill its argument list instead', () => { + const result = plan([{ tool: 'Producer', input: {}, op: '|' }, { xargs: true }, { tool: 'Both', input: {} }]); + + const expected = 'args'; + const actual = result.ok && result.stages[1]?.kind === 'xargs' ? result.stages[1].parameter : undefined; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/walkLazy.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/walkLazy.spec.ts new file mode 100644 index 00000000..89a4bf2b --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/walkLazy.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { walkLazy } from '../../src/Orchestrate/walkLazy.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; + +describe('walkLazy — correctness', () => { + it('yields only files matching the pattern', async () => { + const fs = new MemoryFileSystem({ '/root/a.txt': 'x', '/root/b.md': 'x' }); + + const paths: string[] = []; + for await (const record of walkLazy(fs, '/root', { pattern: '\\.txt$' }, 1, /\.txt$/)) { + paths.push(record.path); + } + + const expected = ['/root/a.txt']; + const actual = paths; + expect(actual).toEqual(expected); + }); + + it('excludes directories named in the exclude list', async () => { + const fs = new MemoryFileSystem({ '/root/keep/a.txt': 'x', '/root/node_modules/b.txt': 'x' }); + + const paths: string[] = []; + for await (const record of walkLazy(fs, '/root', { exclude: ['node_modules'] }, 1, undefined)) { + paths.push(record.path); + } + + const expected = ['/root/keep/a.txt']; + const actual = paths; + expect(actual).toEqual(expected); + }); + + it('respects maxDepth', async () => { + const fs = new MemoryFileSystem({ '/root/shallow.txt': 'x', '/root/deep/nested.txt': 'x' }); + + const paths: string[] = []; + for await (const record of walkLazy(fs, '/root', { maxDepth: 1 }, 1, undefined)) { + paths.push(record.path); + } + + const expected = ['/root/shallow.txt']; + const actual = paths; + expect(actual).toEqual(expected); + }); +}); + +describe('walkLazy — laziness', () => { + // Counts real fs calls, so a short-circuit can be proven by absence: if the walk actually + // stops early, readdir is never called for a directory the caller never reached. + class CountingFileSystem extends MemoryFileSystem { + public readdirCalls: string[] = []; + public override async readdir(path: string) { + this.readdirCalls.push(path); + return super.readdir(path); + } + } + + it('pulls at least one real item before stopping', async () => { + const fs = new CountingFileSystem({ '/root/dir1/match.txt': 'x', '/root/dir2/match.txt': 'x' }); + const gen = walkLazy(fs, '/root', {}, 1, undefined); + + const first = await gen.next(); + await gen.return(undefined); + + const expected = false; + const actual = first.done; + expect(actual).toBe(expected); + }); + + it('does not descend into a later sibling directory once the caller stops pulling', async () => { + const fs = new CountingFileSystem({ + '/root/dir1/match.txt': 'x', + '/root/dir2/match.txt': 'x', + '/root/dir3/match.txt': 'x', + }); + + const gen = walkLazy(fs, '/root', {}, 1, undefined); + await gen.next(); + await gen.return(undefined); + + // /root itself, plus dir1 (where the one item taken came from) — never dir2 or dir3. + const expected = ['/root', '/root/dir1']; + const actual = fs.readdirCalls; + expect(actual).toEqual(expected); + }); + + it('does read every directory when the caller drains the whole walk', async () => { + const fs = new CountingFileSystem({ + '/root/dir1/a.txt': 'x', + '/root/dir2/b.txt': 'x', + }); + + const paths: string[] = []; + for await (const record of walkLazy(fs, '/root', {}, 1, undefined)) { + paths.push(record.path); + } + + const expected = ['/root', '/root/dir1', '/root/dir2']; + const actual = fs.readdirCalls; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Orchestrate/xargsTarget.spec.ts b/packages/claude-sdk-tools/test/Orchestrate/xargsTarget.spec.ts new file mode 100644 index 00000000..33eff89e --- /dev/null +++ b/packages/claude-sdk-tools/test/Orchestrate/xargsTarget.spec.ts @@ -0,0 +1,120 @@ +import { Clock } from '@js-joda/core'; +import { fromLines } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { defineToolV2, xargsTarget, xargsTargetKeys } from '../../src/Orchestrate/defineToolV2.js'; +import { createToolsV2Registry } from '../../src/Orchestrate/registry.js'; +import { RefStore } from '../../src/RefStore/RefStore.js'; +import { FakeExecutor } from '../FakeExecutor.js'; +import { fakeEscalatedRegistryDeps } from '../fakeEscalatedRegistryDeps.js'; +import { noopLogger, passthroughSips } from '../helpers.js'; +import { MemoryFileSystem } from '../MemoryFileSystem.js'; +import { MemoryObjectStore } from '../MemoryObjectStore.js'; +import { RecordingHistoryReader } from '../RecordingHistoryReader.js'; +import { RecordingMemoryStore } from '../RecordingMemoryStore.js'; + +function makeRegistry() { + return createToolsV2Registry({ + fs: new MemoryFileSystem(), + executor: new FakeExecutor(() => ({ exitCode: 0 })), + refStore: new RefStore(new MemoryObjectStore()), + sips: passthroughSips, + logger: noopLogger, + memoryStore: new RecordingMemoryStore(), + historyReader: new RecordingHistoryReader(), + currentSessionId: () => 'session', + clock: Clock.systemUTC(), + skillDirs: [], + ...fakeEscalatedRegistryDeps(), + }); +} + +function accepts(stages: unknown[]): boolean { + return makeRegistry().planCall({ stages }).ok; +} + +/** The reason a call was refused, without the `stages.1: ` prefix that says where. */ +function issueText(stages: unknown[]): string { + const result = makeRegistry().planCall({ stages }); + return result.ok ? '' : (result.error.split('\n')[0]?.replace(/^[\w.]+: /, '') ?? ''); +} + +describe('a tool declaring its xargs target', () => { + it('reports the marked field', () => { + const expected = ['files']; + const actual = xargsTargetKeys(z.object({ files: xargsTarget(z.array(z.string())), severity: z.string() })); + expect(actual).toEqual(expected); + }); + + it('reports a field marked through an optional wrapper', () => { + const expected = ['args']; + const actual = xargsTargetKeys(z.object({ args: xargsTarget(z.array(z.string()).optional()) })); + expect(actual).toEqual(expected); + }); + + it('refuses to define a tool marking two fields, since no pipeline could say which was meant', () => { + expect(() => + defineToolV2({ + name: 'Ambiguous', + description: 'two targets', + operations: () => ['none'], + model: z.object({ files: xargsTarget(z.array(z.string())), extras: xargsTarget(z.array(z.string())) }), + run: () => ({ stdout: fromLines((async function* () {})()), success: () => true }), + }), + ).toThrow('Ambiguous'); + }); +}); + +describe('validating a pipeline before it runs', () => { + it('accepts a stage fed by Xargs that omits the field being fed', () => { + const expected = true; + const actual = accepts([{ tool: 'Find', input: { path: '/root' }, op: '|' }, { xargs: true }, { tool: 'Read', input: {} }]); + expect(actual).toBe(expected); + }); + + it('accepts a stage fed by Xargs that also names files of its own', () => { + const expected = true; + const actual = accepts([{ tool: 'Find', input: { path: '/root' }, op: '|' }, { xargs: true }, { tool: 'Read', input: { paths: ['/known.ts'] } }]); + expect(actual).toBe(expected); + }); + + it('rejects a stage that stands alone without the field it needs', () => { + const expected = 'Read needs paths, either supplied here or fed by an Xargs stage before it.'; + const actual = issueText([{ tool: 'Read', input: {} }]); + expect(actual).toBe(expected); + }); + + it('rejects an Xargs feeding a tool that has no argument list', () => { + const expected = 'Xargs cannot feed Find: it takes no argument list. Pipe into it directly if it reads a pipe, or drop the Xargs.'; + const actual = issueText([{ tool: 'Paths', input: { paths: ['/a'] }, op: '|' }, { xargs: true }, { tool: 'Find', input: { path: '/root' } }]); + expect(actual).toBe(expected); + }); + + it('rejects an Xargs with nothing after it to feed', () => { + const expected = 'Xargs must be followed by the tool stage it feeds.'; + const actual = issueText([{ tool: 'Find', input: { path: '/root' }, op: '|' }, { xargs: true }]); + expect(actual).toBe(expected); + }); +}); + +// Silence was the old behaviour here: the producer ran, the consumer ignored what it produced, and +// three stages reported success with no output. +describe('validating a pipe into a tool that cannot read one', () => { + it('rejects it, naming both ends and the fix', () => { + const expected = "Find pipes into Read, which does not read a pipe, so its output would be discarded. Put an Xargs stage between them to append the piped values to Read's paths."; + const actual = issueText([ + { tool: 'Find', input: { path: '/root' }, op: '|' }, + { tool: 'Read', input: { paths: ['/a.ts'] } }, + ]); + expect(actual).toBe(expected); + }); + + it('accepts a pipe into a tool that does read one', () => { + const expected = true; + const actual = accepts([ + { tool: 'Find', input: { path: '/root' }, op: '|' }, + { tool: 'Match', input: { pattern: 'x' } }, + ]); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Policy/PolicyStore.spec.ts b/packages/claude-sdk-tools/test/Policy/PolicyStore.spec.ts new file mode 100644 index 00000000..6a78d759 --- /dev/null +++ b/packages/claude-sdk-tools/test/Policy/PolicyStore.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { PolicyStore } from '../../src/Policy/PolicyStore.js'; +import type { ToolLookup } from '../../src/Policy/validatePolicy.js'; + +const programModel = z.object({ program: z.string() }); + +function lookup(tools: Record = { Program: programModel }): ToolLookup { + return { get: (name) => (tools[name] ? { model: tools[name] } : undefined) }; +} + +describe('PolicyStore \u2014 construction', () => { + it('starts with the given policy when it is valid', () => { + const initial = [{ tool: 'Program', default: 'deny' as const }]; + const store = new PolicyStore(initial, lookup()); + + const expected = initial; + const actual = store.current; + expect(actual).toEqual(expected); + }); + + it('falls back to a safe ask-everything policy when the given initial policy is invalid, rather than starting with nothing', () => { + const store = new PolicyStore([{ tool: 'Program', default: 'yolo' }], lookup()); + + const resolved = store.current.length > 0; + expect(resolved).toBe(true); + }); +}); + +describe('PolicyStore \u2014 update', () => { + it('accepts a valid replacement policy', () => { + const store = new PolicyStore([{ default: 'ask' as const }], lookup()); + const replacement = [{ tool: 'Program', default: 'deny' as const }]; + + const result = store.update(replacement); + + expect(result.accepted).toBe(true); + expect(store.current).toEqual(replacement); + }); + + it('rejects a case-1 invalid replacement, keeping the previous policy unchanged', () => { + const previous = [{ tool: 'Program', default: 'deny' as const }]; + const store = new PolicyStore(previous, lookup()); + + const result = store.update([{ tool: 'Program', default: 'yolo' }]); + + expect(result.accepted).toBe(false); + expect(store.current).toEqual(previous); + }); + + it('reports the errors when a replacement is rejected', () => { + const store = new PolicyStore([{ default: 'ask' as const }], lookup()); + + const result = store.update([{ tool: 'Program', default: 'yolo' }]); + + const actual = !result.accepted && result.errors.length > 0; + expect(actual).toBe(true); + }); + + it('rejects a case-2 invalid replacement, keeping the previous policy unchanged', () => { + const previous = [{ tool: 'Program', default: 'deny' as const }]; + const store = new PolicyStore(previous, lookup()); + + const result = store.update([{ tool: 'Program', input: { totallyMadeUp: ['x'] }, default: 'deny' as const }]); + + expect(result.accepted).toBe(false); + expect(store.current).toEqual(previous); + }); + + it('accepts a case-3 replacement (an inert rule for an unloaded tool), and still surfaces the warning', () => { + const store = new PolicyStore([{ default: 'ask' as const }], lookup()); + const replacement = [{ tool: 'Git_Reset', default: 'deny' as const }]; + + const result = store.update(replacement); + + expect(result.accepted).toBe(true); + expect(store.current).toEqual(replacement); + expect(result.accepted && result.warnings.some((w) => w.includes('Git_Reset'))).toBe(true); + }); +}); diff --git a/packages/claude-sdk-tools/test/Policy/defaultPolicy.spec.ts b/packages/claude-sdk-tools/test/Policy/defaultPolicy.spec.ts new file mode 100644 index 00000000..f69c2038 --- /dev/null +++ b/packages/claude-sdk-tools/test/Policy/defaultPolicy.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { defaultPolicy } from '../../src/Policy/defaultPolicy.js'; +import { resolve } from '../../src/Policy/resolve.js'; +import type { ToolLookup } from '../../src/Policy/validatePolicy.js'; +import { validatePolicy } from '../../src/Policy/validatePolicy.js'; + +const cwd = '/repo'; +const home = '/home/stephen'; + +/** The default names no tool and no input field, so an empty lookup is the honest one. */ +function lookup(): ToolLookup { + return { get: () => undefined }; +} + +describe('defaultPolicy', () => { + it('is itself a valid policy — the shipped default must pass its own validation', () => { + const result = validatePolicy(defaultPolicy, lookup()); + expect(result.valid).toBe(true); + }); + + it('allows reading inside the working directory', () => { + const actual = resolve(defaultPolicy, { tool: 'Read', input: {}, paths: [`${cwd}/src/a.ts`], operation: 'fs.read', cwd, home, platform: 'linux' }).verdict; + expect(actual).toBe('allow'); + }); + + it('allows listing inside the working directory', () => { + const actual = resolve(defaultPolicy, { tool: 'Find', input: {}, paths: [`${cwd}/src`], operation: 'fs.list', cwd, home, platform: 'linux' }).verdict; + expect(actual).toBe('allow'); + }); + + // The old default allowed reads everywhere and relied on a `~/.ssh/**` carve-out sitting above + // that rule. Scoping the allow to the working directory means anything outside it is asked + // about on its own merits, with no ordering to get wrong. + it('asks before reading outside the working directory', () => { + const actual = resolve(defaultPolicy, { tool: 'Read', input: {}, paths: [`${home}/.ssh/id_ed25519`], operation: 'fs.read', cwd, home, platform: 'linux' }).verdict; + expect(actual).toBe('ask'); + }); + + it('asks before writing, even inside the working directory', () => { + const actual = resolve(defaultPolicy, { tool: 'EditFile', input: {}, paths: [`${cwd}/src/a.ts`], operation: 'fs.write', cwd, home, platform: 'linux' }).verdict; + expect(actual).toBe('ask'); + }); + + it('asks before running a program', () => { + const actual = resolve(defaultPolicy, { tool: 'Program', input: { program: 'rm', args: ['-rf', '/tmp'] }, paths: [cwd], operation: 'fs.exec', cwd, home, platform: 'linux' }).verdict; + expect(actual).toBe('ask'); + }); + + it('never silently allows something with no matching rule', () => { + const actual = resolve(defaultPolicy, { tool: 'SomeFutureTool', input: {}, paths: [], operation: 'escalate', cwd, home, platform: 'linux' }).verdict; + expect(actual).toBe('ask'); + }); +}); diff --git a/packages/claude-sdk-tools/test/Policy/execV3Parity.spec.ts b/packages/claude-sdk-tools/test/Policy/execV3Parity.spec.ts new file mode 100644 index 00000000..b3e0dff8 --- /dev/null +++ b/packages/claude-sdk-tools/test/Policy/execV3Parity.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { resolve } from '../../src/Policy/resolve.js'; +import type { PolicySet } from '../../src/Policy/types.js'; + +// Every real `Exec/ruleConfig.ts` defaultRules entry, ported one for one, matched against +// Program's real input.program/input.args fields — proving genuine parity, not a hand-picked +// subset. `basename` everywhere `programs` was used, since a real call can arrive by full path. +const cwd = '/repo'; +const home = '/home/stephen'; + +const policy: PolicySet = [ + { tool: 'Program', input: { program: { basename: ['rm', 'rmdir', 'mkfs', 'dd', 'shred'] } }, default: 'deny', message: "'{program}' is destructive and irreversible. Ask the user to run it directly." }, + { tool: 'Program', input: { program: { basename: ['xargs'] } }, default: 'deny', message: 'xargs can execute arbitrary commands on piped input. Write commands explicitly, or use Glob/Grep tools.' }, + { tool: 'Program', input: { program: { basename: ['sed'] }, args: { anyOf: ['-i', '--in-place'] } }, default: 'deny', message: 'sed -i modifies files in-place with no undo. Use the redirect option to write to a new file, or use the Edit tool.' }, + { tool: 'Program', input: { program: { basename: ['git'] }, args: { allOf: ['rm'] } }, default: 'deny', message: 'git rm is destructive and irreversible. Ask the user to run it directly.' }, + { tool: 'Program', input: { program: { basename: ['git'] }, args: { allOf: ['checkout'] } }, default: 'deny', message: 'git checkout can discard uncommitted changes with no undo. Use "git switch" for branches, or ask the user to run it directly.' }, + { tool: 'Program', input: { program: { basename: ['git'] }, args: { allOf: ['reset'] } }, default: 'deny', message: 'git reset is destructive and irreversible. Ask the user to run it directly.' }, + { tool: 'Program', input: { program: { basename: ['git'] }, args: { allOf: ['push'], anyOf: ['-f', '--force', '--force-with-lease', '--force-if-includes'] } }, default: 'deny', message: 'Force push overwrites remote history with no undo. Use regular "git push", or ask the user to run it directly.' }, + { tool: 'Program', input: { program: { suffix: '.exe' } }, default: 'deny', message: "'{program}' - there is no reason to call .exe. Run equivalent commands natively." }, + { tool: 'Program', input: { program: { basename: ['sudo'] } }, default: 'deny', message: 'sudo is not permitted. Run commands directly.' }, + { tool: 'Program', input: { program: { basename: ['git'] }, args: { anyOf: ['-C', '--git-dir', '--work-tree', '-c'] } }, default: 'deny', message: 'git -C/--git-dir/--work-tree changes the working directory, and -c overrides config outside review. Use cwd instead, and avoid -c overrides.' }, + { tool: 'Program', input: { program: { basename: ['pnpm'] }, args: { anyOf: ['-C'] } }, default: 'deny', message: 'pnpm -C changes the working directory and bypasses auto-approve path checks. Use cwd instead.' }, + { tool: 'Program', input: { program: { basename: ['env', 'printenv'] }, args: { maxLength: 0 } }, default: 'deny', message: "'{program}' without arguments would dump all environment variables. Specify which variable to read." }, + { tool: 'Program', input: { program: { basename: ['git'] }, args: { allOf: ['clean'] } }, default: 'deny', message: 'git clean deletes untracked files with no undo. Ask the user to run it directly.' }, + { tool: 'Program', input: { program: { basename: ['sh', 'bash', 'zsh', 'python', 'python3', 'node', 'ruby', 'perl', 'osascript'] }, args: { anyOf: ['-c', '-e', '--eval'] } }, default: 'deny', message: "'{program}' with inline code runs unreviewed content directly. Write it to a file, then run that file." }, + { tool: 'Program', input: { program: { basename: ['find'] }, args: { anyOf: ['-exec', '-execdir', '-ok', '-okdir'] } }, default: 'deny', message: "find's -exec/-execdir/-ok/-okdir runs unreviewed commands directly. Write the command to a file and run it, or use the Find/Match tools." }, + + { tool: '*', default: 'ask' }, +]; + +function verdictFor(program: string, args: string[]) { + return resolve(policy, { tool: 'Program', input: { program, args }, paths: [], operation: 'fs.exec', cwd, home, platform: 'linux' }).verdict; +} + +describe('execV3 parity — every defaultRules entry, ported one for one', () => { + it('no-destructive-commands: blocks rm, rmdir, mkfs, dd, shred', () => { + expect(verdictFor('rm', ['-rf', '/tmp'])).toBe('deny'); + expect(verdictFor('shred', ['/tmp/a'])).toBe('deny'); + }); + + it('no-xargs: blocks xargs entirely', () => { + expect(verdictFor('xargs', ['rm'])).toBe('deny'); + }); + + it('no-sed-in-place: blocks sed -i, leaves plain sed alone', () => { + expect(verdictFor('sed', ['-i', 's/a/b/', 'f.txt'])).toBe('deny'); + expect(verdictFor('sed', ['s/a/b/', 'f.txt'])).toBe('ask'); + }); + + it('no-git-rm: blocks git rm', () => { + expect(verdictFor('git', ['rm', 'f.txt'])).toBe('deny'); + }); + + it('no-git-checkout: blocks git checkout', () => { + expect(verdictFor('git', ['checkout', 'main'])).toBe('deny'); + }); + + it('no-git-reset: blocks git reset', () => { + expect(verdictFor('git', ['reset', '--hard'])).toBe('deny'); + }); + + it('no-force-push: blocks git push --force, leaves a plain push alone', () => { + expect(verdictFor('git', ['push', '--force'])).toBe('deny'); + expect(verdictFor('git', ['push', 'origin', 'main'])).toBe('ask'); + }); + + it('no-exe: blocks anything ending in .exe', () => { + expect(verdictFor('malware.exe', [])).toBe('deny'); + }); + + it('no-sudo: blocks sudo entirely', () => { + expect(verdictFor('sudo', ['apt', 'install', 'x'])).toBe('deny'); + }); + + it('no-git-C: blocks git -C', () => { + expect(verdictFor('git', ['-C', '/other', 'status'])).toBe('deny'); + }); + + it('no-pnpm-C: blocks pnpm -C', () => { + expect(verdictFor('pnpm', ['-C', 'packages/foo', 'build'])).toBe('deny'); + }); + + it('no-env-dump: blocks env with no arguments, leaves env VAR_NAME alone', () => { + expect(verdictFor('env', [])).toBe('deny'); + expect(verdictFor('env', ['PATH'])).toBe('ask'); + }); + + it('no-git-clean: blocks git clean', () => { + expect(verdictFor('git', ['clean', '-fd'])).toBe('deny'); + }); + + it('no-inline-interpreter: blocks node -e, leaves running a real file alone', () => { + expect(verdictFor('node', ['-e', 'console.log(1)'])).toBe('deny'); + expect(verdictFor('node', ['script.js'])).toBe('ask'); + }); + + it('no-find-exec: blocks find -exec', () => { + expect(verdictFor('find', ['.', '-exec', 'rm', '{}', ';'])).toBe('deny'); + }); + + it('an ordinary, unrelated command falls through every rule to the catch-all', () => { + expect(verdictFor('pnpm', ['build'])).toBe('ask'); + }); + + it('every rule still catches its program by full path, not just the bare name', () => { + expect(verdictFor('/bin/rm', ['-rf', '/tmp'])).toBe('deny'); + expect(verdictFor('/usr/bin/sudo', ['ls'])).toBe('deny'); + }); +}); diff --git a/packages/claude-sdk-tools/test/Policy/matchInput.spec.ts b/packages/claude-sdk-tools/test/Policy/matchInput.spec.ts new file mode 100644 index 00000000..f2217749 --- /dev/null +++ b/packages/claude-sdk-tools/test/Policy/matchInput.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { matchesInput } from '../../src/Policy/matchInput.js'; + +describe('matchesInput', () => { + it('matches the real input.program field directly, by name', () => { + const expected = true; + const actual = matchesInput({ program: ['rm'] }, { program: 'rm', args: ['-rf', '/tmp'] }); + expect(actual).toBe(expected); + }); + + it('does not match a different program', () => { + const expected = false; + const actual = matchesInput({ program: ['rm'] }, { program: 'pnpm', args: ['build'] }); + expect(actual).toBe(expected); + }); + + it('matches multiple fields at once, all of which must hold', () => { + const expected = true; + const actual = matchesInput({ program: ['git'], args: { allOf: ['reset'] } }, { program: 'git', args: ['reset', '--hard'] }); + expect(actual).toBe(expected); + }); + + it('fails when only one of several named fields matches', () => { + const expected = false; + const actual = matchesInput({ program: ['git'], args: { allOf: ['reset'] } }, { program: 'git', args: ['status'] }); + expect(actual).toBe(expected); + }); + + it('never matches when the named field is absent from the real input entirely', () => { + const expected = false; + const actual = matchesInput({ program: ['rm'] }, { path: '/some/file' }); + expect(actual).toBe(expected); + }); + + it('matches everything when the rule has no input matcher', () => { + const expected = true; + const actual = matchesInput(undefined, { path: '/some/file' }); + expect(actual).toBe(expected); + }); + + it('never matches when the real input is an array rather than an object', () => { + const expected = false; + const actual = matchesInput({ program: ['rm'] }, ['rm', '-rf']); + expect(actual).toBe(expected); + }); + + it('never matches when the real input is a bare string', () => { + const expected = false; + const actual = matchesInput({ program: ['rm'] }, 'rm'); + expect(actual).toBe(expected); + }); + + it('never matches when the real input is null', () => { + const expected = false; + const actual = matchesInput({ program: ['rm'] }, null); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Policy/matchPath.spec.ts b/packages/claude-sdk-tools/test/Policy/matchPath.spec.ts new file mode 100644 index 00000000..9a656e1d --- /dev/null +++ b/packages/claude-sdk-tools/test/Policy/matchPath.spec.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from 'vitest'; +import { matchesPath } from '../../src/Policy/matchPath.js'; + +const cwd = '/repo'; +const home = '/home/stephen'; + +function matches(pattern: string, path: string): boolean { + return matchesPath(pattern, path, cwd, home, 'linux'); +} + +// --------------------------------------------------------------------------- +// A single `*` stays inside one path segment. This is the conventional split +// (shell, .gitignore, minimatch) and the reason `$PWD/*.env` must not reach a +// nested file: a rule written for the project root should not silently govern +// everything beneath it. +// --------------------------------------------------------------------------- + +describe('matchesPath — a single * matches within one segment', () => { + it('matches a file directly inside the base', () => { + expect(matches('$PWD/*.env', '/repo/a.env')).toBe(true); + }); + + it('matches a dotfile, since a leading dot is not special here', () => { + expect(matches('$PWD/*.env', '/repo/.env')).toBe(true); + }); + + it('does not cross a slash into a nested directory', () => { + expect(matches('$PWD/*.env', '/repo/src/a.env')).toBe(false); + }); + + it('matches any single entry when the whole segment is *', () => { + expect(matches('$PWD/*', '/repo/a.txt')).toBe(true); + }); + + it('does not match a nested entry when the whole segment is *', () => { + expect(matches('$PWD/*', '/repo/src/a.txt')).toBe(false); + }); + + it('matches in the middle of a pattern', () => { + expect(matches('$PWD/*/a.ts', '/repo/src/a.ts')).toBe(true); + }); + + it('requires the starred segment to exist', () => { + expect(matches('$PWD/*/a.ts', '/repo/a.ts')).toBe(false); + }); + + it('treats a regex metacharacter in a literal segment as a literal', () => { + expect(matches('$PWD/*.env', '/repo/axenv')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// `**` spans any number of segments, including none. The zero-segment case is +// the one implementations get wrong, and it is the difference between +// `$PWD/**/*.env` protecting the project's own .env or only nested ones. +// --------------------------------------------------------------------------- + +describe('matchesPath — ** spans any number of segments', () => { + it('matches at the base, with no intervening segment at all', () => { + expect(matches('$PWD/**/*.env', '/repo/.env')).toBe(true); + }); + + it('matches one level down', () => { + expect(matches('$PWD/**/*.env', '/repo/src/.env')).toBe(true); + }); + + it('matches several levels down', () => { + expect(matches('$PWD/**/*.env', '/repo/a/b/c/.env')).toBe(true); + }); + + it('matches a named file at any depth', () => { + expect(matches('$PWD/**/world', '/repo/a/b/world')).toBe(true); + }); + + it('still requires the literal after it to match', () => { + expect(matches('$PWD/**/world', '/repo/a/b/worldly')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// A trailing /** is the form that already existed, and its meaning must not +// change: the base itself and everything beneath it. +// --------------------------------------------------------------------------- + +describe('matchesPath — a trailing /** covers the base and everything under it', () => { + it('matches the base directory itself', () => { + expect(matches('$PWD/**', '/repo')).toBe(true); + }); + + it('matches a direct child', () => { + expect(matches('$PWD/**', '/repo/a.txt')).toBe(true); + }); + + it('matches a deeply nested child', () => { + expect(matches('$PWD/**', '/repo/a/b/c.txt')).toBe(true); + }); + + it('does not match a sibling that merely shares the prefix', () => { + expect(matches('$PWD/**', '/repo-other/a.txt')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Combinations, including the shapes that look nonsensical but are lawful. +// --------------------------------------------------------------------------- + +describe('matchesPath — combined and redundant patterns', () => { + it('collapses consecutive ** rather than rejecting them', () => { + expect(matches('$PWD/*/**/**', '/repo/a/b/c')).toBe(true); + }); + + it('still honours the leading * when ** follows it', () => { + expect(matches('$PWD/*/**/**', '/repo')).toBe(false); + }); + + it('matches a literal-then-any-depth-then-literal pattern', () => { + expect(matches('$PWD/*/hello/**/world/**', '/repo/x/hello/a/b/world/c')).toBe(true); + }); + + it('matches that pattern with nothing between the two literals', () => { + expect(matches('$PWD/*/hello/**/world/**', '/repo/x/hello/world')).toBe(true); + }); + + it('does not match when a literal in the middle is absent', () => { + expect(matches('$PWD/*/hello/**/world/**', '/repo/x/goodbye/world')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// `**` with more structure after it. A prefix-based matcher can express "any +// depth" only at the end; here the pattern has to resume matching exact shape +// once the ** has absorbed however many segments it needs. +// --------------------------------------------------------------------------- + +describe('matchesPath — ** in the middle, with segments still to match after it', () => { + const pattern = '$PWD/*/hello/**/world/*/*.txt'; + + it('matches with the ** absorbing nothing', () => { + expect(matches(pattern, '/repo/x/hello/world/sub/a.txt')).toBe(true); + }); + + it('matches with the ** absorbing several segments', () => { + expect(matches(pattern, '/repo/x/hello/a/b/world/sub/a.txt')).toBe(true); + }); + + it('does not match when the single segment before the file is missing', () => { + expect(matches(pattern, '/repo/x/hello/world/a.txt')).toBe(false); + }); + + it('does not match when the tail is nested deeper than the pattern allows', () => { + expect(matches(pattern, '/repo/x/hello/world/sub/deep/a.txt')).toBe(false); + }); + + it('does not match a different extension', () => { + expect(matches(pattern, '/repo/x/hello/world/sub/a.md')).toBe(false); + }); + + it('does not match when the leading single segment is absent', () => { + expect(matches(pattern, '/repo/hello/world/sub/a.txt')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Everything the matcher did before globbing existed still holds. +// --------------------------------------------------------------------------- + +describe('matchesPath — existing behaviour is unchanged', () => { + it('the bare wildcard matches any path', () => { + expect(matches('*', '/anywhere/at/all.txt')).toBe(true); + }); + + it('a bare $PWD matches inside the working directory', () => { + expect(matches('$PWD', '/repo/src/a.ts')).toBe(true); + }); + + it('a bare $PWD does not match outside it', () => { + expect(matches('$PWD', '/elsewhere/a.ts')).toBe(false); + }); + + it('~/ expands against the supplied home', () => { + expect(matches('~/.ssh/**', `${home}/.ssh/id_ed25519`)).toBe(true); + }); + + it('$HOME and ~/ mean the same thing', () => { + expect(matches('$HOME/.ssh/**', `${home}/.ssh/id_ed25519`)).toBe(true); + }); + + it('a relative candidate path is resolved against cwd before comparing', () => { + expect(matches('$PWD', 'src/a.ts')).toBe(true); + }); + + it('a relative candidate that climbs out does not match', () => { + expect(matches('$PWD', '../outside.txt')).toBe(false); + }); + + it('a glob pattern is also boundary-safe against a prefix sibling', () => { + expect(matches('$PWD/*', '/repo-other/a.txt')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Several `**` separated by literals cannot be merged away, so the matcher has +// to stay bounded on input that nearly matches and then fails at the very end. +// A regex built from the same pattern degrades exponentially here: measured at +// 0.4ms for a 16-character segment, doubling every two characters. The walk is +// flat because each retry consumes one more segment and never reconsiders an +// earlier one. +// --------------------------------------------------------------------------- + +describe('matchesPath — pathological input stays bounded', () => { + it('answers a deep near-miss against several ** without hanging', () => { + const deep = `/repo/${Array.from({ length: 40 }, (_, i) => `d${i}`).join('/')}/nope`; + + const started = performance.now(); + const actual = matches('$PWD/**/a/**/b/**/c/**/d/**/e', deep); + const elapsed = performance.now() - started; + + expect(actual).toBe(false); + expect(elapsed).toBeLessThan(100); + }); + + it('answers a long single-segment near-miss against several * without hanging', () => { + const segment = `${'a'.repeat(200)}b`; + + const started = performance.now(); + const actual = matches('$PWD/*a*a*a*a*a*a*a*c', `/repo/${segment}`); + const elapsed = performance.now() - started; + + expect(actual).toBe(false); + expect(elapsed).toBeLessThan(100); + }); +}); + +// --------------------------------------------------------------------------- +// A run of slashes is one separator, and a trailing slash means nothing, the +// same way the kernel reads `/repo///src//a.ts` as `/repo/src/a.ts`. Neither +// side of a match can keep an empty segment, so a policy is never defeated by +// how someone happened to punctuate a path. +// --------------------------------------------------------------------------- + +describe('matchesPath — slashes', () => { + it('a trailing slash on a pattern changes nothing', () => { + expect(matches('$PWD/', '/repo/src/a.ts')).toBe(true); + }); + + it('a trailing slash still does not reach outside the directory', () => { + expect(matches('$PWD/', '/repo-other/a.ts')).toBe(false); + }); + + it('a trailing slash on a named subdirectory scopes the same as without one', () => { + expect(matches('$PWD/src/', '/repo/src/a.ts')).toBe(true); + }); + + it('a run of slashes in a pattern is one separator', () => { + expect(matches('$PWD//src', '/repo/src/a.ts')).toBe(true); + }); + + it('a run of slashes in the candidate path is one separator', () => { + expect(matches('$PWD/src', '/repo///src//a.ts')).toBe(true); + }); + + it('a run of slashes on both sides still matches', () => { + expect(matches('$PWD//src//', '/repo//src///a.ts')).toBe(true); + }); + + it('a run of slashes does not let a path escape a deny pattern', () => { + expect(matches('~/.ssh/**', `${home}//.ssh///id_ed25519`)).toBe(true); + }); + + it('a run of slashes cannot stand in for a segment a pattern requires', () => { + expect(matches('$PWD/src/*/a.ts', '/repo/src//a.ts')).toBe(false); + }); +}); + +// A pattern says where it applies. Local is spelled $PWD, so a pattern that starts with a glob is +// about everywhere: a deny written `**` that quietly meant "under the working directory" covers +// less than it reads, which is the direction that costs you. +describe('matchesPath — a pattern that starts with a glob is global', () => { + it('matches a path outside the working directory', () => { + expect(matches('**', '/etc/passwd')).toBe(true); + }); + + it('matches a path inside the working directory too', () => { + expect(matches('**', '/repo/src/a.ts')).toBe(true); + }); + + it('means the same written with a leading slash', () => { + expect(matches('/**', '/etc/passwd')).toBe(true); + }); + + it('matches by name anywhere when the glob leads', () => { + expect(matches('**/id_rsa', `${home}/.ssh/id_rsa`)).toBe(true); + }); + + it('still scopes a pattern that names the working directory', () => { + expect(matches('$PWD/**', '/etc/passwd')).toBe(false); + }); +}); + +// On macOS and Windows `/Users/x/.ssh` and `/users/x/.ssh` are the same file, so a rule about one +// has to cover the other or it is evaded by typing it differently. Everywhere else they are +// genuinely different paths, and treating them as one would make an allow reach further than it +// was written to. +describe('matchesPath — case, where the filesystem does not distinguish it', () => { + it('matches a differently cased path on macOS', () => { + expect(matchesPath('$HOME/.ssh/**', '/home/Stephen/.SSH/id_rsa', cwd, home, 'darwin')).toBe(true); + }); + + it('matches a differently cased pattern on Windows', () => { + expect(matchesPath('/Users/Stephen/**', '/users/stephen/secret.txt', cwd, home, 'win32')).toBe(true); + }); + + it('does not match a differently cased path on Linux', () => { + expect(matchesPath('/Users/Stephen/**', '/users/stephen/secret.txt', cwd, home, 'linux')).toBe(false); + }); + + it('still matches an exactly cased path on Linux', () => { + expect(matchesPath('/Users/Stephen/**', '/Users/Stephen/secret.txt', cwd, home, 'linux')).toBe(true); + }); +}); diff --git a/packages/claude-sdk-tools/test/Policy/matchTool.spec.ts b/packages/claude-sdk-tools/test/Policy/matchTool.spec.ts new file mode 100644 index 00000000..d3c38b48 --- /dev/null +++ b/packages/claude-sdk-tools/test/Policy/matchTool.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { matchesTool } from '../../src/Policy/matchTool.js'; + +describe('matchesTool', () => { + it('matches when the rule names the exact tool', () => { + const expected = true; + const actual = matchesTool('Program', 'Program'); + expect(actual).toBe(expected); + }); + + it('does not match a different tool name', () => { + const expected = false; + const actual = matchesTool('Program', 'Find'); + expect(actual).toBe(expected); + }); + + it('matches any name in a list', () => { + const expected = true; + const actual = matchesTool(['WriteMemory', 'ReadMemory'], 'ReadMemory'); + expect(actual).toBe(expected); + }); + + it('does not match a name absent from the list', () => { + const expected = false; + const actual = matchesTool(['WriteMemory', 'ReadMemory'], 'DeleteFile'); + expect(actual).toBe(expected); + }); + + it('matches any tool when the rule omits tool entirely', () => { + const expected = true; + const actual = matchesTool(undefined, 'AnythingAtAll'); + expect(actual).toBe(expected); + }); + + it('matches any tool when the rule names the wildcard', () => { + const expected = true; + const actual = matchesTool('*', 'AnythingAtAll'); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Policy/matchValue.spec.ts b/packages/claude-sdk-tools/test/Policy/matchValue.spec.ts new file mode 100644 index 00000000..086d5019 --- /dev/null +++ b/packages/claude-sdk-tools/test/Policy/matchValue.spec.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; +import { matchesValue } from '../../src/Policy/matchValue.js'; + +describe('matchesValue - plain list, membership against a scalar', () => { + it('matches a scalar that is one of the listed values', () => { + const expected = true; + const actual = matchesValue(['rm', 'rmdir'], 'rm'); + expect(actual).toBe(expected); + }); + + it('does not match a scalar absent from the list', () => { + const expected = false; + const actual = matchesValue(['rm', 'rmdir'], 'pnpm'); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - allOf, every value must be present', () => { + it('matches when every listed value is present in the actual array', () => { + const expected = true; + const actual = matchesValue({ allOf: ['reset'] }, ['reset', '--hard']); + expect(actual).toBe(expected); + }); + + it('does not match when one of the listed values is missing', () => { + const expected = false; + const actual = matchesValue({ allOf: ['reset', '--soft'] }, ['reset', '--hard']); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - anyOf, at least one value must be present', () => { + it('matches when at least one listed value is present', () => { + const expected = true; + const actual = matchesValue({ anyOf: ['-f', '--force'] }, ['push', '--force']); + expect(actual).toBe(expected); + }); + + it('does not match when none of the listed values are present', () => { + const expected = false; + const actual = matchesValue({ anyOf: ['-f', '--force'] }, ['push']); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - a plain list against an array actual value', () => { + it('is equivalent to anyOf: matches when the actual array contains one of the listed values', () => { + const expected = matchesValue({ anyOf: ['-f', '--force'] }, ['push', '--force']); + const actual = matchesValue(['-f', '--force'], ['push', '--force']); + expect(actual).toBe(expected); + }); + + it('is equivalent to anyOf: does not match when none of the listed values are present', () => { + const expected = matchesValue({ anyOf: ['-f', '--force'] }, ['push']); + const actual = matchesValue(['-f', '--force'], ['push']); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - allOf and anyOf combined in one pattern', () => { + it('matches only when both hold: all of the required flags, and at least one of the risky ones', () => { + const expected = true; + const actual = matchesValue({ allOf: ['push'], anyOf: ['-f', '--force'] }, ['push', '--force']); + expect(actual).toBe(expected); + }); + + it('does not match when allOf holds but anyOf does not', () => { + const expected = false; + const actual = matchesValue({ allOf: ['push'], anyOf: ['-f', '--force'] }, ['push', 'origin', 'main']); + expect(actual).toBe(expected); + }); + + it('does not match when anyOf holds but allOf does not', () => { + const expected = false; + const actual = matchesValue({ allOf: ['push'], anyOf: ['-f', '--force'] }, ['pull', '--force']); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - allOf combined with suffix, a combination that can never hold', () => { + it('never matches, because allOf needs an array and suffix needs a string on the same value', () => { + const expected = false; + const actual = matchesValue({ allOf: ['reset'], suffix: '.exe' }, ['reset', '--hard']); + expect(actual).toBe(expected); + }); + + it('still never matches even when the actual value would satisfy suffix on its own', () => { + const expected = false; + const actual = matchesValue({ allOf: ['reset'], suffix: '.exe' }, 'malware.exe'); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - anyOf combined with suffix, the same impossible combination', () => { + it('never matches, for the same reason as allOf + suffix', () => { + const expected = false; + const actual = matchesValue({ anyOf: ['-f', '--force'], suffix: '.exe' }, ['push', '--force']); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - a pattern object with no matcher fields set at all never matches', () => { + it('does not match a scalar, even though nothing was specified to check', () => { + const expected = false; + const actual = matchesValue({}, 'anything'); + expect(actual).toBe(expected); + }); + + it('does not match an array either', () => { + const expected = false; + const actual = matchesValue({}, ['anything']); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - allOf/anyOf normalise CLI flag conventions, same as ruleConfigMatches', () => { + it("matches --foo=bar against allOf: ['--foo'], the value is never matched on", () => { + const expected = true; + const actual = matchesValue({ allOf: ['--foo'] }, ['--foo=bar']); + expect(actual).toBe(expected); + }); + + it("matches a bundled short flag -ni against anyOf: ['-i']", () => { + const expected = true; + const actual = matchesValue({ anyOf: ['-i'] }, ['-ni']); + expect(actual).toBe(expected); + }); + + it('still matches the literal bundled token itself, not only its exploded form', () => { + const expected = true; + const actual = matchesValue({ anyOf: ['-ni'] }, ['-ni']); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - maxLength, an array must not exceed this many items', () => { + it('matches when the actual array is within the limit', () => { + const expected = true; + const actual = matchesValue({ maxLength: 0 }, []); + expect(actual).toBe(expected); + }); + + it('does not match when the actual array exceeds the limit', () => { + const expected = false; + const actual = matchesValue({ maxLength: 0 }, ['FOO=bar']); + expect(actual).toBe(expected); + }); + + it('does not match a scalar actual value at all, maxLength only applies to arrays', () => { + const expected = false; + const actual = matchesValue({ maxLength: 5 }, 'not-an-array'); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - basename, strips any path prefix before comparing', () => { + it('matches an absolute path by its basename', () => { + const expected = true; + const actual = matchesValue({ basename: ['rm'] }, '/bin/rm'); + expect(actual).toBe(expected); + }); + + it('matches a relative path by its basename', () => { + const expected = true; + const actual = matchesValue({ basename: ['rm'] }, './rm'); + expect(actual).toBe(expected); + }); + + it('still matches a bare name with no path at all', () => { + const expected = true; + const actual = matchesValue({ basename: ['rm'] }, 'rm'); + expect(actual).toBe(expected); + }); + + it('does not match a name that merely ends with the target, not equals it', () => { + const expected = false; + const actual = matchesValue({ basename: ['rm'] }, '/bin/xrm'); + expect(actual).toBe(expected); + }); + + it('does not match a different program at a similar path', () => { + const expected = false; + const actual = matchesValue({ basename: ['rm'] }, '/bin/pnpm'); + expect(actual).toBe(expected); + }); + + it('scopes the transform to the field that opted in — does not affect a path field elsewhere in the same rule', () => { + // Not this module's concern to prove in isolation (resolve.spec.ts / policy.integration.spec.ts + // cover multi-field rules); this only confirms basename itself never runs unless asked. + const expected = false; + const actual = matchesValue(['rm'], '/bin/rm'); + expect(actual).toBe(expected); + }); +}); + +describe('matchesValue - suffix', () => { + it('matches a scalar ending with the given suffix', () => { + const expected = true; + const actual = matchesValue({ suffix: '.exe' }, 'malware.exe'); + expect(actual).toBe(expected); + }); + + it('does not match a scalar not ending with the given suffix', () => { + const expected = false; + const actual = matchesValue({ suffix: '.exe' }, 'pnpm'); + expect(actual).toBe(expected); + }); +}); + +// The plain list is documented as shorthand for anyOf, so it has to match what anyOf matches. It +// didn't: `anyOf` normalised the arguments and the shorthand compared them raw, so the shorthand +// silently caught less than the form it stands for. +describe('matchesValue — the plain-list shorthand against arguments', () => { + it('matches a bundled short flag, as anyOf does', () => { + const expected = matchesValue({ anyOf: ['-i'] }, ['-ni', 'file']); + const actual = matchesValue(['-i'], ['-ni', 'file']); + expect(actual).toBe(expected); + }); + + it('matches a long flag written with a value, as anyOf does', () => { + const expected = matchesValue({ anyOf: ['--force'] }, ['--force=true']); + const actual = matchesValue(['--force'], ['--force=true']); + expect(actual).toBe(expected); + }); + + it('still does not match an argument that is not there', () => { + const expected = false; + const actual = matchesValue(['-i'], ['-n', 'file']); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Policy/policy.integration.spec.ts b/packages/claude-sdk-tools/test/Policy/policy.integration.spec.ts new file mode 100644 index 00000000..3648b173 --- /dev/null +++ b/packages/claude-sdk-tools/test/Policy/policy.integration.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { resolve } from '../../src/Policy/resolve.js'; +import type { PolicySet } from '../../src/Policy/types.js'; + +// One real, composed policy — not a synthetic toy — replicating what the current CLI already +// does across three previously-separate mechanisms: ExecV3's defaultRules (Exec/ruleConfig.ts), +// the path-zone permission matrix (apps/claude-sdk-cli/src/permissions.ts), and the Memory +// tools' frictionless carve-out. One ordered list, first match wins. Every `input` matcher +// names Program's real field names verbatim (`program`, `args`) — no translated vocabulary. +const cwd = '/repo'; +const home = '/home/stephen'; + +const policy: PolicySet = [ + { tool: ['WriteMemory', 'ReadMemory', 'SearchMemory', 'DeleteMemory', 'MemoryTypes'], default: 'allow' }, + + // program matching uses `basename` throughout — a rule naming 'rm' must also catch '/bin/rm' + // or '/usr/local/bin/rm', the same guarantee `ruleConfigMatches`'s own basename() gives today. + { tool: 'Program', input: { program: { basename: ['rm', 'rmdir', 'mkfs', 'dd', 'shred'] } }, default: 'deny', message: '{program} is destructive and irreversible. Ask the user to run it directly.' }, + { tool: 'Program', input: { program: { basename: ['sed'] }, args: { anyOf: ['-i', '--in-place'] } }, default: 'deny', message: '{program} -i modifies files in-place with no undo. Use the redirect option to write to a new file, or use the Edit tool.' }, + { tool: 'Program', input: { program: { basename: ['git'] }, args: { allOf: ['reset'] } }, default: 'deny', message: 'git reset is destructive and irreversible. Ask the user to run it directly.' }, + { tool: 'Program', input: { program: { basename: ['git'] }, args: { allOf: ['push'] } }, default: 'ask' }, + { tool: 'Program', input: { program: { suffix: '.exe' } }, default: 'deny', message: "there is no reason to call '{program}'. Run equivalent commands natively." }, + + { path: '~/.ssh/**', default: 'deny' }, + { path: '$PWD', operations: { 'fs.read': 'allow', 'fs.list': 'allow', 'fs.write': 'ask', 'fs.delete': 'ask', 'fs.exec': 'ask' } }, + { path: '*', operations: { 'fs.read': 'allow', 'fs.list': 'allow', 'fs.write': 'ask', 'fs.delete': 'deny', 'fs.exec': 'ask' } }, + + { tool: '*', default: 'ask' }, +]; + +function resolveFor(args: { tool: string; input?: unknown; paths?: string[]; operation: string }) { + return resolve(policy, { tool: args.tool, input: args.input ?? {}, paths: args.paths ?? [], operation: args.operation, cwd, home, platform: 'linux' }); +} + +function verdictFor(args: { tool: string; input?: unknown; paths?: string[]; operation: string }) { + return resolveFor(args).verdict; +} + +describe('the composed policy — Memory tools stay frictionless', () => { + it('allows WriteMemory regardless of operation, matching the delete-default that would otherwise ask', () => { + const expected = 'allow'; + const actual = verdictFor({ tool: 'DeleteMemory', operation: 'fs.delete' }); + expect(actual).toBe(expected); + }); +}); + +describe('the composed policy — ExecV3-shaped command blocking, matched against real input fields', () => { + it('blocks rm -rf via Program.input.program', () => { + const expected = 'deny'; + const actual = verdictFor({ tool: 'Program', input: { program: 'rm', args: ['-rf', '/tmp'] }, operation: 'fs.exec' }); + expect(actual).toBe(expected); + }); + + it('tells the model why, interpolated from the real input', () => { + const expected = 'rm is destructive and irreversible. Ask the user to run it directly.'; + const actual = resolveFor({ tool: 'Program', input: { program: 'rm', args: ['-rf', '/tmp'] }, operation: 'fs.exec' }).message; + expect(actual).toBe(expected); + }); + + it('blocks git reset --hard via Program.input.args', () => { + const expected = 'deny'; + const actual = verdictFor({ tool: 'Program', input: { program: 'git', args: ['reset', '--hard'] }, operation: 'fs.exec' }); + expect(actual).toBe(expected); + }); + + it('leaves an ordinary Program call alone, falling through to the fs.exec path tier', () => { + const expected = 'ask'; + const actual = verdictFor({ tool: 'Program', input: { program: 'pnpm', args: ['build'] }, paths: [cwd], operation: 'fs.exec' }); + expect(actual).toBe(expected); + }); + + it('blocks any .exe by suffix, regardless of what it is actually called', () => { + const expected = 'deny'; + const actual = verdictFor({ tool: 'Program', input: { program: 'malware.exe', args: [] }, operation: 'fs.exec' }); + expect(actual).toBe(expected); + }); + + it('blocks rm called by its full path, not just the bare name', () => { + const expected = 'deny'; + const actual = verdictFor({ tool: 'Program', input: { program: '/bin/rm', args: ['-rf', '/tmp'] }, operation: 'fs.exec' }); + expect(actual).toBe(expected); + }); +}); + +describe('the composed policy — path zones', () => { + it('an ssh key carve-out wins even though the key sits inside $PWD in this scenario', () => { + const expected = 'deny'; + const actual = verdictFor({ tool: 'Find', paths: [`${home}/.ssh/id_ed25519`], operation: 'fs.read' }); + expect(actual).toBe(expected); + }); + + it('reads inside the working directory are allowed', () => { + const expected = 'allow'; + const actual = verdictFor({ tool: 'Find', paths: [`${cwd}/src/a.ts`], operation: 'fs.read' }); + expect(actual).toBe(expected); + }); + + it('deletes inside the working directory ask, deletes outside it deny', () => { + const inside = verdictFor({ tool: 'DeleteFile', paths: [`${cwd}/a.txt`], operation: 'fs.delete' }); + const outside = verdictFor({ tool: 'DeleteFile', paths: ['/tmp/b.txt'], operation: 'fs.delete' }); + expect(inside).toBe('ask'); + expect(outside).toBe('deny'); + }); +}); + +describe('the composed policy — the final catch-all', () => { + it('asks for a tool with no path and no matching rule at all, never silently allowing', () => { + const expected = 'ask'; + const actual = verdictFor({ tool: 'SomeFutureTool', operation: 'escalate' }); + expect(actual).toBe(expected); + }); +}); + +describe('the composed policy — command rules and path zones compose for one call', () => { + it('a safe program with a dangerous cwd still falls through to the ssh carve-out, since no command rule catches it', () => { + const expected = 'deny'; + const actual = verdictFor({ tool: 'Program', input: { program: 'cat', args: ['notes.txt'] }, paths: [`${home}/.ssh/id_ed25519`], operation: 'fs.exec' }); + expect(actual).toBe(expected); + }); +}); + +describe('the composed policy — a multi-target call against the real policy', () => { + it('denies a Find naming one safe target and one ssh key, judged as the single act it is', () => { + const expected = 'deny'; + const actual = verdictFor({ tool: 'Find', paths: [`${cwd}/a.txt`, `${home}/.ssh/id_ed25519`], operation: 'fs.read' }); + expect(actual).toBe(expected); + }); +}); + +describe('the composed policy — a rule silent on an operation falls through to a later rule that covers it', () => { + it('a $PWD-shaped rule with no fs.delete key at all does not resolve fs.delete itself — it falls through', () => { + const withGap: PolicySet = [ + { path: '$PWD', operations: { 'fs.read': 'allow', 'fs.write': 'allow', 'fs.list': 'allow' } }, + { path: '*', operations: { 'fs.delete': 'deny' } }, + { tool: '*', default: 'ask' }, + ]; + + const expected = 'deny'; + const actual = resolve(withGap, { tool: 'Delete', input: {}, paths: [`${cwd}/a.txt`], operation: 'fs.delete', cwd, home, platform: 'linux' }).verdict; + expect(actual).toBe(expected); + }); +}); + +describe('the composed policy — rule order is load-bearing, not incidental', () => { + it('would silently allow reading an ssh key if the carve-out were moved below the general path rule', () => { + const reordered: PolicySet = [ + { path: '$PWD', operations: { 'fs.read': 'allow' } }, + { path: '*', operations: { 'fs.read': 'allow' } }, + { path: '~/.ssh/**', default: 'deny' }, + { tool: '*', default: 'ask' }, + ]; + + const correctOrder = verdictFor({ tool: 'Find', paths: [`${home}/.ssh/id_ed25519`], operation: 'fs.read' }); + const wrongOrder = resolve(reordered, { tool: 'Find', input: {}, paths: [`${home}/.ssh/id_ed25519`], operation: 'fs.read', cwd, home, platform: 'linux' }).verdict; + + expect(correctOrder).toBe('deny'); + expect(wrongOrder).toBe('allow'); + }); +}); diff --git a/packages/claude-sdk-tools/test/Policy/resolve.spec.ts b/packages/claude-sdk-tools/test/Policy/resolve.spec.ts new file mode 100644 index 00000000..1820faa1 --- /dev/null +++ b/packages/claude-sdk-tools/test/Policy/resolve.spec.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest'; +import { resolve } from '../../src/Policy/resolve.js'; +import type { PolicySet } from '../../src/Policy/types.js'; + +const cwd = '/repo'; +const home = '/home/stephen'; + +function check(policy: PolicySet, args: { tool: string; input?: unknown; paths?: string[]; operation: string }) { + return resolve(policy, { tool: args.tool, input: args.input ?? {}, paths: args.paths ?? [], operation: args.operation, cwd, home, platform: 'linux' }); +} + +describe('resolve — an unconfigured policy', () => { + it('asks for everything, never silently allows', () => { + const expected = 'ask'; + const actual = check([], { tool: 'Program', operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); +}); + +describe('resolve — first match wins', () => { + it('an earlier matching rule governs even when a later rule would also match', () => { + const policy: PolicySet = [ + { tool: 'Program', default: 'deny' }, + { tool: 'Program', default: 'allow' }, + ]; + const expected = 'deny'; + const actual = check(policy, { tool: 'Program', operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); + + it('a rule silent on this operation — no operations entry for it, no default — falls through to the next matching rule', () => { + const policy: PolicySet = [ + { tool: 'Program', operations: { 'fs.read': 'allow' } }, + { tool: '*', default: 'deny' }, + ]; + const expected = 'deny'; + const actual = check(policy, { tool: 'Program', operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); + + it('a rule that does cover this operation still governs, without falling through', () => { + const policy: PolicySet = [ + { tool: 'Program', operations: { 'fs.read': 'allow', 'fs.exec': 'allow' } }, + { tool: '*', default: 'deny' }, + ]; + const expected = 'allow'; + const actual = check(policy, { tool: 'Program', operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); + + it('a rule silent on every operation (no operations map, no default) falls through entirely', () => { + const policy: PolicySet = [ + { tool: 'Program', message: 'informational only' }, + { tool: '*', default: 'allow' }, + ]; + const expected = 'allow'; + const actual = check(policy, { tool: 'Program', operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); + + it('falling through all the way with no rule covering the operation still asks, never silently allows', () => { + const policy: PolicySet = [{ tool: 'Program', operations: { 'fs.read': 'allow' } }]; + const expected = 'ask'; + const actual = check(policy, { tool: 'Program', operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); +}); + +describe('resolve — operation-specific verdicts', () => { + it('reads the named operation from the matched rule', () => { + const policy: PolicySet = [{ tool: '*', operations: { 'fs.read': 'allow', 'fs.delete': 'deny' } }]; + const expected = 'deny'; + const actual = check(policy, { tool: 'DeleteFile', operation: 'fs.delete' }).verdict; + expect(actual).toBe(expected); + }); +}); + +describe('resolve — input matching, against the real field names', () => { + it('blocks a specific command by its input.program, leaving other Program calls untouched', () => { + const policy: PolicySet = [ + { tool: 'Program', input: { program: ['rm'] }, default: 'deny' }, + { tool: '*', default: 'allow' }, + ]; + const denied = check(policy, { tool: 'Program', input: { program: 'rm', args: ['-rf'] }, operation: 'fs.exec' }).verdict; + const allowed = check(policy, { tool: 'Program', input: { program: 'pnpm', args: ['build'] }, operation: 'fs.exec' }).verdict; + expect(denied).toBe('deny'); + expect(allowed).toBe('allow'); + }); +}); + +describe('resolve — path matching', () => { + it('a carve-out ahead of the general rule wins over it', () => { + const policy: PolicySet = [ + { path: '~/.ssh/**', default: 'deny' }, + { path: '*', default: 'allow' }, + ]; + const expected = 'deny'; + const actual = check(policy, { tool: 'Find', paths: [`${home}/.ssh/id_ed25519`], operation: 'fs.read' }).verdict; + expect(actual).toBe(expected); + }); + + it('a real (non-wildcard) path rule never matches a tool call with no resolved paths at all', () => { + const policy: PolicySet = [ + { path: '$PWD', default: 'deny' }, + { tool: '*', default: 'allow' }, + ]; + const expected = 'allow'; + const actual = check(policy, { tool: 'Program', paths: [], operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); + + it('the wildcard path rule matches even with no resolved paths at all, since it imposes no real constraint', () => { + const policy: PolicySet = [ + { path: '*', default: 'deny' }, + { tool: '*', default: 'allow' }, + ]; + const expected = 'deny'; + const actual = check(policy, { tool: 'Program', paths: [], operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); + + it('the wildcard path rule still matches normally when there are real paths too', () => { + const policy: PolicySet = [ + { path: '*', default: 'deny' }, + { tool: '*', default: 'allow' }, + ]; + const expected = 'deny'; + const actual = check(policy, { tool: 'Find', paths: ['/anywhere/at/all.txt'], operation: 'fs.read' }).verdict; + expect(actual).toBe(expected); + }); +}); + +// A call naming several paths is several calls: the operation is one indivisible act over all +// of them, so it is approved only to the extent every one of them is. Each path resolves on its +// own (tool AND input AND that path, first match wins) and the call takes the conjunction: any +// deny denies, else any ask asks, else allow. +describe('resolve — a call carrying several paths', () => { + const zones: PolicySet = [ + { path: '~/.ssh/**', default: 'deny' }, + { path: '$PWD', operations: { 'fs.read': 'allow', 'fs.delete': 'ask' } }, + { path: '*', operations: { 'fs.read': 'allow', 'fs.delete': 'deny' } }, + ]; + + it('denies the whole call when one path is denied and the other is allowed', () => { + const expected = 'deny'; + const actual = check(zones, { tool: 'Read', paths: [`${home}/.ssh/id_ed25519`, `${cwd}/README.md`], operation: 'fs.read' }).verdict; + expect(actual).toBe(expected); + }); + + it('is unaffected by the order the paths happen to arrive in', () => { + const expected = 'deny'; + const actual = check(zones, { tool: 'Read', paths: [`${cwd}/README.md`, `${home}/.ssh/id_ed25519`], operation: 'fs.read' }).verdict; + expect(actual).toBe(expected); + }); + + it('denies the whole call when one path asks and the other denies', () => { + const expected = 'deny'; + const actual = check(zones, { tool: 'Delete', paths: [`${cwd}/a.txt`, '/tmp/b.txt'], operation: 'fs.delete' }).verdict; + expect(actual).toBe(expected); + }); + + it('asks for the whole call when one path asks and the other allows', () => { + const asksInside: PolicySet = [ + { path: '$PWD', operations: { 'fs.read': 'ask' } }, + { path: '*', operations: { 'fs.read': 'allow' } }, + ]; + const expected = 'ask'; + const actual = check(asksInside, { tool: 'Read', paths: [`${cwd}/a.txt`, '/tmp/b.txt'], operation: 'fs.read' }).verdict; + expect(actual).toBe(expected); + }); + + it('allows the whole call only when every path allows', () => { + const expected = 'allow'; + const actual = check(zones, { tool: 'Read', paths: [`${cwd}/a.txt`, '/tmp/b.txt'], operation: 'fs.read' }).verdict; + expect(actual).toBe(expected); + }); + + it('reports the message belonging to the path that decided the call', () => { + const withMessage: PolicySet = [ + { path: '~/.ssh/**', default: 'deny', message: 'ssh keys are off limits' }, + { path: '*', operations: { 'fs.read': 'allow' } }, + ]; + const expected = 'ssh keys are off limits'; + const actual = check(withMessage, { tool: 'Read', paths: [`${cwd}/README.md`, `${home}/.ssh/id_ed25519`], operation: 'fs.read' }).message; + expect(actual).toBe(expected); + }); +}); + +describe('resolve — tool, input, and path all specified on one rule', () => { + it('matches only when all three hold at once', () => { + const policy: PolicySet = [{ tool: 'Program', input: { program: ['rm'] }, path: '$PWD', default: 'deny' }]; + + const expected = 'deny'; + const actual = check(policy, { tool: 'Program', input: { program: 'rm', args: [] }, paths: [cwd], operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); + + it('does not match when the tool is right but the input is wrong', () => { + const policy: PolicySet = [ + { tool: 'Program', input: { program: ['rm'] }, path: '$PWD', default: 'deny' }, + { tool: '*', default: 'allow' }, + ]; + + const expected = 'allow'; + const actual = check(policy, { tool: 'Program', input: { program: 'pnpm', args: [] }, paths: [cwd], operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); + + it('does not match when the tool and input are right but the path is wrong', () => { + const policy: PolicySet = [ + { tool: 'Program', input: { program: ['rm'] }, path: '$PWD', default: 'deny' }, + { tool: '*', default: 'allow' }, + ]; + + const expected = 'allow'; + const actual = check(policy, { tool: 'Program', input: { program: 'rm', args: [] }, paths: ['/somewhere/else'], operation: 'fs.exec' }).verdict; + expect(actual).toBe(expected); + }); +}); + +describe('resolve — the message shown to the model', () => { + it('interpolates {program} from the real input.program field', () => { + const policy: PolicySet = [{ tool: 'Program', input: { program: ['rm'] }, default: 'deny', message: '{program} is destructive and irreversible.' }]; + + const expected = 'rm is destructive and irreversible.'; + const actual = check(policy, { tool: 'Program', input: { program: 'rm', args: ['-rf'] }, operation: 'fs.exec' }).message; + expect(actual).toBe(expected); + }); + + it('carries no message when the matched rule sets none at all', () => { + const policy: PolicySet = [{ tool: '*', default: 'allow' }]; + + const expected = undefined; + const actual = check(policy, { tool: 'Find', operation: 'fs.read' }).message; + expect(actual).toBe(expected); + }); + + it('leaves a placeholder literally in place when the named key is absent from the input', () => { + const policy: PolicySet = [{ tool: '*', default: 'deny', message: 'blocked: {program}' }]; + + const expected = 'blocked: {program}'; + const actual = check(policy, { tool: 'Find', operation: 'fs.read' }).message; + expect(actual).toBe(expected); + }); + + it('substitutes more than one placeholder in the same message', () => { + const policy: PolicySet = [{ tool: '*', default: 'deny', message: '{program} with {mode} is not allowed' }]; + + const expected = 'rm with interactive is not allowed'; + const actual = check(policy, { tool: 'Program', input: { program: 'rm', mode: 'interactive' }, operation: 'fs.exec' }).message; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Policy/validatePolicy.spec.ts b/packages/claude-sdk-tools/test/Policy/validatePolicy.spec.ts new file mode 100644 index 00000000..8fe9128a --- /dev/null +++ b/packages/claude-sdk-tools/test/Policy/validatePolicy.spec.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import type { ToolLookup } from '../../src/Policy/validatePolicy.js'; +import { validatePolicy } from '../../src/Policy/validatePolicy.js'; + +function lookup(tools: Record): ToolLookup { + return { get: (name) => (tools[name] ? { model: tools[name] } : undefined) }; +} + +const programModel = z.object({ program: z.string(), args: z.array(z.string()).optional() }); +const findModel = z.object({ path: z.string() }); + +describe('validatePolicy \u2014 case 1: wrong shape', () => { + it('is invalid when a verdict is not one of allow/ask/deny', () => { + const policy = [{ tool: 'Program', default: 'yolo' }]; + const registry = lookup({ Program: programModel }); + + const result = validatePolicy(policy, registry); + + expect(result.valid).toBe(false); + }); + + it('names the broken field in the error', () => { + const policy = [{ tool: 'Program', default: 'yolo' }]; + const registry = lookup({ Program: programModel }); + + const result = validatePolicy(policy, registry); + + const actual = !result.valid && result.errors.some((e) => e.includes('default')); + expect(actual).toBe(true); + }); + + it('collects every shape error, not just the first', () => { + const policy = [ + { tool: 'Program', default: 'yolo' }, + { tool: 123, default: 'ask' }, + ]; + const registry = lookup({ Program: programModel }); + + const result = validatePolicy(policy, registry); + + const expected = 2; + const actual = !result.valid ? result.errors.length : 0; + expect(actual).toBe(expected); + }); +}); + +describe('validatePolicy \u2014 case 2: a specific, loaded tool referencing a field it does not have', () => { + it('is invalid when the field does not exist on the only named tool', () => { + const policy = [{ tool: 'Program', input: { totallyMadeUp: ['x'] }, default: 'deny' }]; + const registry = lookup({ Program: programModel }); + + const result = validatePolicy(policy, registry); + + expect(result.valid).toBe(false); + }); + + it('is valid when the field genuinely exists on the tool \u2014 no false positive', () => { + const policy = [{ tool: 'Program', input: { program: ['rm'] }, default: 'deny' }]; + const registry = lookup({ Program: programModel }); + + const result = validatePolicy(policy, registry); + + expect(result.valid).toBe(true); + }); + + it('is valid when at least one of several named tools has the field, even if another does not', () => { + const policy = [{ tool: ['Program', 'Find'], input: { program: ['rm'] }, default: 'deny' }]; + const registry = lookup({ Program: programModel, Find: findModel }); + + const result = validatePolicy(policy, registry); + + expect(result.valid).toBe(true); + }); + + it('a wildcard-scoped rule is never invalid this way, regardless of which tools have the field', () => { + const policy = [{ tool: '*', input: { program: ['rm'] }, default: 'deny' }]; + const registry = lookup({ Find: findModel }); + + const result = validatePolicy(policy, registry); + + expect(result.valid).toBe(true); + }); + + it('a rule with no tool scope at all is never invalid this way either', () => { + const policy = [{ input: { program: ['rm'] }, default: 'deny' }]; + const registry = lookup({ Find: findModel }); + + const result = validatePolicy(policy, registry); + + expect(result.valid).toBe(true); + }); +}); + +describe('validatePolicy \u2014 case 3: a rule scoped to a tool that is not currently loaded', () => { + it('is still valid overall', () => { + const policy = [{ tool: 'Git_Reset', input: { args: ['--hard'] }, default: 'deny' }]; + const registry = lookup({ Program: programModel }); + + const result = validatePolicy(policy, registry); + + expect(result.valid).toBe(true); + }); + + it('carries a warning naming the unloaded tool', () => { + const policy = [{ tool: 'Git_Reset', default: 'deny' }]; + const registry = lookup({ Program: programModel }); + + const result = validatePolicy(policy, registry); + + const actual = result.valid && result.warnings.some((w) => w.includes('Git_Reset')); + expect(actual).toBe(true); + }); + + it('does not warn about a tool that is actually registered', () => { + const policy = [{ tool: 'Program', default: 'deny' }]; + const registry = lookup({ Program: programModel }); + + const result = validatePolicy(policy, registry); + + const actual = result.valid ? result.warnings.length : -1; + expect(actual).toBe(0); + }); +}); + +describe('validatePolicy \u2014 an empty policy', () => { + it('is valid', () => { + const result = validatePolicy([], lookup({})); + expect(result.valid).toBe(true); + }); +}); + +// `src/**` reads as "anywhere called src" and would behave as "this project's src". Neither the +// operator nor the engine can tell which was meant, so it is refused when the policy loads rather +// than silently picking one. +describe('validatePolicy — a path pattern that names nowhere in particular', () => { + it('rejects a bare relative pattern', () => { + const expected = false; + const actual = validatePolicy([{ path: 'src/**', default: 'deny' }], { get: () => undefined }).valid; + expect(actual).toBe(expected); + }); + + it('says how to write what was probably meant', () => { + const result = validatePolicy([{ path: 'src/**', default: 'deny' }], { get: () => undefined }); + + const expected = true; + const actual = result.valid === false && result.errors.some((error) => error.includes('$PWD/src/**')); + expect(actual).toBe(expected); + }); + + it('accepts a pattern that starts with a glob', () => { + const expected = true; + const actual = validatePolicy([{ path: '**', default: 'deny' }], { get: () => undefined }).valid; + expect(actual).toBe(expected); + }); + + it('accepts a pattern anchored to the working directory', () => { + const expected = true; + const actual = validatePolicy([{ path: '$PWD/src/**', default: 'deny' }], { get: () => undefined }).valid; + expect(actual).toBe(expected); + }); +}); + +// Only $PWD and $HOME are expanded, so any other variable in a pattern is text that matches +// nothing. Accepting it would put back the silent, covers-nothing rule the check exists to catch. +describe('validatePolicy — a path pattern naming a variable nothing expands', () => { + it('rejects it', () => { + const expected = false; + const actual = validatePolicy([{ path: '$FOO/**', default: 'deny' }], { get: () => undefined }).valid; + expect(actual).toBe(expected); + }); + + it('accepts $PWD and $HOME, which are expanded', () => { + const expected = true; + const actual = validatePolicy( + [ + { path: '$PWD/**', default: 'deny' }, + { path: '$HOME/.ssh/**', default: 'deny' }, + ], + { get: () => undefined }, + ).valid; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/RefStore.spec.ts b/packages/claude-sdk-tools/test/RefStore.spec.ts index bb5ad9cf..e6f0d283 100644 --- a/packages/claude-sdk-tools/test/RefStore.spec.ts +++ b/packages/claude-sdk-tools/test/RefStore.spec.ts @@ -16,6 +16,43 @@ describe('RefStore — store and retrieve', () => { }); }); +describe('RefStore.getSlice', () => { + it('returns the requested character range', () => { + const store = new RefStore(new MemoryObjectStore()); + const id = store.store('0123456789'); + + const expected = '2345'; + const actual = store.getSlice(id, 2, 4)?.content; + expect(actual).toBe(expected); + }); + + it('caps the end at the content length when limit overruns it', () => { + const store = new RefStore(new MemoryObjectStore()); + const id = store.store('short'); + + const expected = 5; + const actual = store.getSlice(id, 0, 10_000)?.end; + expect(actual).toBe(expected); + }); + + it('returns undefined for an unknown id', () => { + const store = new RefStore(new MemoryObjectStore()); + + const expected = undefined; + const actual = store.getSlice('does-not-exist', 0, 10); + expect(actual).toBe(expected); + }); + + it('reports the total size of the underlying content, not the slice', () => { + const store = new RefStore(new MemoryObjectStore()); + const id = store.store('0123456789'); + + const expected = 10; + const actual = store.getSlice(id, 0, 4)?.totalSize; + expect(actual).toBe(expected); + }); +}); + describe('RefStore.walkAndRef — passthrough', () => { it('passes through short strings unchanged', () => { const store = new RefStore(new MemoryObjectStore()); diff --git a/packages/claude-sdk-tools/test/TsDefinitionGrouping.spec.ts b/packages/claude-sdk-tools/test/TsDefinitionGrouping.spec.ts index f89eb576..5b480f9e 100644 --- a/packages/claude-sdk-tools/test/TsDefinitionGrouping.spec.ts +++ b/packages/claude-sdk-tools/test/TsDefinitionGrouping.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createTsDefinition } from '../src/TsDefinition/TsDefinition'; import type { Definition, ITypeScriptService } from '../src/typescript/ITypeScriptService'; -import { call } from './helpers'; +import { call, fakeScope } from './helpers'; // A service double that returns fixed definitions; only getDefinition is exercised. const stubService = (definitions: Definition[]): ITypeScriptService => ({ @@ -9,7 +9,6 @@ const stubService = (definitions: Definition[]): ITypeScriptService => ({ getHoverInfo: async () => null, getReferences: async () => [], getDefinition: async () => definitions, - blockEnded: async () => {}, }); describe('TsDefinition', () => { @@ -27,7 +26,7 @@ describe('TsDefinition', () => { ], }; - const actual = await call(createTsDefinition(stubService(definitions)), { file: '/abs/path/main.ts', line: 3, character: 21 }); + const actual = await call(createTsDefinition(), { file: '/abs/path/main.ts', line: 3, character: 21 }, fakeScope(stubService(definitions))); expect(actual).toEqual(expected); }); @@ -35,7 +34,7 @@ describe('TsDefinition', () => { it('returns an empty object when there is no definition', async () => { const expected = {}; - const actual = await call(createTsDefinition(stubService([])), { file: '/abs/path/main.ts', line: 9, character: 9 }); + const actual = await call(createTsDefinition(), { file: '/abs/path/main.ts', line: 9, character: 9 }, fakeScope(stubService([]))); expect(actual).toEqual(expected); }); diff --git a/packages/claude-sdk-tools/test/TsDiagnostics.spec.ts b/packages/claude-sdk-tools/test/TsDiagnostics.spec.ts index 892f7702..1b5254fe 100644 --- a/packages/claude-sdk-tools/test/TsDiagnostics.spec.ts +++ b/packages/claude-sdk-tools/test/TsDiagnostics.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createTsDiagnostics } from '../src/TsDiagnostics/TsDiagnostics'; import type { Diagnostic, ITypeScriptService } from '../src/typescript/ITypeScriptService'; -import { call } from './helpers'; +import { call, fakeScope } from './helpers'; // A service double that returns fixed diagnostics; only getDiagnostics is exercised. const stubService = (diagnostics: Diagnostic[]): ITypeScriptService => ({ @@ -9,7 +9,6 @@ const stubService = (diagnostics: Diagnostic[]): ITypeScriptService => ({ getHoverInfo: async () => null, getReferences: async () => [], getDefinition: async () => [], - blockEnded: async () => {}, }); // A service double that answers per file, so a batch of files gets each file's own diagnostics. @@ -18,7 +17,6 @@ const stubServiceByFile = (byFile: Record): ITypeScriptSer getHoverInfo: async () => null, getReferences: async () => [], getDefinition: async () => [], - blockEnded: async () => {}, }); describe('TsDiagnostics', () => { @@ -36,7 +34,7 @@ describe('TsDiagnostics', () => { ], }; - const actual = await call(createTsDiagnostics(stubService(diagnostics)), { files: [{ file }] }); + const actual = await call(createTsDiagnostics(), { files: [{ file }] }, fakeScope(stubService(diagnostics))); expect(actual).toEqual(expected); }); @@ -44,7 +42,7 @@ describe('TsDiagnostics', () => { it('returns an empty object when there are no diagnostics', async () => { const expected = {}; - const actual = await call(createTsDiagnostics(stubService([])), { files: [{ file: '/abs/path/View.ts' }] }); + const actual = await call(createTsDiagnostics(), { files: [{ file: '/abs/path/View.ts' }] }, fakeScope(stubService([]))); expect(actual).toEqual(expected); }); @@ -61,7 +59,7 @@ describe('TsDiagnostics', () => { [mainFile]: [{ line: 3, character: 1, message: 'main error', code: 2345, severity: 'error' }], }; - const actual = await call(createTsDiagnostics(stubServiceByFile(byFile)), { files: [{ file: viewFile }, { file: mainFile }] }); + const actual = await call(createTsDiagnostics(), { files: [{ file: viewFile }, { file: mainFile }] }, fakeScope(stubServiceByFile(byFile))); expect(actual).toEqual(expected); }); diff --git a/packages/claude-sdk-tools/test/TsReferencesGrouping.spec.ts b/packages/claude-sdk-tools/test/TsReferencesGrouping.spec.ts index d09d0102..bf8e4a68 100644 --- a/packages/claude-sdk-tools/test/TsReferencesGrouping.spec.ts +++ b/packages/claude-sdk-tools/test/TsReferencesGrouping.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createTsReferences } from '../src/TsReferences/TsReferences'; import type { ITypeScriptService, Reference } from '../src/typescript/ITypeScriptService'; -import { call } from './helpers'; +import { call, fakeScope } from './helpers'; // A service double that returns fixed references; only getReferences is exercised. const stubService = (references: Reference[]): ITypeScriptService => ({ @@ -9,7 +9,6 @@ const stubService = (references: Reference[]): ITypeScriptService => ({ getHoverInfo: async () => null, getReferences: async () => references, getDefinition: async () => [], - blockEnded: async () => {}, }); describe('TsReferences', () => { @@ -30,7 +29,7 @@ describe('TsReferences', () => { ], }; - const actual = await call(createTsReferences(stubService(references)), { file: greeterFile, line: 2, character: 14 }); + const actual = await call(createTsReferences(), { file: greeterFile, line: 2, character: 14 }, fakeScope(stubService(references))); expect(actual).toEqual(expected); }); @@ -38,7 +37,7 @@ describe('TsReferences', () => { it('returns an empty object when there are no references', async () => { const expected = {}; - const actual = await call(createTsReferences(stubService([])), { file: '/abs/path/main.ts', line: 7, character: 7 }); + const actual = await call(createTsReferences(), { file: '/abs/path/main.ts', line: 7, character: 7 }, fakeScope(stubService([]))); expect(actual).toEqual(expected); }); diff --git a/packages/claude-sdk-tools/test/fakeEnvProvider.ts b/packages/claude-sdk-tools/test/fakeEnvProvider.ts new file mode 100644 index 00000000..d938909e --- /dev/null +++ b/packages/claude-sdk-tools/test/fakeEnvProvider.ts @@ -0,0 +1,8 @@ +import type { IEnvProvider } from '../src/exec-shared.js'; + +/** An env provider that strips nothing and injects nothing — the ambient environment plus whatever + * the call itself supplied. Tests that care about variable expansion pass their own `vars`; tests + * that don't get the plain pass-through. */ +export function fakeEnvProvider(vars: NodeJS.ProcessEnv = {}): IEnvProvider { + return { buildEnv: (cmdEnv) => ({ ...process.env, ...vars, ...cmdEnv }) }; +} diff --git a/packages/claude-sdk-tools/test/fakeEscalatedRegistryDeps.ts b/packages/claude-sdk-tools/test/fakeEscalatedRegistryDeps.ts new file mode 100644 index 00000000..3c57d37a --- /dev/null +++ b/packages/claude-sdk-tools/test/fakeEscalatedRegistryDeps.ts @@ -0,0 +1,18 @@ +import { Clock } from '@js-joda/core'; +import { AzSessionCache } from '../src/Az/AzSessionCache.js'; +import { fakeEnvProvider } from './fakeEnvProvider.js'; + +/** The escalated (gh/az) deps `createToolsV2Registry` needs, faked for tests that never actually + * call GitHub/AzureDevOps/Az — spread into the deps object so every registry-construction call + * site doesn't need its own boilerplate. */ +export function fakeEscalatedRegistryDeps() { + const executor = { run: () => Promise.reject(new Error('no real process execution in this fake')) } as never; + return { + ghDeps: { executor, getHolderToken: () => 'fake-gh-token' }, + adoDeps: { executor, getCert: () => 'fake-cert', getIdentity: () => ({ type: 'cert' as const, clientId: 'fake-client-id', subscriptionIds: [] }), getTenantId: () => 'fake-tenant-id' }, + azDeps: { executor, getCert: () => 'fake-cert', getIdentity: () => ({ type: 'cert' as const, clientId: 'fake-client-id', subscriptionIds: [] }), getTenantId: () => 'fake-tenant-id' }, + azSessionCache: new AzSessionCache(Clock.systemUTC()), + getAzAccounts: () => ({}), + envProvider: fakeEnvProvider(), + }; +} diff --git a/packages/claude-sdk-tools/test/helpers.ts b/packages/claude-sdk-tools/test/helpers.ts index ba2023ee..a18bc94d 100644 --- a/packages/claude-sdk-tools/test/helpers.ts +++ b/packages/claude-sdk-tools/test/helpers.ts @@ -1,8 +1,16 @@ import type { SipsBridge } from '@shellicar/claude-core/image/SipsBridge'; import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; import type { ToolAttachmentBlock, ToolDefinition } from '@shellicar/claude-sdk'; +import type { IScopedProvider } from '@shellicar/core-di'; import type { z } from 'zod'; +/** A fake block scope whose `resolve` always returns the given instance, regardless of the + * identifier asked for — enough for a tool that resolves exactly one scoped dependency + * (e.g. the TS tools' `ITypeScriptService`) from `scope` at call time. */ +export function fakeScope(instance: unknown): IScopedProvider { + return { resolve: () => instance } as unknown as IScopedProvider; +} + /** Test double: sips unavailable, so ReadFile images pass through unconditioned. */ export const passthroughSips: SipsBridge = { dimensions: () => Promise.reject(new Error('no sips in tests')), @@ -12,8 +20,8 @@ export const passthroughSips: SipsBridge = { /** Test double: a logger that discards everything, so the tool builds without the app's logger. */ export const noopLogger: ILogger = { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; -export async function call(tool: ToolDefinition, input: z.input): Promise> { - const { textContent } = await tool.handler(tool.input_schema.parse(input)); +export async function call(tool: ToolDefinition, input: z.input, scope?: IScopedProvider): Promise> { + const { textContent } = await tool.handler(tool.input_schema.parse(input), undefined, scope); return textContent; } diff --git a/packages/claude-sdk-tools/test/integration/TsDefinition.spec.ts b/packages/claude-sdk-tools/test/integration/TsDefinition.spec.ts index 05553d77..0515739b 100644 --- a/packages/claude-sdk-tools/test/integration/TsDefinition.spec.ts +++ b/packages/claude-sdk-tools/test/integration/TsDefinition.spec.ts @@ -14,7 +14,7 @@ describe('TsDefinition', () => { }); afterAll(async () => { - await service.blockEnded(); + await service[Symbol.asyncDispose](); }); describe('navigating to definitions', () => { diff --git a/packages/claude-sdk-tools/test/integration/TsDiagnosticsFreshness.spec.ts b/packages/claude-sdk-tools/test/integration/TsDiagnosticsFreshness.spec.ts index ae4496e9..0e2e1556 100644 --- a/packages/claude-sdk-tools/test/integration/TsDiagnosticsFreshness.spec.ts +++ b/packages/claude-sdk-tools/test/integration/TsDiagnosticsFreshness.spec.ts @@ -17,7 +17,7 @@ describe('TsDiagnostics freshness', () => { }); afterAll(async () => { - await service.blockEnded(); + await service[Symbol.asyncDispose](); rmSync(dir, { recursive: true, force: true }); }); @@ -27,7 +27,7 @@ describe('TsDiagnostics freshness', () => { // Block boundary: dispose the first server. The next call spawns fresh and // reads disk. - await service.blockEnded(); + await service[Symbol.asyncDispose](); // Introduce a type error on disk: assigning a string to a number-typed // const produces TS2322 (Type 'string' is not assignable to type 'number'). diff --git a/packages/claude-sdk-tools/test/integration/TsHover.spec.ts b/packages/claude-sdk-tools/test/integration/TsHover.spec.ts index 9562d4fb..6af01b5f 100644 --- a/packages/claude-sdk-tools/test/integration/TsHover.spec.ts +++ b/packages/claude-sdk-tools/test/integration/TsHover.spec.ts @@ -14,7 +14,7 @@ describe('TsHover', () => { }); afterAll(async () => { - await service.blockEnded(); + await service[Symbol.asyncDispose](); }); describe('type info', () => { diff --git a/packages/claude-sdk-tools/test/integration/TsReferences.spec.ts b/packages/claude-sdk-tools/test/integration/TsReferences.spec.ts index 4a58617b..d6ce4b65 100644 --- a/packages/claude-sdk-tools/test/integration/TsReferences.spec.ts +++ b/packages/claude-sdk-tools/test/integration/TsReferences.spec.ts @@ -14,7 +14,7 @@ describe('TsReferences', () => { }); afterAll(async () => { - await service.blockEnded(); + await service[Symbol.asyncDispose](); }); describe('finding references', () => { diff --git a/packages/claude-sdk-tools/test/integration/TsSpawnCwd.spec.ts b/packages/claude-sdk-tools/test/integration/TsSpawnCwd.spec.ts index 94562492..5a6fc1f2 100644 --- a/packages/claude-sdk-tools/test/integration/TsSpawnCwd.spec.ts +++ b/packages/claude-sdk-tools/test/integration/TsSpawnCwd.spec.ts @@ -20,7 +20,7 @@ describe('tsserver spawn cwd is inert', () => { }); afterAll(async () => { - await service.blockEnded(); + await service[Symbol.asyncDispose](); rmSync(dir, { recursive: true, force: true }); }); diff --git a/packages/claude-sdk-tools/test/integration/orchestrate-pipeline.spec.ts b/packages/claude-sdk-tools/test/integration/orchestrate-pipeline.spec.ts new file mode 100644 index 00000000..d30443a0 --- /dev/null +++ b/packages/claude-sdk-tools/test/integration/orchestrate-pipeline.spec.ts @@ -0,0 +1,116 @@ +import { Executor } from '@shellicar/exec-core'; +import { execute, type Stage } from '@shellicar/orchestrate-core'; +import { describe, expect, it } from 'vitest'; +import { nodeFs } from '../../src/fs/nodeFs.js'; +import { createProgramToolV2 } from '../../src/Orchestrate/tools/Program.js'; + +// Real processes, because the behaviour only exists when there is one: a producer that has to be +// signalled and reaped, a buffer that has to make it wait, and output that has to be bounded before +// it is held. Every equivalent test in the default tier hands itself the answer through a fake, and +// each of the cases below has broken at least once behind a green suite. + +const env = { buildEnv: () => process.env, get: (name: string) => process.env[name] } as never; +const tool = createProgramToolV2(new Executor(), nodeFs, env); + +function stage(program: string, args: string[], op?: '|'): Stage { + return { kind: 'tool', tool: tool as never, input: { program, args, cwd: process.cwd() }, op }; +} + +/** A run that cannot stall the suite: a hang surfaces as a value no assertion can match. */ +async function run(stages: Stage[], ms = 20_000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error('timed out')), ms); + try { + return await execute(stages, { env, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +describe('a consumer that stops early', () => { + it('returns what the consumer asked for', async () => { + const { result } = await run([stage('seq', ['1', '1000000'], '|'), stage('head', ['-3'])]); + + const expected = ['1', '2', '3']; + const actual = result; + expect(actual).toEqual(expected); + }); + + it('kills the producer with SIGPIPE', async () => { + const { reports } = await run([stage('seq', ['1', '1000000'], '|'), stage('head', ['-3'])]); + + const expected = 'SIGPIPE'; + const actual = reports[0]?.signal; + expect(actual).toBe(expected); + }); + + // Nobody split those bytes into lines: they went to a process's stdin as they were. + it('reports no line count for a stage a process read', async () => { + const { reports } = await run([stage('seq', ['1', '1000000'], '|'), stage('head', ['-3'])]); + + const expected = null; + const actual = reports[0]?.emitted; + expect(actual).toBe(expected); + }); + + it('reports the lines of the stage the run itself read', async () => { + const { reports } = await run([stage('seq', ['1', '1000000'], '|'), stage('head', ['-3'])]); + + const expected = 3; + const actual = reports[1]?.emitted; + expect(actual).toBe(expected); + }); + + it('stops the producer rather than draining it', async () => { + const { reports } = await run([stage('yes', [], '|'), stage('head', ['-1'])]); + + const expected = 'SIGPIPE'; + const actual = reports[0]?.signal; + expect(actual).toBe(expected); + }); + + it('reaches a producer two stages back', async () => { + const { reports } = await run([stage('yes', [], '|'), stage('cat', [], '|'), stage('head', ['-1'])]); + + const expected = ['SIGPIPE', 'SIGPIPE']; + const actual = [reports[0]?.signal, reports[1]?.signal]; + expect(actual).toEqual(expected); + }); +}); + +describe('a producer with no end', () => { + it('terminates when nothing downstream stops it', async () => { + const { result } = await run([stage('yes', [])]); + + const expected = true; + const actual = result.length > 0; + expect(actual).toBe(expected); + }); + + it('says what came back is only the start of its output', async () => { + const { reports } = await run([stage('yes', [])]); + + const expected = true; + const actual = (reports[0]?.message ?? '').includes('start of its output'); + expect(actual).toBe(expected); + }); +}); + +describe('output with no line separator in it', () => { + it('is bounded rather than held whole', async () => { + const { result } = await run([stage('head', ['-c', '20000000', '/dev/zero'])]); + + const expected = true; + const actual = result.length > 1 && result.every((value) => String(value).length <= 1024 * 1024); + expect(actual).toBe(expected); + }); + + it('does not take longer than assembling it once would', async () => { + const started = Date.now(); + await run([stage('head', ['-c', '20000000', '/dev/zero'])]); + + const expected = true; + const actual = Date.now() - started < 10_000; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/normaliseArgs.spec.ts b/packages/claude-sdk-tools/test/normaliseArgs.spec.ts new file mode 100644 index 00000000..8a55dfa6 --- /dev/null +++ b/packages/claude-sdk-tools/test/normaliseArgs.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { normaliseArg, normaliseArgs } from '../src/normaliseArgs.js'; + +describe('normaliseArg', () => { + it('leaves a bare token untouched', () => { + const expected = ['status']; + const actual = normaliseArg('status'); + expect(actual).toEqual(expected); + }); + + it('strips the value off a long flag', () => { + const expected = ['--foo']; + const actual = normaliseArg('--foo=bar'); + expect(actual).toEqual(expected); + }); + + it('leaves a long flag with no value untouched', () => { + const expected = ['--force']; + const actual = normaliseArg('--force'); + expect(actual).toEqual(expected); + }); + + it('keeps the literal token and explodes a bundled short flag group', () => { + const expected = ['-ni', '-n', '-i']; + const actual = normaliseArg('-ni'); + expect(actual).toEqual(expected); + }); + + it('leaves a single-character short flag untouched, with no explosion', () => { + const expected = ['-i']; + const actual = normaliseArg('-i'); + expect(actual).toEqual(expected); + }); +}); + +describe('normaliseArgs', () => { + it('flattens normalisation across every arg in order', () => { + const expected = ['push', '--force', '-ni', '-n', '-i']; + const actual = normaliseArgs(['push', '--force', '-ni']); + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/tsServerFailureMessage.spec.ts b/packages/claude-sdk-tools/test/tsServerFailureMessage.spec.ts new file mode 100644 index 00000000..e44981a5 --- /dev/null +++ b/packages/claude-sdk-tools/test/tsServerFailureMessage.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { tsServerFailureMessage } from '../src/typescript/TsServerClient.js'; + +describe('tsServerFailureMessage', () => { + it('folds tsserver own reason into the message when one is present', () => { + const expected = 'tsserver references failed for /abs/View.ts: file has not been opened'; + const actual = tsServerFailureMessage('references', '/abs/View.ts', 'file has not been opened'); + expect(actual).toBe(expected); + }); + + it('falls back to the generic message when tsserver sent no reason', () => { + const expected = 'tsserver definition failed for /abs/View.ts'; + const actual = tsServerFailureMessage('definition', '/abs/View.ts', undefined); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk/CHANGELOG.md b/packages/claude-sdk/CHANGELOG.md index b38e3a40..6d698ed3 100644 --- a/packages/claude-sdk/CHANGELOG.md +++ b/packages/claude-sdk/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A tool approval request now carries the stage's position within its pipeline, so an approver can show which step of how many is being asked about - Add `CompactConfig` type; `cloneForRequest` converts compaction blocks to text when compact is disabled - Add a per-block tool lifecycle: a tool can declare a blockLifetime that is torn down when the tool-execution block of a turn ends - Add Claude Sonnet 5 calibration and fall back to a family's most recent known config for unrecognised model versions @@ -31,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Inject a live per-turn date/time stamp into every request - isSystemReminderBlock is now exported, so a consumer can tell a block apart from a message's own words without reimplementing the test - Mark a tool-schema field as a filesystem path and normalise all marked paths once from that marker, so the display, the permission check, and handler execution read one produced path +- New IOrchestrateEngine hook: a consumer can own a whole round's tool calls, receiving them as one batch and returning their outcomes, with approval decided entirely by the engine - Publish defineTool, ToolCancelledError, ToolRefusedError, and pathSchema as their own subpath exports, so a consumer can import just one without pulling in the whole SDK module graph - Stamp `messageId`, `turnId`, and `queryId` into each conversation record as nested fields, carried through the jsonl save and load round-trip - Support tool search for on-demand tool discovery diff --git a/packages/claude-sdk/changes.jsonl b/packages/claude-sdk/changes.jsonl index a16a4d64..bcd066d1 100644 --- a/packages/claude-sdk/changes.jsonl +++ b/packages/claude-sdk/changes.jsonl @@ -68,3 +68,5 @@ {"description":"A request with no stored credentials now fails instead of opening a browser login and waiting on it forever","category":"fixed"} {"description":"Stored credentials and the browser login are now separate services a consumer resolves and can substitute (ICredentialProvider and ILoginFlow), replacing AnthropicAuth. A per-request caller holds the credential provider, which cannot open a browser","category":"changed"} {"description":"The OAuth callback's state is checked against the authorisation request it was built for, so a callback arriving from anywhere else is refused instead of exchanged","category":"security"} +{"description":"New IOrchestrateEngine hook: a consumer can own a whole round's tool calls, receiving them as one batch and returning their outcomes, with approval decided entirely by the engine","category":"added"} +{"description":"A tool approval request now carries the stage's position within its pipeline, so an approver can show which step of how many is being asked about","category":"added"} diff --git a/packages/claude-sdk/package.json b/packages/claude-sdk/package.json index fb83dc42..23a47ca9 100644 --- a/packages/claude-sdk/package.json +++ b/packages/claude-sdk/package.json @@ -91,7 +91,7 @@ "@js-joda/locale_en": "^4.15.3", "@js-joda/timezone": "^2.25.2", "@shellicar/claude-core": "workspace:^", - "@shellicar/core-di": "^5.0.0-alpha.3", + "@shellicar/core-di": "^5.0.0-alpha.5", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/claude-sdk/src/index.ts b/packages/claude-sdk/src/index.ts index 5f86238e..e7e50c2d 100644 --- a/packages/claude-sdk/src/index.ts +++ b/packages/claude-sdk/src/index.ts @@ -20,7 +20,6 @@ import { calculateCost, calculateCostSplit, getContextWindow, reconstructCacheSp import { QueryRunner } from './private/QueryRunner'; import { isSystemReminderBlock, toWireTool } from './private/RequestBuilder'; import { StreamProcessor } from './private/StreamProcessor'; -import { ToolBlockNotifier } from './private/ToolBlockNotifier'; import { ToolRegistry } from './private/ToolRegistry'; import { TurnRunner } from './private/TurnRunner'; import { defineTool } from './public/defineTool'; @@ -30,8 +29,9 @@ import { IDurableConfigProvider } from './public/IDurableConfigProvider'; import { ISdkMessagePublisher } from './public/ISdkMessagePublisher'; import { ISkillGateProvider, type SkillGateResult } from './public/ISkillGateProvider'; import { IToolProvider } from './public/IToolProvider'; -import { IQueryRunner, IStreamProcessor, IToolRegistry, ITurnRunner, IWakeLock } from './public/interfaces'; -import { annotatePathDescriptions, collectPaths, IS_PATH, normalisePaths, pathSchema, TOOL_INPUT_KEYED_BY } from './public/pathSchema'; +import type { OrchestrateApprovalContext, OrchestrateBatchItem } from './public/interfaces'; +import { IOrchestrateEngine, IQueryRunner, IStreamProcessor, IToolRegistry, ITurnRunner, IWakeLock } from './public/interfaces'; +import { annotatePathDescriptions, collectPaths, IS_PATH, normalisePaths, pathSchema, TOOL_INPUT_KEYED_BY, withResolvedPaths } from './public/pathSchema'; import { ToolCancelledError } from './public/ToolCancelledError'; import { ToolRefusedError } from './public/ToolRefusedError'; import type { @@ -59,17 +59,17 @@ import type { TextBlock, ThinkingEffort, ToolAttachmentBlock, - ToolBlockLifetime, ToolDefinition, ToolHandler, ToolHandlerResult, ToolOperation, + ToolOutcome, ToolResultBlock, ToolResultBlockContent, TransformToolResult, WakeLockHandle, } from './public/types'; -import { AccountLimitListener, IRequestClockListener, IToolBlockNotifier, IToolsClockListener, StreamInterruptListener } from './public/types'; +import { AccountLimitListener, IRequestClockListener, IToolsClockListener, StreamInterruptListener } from './public/types'; export type { BetaMessage, BetaMessageParam } from '@anthropic-ai/sdk/resources/beta.js'; export type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta.mjs'; @@ -89,6 +89,8 @@ export type { ImageBlock, IPublisher, ISubscriber, + OrchestrateApprovalContext, + OrchestrateBatchItem, SdkDone, SdkError, SdkMessage, @@ -106,11 +108,11 @@ export type { TextBlock, ThinkingEffort, ToolAttachmentBlock, - ToolBlockLifetime, ToolDefinition, ToolHandler, ToolHandlerResult, ToolOperation, + ToolOutcome, ToolResultBlock, ToolResultBlockContent, TransformToolResult, @@ -147,6 +149,7 @@ export { ILoginFlow, IMessageStreamer, IModelCatalog, + IOrchestrateEngine, IProfileEndpoint, IQueryRunner, IRequestClockListener, @@ -155,7 +158,6 @@ export { ISkillGateProvider, IStreamProcessor, ITokenEndpoint, - IToolBlockNotifier, IToolProvider, IToolRegistry, IToolsClockListener, @@ -174,10 +176,10 @@ export { StreamInterruptListener, StreamProcessor, TOOL_INPUT_KEYED_BY, - ToolBlockNotifier, ToolCancelledError, ToolRefusedError, ToolRegistry, TurnRunner, toWireTool, + withResolvedPaths, }; diff --git a/packages/claude-sdk/src/private/ApprovalCoordinator.ts b/packages/claude-sdk/src/private/ApprovalCoordinator.ts index 840d1c9a..726c8897 100644 --- a/packages/claude-sdk/src/private/ApprovalCoordinator.ts +++ b/packages/claude-sdk/src/private/ApprovalCoordinator.ts @@ -39,10 +39,17 @@ export class ApprovalCoordinator { * query. Clearing `#toolCancelled` here scopes escalation to a single run: * two cancels on the same running tool escalate; one cancel each on two * different tools in a batch are two independent tool-cancels. + * + * An already-aborted controller keeps the flag instead: it is the same run + * the first cancel hit, re-registered by a later part of the batch, so + * clearing it would read the user's second cancel as a fresh first one and + * abort a controller that is already dead rather than cancelling the query. */ public toolRunStarted(controller: AbortController): void { this.#toolController = controller; - this.#toolCancelled = false; + if (!controller.signal.aborted) { + this.#toolCancelled = false; + } } public toolRunFinished(): void { diff --git a/packages/claude-sdk/src/private/QueryRunner.ts b/packages/claude-sdk/src/private/QueryRunner.ts index 60ad470a..7fb7adcf 100644 --- a/packages/claude-sdk/src/private/QueryRunner.ts +++ b/packages/claude-sdk/src/private/QueryRunner.ts @@ -1,11 +1,12 @@ import { randomUUID } from 'node:crypto'; import { ILogger } from '@shellicar/claude-core/logging/ILogger'; -import { dependsOn } from '@shellicar/core-di'; +import type { IScopedProvider } from '@shellicar/core-di'; +import { dependsOn, IServiceProvider } from '@shellicar/core-di'; import { IDurableConfigProvider } from '../public/IDurableConfigProvider'; import { ISdkMessagePublisher } from '../public/ISdkMessagePublisher'; -import { IQueryRunner, IToolRegistry, ITurnRunner } from '../public/interfaces'; +import { IOrchestrateEngine, IQueryRunner, IToolRegistry, ITurnRunner } from '../public/interfaces'; import type { PerQueryInput, SdkMessage, ToolOutcome, ToolResultBlock, TransformToolResult } from '../public/types'; -import { IToolBlockNotifier, IToolsClockListener } from '../public/types'; +import { IToolsClockListener } from '../public/types'; import { ApprovalCoordinator } from './ApprovalCoordinator'; import { IConversation } from './Conversation'; import { buildReminderBlocks } from './claudeMdReminders'; @@ -54,12 +55,13 @@ export class QueryRunner extends IQueryRunner { @dependsOn(ITurnRunner) private readonly turnRunner!: ITurnRunner; @dependsOn(IConversation) private readonly conversation!: IConversation; @dependsOn(IToolRegistry) private readonly registry!: IToolRegistry; + @dependsOn(IOrchestrateEngine) private readonly orchestrateEngine!: IOrchestrateEngine; @dependsOn(ApprovalCoordinator) private readonly approval!: ApprovalCoordinator; @dependsOn(ISdkMessagePublisher) private readonly publisher!: ISdkMessagePublisher; @dependsOn(IDurableConfigProvider) private readonly durableProvider!: IDurableConfigProvider; @dependsOn(ILogger) private readonly logger!: ILogger; @dependsOn(IToolsClockListener) private readonly toolsClock!: IToolsClockListener; - @dependsOn(IToolBlockNotifier) private readonly blockNotifier!: IToolBlockNotifier; + @dependsOn(IServiceProvider) private readonly provider!: IServiceProvider; public async run(input: PerQueryInput): Promise { // Clear any `cancelled` flag left over from a previous cancelled query @@ -203,17 +205,23 @@ export class QueryRunner extends IQueryRunner { this.toolsClock.toolsStarted(); this.publisher.send({ type: 'tool_exec_start' } satisfies SdkMessage); try { - return await this.#runTools(toolUses, transformToolResult); + return await this.#runToolsScoped(toolUses, transformToolResult); } finally { - // Tear down every block-scoped tool resource (e.g. the on-demand tsserver) - // before the batch's clock stops. Runs on every exit — return, throw, or a - // batch where nothing ran. A no-op when no tool declared a block lifetime. - await this.blockNotifier.blockEnded(); this.toolsClock.toolsStopped(); this.publisher.send({ type: 'tool_exec_end' } satisfies SdkMessage); } } + /** Opens the block's own DI scope: any tool with a genuinely per-block-scoped dependency + * (e.g. the TS tools' tsserver process) resolves it through `scope`, passed to every handler + * call (see `ToolRegistry.resolve`'s `run` closure). Disposed the moment `#runTools` settles + * — return or throw — which tears down whatever scoped instances were resolved from it, before + * `#handleTools`'s own finally reports the batch as stopped. */ + async #runToolsScoped(toolUses: ToolUseResult[], transformToolResult: TransformToolResult | undefined) { + await using scope = this.provider.createScope(); + return await this.#runTools(toolUses, transformToolResult, scope); + } + /** * Tool dispatch logic, structured around `IToolRegistry.resolve`. * @@ -234,15 +242,46 @@ export class QueryRunner extends IQueryRunner { * still running in the batch, exactly as it did when execution was * sequential — only the concurrency changed, not the cancel contract. */ - async #runTools(toolUses: ToolUseResult[], transformToolResult: TransformToolResult | undefined) { + async #runTools(allToolUses: ToolUseResult[], transformToolResult: TransformToolResult | undefined, scope: IScopedProvider) { const requireApproval = this.durableProvider.config.requireToolApproval ?? false; const toolResults: ToolResultBlock[] = []; + // A tool-scoped controller, distinct from the query's AbortController. ESC // aborts this to cancel the running tool without ending the query, so the - // delivery turn still has the query's live signal. One controller per batch: - // a cancel aborts every Exec tool in the batch (see Open decision 2). + // delivery turn still has the query's live signal. One controller per batch, + // shared by both the V1 and V2 phases below (they run sequentially, never + // concurrently, within one #runTools call) — a cancel aborts whichever phase + // is actually running (see Open decision 2). The V2 phase's own DI scope is + // opened by IOrchestrateEngine.runBatch itself, not by QueryRunner — this + // scope (below) only ever reaches the V1 phase's registry.resolve().run closure. const toolController = new AbortController(); + // Dispatch fork: a V2 name never reaches the V1 registry/permission path at all — + // Tools V2 is a genuinely separate system (own execution, own per-stage approval), + // not a tool bolted onto V1. Run V2 calls before the V1 phase below, registering the + // same shared controller so ESC routes to it as a tool-cancel exactly as V1's phase + // does; `execute()` only ever reads `.aborted` to stop advancing stages, it's each V2 + // tool's own `run` that decides whether/how to react to the signal (e.g. `Program` + // ties it into the real process kill it already does for its own timeout/caps). + const v2ToolUses = allToolUses.filter((t) => this.orchestrateEngine.owns(t.name)); + const toolUses = allToolUses.filter((t) => !this.orchestrateEngine.owns(t.name)); + // Every tool_use must be answered, so a cancelled round says so per block rather than leaving + // the reply without a result the API requires. Reaching this needs a cancel to land before the + // round starts, which today the turn loop prevents; that it cannot happen is a fact about a + // consumer aborting the query, not something this code enforces, so it answers anyway. + if (v2ToolUses.length > 0 && this.approval.cancelled) { + for (const toolUse of v2ToolUses) { + toolResults.push(this.#emitApprovalRejection(toolUse, 'cancelled')); + } + } else if (v2ToolUses.length > 0) { + this.approval.toolRunStarted(toolController); + try { + toolResults.push(...(await this.#runOrchestrateBatch(v2ToolUses, requireApproval, toolController.signal))); + } finally { + this.approval.toolRunFinished(); + } + } + // Phase 1: resolve and filter. Parse every tool_use once; route errors // to immediate tool_result blocks without requesting approval or // running any handler. @@ -265,7 +304,7 @@ export class QueryRunner extends IQueryRunner { toolUse: toolUseRef, run: async (transform) => { try { - return this.#emitOutcome(toolUseRef, await resolvedRun(transform, toolController.signal)); + return this.#emitOutcome(toolUseRef, await resolvedRun(transform, toolController.signal, scope)); } catch (err) { const error = err instanceof Error ? err.message : String(err); return this.#emitOutcome(toolUseRef, { kind: 'failed', error }); @@ -296,7 +335,9 @@ export class QueryRunner extends IQueryRunner { toolUse, run, promise: this.approval.request(requestId, () => { - this.publisher.send({ type: 'tool_approval_request', requestId, name: toolUse.name, input: toolUse.input } satisfies SdkMessage); + // V1 gates once per tool_use, so its requestId IS the tool_use id; sent explicitly + // anyway so every consumer reads one field for correlation, V1 and V2 alike. + this.publisher.send({ type: 'tool_approval_request', requestId, toolUseId: toolUse.id, name: toolUse.name, input: toolUse.input } satisfies SdkMessage); }), }; }); @@ -365,6 +406,19 @@ export class QueryRunner extends IQueryRunner { return { type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: [{ type: 'text' as const, text: content }] }; } + /** Runs a whole round's V2 tool_uses through `IOrchestrateEngine.runBatch` in one call. QueryRunner + * does no tool coordination of any kind for V2: no scope, no approval bookkeeping, no requestId + * minting, no wire-message construction — it only converts each `tool_use` to a plain + * `{ id, name, input }` item and maps the returned outcomes back onto their tool_use blocks. + * Everything else (opening the batch's DI scope, deciding whether a gated stage needs approval, + * keying/minting requestIds, sending `tool_approval_request`) is `OrchestrateEngine`'s own job, + * since it already holds `ApprovalCoordinator`/the publisher directly. */ + async #runOrchestrateBatch(toolUses: ToolUseResult[], requireApproval: boolean, signal: AbortSignal): Promise { + const items = toolUses.map((t) => ({ id: t.id, name: t.name, input: t.input })); + const outcomes = await this.orchestrateEngine.runBatch(items, requireApproval, signal); + return toolUses.map((t) => this.#emitOutcome(t, outcomes.get(t.id) ?? { kind: 'failed', error: `no outcome returned for tool_use ${t.id}` })); + } + /** Map a tool outcome to its channel events and the tool_result block. The one place an * outcome becomes wire effects: `ok` carries the payload; every error category sets * `is_error` and broadcasts a `tool_error` for visibility, except `unavailable`, which diff --git a/packages/claude-sdk/src/private/RequestBuilder.ts b/packages/claude-sdk/src/private/RequestBuilder.ts index 19aeaa47..d9f3f5fe 100644 --- a/packages/claude-sdk/src/private/RequestBuilder.ts +++ b/packages/claude-sdk/src/private/RequestBuilder.ts @@ -31,6 +31,8 @@ export type RequestBuilderOptions = { tools: AnyToolDefinition[]; /** Server-side tools prepended to the wire tools array before client tools. */ serverTools?: BetaToolUnion[]; + /** Tools V2's own wire entries — already wire-shaped, not converted via `toWireTool`/`transformTool` (those are V1-only). */ + toolsV2?: BetaToolUnion[]; /** Applied to each client tool after conversion. Used to add ATU-specific fields without the SDK needing to know about them. */ transformTool?: (tool: BetaToolUnion) => BetaToolUnion; betas?: AnthropicBetaFlags; @@ -142,12 +144,19 @@ export function toWireTool(tool: AnyToolDefinition): BetaToolUnion { * client, since the signal is tied to the per-query abort lifecycle. */ export function buildRequestParams(options: RequestBuilderOptions, messages: Anthropic.Beta.Messages.BetaMessageParam[]): RequestParams { - const customTools: BetaToolUnion[] = options.tools.map((t) => { - const wire = toWireTool(t); - return options.transformTool ? options.transformTool(wire) : wire; - }); - - const tools: BetaToolUnion[] = [...(options.serverTools ?? []), ...customTools]; + // A name registered in both V1 and V2 must appear on the wire exactly once — the API + // rejects duplicate tool names outright — and V2 wins: it's the tool actively being built + // out to replace its V1 counterpart, so the V1 entry for that name is simply not sent, + // never the reverse. + const toolsV2Names = new Set((options.toolsV2 ?? []).filter((t): t is Extract => 'name' in t).map((t) => t.name)); + const customTools: BetaToolUnion[] = options.tools + .filter((t) => !toolsV2Names.has(t.name)) + .map((t) => { + const wire = toWireTool(t); + return options.transformTool ? options.transformTool(wire) : wire; + }); + + const tools: BetaToolUnion[] = [...(options.serverTools ?? []), ...(options.toolsV2 ?? []), ...customTools]; const betas = resolveCapabilities(options.betas, AnthropicBeta); diff --git a/packages/claude-sdk/src/private/ToolBlockNotifier.ts b/packages/claude-sdk/src/private/ToolBlockNotifier.ts deleted file mode 100644 index 55382b72..00000000 --- a/packages/claude-sdk/src/private/ToolBlockNotifier.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ToolBlockLifetime } from '../public/types'; -import { IToolBlockNotifier } from '../public/types'; - -export class ToolBlockNotifier extends IToolBlockNotifier { - readonly #lifetimes: readonly ToolBlockLifetime[]; - - public constructor(lifetimes: readonly ToolBlockLifetime[]) { - super(); - this.#lifetimes = lifetimes; - } - - public async blockEnded(): Promise { - // allSettled, not all: a rejecting lifetime must not stop the others being - // torn down, nor throw out of QueryRunner's finally and skip toolsStopped(). - await Promise.allSettled(this.#lifetimes.map((l) => l.blockEnded())); - } -} diff --git a/packages/claude-sdk/src/private/ToolRegistry.ts b/packages/claude-sdk/src/private/ToolRegistry.ts index 13713716..a6e17161 100644 --- a/packages/claude-sdk/src/private/ToolRegistry.ts +++ b/packages/claude-sdk/src/private/ToolRegistry.ts @@ -1,6 +1,7 @@ import type { Anthropic } from '@anthropic-ai/sdk'; import type { BetaTool } from '@anthropic-ai/sdk/resources/beta.mjs'; import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import type { IScopedProvider } from '@shellicar/core-di'; import type { IDisabledToolsProvider } from '../public/IDisabledToolsProvider'; import type { ISkillGateProvider } from '../public/ISkillGateProvider'; import { IToolRegistry } from '../public/interfaces'; @@ -124,11 +125,11 @@ export class ToolRegistry extends IToolRegistry { const parsedInput = parseResult.data; const logger = this.#logger; const handler = entry.definition.handler as ToolHandler; - const run = async (transform?: TransformToolResult, signal?: AbortSignal): Promise => { + const run = async (transform?: TransformToolResult, signal?: AbortSignal, scope?: IScopedProvider): Promise => { const startMs = Date.now(); logger?.debug('tool_call', { name, input }); try { - const { textContent, attachments } = await handler(parsedInput, signal); + const { textContent, attachments } = await handler(parsedInput, signal, scope); logger?.debug('tool_result', { name, output: textContent }); const transformed = transform ? transform(name, textContent) : textContent; const content = typeof transformed === 'string' ? transformed : JSON.stringify(transformed); diff --git a/packages/claude-sdk/src/private/TurnRunner.ts b/packages/claude-sdk/src/private/TurnRunner.ts index 5f051355..06b73ba9 100644 --- a/packages/claude-sdk/src/private/TurnRunner.ts +++ b/packages/claude-sdk/src/private/TurnRunner.ts @@ -148,6 +148,7 @@ export class TurnRunner extends ITurnRunner { thinkingEffort: durable.thinkingEffort, tools: durable.tools, serverTools: durable.serverTools, + toolsV2: durable.toolsV2, transformTool: durable.transformTool, betas: durable.betas, systemPrompts: durable.systemPrompts, diff --git a/packages/claude-sdk/src/public/interfaces.ts b/packages/claude-sdk/src/public/interfaces.ts index 59775971..67bab003 100644 --- a/packages/claude-sdk/src/public/interfaces.ts +++ b/packages/claude-sdk/src/public/interfaces.ts @@ -3,7 +3,7 @@ import type { BetaMessageParam, BetaTool } from '@anthropic-ai/sdk/resources/bet import type { IConversation, MessageIdentity } from '../private/Conversation'; import type { IMessageStream } from '../private/MessageStreamer'; import type { MessageStreamEvents, MessageStreamResult } from '../private/types'; -import type { DurableConfig, PerQueryInput, ToolResolveResult, TurnInput, WakeLockHandle } from './types'; +import type { DurableConfig, PerQueryInput, ToolOutcome, ToolResolveResult, TurnInput, WakeLockHandle } from './types'; /** * Long-lived stream processor. A concrete implementation is constructed once @@ -56,6 +56,50 @@ export abstract class IToolRegistry { public abstract normaliseInputPaths(name: string, input: Record): void; } +/** + * Tools V2's dispatch seam — the one place `QueryRunner` asks "does this `tool_use` name + * belong to V2" and, if so, routes to it instead of `IToolRegistry`. Genuinely separate from + * V1: no `ToolRegistry`/permission-matrix involvement, own execution (`orchestrate-core`'s + * `execute()`), own per-stage approval story via the `requestApproval` callback `QueryRunner` + * supplies (built from its own `ApprovalCoordinator`/publisher — the callback is reused + * plumbing, not a shared policy decision; V2 always asks per gated stage, it never consults + * V1's read/write/delete matrix). + * + * `run` covers both shapes a V2 wire tool call can be: a direct call to one registered tool + * (`name` is that tool's own name, `input` is that tool's own input) or a call to `Orchestrate` + * itself (`name === 'Orchestrate'`, `input` is `{ stages: [...] }`). The implementation decides + * which by name, since both ultimately reduce to the same `execute()` call over a stage list. + */ +/** Structurally compatible with orchestrate-core's own `ApprovalContext` — duck-typed rather + * than an actual dependency, since claude-sdk has no reason to depend on orchestrate-core + * directly. Carries the gated stage's own resolved `input` (not just what's piped into it), + * since a decision based only on the batch can never express "deny this specific command" — + * most stages have no upstream at all. */ +/** `stagePosition`/`stageCount` are the gated stage's own 1-based place in the pipeline it was + * declared in, and that pipeline's total length — both counting every stage, gated or not, so a + * consumer can say where in the run the ask is coming from. */ +/** `input` is what the stage will actually do, every variable resolved: what a decision is made + * against. `asWritten` is the same stage as the caller wrote it, which is what an approver is + * shown, since the request is published whether or not it is granted. */ +export type OrchestrateApprovalContext = { name: string; operations: string[]; input: unknown; asWritten: unknown; batch: () => Promise; stagePosition: number; stageCount: number }; + +/** One `tool_use` block's worth of a V2 batch call: its wire id (for keying the returned + * outcome and any per-stage approval requests back to the right block), name, and input. */ +export type OrchestrateBatchItem = { id: string; name: string; input: unknown }; + +export abstract class IOrchestrateEngine { + public abstract owns(name: string): boolean; + /** Runs every item in one round's V2 batch against a single DI scope, opened once for the + * whole call and disposed once every item has settled — so a tool needing a genuinely + * per-round-scoped resource (e.g. the TS tools' shared tsserver process) gets the same + * instance across every V2 tool_use in the round, not a fresh one per call. QueryRunner does + * no tool coordination for V2: it only supplies the batch and `requireApproval`. Every other + * approval concern — whether a gated stage needs asking, minting/keying its requestId per + * tool_use, sending the `tool_approval_request` wire message — is this engine's own job, + * since it already holds `ApprovalCoordinator`/the publisher directly. */ + public abstract runBatch(items: OrchestrateBatchItem[], requireApproval: boolean, signal?: AbortSignal): Promise>; +} + /** * Long-lived turn runner. Runs one request-and-response cycle between the * SDK and the Anthropic API per call to `run`. diff --git a/packages/claude-sdk/src/public/pathSchema.ts b/packages/claude-sdk/src/public/pathSchema.ts index 48d8fa1f..d8b4a858 100644 --- a/packages/claude-sdk/src/public/pathSchema.ts +++ b/packages/claude-sdk/src/public/pathSchema.ts @@ -120,6 +120,18 @@ export function collectPaths(schema: z.ZodType, input: unknown, resolve?: Schema return out; } +/** A resolved COPY of `input` for a tool's own execution — every `isPath` field replaced by + * `expand(value)`, on a clone. `input` itself is never touched: what the model wrote, what a + * human approves, and what gets logged all stay exactly that, forever. Only this throwaway + * copy, built once right before a tool's own `run()`, ever sees the expanded form — the same + * `isPath` marker `collectPaths`/`normalisePaths` already use, so any tool gets this for free + * by marking a field, never by writing its own `~`/`$VAR`/cwd-relative resolution. */ +export function withResolvedPaths(schema: z.ZodType, input: T, expand: (p: string) => string, resolve?: SchemaResolver): T { + const clone = structuredClone(input); + normalisePaths(schema, clone, expand, resolve); + return clone; +} + /** Replace every isPath value in `input`, in place, with `expand(value)`. */ export function normalisePaths(schema: z.ZodType, input: unknown, expand: (p: string) => string, resolve?: SchemaResolver): void { walkPaths( diff --git a/packages/claude-sdk/src/public/types.ts b/packages/claude-sdk/src/public/types.ts index b70e6664..07ebebb0 100644 --- a/packages/claude-sdk/src/public/types.ts +++ b/packages/claude-sdk/src/public/types.ts @@ -1,6 +1,7 @@ import type { Anthropic } from '@anthropic-ai/sdk'; import type { BetaBase64ImageSource, BetaBase64PDFSource, BetaToolUnion } from '@anthropic-ai/sdk/resources/beta.mjs'; import type { Model } from '@anthropic-ai/sdk/resources/messages'; +import type { IScopedProvider } from '@shellicar/core-di'; import type { z } from 'zod'; import type { Sender } from '../private/Conversation'; import type { AnthropicBeta, CacheTtl } from './enums'; @@ -16,17 +17,12 @@ export type ToolHandlerResult = { attachments?: ToolAttachmentBlock[]; }; -export type ToolHandler = (input: TInput, signal?: AbortSignal) => Promise>; - -/** A tool's hook into the managed per-block server lifecycle. A tool that owns - * a resource scoped to one tool-execution block (e.g. an on-demand child - * process) sets this on its definition; `blockEnded` runs once after the block - * that used it finishes, tearing the resource down. Structural: any object with - * this method satisfies it, so a class already extending another abstract (the - * tsserver bridge) can still provide it. */ -export type ToolBlockLifetime = { - blockEnded(): Promise; -}; +/** `scope` is the block's DI scope (see `QueryRunner`'s tool-execution block), passed to + * every handler unconditionally. Only a tool with a genuinely per-block-scoped dependency + * (e.g. the TS tools' tsserver process) ever reads it — `scope!.resolve(SomeScopedService)` + * gets a fresh instance for this block, torn down when the scope disposes at the block's end. + * Everything else ignores the argument, same as most handlers already ignore `signal`. */ +export type ToolHandler = (input: TInput, signal?: AbortSignal, scope?: IScopedProvider) => Promise>; export type ToolDefinition = { name: string; @@ -37,10 +33,13 @@ export type ToolDefinition[]; handler: ToolHandler, z.output>; - /** Set when this tool owns a resource scoped to one tool-execution block. The - * build-tools step collects every tool that sets it and tears the resource - * down once per block, deduped by identity. */ - blockLifetime?: ToolBlockLifetime; + /** The tool's own one-line rendering of its resolved input — what a human sees in the approval + * prompt and the tools block, instead of a central display function guessing generically at + * every tool's shape (a marked path, then a handful of hardcoded fallback field names). Only + * the tool itself knows which of its fields is worth showing, and in what priority — e.g. Find + * has both a path and a pattern, and only Find knows both belong in its own summary. Absent + * falls back to the generic display. */ + summarize?: (input: z.output) => string; }; export type AnyToolDefinition = { @@ -59,7 +58,7 @@ export type AnyToolDefinition = { * erase boundary when it actually invokes the handler. */ handler: ToolHandler; - blockLifetime?: ToolBlockLifetime; + summarize?: (input: never) => string; }; export type AnthropicBetaFlags = Partial>; @@ -102,7 +101,7 @@ export type ToolRunResult = Extract Promise } | Extract; +export type ToolResolveResult = { kind: 'ready'; run: (transform?: TransformToolResult, signal?: AbortSignal, scope?: IScopedProvider) => Promise } | Extract; /** The durable, long-lived configuration the consumer holds once and reuses across queries. * @@ -132,6 +131,8 @@ export type DurableConfig = { tools: AnyToolDefinition[]; /** Server-side tools (e.g. search, web fetch) prepended to the wire tools array before client tools. The caller constructs these directly from Anthropic SDK types. */ serverTools?: BetaToolUnion[]; + /** Tools V2's own wire entries — genuinely separate from `tools`/`AnyToolDefinition`: no V1 `ToolRegistry`/permission-matrix involvement, no `handler` field, dispatched by `IOrchestrateEngine` instead. The caller constructs these directly (e.g. from a `ToolsV2Registry`'s `wireTools`, plus Orchestrate's own composed entry). */ + toolsV2?: BetaToolUnion[]; /** Applied to each client tool after `toWireTool` converts it. Use to add ATU-specific fields (defer_loading, allowed_callers, input_examples) without the SDK needing to know about them. Not called for serverTools. */ transformTool?: (tool: BetaToolUnion) => BetaToolUnion; betas?: AnthropicBetaFlags; @@ -210,7 +211,17 @@ export type SdkMessageThinking = { type: 'message_thinking'; text: string }; export type SdkMessageCompactionStart = { type: 'message_compaction_start' }; export type SdkMessageCompaction = { type: 'message_compaction'; summary: string }; export type SdkMessageEnd = { type: 'message_end'; stopReason: string }; -export type SdkToolApprovalRequest = { type: 'tool_approval_request'; requestId: string; name: string; input: Record }; +/** `v2` marks a request raised for a Tools V2 stage (from `IOrchestrateEngine`'s `requestApproval` + * callback), never V1's permission matrix — the consumer must route it straight to a live prompt, + * skipping any name-based permission-matrix lookup, since a V2 stage name was never registered + * there and a lookup miss would otherwise read as a false "tool not found" auto-rejection. */ +/** `stageIndex`/`stageCount` are present only for a request raised from inside a multi-stage + * `Orchestrate` pipeline, letting the consumer label the prompt "stage 2 of 3" instead of + * showing the gated stage with no sense of where it sits in the pipeline. */ +/** `toolUseId` is the real `tool_use` block this ask belongs to, which `requestId` is not: a V2 + * pipeline gates per stage, so its `requestId` is `${toolUseId}:${stage}` and exists nowhere in the + * conv stream. Consumers correlating an ask back to the conversation read this. */ +export type SdkToolApprovalRequest = { type: 'tool_approval_request'; requestId: string; toolUseId: string; name: string; input: Record; v2?: boolean; stageIndex?: number; stageCount?: number }; export type SdkServerToolUse = { type: 'server_tool_use'; id: string; name: string; input: Record }; export type SdkServerToolResult = { type: 'server_tool_result'; id: string; name: string; result: unknown }; /** A client tool's result, published as the query runner builds the tool_result block. `content` is post-transform (ref-swapped for large outputs). The history view reads this to show the output the model saw. `cancelled` distinguishes a user-aborted run from any other error, so the consumer can render it distinctly from a genuine failure. */ @@ -319,16 +330,6 @@ export abstract class IToolsClockListener { public abstract toolsStopped(): void; } -/** The pipeline's tool-block end edge. QueryRunner calls `blockEnded()` once - * after each tool batch finishes — normal return, thrown error, or a batch - * where nothing ran. The concrete fans the edge out to every tool that declared - * a `blockLifetime`; the pipeline neither knows nor names what is subscribed, - * exactly as it notifies IToolsClockListener without knowing the clock. A - * fan-out over a list — never bound to one tool implementation. */ -export abstract class IToolBlockNotifier { - public abstract blockEnded(): Promise; -} - export type ServerToolResultBlock = { type: 'web_search_tool_result' | 'web_fetch_tool_result' | 'code_execution_tool_result' | 'bash_code_execution_tool_result' | 'text_editor_code_execution_tool_result' | 'tool_search_tool_result' | 'mcp_tool_result'; toolUseId: string; diff --git a/packages/claude-sdk/test/QueryRunner.spec.ts b/packages/claude-sdk/test/QueryRunner.spec.ts index 106b162b..68dd0f73 100644 --- a/packages/claude-sdk/test/QueryRunner.spec.ts +++ b/packages/claude-sdk/test/QueryRunner.spec.ts @@ -8,15 +8,14 @@ import type { IPublisher } from '../src/private/ControlChannel.js'; import { Conversation, IConversation } from '../src/private/Conversation.js'; import { AccountLimitStoppedError, ApiStreamError, HttpError } from '../src/private/http/errors.js'; import { QueryRunner } from '../src/private/QueryRunner.js'; -import { ToolBlockNotifier } from '../src/private/ToolBlockNotifier.js'; import { ToolRegistry } from '../src/private/ToolRegistry.js'; import type { MessageStreamResult } from '../src/private/types.js'; import { IDurableConfigProvider } from '../src/public/IDurableConfigProvider.js'; import { ISdkMessagePublisher } from '../src/public/ISdkMessagePublisher.js'; -import { IToolRegistry, ITurnRunner } from '../src/public/interfaces.js'; +import { IOrchestrateEngine, IToolRegistry, ITurnRunner } from '../src/public/interfaces.js'; import { ToolCancelledError } from '../src/public/ToolCancelledError.js'; import type { AnyToolDefinition, ContentBlock, DocumentBlock, DurableConfig, PerQueryInput, SdkMessage, TextBlock, ToolResolveResult, ToolResultBlock, TurnInput } from '../src/public/types.js'; -import { IToolBlockNotifier, IToolsClockListener } from '../src/public/types.js'; +import { IToolsClockListener } from '../src/public/types.js'; // --------------------------------------------------------------------------- // Fake TurnRunner. QueryRunner tests verify *conversation* behaviour, so the @@ -264,7 +263,9 @@ type Wiring = { queryRunner: QueryRunner; }; -function makeWiring(responses: Array, tools: AnyToolDefinition[] = [], durableOverrides: Partial = {}, conversation?: Conversation, toolsClock: IToolsClockListener = new NoopToolsClock()): Wiring { +const noopOrchestrateEngine: IOrchestrateEngine = { owns: () => false, runBatch: async () => new Map() }; + +function makeWiring(responses: Array, tools: AnyToolDefinition[] = [], durableOverrides: Partial = {}, conversation?: Conversation, toolsClock: IToolsClockListener = new NoopToolsClock(), orchestrateEngine: IOrchestrateEngine = noopOrchestrateEngine): Wiring { const turnRunner = new FakeTurnRunner(responses); const approval = new ApprovalCoordinator(); const channel = new FakeSdkPublisher(); @@ -287,6 +288,10 @@ function makeWiring(responses: Array, tools: AnyToo .register(IToolRegistry) .using(() => registry) .asSelf(); + services + .register(IOrchestrateEngine) + .using(() => orchestrateEngine) + .asSelf(); services .register(ApprovalCoordinator) .using(() => approval) @@ -307,10 +312,6 @@ function makeWiring(responses: Array, tools: AnyToo .register(IToolsClockListener) .using(() => toolsClock) .asSelf(); - services - .register(IToolBlockNotifier) - .using(() => new ToolBlockNotifier([])) - .asSelf(); services.register(QueryRunner).asSelf(); const queryRunner = services.buildProvider().resolve(QueryRunner); return { turnRunner, registry, approval, channel, conversation: conv, queryRunner }; @@ -592,6 +593,39 @@ describe('QueryRunner — approval', () => { }); }); +describe('QueryRunner — Tools V2 dispatch', () => { + it('carries the V2 outcome content into the tool_result, proving the name never reached the (empty) V1 registry', async () => { + const orchestrateEngine: IOrchestrateEngine = { + owns: (name) => name === 'Orchestrate', + runBatch: async (items) => new Map(items.map((i) => [i.id, { kind: 'ok', content: 'Find: ok\n\na.txt' }])), + }; + const w = makeWiring([toolUseResult('tu_1', 'Orchestrate', { stages: [] }), endTurnResult('done')], [], {}, undefined, undefined, orchestrateEngine); + + await w.queryRunner.run(makeInput()); + + const actual = getTextBlock(findToolResult(w.conversation))?.text; + expect(actual).toBe('Find: ok\n\na.txt'); + }); + + it('runs the batch with requireApproval carried straight through, no callback of its own', async () => { + const requireApprovalSeen: boolean[] = []; + const orchestrateEngine: IOrchestrateEngine = { + owns: (name) => name === 'Orchestrate', + runBatch: async (items, requireApproval) => { + requireApprovalSeen.push(requireApproval); + return new Map(items.map((i) => [i.id, { kind: 'ok', content: 'done' }])); + }, + }; + const w = makeWiring([toolUseResult('tu_1', 'Orchestrate', { stages: [] }), endTurnResult('done')], [], { requireToolApproval: true }, undefined, undefined, orchestrateEngine); + + await w.queryRunner.run(makeInput()); + + const expected = [true]; + const actual = requireApprovalSeen; + expect(actual).toEqual(expected); + }); +}); + // --------------------------------------------------------------------------- // Long-lived instance and reset // --------------------------------------------------------------------------- @@ -1036,6 +1070,10 @@ describe('QueryRunner — cancel already settled before approval requests are cr .register(IToolRegistry) .using(() => registry) .asSelf(); + services + .register(IOrchestrateEngine) + .using(() => noopOrchestrateEngine) + .asSelf(); services .register(ApprovalCoordinator) .using(() => approval) @@ -1056,10 +1094,6 @@ describe('QueryRunner — cancel already settled before approval requests are cr .register(IToolsClockListener) .using(() => new NoopToolsClock()) .asSelf(); - services - .register(IToolBlockNotifier) - .using(() => new ToolBlockNotifier([])) - .asSelf(); services.register(QueryRunner).asSelf(); const queryRunner = services.buildProvider().resolve(QueryRunner); @@ -1143,6 +1177,77 @@ describe('QueryRunner — cancel escalation across concurrent approvals (regress }); }); +// --------------------------------------------------------------------------- +// The same escalation, across the phase boundary rather than within one batch. A round runs its +// V2 tool_uses first and its V1 ones second, both registering the same controller, so the second +// registration resets the escalation flag on a controller the first cancel already aborted. +// --------------------------------------------------------------------------- + +describe('QueryRunner — cancel escalation across the V2 and V1 phases (regression)', () => { + it('escalates to a full query cancel on the second cancel, even when the V1 phase started after the first', async () => { + let releaseV2!: () => void; + let releaseV1!: () => void; + const v2Gate = new Promise((resolve) => { + releaseV2 = resolve; + }); + const v1Gate = new Promise((resolve) => { + releaseV1 = resolve; + }); + let markV2Started!: () => void; + const v2Started = new Promise((resolve) => { + markV2Started = resolve; + }); + const orchestrateEngine: IOrchestrateEngine = { + owns: (name) => name === 'Orchestrate', + runBatch: async (items) => { + markV2Started(); + await v2Gate; + return new Map(items.map((item) => [item.id, { kind: 'ok', content: 'v2 done' } as const])); + }, + }; + const v1 = makeGatedTool('a', v1Gate); + const w = makeWiring( + [ + multiToolUseResult([ + { id: 'tu_1', name: 'Orchestrate', input: { stages: [] } }, + { id: 'tu_2', name: 'a', input: { value: 'x' } }, + ]), + endTurnResult('done'), + ], + [v1.tool], + { requireToolApproval: true }, + undefined, + undefined, + orchestrateEngine, + ); + + const runPromise = w.queryRunner.run(makeInput()); + await v2Started; + + // First cancel: the V2 phase is running, so this is a tool-cancel and aborts its controller. + w.approval.handle({ type: 'cancel' }); + releaseV2(); + + // The V1 phase now starts and registers that same, already-aborted controller. + await new Promise((resolve) => setImmediate(resolve)); + const request = w.channel.messages.filter((m): m is Extract => m.type === 'tool_approval_request').find((r) => r.name === 'a'); + if (request == null) { + throw new Error('unreachable'); + } + w.approval.handle({ type: 'tool_approval_response', requestId: request.requestId, approved: true }); + await v1.started; + + // Second cancel: the user pressed cancel again meaning to kill the query outright. + w.approval.handle({ type: 'cancel' }); + + releaseV1(); + await runPromise; + + const actual = w.approval.cancelled; + expect(actual).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // Account-limit give-up termination (§9) // --------------------------------------------------------------------------- @@ -1394,6 +1499,10 @@ describe('QueryRunner — concurrent tool execution regression', () => { .register(IToolRegistry) .using(() => new ThrowingReadyRegistry()) .asSelf(); + services + .register(IOrchestrateEngine) + .using(() => noopOrchestrateEngine) + .asSelf(); services .register(ApprovalCoordinator) .using(() => approval) @@ -1414,10 +1523,6 @@ describe('QueryRunner — concurrent tool execution regression', () => { .register(IToolsClockListener) .using(() => new NoopToolsClock()) .asSelf(); - services - .register(IToolBlockNotifier) - .using(() => new ToolBlockNotifier([])) - .asSelf(); services.register(QueryRunner).asSelf(); const queryRunner = services.buildProvider().resolve(QueryRunner); @@ -1442,3 +1547,40 @@ describe('QueryRunner — concurrent tool execution regression', () => { expect(actual).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// Every tool_use must be answered with a tool_result, or the next request is malformed. A round +// cancelled before it starts is the one path where a V2 block could go unanswered; whether that +// path is reachable depends on a consumer aborting the query, which is not something this code +// enforces, so it answers regardless. +// --------------------------------------------------------------------------- + +describe('QueryRunner — a V2 batch in a round that was already cancelled', () => { + it('answers every tool_use with a cancelled tool_result', async () => { + const orchestrateEngine: IOrchestrateEngine = { + owns: (name) => name === 'Orchestrate', + runBatch: async () => new Map(), + }; + const w = makeWiring( + [ + multiToolUseResult([ + { id: 'tu_1', name: 'Orchestrate', input: { stages: [] } }, + { id: 'tu_2', name: 'Orchestrate', input: { stages: [] } }, + ]), + endTurnResult('done'), + ], + [], + { requireToolApproval: true }, + undefined, + undefined, + orchestrateEngine, + ); + w.approval.handle({ type: 'cancel' }); + + await w.queryRunner.run(makeInput()); + + const expected = ['tu_1', 'tu_2']; + const actual = w.channel.messages.filter((m) => m.type === 'tool_result').map((m) => (m as Extract).id); + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk/test/RequestBuilder.spec.ts b/packages/claude-sdk/test/RequestBuilder.spec.ts index 225fabfe..de4461f3 100644 --- a/packages/claude-sdk/test/RequestBuilder.spec.ts +++ b/packages/claude-sdk/test/RequestBuilder.spec.ts @@ -335,6 +335,32 @@ describe('buildRequestParams — tools', () => { }); }); +describe('buildRequestParams — tools V2 wins on a name collision', () => { + it('omits the V1 tool of the same name, since the API rejects duplicate tool names outright', () => { + const { body } = buildRequestParams(makeOptions({ tools: [makeTool('Ref')], toolsV2: [{ name: 'Ref', description: 'v2', input_schema: { type: 'object' } }] }), noMessages); + + const expected = 1; + const actual = (body.tools as { name: string }[]).filter((t) => t.name === 'Ref').length; + expect(actual).toBe(expected); + }); + + it('keeps the V2 version of the colliding name, not the V1 one', () => { + const { body } = buildRequestParams(makeOptions({ tools: [makeTool('Ref')], toolsV2: [{ name: 'Ref', description: 'v2', input_schema: { type: 'object' } }] }), noMessages); + + const expected = 'v2'; + const actual = (body.tools as { name: string; description: string }[]).find((t) => t.name === 'Ref')?.description; + expect(actual).toBe(expected); + }); + + it('leaves a non-colliding V1 tool untouched alongside the V2 tools', () => { + const { body } = buildRequestParams(makeOptions({ tools: [makeTool('OnlyV1')], toolsV2: [{ name: 'Ref', description: 'v2', input_schema: { type: 'object' } }] }), noMessages); + + const expected = ['Ref', 'OnlyV1'].sort(); + const actual = (body.tools as { name: string }[]).map((t) => t.name).sort(); + expect(actual).toEqual(expected); + }); +}); + // --------------------------------------------------------------------------- // Messages // --------------------------------------------------------------------------- diff --git a/packages/claude-sdk/test/timestampAfterCancelledToolResult.spec.ts b/packages/claude-sdk/test/timestampAfterCancelledToolResult.spec.ts index 2492afa6..b1a98499 100644 --- a/packages/claude-sdk/test/timestampAfterCancelledToolResult.spec.ts +++ b/packages/claude-sdk/test/timestampAfterCancelledToolResult.spec.ts @@ -12,9 +12,9 @@ import { TurnRunner } from '../src/private/TurnRunner.js'; import type { MessageStreamResult } from '../src/private/types.js'; import { IDurableConfigProvider } from '../src/public/IDurableConfigProvider.js'; import { ISdkMessagePublisher } from '../src/public/ISdkMessagePublisher.js'; -import { IStreamProcessor, IToolRegistry, ITurnRunner, IWakeLock } from '../src/public/interfaces.js'; +import { IOrchestrateEngine, IStreamProcessor, IToolRegistry, ITurnRunner, IWakeLock } from '../src/public/interfaces.js'; import type { DurableConfig, PerQueryInput, SystemReminder, ToolResolveResult } from '../src/public/types.js'; -import { AccountLimitListener, IRequestClockListener, IToolBlockNotifier, IToolsClockListener, StreamInterruptListener } from '../src/public/types.js'; +import { AccountLimitListener, IRequestClockListener, IToolsClockListener, StreamInterruptListener } from '../src/public/types.js'; class NoopLogger extends ILogger { public trace(): void {} @@ -147,6 +147,10 @@ function runQuery(conversation: Conversation, streamer: IMessageStreamer, proces .register(IToolRegistry) .using(() => new OkToolRegistry()) .asSelf(); + services + .register(IOrchestrateEngine) + .using(() => ({ owns: () => false, runBatch: async () => new Map() })) + .asSelf(); services.register(ApprovalCoordinator).asSelf(); services .register(ISdkMessagePublisher) @@ -160,10 +164,6 @@ function runQuery(conversation: Conversation, streamer: IMessageStreamer, proces .register(IToolsClockListener) .using(() => ({ toolsStarted: () => {}, toolsStopped: () => {} })) .asSelf(); - services - .register(IToolBlockNotifier) - .using(() => ({ blockEnded: async () => {} })) - .asSelf(); services.register(QueryRunner).asSelf(); return services.buildProvider().resolve(QueryRunner).run(input); } diff --git a/packages/exec-core/src/Executor.ts b/packages/exec-core/src/Executor.ts index 1deaa121..4a0aaf3f 100644 --- a/packages/exec-core/src/Executor.ts +++ b/packages/exec-core/src/Executor.ts @@ -147,7 +147,8 @@ export class Executor implements IExecutor { try { process.kill(-pid, signal); } catch { - return; + // The first signal failing is not a reason to stop trying: the group may exist while this + // particular signal cannot be delivered, and returning here left the process alive. } // If the process ignores the signal (a producer that handles SIGPIPE), the SIGKILL // below reaps it after the grace period, and it then reports SIGKILL, not SIGPIPE. diff --git a/packages/mcp-exec/package.json b/packages/mcp-exec/package.json index 1e3fff74..0cd53d7a 100644 --- a/packages/mcp-exec/package.json +++ b/packages/mcp-exec/package.json @@ -42,7 +42,7 @@ "@js-joda/locale_en": "^4.15.3", "@js-joda/timezone": "^2.25.2", "@modelcontextprotocol/sdk": "^1.29.0", - "@shellicar/core-di": "^5.0.0-alpha.3" + "@shellicar/core-di": "^5.0.0-alpha.5" }, "devDependencies": { "@shellicar/build-clean": "^1.3.6", diff --git a/packages/mcp-memory/package.json b/packages/mcp-memory/package.json index b2df1657..02eb2838 100644 --- a/packages/mcp-memory/package.json +++ b/packages/mcp-memory/package.json @@ -42,7 +42,7 @@ "@js-joda/locale_en": "^4.15.3", "@js-joda/timezone": "^2.25.2", "@modelcontextprotocol/sdk": "^1.29.0", - "@shellicar/core-di": "^5.0.0-alpha.3", + "@shellicar/core-di": "^5.0.0-alpha.5", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/mcp-typescript/package.json b/packages/mcp-typescript/package.json index c49f3c9b..57f8699d 100644 --- a/packages/mcp-typescript/package.json +++ b/packages/mcp-typescript/package.json @@ -45,7 +45,7 @@ "@shellicar/build-version": "^2.0.0", "@shellicar/claude-core": "workspace:^", "@shellicar/claude-sdk-tools": "workspace:^", - "@shellicar/core-di": "^5.0.0-alpha.3", + "@shellicar/core-di": "^5.0.0-alpha.5", "@shellicar/typescript-config": "workspace:^", "@tsconfig/node24": "^24.0.4", "@types/node": "^25.9.5", diff --git a/packages/mcp-typescript/src/entry/cli.ts b/packages/mcp-typescript/src/entry/cli.ts index 984e207d..03e6aad2 100644 --- a/packages/mcp-typescript/src/entry/cli.ts +++ b/packages/mcp-typescript/src/entry/cli.ts @@ -12,12 +12,12 @@ async function main() { return; } shuttingDown = true; - // Backstop for the abnormal paths a per-call blockEnded() can't reach: - // the process is signalled or the client drops the stdio pipe mid-call, - // which would otherwise leave a tsserver child orphaned. Each in-flight - // call owns its own tsService instance, so every one still active gets - // its own teardown rather than assuming a single shared instance. - await Promise.allSettled([...active].map((ts) => ts.blockEnded())); + // Backstop for the abnormal paths a per-call teardown can't reach: the + // process is signalled or the client drops the stdio pipe mid-call, which + // would otherwise leave a tsserver child orphaned. Each in-flight call owns + // its own tsService instance, so every one still active gets its own + // teardown rather than assuming a single shared instance. + await Promise.allSettled([...active].map((ts) => ts[Symbol.asyncDispose]())); process.exit(0); }; diff --git a/packages/mcp-typescript/src/entry/index.ts b/packages/mcp-typescript/src/entry/index.ts index 1de66803..855053de 100644 --- a/packages/mcp-typescript/src/entry/index.ts +++ b/packages/mcp-typescript/src/entry/index.ts @@ -7,14 +7,14 @@ import { createTsDiagnostics } from '@shellicar/claude-sdk-tools/TsDiagnostics'; import { createTsHover } from '@shellicar/claude-sdk-tools/TsHover'; import { createTsReferences } from '@shellicar/claude-sdk-tools/TsReferences'; import { ITsServerClient, ITsServerOptions, ITypeScriptService, resolveTsServerPath, TsServerBridge, TsServerClient } from '@shellicar/claude-sdk-tools/TsService'; -import { createServiceCollection, type IServiceCollection, Lifetime } from '@shellicar/core-di'; +import { createServiceCollection, type IScopedProvider, type IServiceCollection, Lifetime } from '@shellicar/core-di'; // biome-ignore-start lint/suspicious/noExplicitAny: mirrors the zod shape registerTool's inputSchema/handler accept across every tool type AnyToolDefinition = { name: string; description: string; input_schema: any; - handler: (input: any) => Promise<{ textContent: unknown }>; + handler: (input: any, signal?: AbortSignal, scope?: IScopedProvider) => Promise<{ textContent: unknown }>; }; // biome-ignore-end lint/suspicious/noExplicitAny: mirrors the zod shape registerTool's inputSchema/handler accept across every tool @@ -68,15 +68,22 @@ export function buildTypeScriptServiceCollection(): IServiceCollection { services.register(NodeFileSystem).as(IFileSystem); services.register(StderrLogger).as(ILogger); services.register(TsServerClient).as(ITsServerClient); - services.register(TsServerBridge).as(ITypeScriptService); + // Resolved both ways: as ITypeScriptService (the tool-facing contract) and as itself, so a + // caller that needs its `[Symbol.asyncDispose]` (not part of ITypeScriptService — disposal is + // a DI-scope concern, not something a TS tool ever calls) can resolve the concrete class. + services.register(TsServerBridge).as(ITypeScriptService).asSelf(); return services; } -function buildTypeScriptService(): ITypeScriptService { - return buildTypeScriptServiceCollection().buildProvider().resolve(ITypeScriptService); +function buildTypeScriptService(): TsServerBridge { + return buildTypeScriptServiceCollection().buildProvider().resolve(TsServerBridge); } -type ToolFactory = (ts: ITypeScriptService) => AnyToolDefinition; +/** Each TS tool no longer closes over a fixed `ITypeScriptService` — it resolves one fresh from + * the `scope` argument its handler is called with (see `ToolHandler`). This server has no block + * concept of its own, so `registerTool` below hands each call a minimal scope whose `resolve` + * always returns that one call's own `tsService`. */ +type ToolFactory = () => AnyToolDefinition; /** * Registers one TS tool. stdio MCP permits a client to pipeline overlapping @@ -92,8 +99,8 @@ type ToolFactory = (ts: ITypeScriptService) => AnyToolDefinition; * never started (`ITypeScriptService` only spawns `tsserver` lazily, on the * first actual tool call it handles), so it costs nothing. */ -function registerTool(server: McpServer, active: Set, factory: ToolFactory): void { - const meta = factory(buildTypeScriptService()); +function registerTool(server: McpServer, active: Set, factory: ToolFactory): void { + const meta = factory(); server.registerTool( meta.name, { @@ -106,8 +113,9 @@ function registerTool(server: McpServer, active: Set, factor process.stderr.write(`[mcp-typescript] [timing] ${meta.name} start ${start} (${active.size} already in flight)\n`); const tsService = buildTypeScriptService(); active.add(tsService); + const scope: IScopedProvider = { resolve: () => tsService } as unknown as IScopedProvider; try { - const { textContent: result } = await factory(tsService).handler(input); + const { textContent: result } = await factory().handler(input, undefined, scope); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], // MCP requires structuredContent to be a record when present; TsHover @@ -117,7 +125,7 @@ function registerTool(server: McpServer, active: Set, factor }; } finally { active.delete(tsService); - await tsService.blockEnded(); + await tsService[Symbol.asyncDispose](); process.stderr.write(`[mcp-typescript] [timing] ${meta.name} end ${Date.now()} (${Date.now() - start}ms)\n`); } }, @@ -130,7 +138,7 @@ export type TypeScriptServer = { * Exposed so the entry point can tear each of them down on shutdown as a * backstop for calls interrupted mid-flight; every call already tears down * its own instance in its `finally` on the ordinary completion path. */ - active: ReadonlySet; + active: ReadonlySet; }; /** @@ -139,7 +147,7 @@ export type TypeScriptServer = { */ export function createTypeScriptServer(): TypeScriptServer { const server = new McpServer({ name: 'mcp-typescript', version: '1.0.0' }); - const active = new Set(); + const active = new Set(); registerTool(server, active, createTsDiagnostics); registerTool(server, active, createTsHover); diff --git a/packages/orchestrate-core/CHANGELOG.md b/packages/orchestrate-core/CHANGELOG.md new file mode 100644 index 00000000..23ac4f53 --- /dev/null +++ b/packages/orchestrate-core/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- New package: a runtime for composing tools into a pipeline — plan and execute a list of stages joined by |, && and ||, stream one stage's output into the next, fan a batch out with Xargs, and capture a stage's output as a named variable later stages reference diff --git a/packages/orchestrate-core/README.md b/packages/orchestrate-core/README.md new file mode 100644 index 00000000..58d84b34 --- /dev/null +++ b/packages/orchestrate-core/README.md @@ -0,0 +1,10 @@ +# @shellicar/orchestrate-core + +> Tool orchestration used by the claude-cli tools: planning and executing a pipeline of stages, streaming, and named captures. + +[![npm package](https://img.shields.io/npm/v/@shellicar/orchestrate-core.svg)](https://npmjs.com/package/@shellicar/orchestrate-core) +[![build status](https://github.com/shellicar/claude-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/shellicar/claude-cli/actions/workflows/ci.yml) + +The runtime the Orchestrate tool is built on: stages joined by `|`, `&&` and `;`, one stage's output streamed into the next, batch fan-out via Xargs, and a stage's output captured as a named variable later stages reference. + +This is an internal package. It is published as part of [claude-cli](https://github.com/shellicar/claude-cli#readme) but is not intended for standalone use. See the [main documentation](https://github.com/shellicar/claude-cli#readme). diff --git a/packages/orchestrate-core/changes.jsonl b/packages/orchestrate-core/changes.jsonl new file mode 100644 index 00000000..8eef0709 --- /dev/null +++ b/packages/orchestrate-core/changes.jsonl @@ -0,0 +1 @@ +{"description":"New package: a runtime for composing tools into a pipeline — plan and execute a list of stages joined by |, && and ||, stream one stage's output into the next, fan a batch out with Xargs, and capture a stage's output as a named variable later stages reference","category":"added"} diff --git a/packages/orchestrate-core/package.json b/packages/orchestrate-core/package.json new file mode 100644 index 00000000..913baf91 --- /dev/null +++ b/packages/orchestrate-core/package.json @@ -0,0 +1,60 @@ +{ + "name": "@shellicar/orchestrate-core", + "version": "1.0.0-beta.23", + "description": "Composable tool orchestration — leaves, plan/execute, capture/reference, Xargs, &&/||/;/| operators.", + "private": false, + "license": "MIT", + "author": "Stephen Hellicar", + "contributors": [ + "BananaBot9000 ", + "Claude (Anthropic) " + ], + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/shellicar/claude-cli.git" + }, + "bugs": { + "url": "https://github.com/shellicar/claude-cli/issues" + }, + "homepage": "https://github.com/shellicar/claude-cli#readme", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "CHANGELOG.md", + "README.md" + ], + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "watch": "tsup --watch", + "test": "vitest run", + "type-check": "tsc -p tsconfig.check.json" + }, + "devDependencies": { + "@shellicar/build-clean": "^1.3.6", + "@shellicar/build-version": "^2.0.0", + "@shellicar/typescript-config": "workspace:^", + "@tsconfig/node24": "^24.0.4", + "@types/node": "^25.9.5", + "esbuild": "^0.28.0", + "tsup": "^8.5.1", + "tsx": "^4.22.5", + "typescript": "^5.9.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/orchestrate-core/src/attachable.ts b/packages/orchestrate-core/src/attachable.ts new file mode 100644 index 00000000..c882f9ac --- /dev/null +++ b/packages/orchestrate-core/src/attachable.ts @@ -0,0 +1,26 @@ +/** What a request can carry as one attachment. */ +const MAX_BYTES = 32 * 1024 * 1024; + +const SIGNATURES: Record boolean> = { + 'application/pdf': (bytes) => bytes.subarray(0, 5).toString('binary') === '%PDF-' && isOnePdf(bytes), + 'image/png': (bytes) => bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), + 'image/jpeg': (bytes) => bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff, + 'image/gif': (bytes) => bytes.subarray(0, 6).toString('binary') === 'GIF87a' || bytes.subarray(0, 6).toString('binary') === 'GIF89a', + 'image/webp': (bytes) => bytes.subarray(0, 4).toString('binary') === 'RIFF' && bytes.subarray(8, 12).toString('binary') === 'WEBP', +}; + +/** A PDF ends with its trailer, and has exactly one header: `cat a.pdf b.pdf` begins like a PDF and + * is not one, which the API rejects at the cost of the whole turn. */ +function isOnePdf(bytes: Buffer): boolean { + const text = bytes.toString('binary'); + return text.trimEnd().endsWith('%%EOF') && text.indexOf('%PDF-', 5) === -1; +} + +/** Whether these bytes may be sent as what they claim to be. Anything doubtful is read as text + * instead: a request the API refuses costs the turn, and being wrong here is not recoverable. */ +export function attachable(bytes: Buffer, type: string): boolean { + if (bytes.length === 0 || bytes.length > MAX_BYTES) { + return false; + } + return SIGNATURES[type]?.(bytes) ?? false; +} diff --git a/packages/orchestrate-core/src/channel.ts b/packages/orchestrate-core/src/channel.ts new file mode 100644 index 00000000..ecb8ea44 --- /dev/null +++ b/packages/orchestrate-core/src/channel.ts @@ -0,0 +1,85 @@ +/** What sits between one stage and the next. It holds bytes, and its size is how far a writer may + * be ahead of its reader. Nothing about what passes through it is interpreted, and how much passes + * through in total is not bounded. */ +export type Channel = { + /** Resolves when the bytes are taken, or `false` when the reader has gone. */ + write: (bytes: Buffer) => Promise; + /** The next bytes, at most `max` of them, or `undefined` once the writer has finished. */ + read: (max?: number) => Promise; + /** The writer has nothing more to send. */ + end: () => void; + /** The reader has gone. */ + close: () => void; + /** The writer broke; the reader is told rather than seeing a clean end. */ + fail: (err: unknown) => void; +}; + +export function channel(size: number): Channel { + const queued: Buffer[] = []; + let held = 0; + let ended = false; + let closed = false; + let failure: unknown; + let wakeReader: (() => void) | undefined; + let wakeWriter: (() => void) | undefined; + + const wake = (waiter: (() => void) | undefined): undefined => { + waiter?.(); + return undefined; + }; + + return { + write: async (bytes) => { + while (held >= size && !closed) { + await new Promise((resolve) => { + wakeWriter = resolve; + }); + } + if (closed) { + return false; + } + queued.push(bytes); + held += bytes.length; + wakeReader = wake(wakeReader); + return true; + }, + + read: async (max) => { + while (queued.length === 0 && !ended && failure === undefined && !closed) { + await new Promise((resolve) => { + wakeReader = resolve; + }); + } + const next = queued.shift(); + if (next === undefined) { + if (failure !== undefined) { + throw failure; + } + return undefined; + } + const taken = max != null && max < next.length ? next.subarray(0, max) : next; + if (taken !== next) { + queued.unshift(next.subarray(taken.length)); + } + held -= taken.length; + wakeWriter = wake(wakeWriter); + return taken; + }, + + end: () => { + ended = true; + wakeReader = wake(wakeReader); + }, + + close: () => { + closed = true; + wakeWriter = wake(wakeWriter); + wakeReader = wake(wakeReader); + }, + + fail: (err) => { + failure = err; + wakeReader = wake(wakeReader); + }, + }; +} diff --git a/packages/orchestrate-core/src/entry/index.ts b/packages/orchestrate-core/src/entry/index.ts new file mode 100644 index 00000000..a4eccd26 --- /dev/null +++ b/packages/orchestrate-core/src/entry/index.ts @@ -0,0 +1,9 @@ +import { attachable } from '../attachable.js'; +import { channel } from '../channel.js'; +import { run } from '../run.js'; +import type { ApprovalContext, ApprovalOutcome, Outcome, RunOptions, RunResult, StageReport } from '../run.js'; +import type { Channel } from '../channel.js'; +import type { Ended, FsOperation, Op, Operation, Reader, SetStage, Stage, Tool, ToolStage, Running, Writer, XargsStage } from '../types.js'; + +export type { ApprovalContext, ApprovalOutcome, Channel, Ended, FsOperation, Op, Operation, Outcome, Reader, Running, RunOptions, RunResult, SetStage, Stage, StageReport, Tool, ToolStage, Writer, XargsStage }; +export { attachable, channel, run }; diff --git a/packages/orchestrate-core/src/run.ts b/packages/orchestrate-core/src/run.ts new file mode 100644 index 00000000..7784c599 --- /dev/null +++ b/packages/orchestrate-core/src/run.ts @@ -0,0 +1,320 @@ +import { type Channel, channel } from './channel.js'; +import type { Ended, Op, Reader, Running, Stage, ToolStage } from './types.js'; + +/** How a stage ended: what its tool said, or what the run had to say instead. */ +export type Outcome = Ended | { kind: 'refused'; reason?: string } | { kind: 'skipped' } | { kind: 'threw'; error: unknown } | { kind: 'truncated' } | { kind: 'timedOut' } | { kind: 'cancelled' }; + +export type StageReport = { + name: string; + ended: Outcome; + /** What the stage had to say to whoever asked for the run. */ + said: string[]; + /** What the stage sent back that is not text. */ + attached: { bytes: Buffer; type: string }[]; +}; + +export type RunResult = { output: Buffer; stages: StageReport[] }; + +export type ApprovalContext = { + name: string; + operations: string[]; + input: Record; + /** What this stage would act on, held whole. Nothing is held unless this is called. */ + batch: () => Promise; +}; + +export type ApprovalOutcome = { verdict: 'allow' } | { verdict: 'refuse'; reason?: string }; + +export type RunOptions = { + decide: (ctx: ApprovalContext) => Promise; + sleep: (ms: number, signal: AbortSignal) => Promise; + /** How much may be held whole: to be shown to whoever decides, or to be handed back. */ + hold: number; + /** How far a stage may be ahead of whoever reads it. */ + ahead: number; + timeout?: number; + signal?: AbortSignal; + /** Where a name bound to a stage's output goes. */ + bind?: (name: string, value: string) => void; + /** How bytes become an argument list. */ + split?: (bytes: Buffer) => string[]; +}; + +/** More was held than may be. */ +class TooMuchHeld extends Error {} + +const EMPTY = Buffer.alloc(0); + +async function holdAll(from: Reader, limit: number): Promise> { + const held: Buffer[] = []; + let size = 0; + for (let chunk = await from.read(); chunk != null; chunk = await from.read()) { + held.push(chunk); + size += chunk.length; + if (size > limit) { + throw new TooMuchHeld(); + } + } + return held.length === 0 ? EMPTY : (Buffer.concat(held) as Buffer); +} + +function readerOver(bytes: Buffer): Reader { + let taken = bytes.length === 0; + return { + read: async () => { + if (taken) { + return undefined; + } + taken = true; + return bytes; + }, + }; +} + +/** Whether a stage follows the one before it, given how they were joined. */ +function follows(op: Op | undefined, previous: Outcome | undefined): boolean { + if (op == null || previous == null) { + return true; + } + if (op === '&&') { + return previous.kind === 'finished'; + } + if (op === '||') { + return previous.kind !== 'finished'; + } + // A pipe starts both at once, so the producer's fate is not yet known and cannot be waited for. + // What stops the reader is there being nothing to read from at all. + return previous.kind !== 'skipped' && previous.kind !== 'refused' && previous.kind !== 'cancelled'; +} + +type Started = { report: StageReport; running: Running; out: Channel; failed: () => unknown; captured: string[]; showCaptured: 'onError' | 'always' | 'never' }; + +const defaultSplit = (bytes: Buffer): string[] => + bytes + .toString('utf8') + .split('\n') + .filter((argument) => argument.length > 0); + +export async function run(stages: Stage[], options: RunOptions): Promise { + const reports: StageReport[] = []; + const started: Started[] = []; + const expiry = new AbortController(); + const split = options.split ?? defaultSplit; + let timedOut = false; + let cancelled = options.signal?.aborted === true; + let done = false; + + // Time running out, or the caller saying stop, closes whatever is open: a read that would never + // end does, and the stage behind it is told the only way a stage is ever told. + if (options.timeout != null) { + void options.sleep(options.timeout, expiry.signal).then(() => { + if (!done) { + timedOut = true; + expiry.abort(); + } + }); + } + options.signal?.addEventListener( + 'abort', + () => { + cancelled = true; + expiry.abort(); + }, + { once: true }, + ); + + let upstream: Reader | undefined; + let previous: Outcome | undefined; + let previousOp: Op | undefined; + let pendingList: string[] | undefined; + let output = EMPTY; + + for (const stage of stages) { + if (stage.kind === 'set') { + const held = upstream == null ? undefined : await takeAll(upstream, options.hold); + if (held?.bytes != null && !held.tooMuch) { + options.bind?.(stage.name, held.bytes.toString('utf8')); + } + await settle(started, held?.tooMuch === true, timedOut, cancelled); + started.length = 0; + upstream = undefined; + continue; + } + + if (stage.kind === 'xargs') { + const held = upstream == null ? undefined : await takeAll(upstream, options.hold); + await settle(started, held?.tooMuch === true, timedOut, cancelled); + started.length = 0; + upstream = undefined; + // Nothing collected, whether because there was nothing or because there was too much: either + // way the stage after this one has no argument list, and a command with no arguments is a + // different command. + pendingList = held == null || held.tooMuch ? [] : split(held.bytes); + previousOp = undefined; + continue; + } + + if (cancelled) { + previous = { kind: 'cancelled' }; + reports.push({ name: stage.tool.name, ended: previous, said: [], attached: [] }); + previousOp = stage.op; + upstream = undefined; + continue; + } + + const list = pendingList; + const fedByList = list != null; + pendingList = undefined; + + if (!follows(previousOp, previous) || (fedByList && list.length === 0)) { + previous = { kind: 'skipped' }; + previousOp = stage.op; + reports.push({ name: stage.tool.name, ended: previous, said: [], attached: [] }); + upstream = undefined; + continue; + } + + if (fedByList && stage.tool.takesListIn == null) { + previous = { kind: 'refused', reason: `${stage.tool.name} takes no argument list` }; + previousOp = stage.op; + reports.push({ name: stage.tool.name, ended: previous, said: [], attached: [] }); + upstream = undefined; + continue; + } + + const input = fedByList ? withList(stage.input, stage.tool.takesListIn as string, list) : stage.input; + const decided = await decide(stage, input, upstream, options); + if (decided.refused != null) { + previous = decided.refused; + previousOp = stage.op; + reports.push({ name: stage.tool.name, ended: previous, said: [], attached: [] }); + upstream = undefined; + continue; + } + + const out = channel(options.ahead); + // A stage that fails is recorded here rather than thrown at its reader: a reader sees the end, + // the way it does when a process dies, and the failure belongs to the stage that had it. + let failure: unknown; + const said: string[] = []; + const captured: string[] = []; + const attached: { bytes: Buffer; type: string }[] = []; + let saidBytes = 0; + let attachedBytes = 0; + const running = stage.tool.run( + input, + fedByList ? undefined : decided.source, + { + write: out.write, + end: out.end, + fail: (err) => { + failure = err; + out.end(); + }, + }, + (line, said_options) => { + // Bounded like anything else held whole, and being cut short here is not the stage failing. + if (saidBytes + line.length > options.hold) { + return; + } + saidBytes += line.length; + (said_options?.captured === true ? captured : said).push(line); + }, + (bytes, type) => { + if (attachedBytes + bytes.length > options.hold) { + return; + } + attachedBytes += bytes.length; + attached.push({ bytes, type }); + }, + ); + if (expiry.signal.aborted) { + out.close(); + } else { + expiry.signal.addEventListener('abort', () => out.close(), { once: true }); + } + const report: StageReport = { name: stage.tool.name, ended: { kind: 'finished' }, said, attached }; + reports.push(report); + started.push({ report, running, out, failed: () => failure, captured, showCaptured: stage.captured ?? 'onError' }); + + if (stage.op === '|') { + // Both are alive: the next stage reads this one while it is still writing. + upstream = out; + previous = { kind: 'finished' }; + previousOp = stage.op; + continue; + } + + const taken = await takeAll(out, options.hold); + await settle(started, taken.tooMuch, timedOut, cancelled); + started.length = 0; + previous = report.ended; + previousOp = stage.op; + upstream = undefined; + output = taken.bytes; + } + + await settle(started, false, timedOut, cancelled); + done = true; + expiry.abort(); + return { output, stages: reports }; +} + +function withList(input: Record, field: string, list: string[]): Record { + const existing = input[field]; + return { ...input, [field]: Array.isArray(existing) ? [...existing, ...list] : list }; +} + +async function decide(stage: ToolStage, input: Record, upstream: Reader | undefined, options: RunOptions): Promise<{ source?: Reader; refused?: Outcome }> { + let shown: Buffer | undefined; + + try { + const outcome = await options.decide({ + name: stage.tool.name, + operations: stage.tool.operations(input), + input, + batch: async () => { + shown ??= upstream == null ? EMPTY : await holdAll(upstream, options.hold); + return shown; + }, + }); + if (outcome.verdict === 'refuse') { + return { refused: { kind: 'refused', ...(outcome.reason != null ? { reason: outcome.reason } : {}) } }; + } + } catch (err) { + if (!(err instanceof TooMuchHeld)) { + throw err; + } + // Half of what a stage would act on is not something anyone can decide about. + return { refused: { kind: 'refused', reason: 'more was piped in than can be held to be shown' } }; + } + + return { source: shown != null ? readerOver(shown) : upstream }; +} + +async function takeAll(from: Reader, limit: number): Promise<{ bytes: Buffer; tooMuch: boolean }> { + try { + return { bytes: await holdAll(from, limit), tooMuch: false }; + } catch (err) { + if (err instanceof TooMuchHeld) { + return { bytes: EMPTY, tooMuch: true }; + } + throw err; + } +} + +/** Every stage still open behind the current point: stopped, then asked how it went. */ +async function settle(open: Started[], tooMuch: boolean, timedOut: boolean, cancelled: boolean): Promise { + for (let index = open.length - 1; index >= 0; index--) { + const stage = open[index] as Started; + stage.out.close(); + await stage.running.stop(); + const failure = stage.failed(); + stage.report.ended = failure !== undefined ? { kind: 'threw', error: failure } : tooMuch ? { kind: 'truncated' } : cancelled ? { kind: 'cancelled' } : timedOut ? { kind: 'timedOut' } : stage.running.ended(); + // What a stage captured is worth reading when the stage did not finish cleanly, or when the + // call said it wanted it. Otherwise it is a progress meter nobody asked for. + if (stage.showCaptured === 'always' || (stage.showCaptured === 'onError' && stage.report.ended.kind !== 'finished')) { + stage.report.said.push(...stage.captured); + } + } +} diff --git a/packages/orchestrate-core/src/types.ts b/packages/orchestrate-core/src/types.ts new file mode 100644 index 00000000..7c6a6427 --- /dev/null +++ b/packages/orchestrate-core/src/types.ts @@ -0,0 +1,60 @@ +/** Filesystem permission tiers, named after Unix's own model: `list` is directory entries, `read` + * is file content, the way `r` differs on a directory and a file. */ +export type FsOperation = 'fs.list' | 'fs.read' | 'fs.write' | 'fs.delete' | 'fs.exec'; + +/** What a call does to the world. `escalate` crosses a privilege boundary and is never a filesystem + * operation. Future categories join here. */ +export type Operation = 'none' | FsOperation | 'escalate'; + +/** How a stage joins the next: pipe its bytes, run on success, run on failure, or merely follow. */ +export type Op = '|' | '&&' | '||'; + +/** Where a stage reads its bytes from. */ +export type Reader = { read: (max?: number) => Promise }; + +/** Where a stage writes its bytes to. */ +export type Writer = { + write: (bytes: Buffer) => Promise; + end: () => void; + fail: (err: unknown) => void; +}; + +/** How a stage ended, as far as its own tool can know. Everything else is the run's to say. */ +export type Ended = { kind: 'finished' } | { kind: 'failed'; code: number } | { kind: 'signalled'; signal: string }; + +/** A running stage. `ended` is answerable once it is finished with; `stop` ends it early and waits + * for whatever is behind it to be finished with. */ +export type Running = { + ended: () => Ended; + stop: () => Promise; +}; + +/** A tool a run can execute. It writes bytes, reads bytes, and answers for itself. */ +export type Tool = { + name: string; + operations: (input: Record) => Operation[]; + /** The input field an argument list is put into, for a tool that takes one. */ + takesListIn?: string; + /** `say` is for whoever asked for the run, never for the stage after this one. A line about the + * stage itself always comes back; one marked as captured from whatever the stage ran comes back + * only when the stage did not finish cleanly, or when the call asked for it. */ + /** `attach` is for what is not text and cannot say what it is: a document, an image. The type is + * the tool's, because bytes alone cannot carry one and guessing at the boundary is how a request + * gets rejected. */ + run: (input: Record, upstream: Reader | undefined, out: Writer, say: (line: string, options?: { captured?: boolean }) => void, attach: (bytes: Buffer, type: string) => void) => Running; +}; + +export type ToolStage = { + kind: 'tool'; + tool: Tool; + input: Record; + op?: Op; + /** When what this stage captured is worth reading. Defaults to only when it did not finish + * cleanly: a progress meter is noise on success, and the reason for a failure is not. */ + captured?: 'onError' | 'always' | 'never'; +}; +/** Turns what came before it into an argument list for the stage after it. */ +export type XargsStage = { kind: 'xargs' }; +/** Binds a name to what came before it. */ +export type SetStage = { kind: 'set'; name: string }; +export type Stage = ToolStage | XargsStage | SetStage; diff --git a/packages/orchestrate-core/test/attachable.spec.ts b/packages/orchestrate-core/test/attachable.spec.ts new file mode 100644 index 00000000..51ddb8dc --- /dev/null +++ b/packages/orchestrate-core/test/attachable.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { attachable } from '../src/attachable.js'; + +// The guard in front of the API. Anything that goes over as a document or an image has to be what +// it claims: a request the API rejects costs the whole turn, so a doubtful attachment is refused +// here and read as text instead. + +const pdf = (body = 'body', trailer = '\n%%EOF\n') => Buffer.from(`%PDF-1.7\n${body}${trailer}`, 'binary'); +const png = () => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from('rest')]); +const jpeg = () => Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.from('rest'), Buffer.from([0xff, 0xd9])]); +const gif = () => Buffer.from('GIF89a....................', 'binary'); +const webp = () => Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x20, 0x00, 0x00, 0x00]), Buffer.from('WEBP'), Buffer.from('rest')]); + +describe('bytes that are what they claim to be', () => { + it('accepts a PDF', () => { + const expected = true; + const actual = attachable(pdf(), 'application/pdf'); + expect(actual).toBe(expected); + }); + + it('accepts a PNG', () => { + const expected = true; + const actual = attachable(png(), 'image/png'); + expect(actual).toBe(expected); + }); + + it('accepts a JPEG', () => { + const expected = true; + const actual = attachable(jpeg(), 'image/jpeg'); + expect(actual).toBe(expected); + }); + + it('accepts a GIF', () => { + const expected = true; + const actual = attachable(gif(), 'image/gif'); + expect(actual).toBe(expected); + }); + + it('accepts a WebP', () => { + const expected = true; + const actual = attachable(webp(), 'image/webp'); + expect(actual).toBe(expected); + }); +}); + +describe('bytes that are not what they claim to be', () => { + it('refuses a PNG claimed as a PDF', () => { + const expected = false; + const actual = attachable(png(), 'application/pdf'); + expect(actual).toBe(expected); + }); + + it('refuses text claimed as an image', () => { + const expected = false; + const actual = attachable(Buffer.from('hello, world'), 'image/png'); + expect(actual).toBe(expected); + }); + + it('refuses nothing at all', () => { + const expected = false; + const actual = attachable(Buffer.alloc(0), 'application/pdf'); + expect(actual).toBe(expected); + }); + + it('refuses a type the API does not take', () => { + const expected = false; + const actual = attachable(Buffer.from('BM....'), 'image/bmp'); + expect(actual).toBe(expected); + }); +}); + +// `cat *.pdf` concatenates them, and what arrives starts with a PDF signature while being no PDF at +// all. Sending it costs the turn, so the end matters as much as the beginning. +describe('bytes that begin as a PDF but are not one', () => { + it('refuses a PDF with no trailer', () => { + const expected = false; + const actual = attachable(pdf('body', ''), 'application/pdf'); + expect(actual).toBe(expected); + }); + + it('refuses two PDFs concatenated', () => { + const expected = false; + const actual = attachable(Buffer.concat([pdf(), pdf()]), 'application/pdf'); + expect(actual).toBe(expected); + }); +}); + +describe('bytes larger than a request can carry', () => { + it('refuses them however well formed they are', () => { + const enormous = Buffer.concat([Buffer.from('%PDF-1.7\n', 'binary'), Buffer.alloc(40 * 1024 * 1024), Buffer.from('\n%%EOF\n', 'binary')]); + + const expected = false; + const actual = attachable(enormous, 'application/pdf'); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/orchestrate-core/test/channel.spec.ts b/packages/orchestrate-core/test/channel.spec.ts new file mode 100644 index 00000000..978e650a --- /dev/null +++ b/packages/orchestrate-core/test/channel.spec.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest'; +import { channel } from '../src/channel.js'; + +// What sits between one stage and the next. It holds bytes and nothing else: no separator, no +// encoding, no notion of a line. Its size is how far a writer may be ahead of its reader, which is +// the only thing it bounds — how much passes through it in total is nobody's business. + +const SIZE = 64; + +describe('a writer with a reader that has not read', () => { + it('is allowed to write until the channel holds its size', async () => { + const { write } = channel(SIZE); + + const accepted = await write(Buffer.alloc(SIZE)); + + const expected = true; + const actual = accepted; + expect(actual).toBe(expected); + }); + + it('waits once the channel is full', async () => { + const { write } = channel(SIZE); + await write(Buffer.alloc(SIZE)); + + let settled = false; + void write(Buffer.alloc(1)).then(() => { + settled = true; + }); + await Promise.resolve(); + + const expected = false; + const actual = settled; + expect(actual).toBe(expected); + }); + + it('is still waiting after a second buffer’s worth, rather than having taken it', async () => { + const { write } = channel(SIZE); + await write(Buffer.alloc(SIZE)); + + let settled = false; + void write(Buffer.alloc(SIZE)).then(() => { + settled = true; + }); + await Promise.resolve(); + + const expected = false; + const actual = settled; + expect(actual).toBe(expected); + }); +}); + +describe('a reader taking bytes out', () => { + it('lets the writer continue', async () => { + const { write, read } = channel(SIZE); + await write(Buffer.alloc(SIZE)); + let settled = false; + void write(Buffer.alloc(1)).then(() => { + settled = true; + }); + + await read(); + await Promise.resolve(); + + const expected = true; + const actual = settled; + expect(actual).toBe(expected); + }); + + it('gets the bytes that were written, in order', async () => { + const { write, read, end } = channel(SIZE); + await write(Buffer.from('one')); + await write(Buffer.from('two')); + end(); + + const expected = 'onetwo'; + const actual = Buffer.concat(await drain(read)).toString('utf8'); + expect(actual).toBe(expected); + }); + + it('gets the rest of a write it only partly took', async () => { + const { write, read, end } = channel(SIZE); + await write(Buffer.from('abcdef')); + end(); + + const first = await read(2); + const rest = Buffer.concat(await drain(read)); + + const expected = 'ab|cdef'; + const actual = `${first?.toString('utf8')}|${rest.toString('utf8')}`; + expect(actual).toBe(expected); + }); + + it('sees the end once the writer has finished and nothing is left', async () => { + const { write, read, end } = channel(SIZE); + await write(Buffer.from('x')); + end(); + await read(); + + const expected = undefined; + const actual = await read(); + expect(actual).toBe(expected); + }); +}); + +// A pipeline moves far more than it ever holds. What is bounded is the distance between the two +// ends, never the total. +describe('a writer producing far more than the channel holds', () => { + it('is made to wait on every write once the reader falls behind', async () => { + const { write, read } = channel(SIZE); + await write(Buffer.alloc(SIZE)); + let waited = 0; + + for (let round = 0; round < 100; round++) { + let settled = false; + const writing = write(Buffer.alloc(SIZE)).then((accepted) => { + settled = true; + return accepted; + }); + await Promise.resolve(); + waited += settled ? 0 : 1; + await read(); + await writing; + } + + const expected = 100; + const actual = waited; + expect(actual).toBe(expected); + }); + + it('is never refused for how much it has written in total', async () => { + const { write, read } = channel(SIZE); + let accepted = true; + + for (let round = 0; round < 100; round++) { + const writing = write(Buffer.alloc(SIZE)); + await read(); + accepted = accepted && (await writing); + } + + const expected = true; + const actual = accepted; + expect(actual).toBe(expected); + }); +}); + +// The size bounds how far ahead a writer may be, not what a single write may contain. +describe('one write larger than the channel', () => { + it('is accepted', async () => { + const { write, read } = channel(SIZE); + + const writing = write(Buffer.alloc(SIZE * 10)); + await read(); + + const expected = true; + const actual = writing != null; + expect(actual).toBe(expected); + }); + + it('is delivered whole', async () => { + const { write, read, end } = channel(SIZE); + const big = Buffer.alloc(SIZE * 10, 7); + + void write(big).then(() => end()); + const taken = Buffer.concat(await drain(read)); + + const expected = big.length; + const actual = taken.length; + expect(actual).toBe(expected); + }); +}); + +describe('bytes with no structure to them', () => { + it('arrive exactly as they were sent', async () => { + const { write, read, end } = channel(1024); + const bytes = Buffer.from([0x00, 0xff, 0x0a, 0x80, 0xc3, 0x28, 0x0d]); + + await write(bytes); + end(); + + const expected = bytes.toString('hex'); + const actual = Buffer.concat(await drain(read)).toString('hex'); + expect(actual).toBe(expected); + }); + + it('are not divided on any separator', async () => { + const { write, read, end } = channel(1024); + + await write(Buffer.from('a\nb\nc')); + end(); + + const expected = 'a\nb\nc'; + const actual = Buffer.concat(await drain(read)).toString('utf8'); + expect(actual).toBe(expected); + }); +}); + +describe('a reader that stops', () => { + it('stops the writer waiting on a full channel', async () => { + const { write, close } = channel(SIZE); + await write(Buffer.alloc(SIZE)); + const blocked = write(Buffer.alloc(1)); + + close(); + + const expected = false; + const actual = await blocked; + expect(actual).toBe(expected); + }); + + it('refuses anything written afterwards', async () => { + const { write, close } = channel(SIZE); + close(); + + const expected = false; + const actual = await write(Buffer.from('x')); + expect(actual).toBe(expected); + }); +}); + +describe('a writer that fails', () => { + it('makes the reader see the failure rather than a clean end', async () => { + const { fail, read } = channel(SIZE); + const boom = new Error('producer exploded'); + + fail(boom); + + const expected = boom; + const actual = await read().catch((err: unknown) => err); + expect(actual).toBe(expected); + }); + + it('leaves what was already written readable first', async () => { + const { write, fail, read } = channel(SIZE); + await write(Buffer.from('before')); + + fail(new Error('producer exploded')); + + const expected = 'before'; + const actual = (await read())?.toString('utf8'); + expect(actual).toBe(expected); + }); +}); + +async function drain(read: (max?: number) => Promise): Promise { + const taken: Buffer[] = []; + for (let chunk = await read(); chunk != null; chunk = await read()) { + taken.push(chunk); + } + return taken; +} diff --git a/packages/orchestrate-core/test/fakes.ts b/packages/orchestrate-core/test/fakes.ts new file mode 100644 index 00000000..1224a329 --- /dev/null +++ b/packages/orchestrate-core/test/fakes.ts @@ -0,0 +1,168 @@ +import type { ApprovalContext, ApprovalOutcome } from '../src/run.js'; +import type { Stage, Tool, ToolResult } from '../src/types.js'; + +/** Decides by stage name, and remembers what it was asked about. */ +export class FakeApprover { + public readonly asked: ApprovalContext[] = []; + /** What it was shown, by the stage it was shown for. */ + public readonly shown = new Map(); + + public constructor(private readonly verdicts: Record = {}) {} + + public decide = async (ctx: ApprovalContext): Promise => { + this.asked.push(ctx); + return this.verdicts[ctx.name] ?? { verdict: 'allow' }; + }; + + /** Decides like `decide`, and asks to see what the stage would act on first. */ + public look = async (ctx: ApprovalContext): Promise => { + this.shown.set(ctx.name, await ctx.batch()); + return this.decide(ctx); + }; + + public names(): string[] { + return this.asked.map((ctx) => ctx.name); + } +} + +/** Resolves when the test says so, rather than when time passes. A delay asked for after the test + * said so has already elapsed: otherwise a test would have to know when the run got round to + * asking, and would pass or hang depending on that. */ +export class FakeSleep { + #elapsed = false; + #waiting: (() => void)[] = []; + + public sleep = (_ms: number, signal: AbortSignal): Promise => + new Promise((resolve) => { + if (this.#elapsed) { + resolve(); + return; + } + this.#waiting.push(resolve); + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + + /** The delay elapses, whether or not anything has asked for one yet. */ + public elapse(): void { + this.#elapsed = true; + const waiting = this.#waiting; + this.#waiting = []; + for (const resolve of waiting) { + resolve(); + } + } +} + +type ToolBehaviour = { + /** What it writes, in the order given. */ + writes?: (string | Buffer)[]; + /** How it answers for itself once it is finished with. Defaults to finished. */ + ends?: ToolResult['ended']; + /** Throws instead of producing anything. */ + throws?: Error; + /** Writes without end. */ + endless?: boolean; + /** Passes on whatever it read, rather than writing its own. */ + echoes?: boolean; + /** Waits for this before writing anything more. */ + waitsFor?: Promise; + /** The field an argument list is put into, for a tool that takes one. */ + takesListIn?: string; + /** What it has to say about itself. */ + says?: string[]; + /** What it captured from whatever it ran. */ + captured?: string[]; + /** Says without end. */ + saysEndlessly?: boolean; + /** What it sends back that is not text. */ + attaches?: { bytes: Buffer; type: string }[]; +}; + +/** A tool that does what the test told it to, and records what happened to it. */ +export class FakeTool { + public ran = false; + /** The input it was actually given. */ + public input: Record = {}; + public readonly received: Buffer[] = []; + public readonly written: Buffer[] = []; + public stopped = false; + + public constructor( + public readonly name: string, + private readonly behaviour: ToolBehaviour = {}, + ) {} + + public get tool(): Tool { + return { + name: this.name, + operations: () => ['none'], + ...(this.behaviour.takesListIn != null ? { takesListIn: this.behaviour.takesListIn } : {}), + run: (input, upstream, channel, say, attach) => { + this.ran = true; + this.input = input; + for (const item of this.behaviour.attaches ?? []) { + attach(item.bytes, item.type); + } + for (const line of this.behaviour.says ?? []) { + say(line); + } + for (const line of this.behaviour.captured ?? []) { + say(line, { captured: true }); + } + if (this.behaviour.saysEndlessly === true) { + for (let index = 0; index < 10_000; index++) { + say(`saying ${index}`); + } + } + void this.#produce(upstream, channel); + return { + ended: () => this.behaviour.ends ?? { kind: 'finished' }, + stop: async () => { + this.stopped = true; + }, + }; + }, + }; + } + + async #produce(upstream: { read: () => Promise } | undefined, out: { write: (bytes: Buffer) => Promise; end: () => void; fail: (err: unknown) => void }): Promise { + if (this.behaviour.throws) { + out.fail(this.behaviour.throws); + return; + } + if (this.behaviour.echoes && upstream != null) { + for (let chunk = await upstream.read(); chunk != null; chunk = await upstream.read()) { + this.received.push(chunk); + if (!(await out.write(chunk))) { + return; + } + } + out.end(); + return; + } + if (this.behaviour.endless) { + for (let index = 0; ; index++) { + const bytes = Buffer.from(`line${index}\n`); + this.written.push(bytes); + if (!(await out.write(bytes))) { + return; + } + } + } + for (const value of this.behaviour.writes ?? []) { + const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8'); + this.written.push(bytes); + if (!(await out.write(bytes))) { + return; + } + if (this.behaviour.waitsFor != null) { + await this.behaviour.waitsFor; + } + } + out.end(); + } +} + +export function stage(tool: FakeTool, op?: Stage['op'], input: Record = {}): Stage { + return { kind: 'tool', tool: tool.tool, input, op }; +} diff --git a/packages/orchestrate-core/test/run.attachments.spec.ts b/packages/orchestrate-core/test/run.attachments.spec.ts new file mode 100644 index 00000000..8f10d8c3 --- /dev/null +++ b/packages/orchestrate-core/test/run.attachments.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { run } from '../src/run.js'; +import { FakeApprover, FakeSleep, FakeTool, stage } from './fakes.js'; + +// A stage can send something back that is not text: a document, an image. It goes to whoever asked +// for the run, never to the stage after it, and it carries the type the tool knows it to be — +// bytes alone cannot say what they are, and guessing at the boundary is how a request gets rejected. + +const PDF = Buffer.from('%PDF-1.7\nbody\n%%EOF\n', 'binary'); + +function options(overrides: { hold?: number } = {}) { + return { decide: new FakeApprover().decide, sleep: new FakeSleep().sleep, hold: overrides.hold ?? 64 * 1024, ahead: 4096 }; +} + +describe('what a stage attaches', () => { + it('comes back against the stage that attached it', async () => { + const tool = new FakeTool('ReadBinaryFile', { attaches: [{ bytes: PDF, type: 'application/pdf' }] }); + + const { stages } = await run([stage(tool)], options()); + + const expected = ['application/pdf']; + const actual = stages[0]?.attached.map((item) => item.type); + expect(actual).toEqual(expected); + }); + + it('comes back with the bytes it was given', async () => { + const tool = new FakeTool('ReadBinaryFile', { attaches: [{ bytes: PDF, type: 'application/pdf' }] }); + + const { stages } = await run([stage(tool)], options()); + + const expected = PDF.toString('hex'); + const actual = stages[0]?.attached[0]?.bytes.toString('hex'); + expect(actual).toBe(expected); + }); + + it('does not reach the stage after it', async () => { + const attaching = new FakeTool('ReadBinaryFile', { writes: ['a.pdf read\n'], attaches: [{ bytes: PDF, type: 'application/pdf' }] }); + const consumer = new FakeTool('Match', { echoes: true }); + + const { output } = await run([stage(attaching, '|'), stage(consumer)], options()); + + const expected = 'a.pdf read\n'; + const actual = output.toString('utf8'); + expect(actual).toBe(expected); + }); + + it('is nothing at all for a stage that attached nothing', async () => { + const { stages } = await run([stage(new FakeTool('Find', { writes: ['a.ts\n'] }))], options()); + + const expected: unknown[] = []; + const actual = stages[0]?.attached; + expect(actual).toEqual(expected); + }); + + it('is nothing for a stage that never ran', async () => { + const failing = new FakeTool('Find', { ends: { kind: 'failed', code: 1 } }); + const never = new FakeTool('ReadBinaryFile', { attaches: [{ bytes: PDF, type: 'application/pdf' }] }); + + const { stages } = await run([stage(failing, '&&'), stage(never)], options()); + + const expected: unknown[] = []; + const actual = stages[1]?.attached; + expect(actual).toEqual(expected); + }); + + it('comes back even when the stage failed', async () => { + const tool = new FakeTool('ReadBinaryFile', { attaches: [{ bytes: PDF, type: 'application/pdf' }], ends: { kind: 'failed', code: 1 } }); + + const { stages } = await run([stage(tool)], options()); + + const expected = 1; + const actual = stages[0]?.attached.length; + expect(actual).toBe(expected); + }); +}); + +describe('a stage attaching more than may be held', () => { + it('keeps what fit and no more', async () => { + const big = { bytes: Buffer.alloc(200), type: 'application/pdf' }; + const tool = new FakeTool('ReadBinaryFile', { attaches: [big, big, big] }); + + const { stages } = await run([stage(tool)], options({ hold: 256 })); + + const expected = true; + const actual = (stages[0]?.attached.length ?? 0) < 3; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/orchestrate-core/test/run.cancel.spec.ts b/packages/orchestrate-core/test/run.cancel.spec.ts new file mode 100644 index 00000000..7cae1a63 --- /dev/null +++ b/packages/orchestrate-core/test/run.cancel.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { run } from '../src/run.js'; +import { FakeApprover, FakeSleep, FakeTool, stage } from './fakes.js'; + +// Cancelling is the run being told to stop, which is the same thing a timeout does and the same +// thing a reader leaving does. A tool has one way of being told. + +function options(signal: AbortSignal) { + return { decide: new FakeApprover().decide, sleep: new FakeSleep().sleep, hold: 64 * 1024, ahead: 4096, signal }; +} + +const never = () => new Promise(() => {}); + +describe('a run that is cancelled while a stage is running', () => { + it('stops that stage', async () => { + const cancel = new AbortController(); + const producer = new FakeTool('Find', { writes: ['one\n'], waitsFor: never() }); + + const running = run([stage(producer)], options(cancel.signal)); + cancel.abort(); + await running; + + const expected = true; + const actual = producer.stopped; + expect(actual).toBe(expected); + }); + + it('reports it as cancelled', async () => { + const cancel = new AbortController(); + const producer = new FakeTool('Find', { writes: ['one\n'], waitsFor: never() }); + + const running = run([stage(producer)], options(cancel.signal)); + cancel.abort(); + const { stages } = await running; + + const expected = 'cancelled'; + const actual = stages[0]?.ended.kind; + expect(actual).toBe(expected); + }); + + it('does not start the stage after it', async () => { + const cancel = new AbortController(); + const after = new FakeTool('Report'); + const producer = new FakeTool('Find', { writes: ['one\n'], waitsFor: never() }); + + const running = run([stage(producer), stage(after)], options(cancel.signal)); + cancel.abort(); + await running; + + const expected = false; + const actual = after.ran; + expect(actual).toBe(expected); + }); + + it('hands back what was produced before it was cancelled', async () => { + const cancel = new AbortController(); + const producer = new FakeTool('Find', { writes: ['one\n'], waitsFor: never() }); + + const running = run([stage(producer)], options(cancel.signal)); + await Promise.resolve(); + cancel.abort(); + const { output } = await running; + + const expected = 'one\n'; + const actual = output.toString('utf8'); + expect(actual).toBe(expected); + }); +}); + +describe('a run cancelled before it starts', () => { + it('runs nothing', async () => { + const cancel = new AbortController(); + cancel.abort(); + const tool = new FakeTool('Find', { writes: ['one\n'] }); + + await run([stage(tool)], options(cancel.signal)); + + const expected = false; + const actual = tool.ran; + expect(actual).toBe(expected); + }); + + it('reports every stage as cancelled', async () => { + const cancel = new AbortController(); + cancel.abort(); + + const { stages } = await run([stage(new FakeTool('Find')), stage(new FakeTool('Report'))], options(cancel.signal)); + + const expected = ['cancelled', 'cancelled']; + const actual = stages.map((report) => report.ended.kind); + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/orchestrate-core/test/run.capture.spec.ts b/packages/orchestrate-core/test/run.capture.spec.ts new file mode 100644 index 00000000..c0354ece --- /dev/null +++ b/packages/orchestrate-core/test/run.capture.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { run } from '../src/run.js'; +import type { Stage } from '../src/types.js'; +import { FakeApprover, FakeSleep, FakeTool, stage } from './fakes.js'; + +// `TOKEN=$(echo hello)` is two things: a command, and a name bound to what it produced. The command +// is unchanged by being captured — it writes to its output and knows nothing about where that goes. +// So binding the name is its own stage, reading what the stage before it produced, the same shape +// as an Xargs stage. + +function options(overrides: { names?: Map; approver?: FakeApprover; hold?: number } = {}) { + return { + decide: (overrides.approver ?? new FakeApprover()).decide, + sleep: new FakeSleep().sleep, + hold: overrides.hold ?? 64 * 1024, + ahead: 4096, + bind: (name: string, value: string) => void (overrides.names ?? new Map()).set(name, value), + }; +} + +const set = (name: string): Stage => ({ kind: 'set', name }); + +describe('a stage that binds a name to what came before it', () => { + it('binds everything that stage produced', async () => { + const names = new Map(); + + await run([stage(new FakeTool('AzCli', { writes: ['secret-', 'token'] }), '|'), set('TOKEN')], options({ names })); + + const expected = 'secret-token'; + const actual = names.get('TOKEN'); + expect(actual).toBe(expected); + }); + + it('binds what that stage produced, not what the run ends up with', async () => { + const names = new Map(); + + await run([stage(new FakeTool('AzCli', { writes: ['mine\n'] }), '|'), set('MINE'), stage(new FakeTool('Program', { writes: ['theirs\n'] }))], options({ names })); + + const expected = 'mine\n'; + const actual = names.get('MINE'); + expect(actual).toBe(expected); + }); + + it('leaves the stage that produced it ending as it would have anyway', async () => { + const { stages } = await run([stage(new FakeTool('AzCli', { writes: ['value\n'] }), '|'), set('TOKEN')], options()); + + const expected = { kind: 'finished' }; + const actual = stages[0]?.ended; + expect(actual).toEqual(expected); + }); + + it('binds nothing when the stage before it was refused', async () => { + const names = new Map(); + const refuser = new FakeApprover({ AzCli: { verdict: 'refuse' } }); + + await run([stage(new FakeTool('AzCli', { writes: ['secret'] }), '|'), set('TOKEN')], options({ names, approver: refuser })); + + const expected = undefined; + const actual = names.get('TOKEN'); + expect(actual).toBe(expected); + }); + + it('binds nothing when what came before it is more than may be held', async () => { + const names = new Map(); + + await run([stage(new FakeTool('AzCli', { endless: true }), '|'), set('TOKEN')], options({ names, hold: 128 })); + + const expected = undefined; + const actual = names.get('TOKEN'); + expect(actual).toBe(expected); + }); +}); + +// The value reaches a command through the environment it runs under. A stage's input is what a +// decision is made on and what is published, so a bound value put there would be exposed by the +// asking rather than by the answer. +describe('a later stage that names a bound value in its input', () => { + it('receives the name, not the value', async () => { + const later = new FakeTool('Program'); + + await run([stage(new FakeTool('AzCli', { writes: ['secret-token'] }), '|'), set('TOKEN'), stage(later, undefined, { args: ['Bearer $TOKEN'] })], options()); + + const expected = ['Bearer $TOKEN']; + const actual = (later.input as { args: string[] }).args; + expect(actual).toEqual(expected); + }); + + it('is judged on the name, not the value', async () => { + const looker = new FakeApprover(); + + await run([stage(new FakeTool('AzCli', { writes: ['secret-token'] }), '|'), set('TOKEN'), stage(new FakeTool('Program'), undefined, { args: ['Bearer $TOKEN'] })], { ...options(), decide: looker.decide }); + + const expected = false; + const actual = JSON.stringify(looker.asked).includes('secret-token'); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/orchestrate-core/test/run.diagnostics.spec.ts b/packages/orchestrate-core/test/run.diagnostics.spec.ts new file mode 100644 index 00000000..e32740ad --- /dev/null +++ b/packages/orchestrate-core/test/run.diagnostics.spec.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; +import { run } from '../src/run.js'; +import { FakeApprover, FakeSleep, FakeTool, stage } from './fakes.js'; + +// A stage has two outputs: what the next stage reads, and what it has to say to whoever asked for +// the run. A process's stderr and a filter's "12 matched" are the same thing arriving the same way. +// Kept apart from the bytes, and kept per stage, so nothing has to decide how they interleave. + +function options(overrides: { hold?: number } = {}) { + return { decide: new FakeApprover().decide, sleep: new FakeSleep().sleep, hold: overrides.hold ?? 64 * 1024, ahead: 4096 }; +} + +describe('what a stage has to say', () => { + it('comes back against the stage that said it', async () => { + const tool = new FakeTool('Match', { writes: ['a.ts\n'], says: ['12 matched'] }); + + const { stages } = await run([stage(tool)], options()); + + const expected = ['12 matched']; + const actual = stages[0]?.said; + expect(actual).toEqual(expected); + }); + + it('does not reach the stage after it', async () => { + const consumer = new FakeTool('Head', { echoes: true }); + + const { output } = await run([stage(new FakeTool('Find', { writes: ['a.ts\n'], says: ['walked 400 directories'] }), '|'), stage(consumer)], options()); + + const expected = 'a.ts\n'; + const actual = output.toString('utf8'); + expect(actual).toBe(expected); + }); + + it('is kept apart from what the run hands back', async () => { + const tool = new FakeTool('Program', { writes: ['result\n'], says: ['warning: something'] }); + + const { output } = await run([stage(tool)], options()); + + const expected = 'result\n'; + const actual = output.toString('utf8'); + expect(actual).toBe(expected); + }); + + it('is kept per stage, so three stages do not merge into one account', async () => { + const stages = [stage(new FakeTool('Find', { writes: ['a.ts\n'], says: ['walked 400'] }), '|'), stage(new FakeTool('Match', { echoes: true, says: ['12 matched'] }), '|'), stage(new FakeTool('Head', { echoes: true, says: ['stopped early'] }))]; + + const { stages: reports } = await run(stages, options()); + + const expected = [['walked 400'], ['12 matched'], ['stopped early']]; + const actual = reports.map((report) => report.said); + expect(actual).toEqual(expected); + }); + + it('comes back even when the stage failed', async () => { + const tool = new FakeTool('Program', { says: ['rm: no such file'], ends: { kind: 'failed', code: 1 } }); + + const { stages } = await run([stage(tool)], options()); + + const expected = ['rm: no such file']; + const actual = stages[0]?.said; + expect(actual).toEqual(expected); + }); + + it('comes back even when the stage was stopped early', async () => { + const producer = new FakeTool('Find', { endless: true, says: ['walking'] }); + + const { stages } = await run([stage(producer)], options({ hold: 128 })); + + const expected = ['walking']; + const actual = stages[0]?.said; + expect(actual).toEqual(expected); + }); + + it('is nothing at all for a stage with nothing to say', async () => { + const { stages } = await run([stage(new FakeTool('Find', { writes: ['a.ts\n'] }))], options()); + + const expected: string[] = []; + const actual = stages[0]?.said; + expect(actual).toEqual(expected); + }); + + it('is nothing for a stage that never ran', async () => { + const failing = new FakeTool('Find', { ends: { kind: 'failed', code: 1 } }); + + const { stages } = await run([stage(failing, '&&'), stage(new FakeTool('Report', { says: ['never happens'] }))], options()); + + const expected: string[] = []; + const actual = stages[1]?.said; + expect(actual).toEqual(expected); + }); +}); + +// A stage says two kinds of thing: what it has to say about itself, which is short and always +// wanted, and whatever it captured from what it ran, which is arbitrary volume from something that +// may have succeeded anyway. The first always comes back; the second only when it is worth reading. +describe('what a stage captured from what it ran', () => { + it('does not come back when the stage finished cleanly', async () => { + const tool = new FakeTool('Program', { writes: ['out\n'], captured: ['downloading: 10%', 'downloading: 90%'] }); + + const { stages } = await run([stage(tool)], options()); + + const expected: string[] = []; + const actual = stages[0]?.said; + expect(actual).toEqual(expected); + }); + + it('comes back when the stage did not', async () => { + const tool = new FakeTool('Program', { captured: ['curl: could not resolve host'], ends: { kind: 'failed', code: 6 } }); + + const { stages } = await run([stage(tool)], options()); + + const expected = ['curl: could not resolve host']; + const actual = stages[0]?.said; + expect(actual).toEqual(expected); + }); + + it('comes back whatever happened when the stage asked for always', async () => { + const tool = new FakeTool('Program', { writes: ['out\n'], captured: ['downloading: 10%'] }); + + const { stages } = await run([{ ...stage(tool), captured: 'always' as const }], options()); + + const expected = ['downloading: 10%']; + const actual = stages[0]?.said; + expect(actual).toEqual(expected); + }); + + it('comes back for nothing when the stage asked for never, even on failure', async () => { + const tool = new FakeTool('Program', { captured: ['curl: could not resolve host'], ends: { kind: 'failed', code: 6 } }); + + const { stages } = await run([{ ...stage(tool), captured: 'never' as const }], options()); + + const expected: string[] = []; + const actual = stages[0]?.said; + expect(actual).toEqual(expected); + }); + + it('does not hide what the stage said about itself', async () => { + const tool = new FakeTool('Match', { writes: ['a.ts\n'], says: ['12 matched'], captured: ['noise'] }); + + const { stages } = await run([stage(tool)], options()); + + const expected = ['12 matched']; + const actual = stages[0]?.said; + expect(actual).toEqual(expected); + }); +}); + +// A stage with a great deal to say is bounded like anything else held whole, and being cut short +// there is not the same as the stage failing. +describe('a stage that says more than may be held', () => { + it('keeps what fit and no more', async () => { + const tool = new FakeTool('Program', { writes: ['out\n'], saysEndlessly: true }); + + const { stages } = await run([stage(tool)], options({ hold: 128 })); + + const said = stages[0]?.said ?? []; + const expected = true; + const actual = said.length > 0 && said.join('').length <= 128; + expect(actual).toBe(expected); + }); + + it('does not make the stage itself fail', async () => { + const tool = new FakeTool('Program', { writes: ['out\n'], saysEndlessly: true }); + + const { stages } = await run([stage(tool)], options({ hold: 128 })); + + const expected = 'finished'; + const actual = stages[0]?.ended.kind; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/orchestrate-core/test/run.spec.ts b/packages/orchestrate-core/test/run.spec.ts new file mode 100644 index 00000000..c6201429 --- /dev/null +++ b/packages/orchestrate-core/test/run.spec.ts @@ -0,0 +1,363 @@ +import { describe, expect, it } from 'vitest'; +import { run } from '../src/run.js'; +import { FakeApprover, FakeSleep, FakeTool, stage } from './fakes.js'; + +// The engine, with everything below it faked. A tool answers for itself; the run answers for +// everything the tool cannot know. + +const approver = () => new FakeApprover(); +const sleep = () => new FakeSleep(); + +function options(overrides: { approver?: FakeApprover; sleep?: FakeSleep; hold?: number; ahead?: number } = {}) { + return { + decide: (overrides.approver ?? approver()).decide, + sleep: (overrides.sleep ?? sleep()).sleep, + hold: overrides.hold ?? 64 * 1024, + ahead: overrides.ahead ?? 4096, + }; +} + +describe('a stage that runs to the end', () => { + it('reports what its tool said about itself', async () => { + const tool = new FakeTool('Find', { writes: ['a.ts\n'] }); + + const { stages } = await run([stage(tool)], options()); + + const expected = { kind: 'finished' }; + const actual = stages[0]?.ended; + expect(actual).toEqual(expected); + }); + + it('reports a failure its tool declared', async () => { + const tool = new FakeTool('Program', { writes: [], ends: { kind: 'failed', code: 2 } }); + + const { stages } = await run([stage(tool)], options()); + + const expected = { kind: 'failed', code: 2 }; + const actual = stages[0]?.ended; + expect(actual).toEqual(expected); + }); + + it('reports a signal its tool declared', async () => { + const tool = new FakeTool('Program', { writes: [], ends: { kind: 'signalled', signal: 'SIGPIPE' } }); + + const { stages } = await run([stage(tool)], options()); + + const expected = { kind: 'signalled', signal: 'SIGPIPE' }; + const actual = stages[0]?.ended; + expect(actual).toEqual(expected); + }); + + it('hands back what the last stage wrote', async () => { + const tool = new FakeTool('Find', { writes: ['a.ts\n', 'b.ts\n'] }); + + const { output } = await run([stage(tool)], options()); + + const expected = 'a.ts\nb.ts\n'; + const actual = output.toString('utf8'); + expect(actual).toBe(expected); + }); +}); + +describe('a stage that throws', () => { + it('is reported as having thrown', async () => { + const boom = new Error('exploded'); + const tool = new FakeTool('Find', { throws: boom }); + + const { stages } = await run([stage(tool)], options()); + + const expected = { kind: 'threw', error: boom }; + const actual = stages[0]?.ended; + expect(actual).toEqual(expected); + }); + + // Same as a failure: a pipe has already started the reader, which simply gets nothing. + it('leaves the stage piped from it with nothing to read', async () => { + const after = new FakeTool('Report', { echoes: true }); + const stages = [stage(new FakeTool('Find', { throws: new Error('exploded') }), '|'), stage(after)]; + + const { output } = await run(stages, options()); + + const expected = ''; + const actual = output.toString('utf8'); + expect(actual).toBe(expected); + }); +}); + +describe('a stage that was refused', () => { + it('never runs', async () => { + const tool = new FakeTool('Delete'); + const refuser = new FakeApprover({ Delete: { verdict: 'refuse' } }); + + await run([stage(tool)], options({ approver: refuser })); + + const expected = false; + const actual = tool.ran; + expect(actual).toBe(expected); + }); + + it('is reported as refused', async () => { + const refuser = new FakeApprover({ Delete: { verdict: 'refuse', reason: 'not allowed here' } }); + + const { stages } = await run([stage(new FakeTool('Delete'))], options({ approver: refuser })); + + const expected = { kind: 'refused', reason: 'not allowed here' }; + const actual = stages[0]?.ended; + expect(actual).toEqual(expected); + }); + + it('stops the stage piped from it from running', async () => { + const after = new FakeTool('Report'); + const refuser = new FakeApprover({ Delete: { verdict: 'refuse' } }); + + await run([stage(new FakeTool('Delete'), '|'), stage(after)], options({ approver: refuser })); + + const expected = false; + const actual = after.ran; + expect(actual).toBe(expected); + }); +}); + +describe('a stage after one that failed', () => { + // A pipe starts both: the reader is running long before the writer's fate is known, which is why + // `false | cat` runs `cat`. Only a producer that never ran at all leaves nothing to read. + it('still runs when joined by a pipe', async () => { + const after = new FakeTool('Report', { echoes: true }); + const failing = new FakeTool('Find', { ends: { kind: 'failed', code: 1 } }); + + await run([stage(failing, '|'), stage(after)], options()); + + const expected = true; + const actual = after.ran; + expect(actual).toBe(expected); + }); + + it('does not run when the run was told to stop on failure', async () => { + const after = new FakeTool('Report'); + const failing = new FakeTool('Find', { ends: { kind: 'failed', code: 1 } }); + + await run([stage(failing, '&&'), stage(after)], options()); + + const expected = false; + const actual = after.ran; + expect(actual).toBe(expected); + }); + + it('is reported as never started when it never ran', async () => { + const failing = new FakeTool('Find', { ends: { kind: 'failed', code: 1 } }); + + const { stages } = await run([stage(failing, '&&'), stage(new FakeTool('Report'))], options()); + + const expected = { kind: 'skipped' }; + const actual = stages[1]?.ended; + expect(actual).toEqual(expected); + }); +}); + +describe('joining stages', () => { + it('runs the next stage after a success when joined by &&', async () => { + const after = new FakeTool('Report'); + + await run([stage(new FakeTool('Find', { writes: ['a\n'] }), '&&'), stage(after)], options()); + + const expected = true; + const actual = after.ran; + expect(actual).toBe(expected); + }); + + it('does not run it after a failure when joined by &&', async () => { + const after = new FakeTool('Report'); + const failing = new FakeTool('Find', { ends: { kind: 'failed', code: 1 } }); + + await run([stage(failing, '&&'), stage(after)], options()); + + const expected = false; + const actual = after.ran; + expect(actual).toBe(expected); + }); + + it('runs the next stage after a failure when joined by ||', async () => { + const after = new FakeTool('Fallback'); + const failing = new FakeTool('Find', { ends: { kind: 'failed', code: 1 } }); + + await run([stage(failing, '||'), stage(after)], options()); + + const expected = true; + const actual = after.ran; + expect(actual).toBe(expected); + }); + + it('does not run it after a success when joined by ||', async () => { + const after = new FakeTool('Fallback'); + + await run([stage(new FakeTool('Find', { writes: ['a\n'] }), '||'), stage(after)], options()); + + const expected = false; + const actual = after.ran; + expect(actual).toBe(expected); + }); + + it('runs the next stage whatever happened when they are merely sequential', async () => { + const after = new FakeTool('Report'); + const failing = new FakeTool('Find', { ends: { kind: 'failed', code: 1 } }); + + await run([stage(failing), stage(after)], options()); + + const expected = true; + const actual = after.ran; + expect(actual).toBe(expected); + }); + + it('counts a refusal as a failure, so a fallback runs', async () => { + const after = new FakeTool('Fallback'); + const refuser = new FakeApprover({ Delete: { verdict: 'refuse' } }); + + await run([stage(new FakeTool('Delete'), '||'), stage(after)], options({ approver: refuser })); + + const expected = true; + const actual = after.ran; + expect(actual).toBe(expected); + }); +}); + +describe('bytes between stages', () => { + it('gives the next stage what the previous one wrote', async () => { + const consumer = new FakeTool('Match', { echoes: true }); + + const { output } = await run([stage(new FakeTool('Find', { writes: ['a.ts\n', 'b.ts\n'] }), '|'), stage(consumer)], options()); + + const expected = 'a.ts\nb.ts\n'; + const actual = output.toString('utf8'); + expect(actual).toBe(expected); + }); + + it('passes bytes that are not text through unchanged', async () => { + const bytes = Buffer.from([0x00, 0xff, 0x80, 0x28]); + const consumer = new FakeTool('Match', { echoes: true }); + + const { output } = await run([stage(new FakeTool('Find', { writes: [bytes] }), '|'), stage(consumer)], options()); + + const expected = bytes.toString('hex'); + const actual = output.toString('hex'); + expect(actual).toBe(expected); + }); + + it('assumes nothing about separators', async () => { + const consumer = new FakeTool('Match', { echoes: true }); + + const { output } = await run([stage(new FakeTool('Find', { writes: ['no separator at all'] }), '|'), stage(consumer)], options()); + + const expected = 'no separator at all'; + const actual = output.toString('utf8'); + expect(actual).toBe(expected); + }); +}); + +describe('a stage whose reader stops', () => { + it('is told to stop', async () => { + const producer = new FakeTool('Find', { endless: true }); + const consumer = new FakeTool('Head', { writes: ['one\n'] }); + + await run([stage(producer, '|'), stage(consumer)], options({ ahead: 64 })); + + const expected = true; + const actual = producer.stopped; + expect(actual).toBe(expected); + }); + + it('stops a producer two stages back', async () => { + const producer = new FakeTool('Find', { endless: true }); + const middle = new FakeTool('Cat', { echoes: true }); + const consumer = new FakeTool('Head', { writes: ['one\n'] }); + + await run([stage(producer, '|'), stage(middle, '|'), stage(consumer)], options({ ahead: 64 })); + + const expected = true; + const actual = producer.stopped; + expect(actual).toBe(expected); + }); +}); + +describe('a run that holds more than it may', () => { + it('stops the producer', async () => { + const producer = new FakeTool('Find', { endless: true }); + + await run([stage(producer)], options({ hold: 128 })); + + const expected = true; + const actual = producer.stopped; + expect(actual).toBe(expected); + }); + + it('says the output is only the start of what there was', async () => { + const producer = new FakeTool('Find', { endless: true }); + + const { stages } = await run([stage(producer)], options({ hold: 128 })); + + const expected = 'truncated'; + const actual = stages[0]?.ended.kind; + expect(actual).toBe(expected); + }); +}); + +describe('a run that takes too long', () => { + it('stops the stage that was running', async () => { + const clock = new FakeSleep(); + // Slow rather than prolific: a timeout is about time passing, not about volume. + const producer = new FakeTool('Find', { writes: ['one\n'], waitsFor: new Promise(() => {}) }); + + const running = run([stage(producer)], { ...options({ sleep: clock }), timeout: 5000 }); + clock.elapse(); + await running; + + const expected = true; + const actual = producer.stopped; + expect(actual).toBe(expected); + }); + + it('reports it as having timed out', async () => { + const clock = new FakeSleep(); + const producer = new FakeTool('Find', { writes: ['one\n'], waitsFor: new Promise(() => {}) }); + + const running = run([stage(producer)], { ...options({ sleep: clock }), timeout: 5000 }); + clock.elapse(); + const { stages } = await running; + + const expected = 'timedOut'; + const actual = stages[0]?.ended.kind; + expect(actual).toBe(expected); + }); +}); + +describe('what is judged', () => { + it('puts every stage to the decision, including one that touches nothing', async () => { + const asked = new FakeApprover(); + + await run([stage(new FakeTool('Find', { writes: ['a\n'] }), '|'), stage(new FakeTool('Match', { echoes: true }))], options({ approver: asked })); + + const expected = ['Find', 'Match']; + const actual = asked.names(); + expect(actual).toEqual(expected); + }); + + it('shows what a stage would act on when the decision asks to see it', async () => { + const looker = new FakeApprover(); + + await run([stage(new FakeTool('Find', { writes: ['a.ts\nb.ts\n'] }), '|'), stage(new FakeTool('Delete', { echoes: true }))], { ...options(), decide: looker.look }); + + const expected = 'a.ts\nb.ts\n'; + const actual = looker.shown.get('Delete')?.toString('utf8'); + expect(actual).toBe(expected); + }); + + it('refuses rather than showing part of what a stage would act on', async () => { + const looker = new FakeApprover(); + const producer = new FakeTool('Find', { endless: true }); + + const { stages } = await run([stage(producer, '|'), stage(new FakeTool('Delete', { echoes: true }))], { ...options({ hold: 128 }), decide: looker.look }); + + const expected = 'refused'; + const actual = stages[1]?.ended.kind; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/orchestrate-core/test/run.xargs.spec.ts b/packages/orchestrate-core/test/run.xargs.spec.ts new file mode 100644 index 00000000..37a3e79f --- /dev/null +++ b/packages/orchestrate-core/test/run.xargs.spec.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { run } from '../src/run.js'; +import type { Stage } from '../src/types.js'; +import { FakeApprover, FakeSleep, FakeTool, stage } from './fakes.js'; + +// An Xargs stage turns what it read into an argument list, and the run puts that into the next +// stage's declared field. The splitting rule is the tool's; the handing over is the run's. + +function options(overrides: { hold?: number } = {}) { + return { decide: new FakeApprover().decide, sleep: new FakeSleep().sleep, hold: overrides.hold ?? 64 * 1024, ahead: 4096 }; +} + +// Where the list goes is the receiving tool's own declaration, never something a caller names. +const xargs = (): Stage => ({ kind: 'xargs' }); + +describe('an Xargs stage between two others', () => { + it('puts what it read into the next stage’s declared field', async () => { + const consumer = new FakeTool('Delete', { takesListIn: 'files' }); + + await run([stage(new FakeTool('Find', { writes: ['a.ts\nb.ts\n'] }), '|'), xargs(), stage(consumer)], options()); + + const expected = ['a.ts', 'b.ts']; + const actual = (consumer.input as { files: string[] }).files; + expect(actual).toEqual(expected); + }); + + it('keeps what that field already held, with the new values after it', async () => { + const consumer = new FakeTool('Delete', { takesListIn: 'files' }); + + await run([stage(new FakeTool('Find', { writes: ['b.ts\n'] }), '|'), xargs(), stage(consumer, undefined, { files: ['a.ts'] })], options()); + + const expected = ['a.ts', 'b.ts']; + const actual = (consumer.input as { files: string[] }).files; + expect(actual).toEqual(expected); + }); + + it('leaves the rest of that stage’s input alone', async () => { + const consumer = new FakeTool('TsDiagnostics', { takesListIn: 'files' }); + + await run([stage(new FakeTool('Find', { writes: ['a.ts\n'] }), '|'), xargs(), stage(consumer, undefined, { severity: 'all' })], options()); + + const expected = 'all'; + const actual = (consumer.input as { severity: string }).severity; + expect(actual).toBe(expected); + }); + + it('gives the stage nothing to read, because it was read to build the list', async () => { + const consumer = new FakeTool('Delete', { takesListIn: 'files', echoes: true }); + + const { output } = await run([stage(new FakeTool('Find', { writes: ['a.ts\n'] }), '|'), xargs(), stage(consumer)], options()); + + const expected = ''; + const actual = output.toString('utf8'); + expect(actual).toBe(expected); + }); +}); + +// A command with no arguments is a different command: `find -name nothing | xargs rm` runs `rm` +// with nothing and fails, which is why GNU grew `--no-run-if-empty`. +describe('an Xargs stage that read nothing', () => { + it('does not run the stage it would have fed', async () => { + const consumer = new FakeTool('Delete', { takesListIn: 'files' }); + + await run([stage(new FakeTool('Find', { writes: [] }), '|'), xargs(), stage(consumer)], options()); + + const expected = false; + const actual = consumer.ran; + expect(actual).toBe(expected); + }); + + it('reports that stage as never started', async () => { + const { stages } = await run([stage(new FakeTool('Find', { writes: [] }), '|'), xargs(), stage(new FakeTool('Delete', { takesListIn: 'files' }))], options()); + + const expected = 'skipped'; + const actual = stages[1]?.ended.kind; + expect(actual).toBe(expected); + }); +}); + +// The sequence is refused before it runs, so reaching this means that check was bypassed. A stage +// that cannot receive a list is still a stage the run has to answer for. +describe('an Xargs stage before a tool that takes no list', () => { + it('does not run that tool', async () => { + const consumer = new FakeTool('Find'); + + await run([stage(new FakeTool('Find', { writes: ['a.ts\n'] }), '|'), xargs(), stage(consumer)], options()); + + const expected = false; + const actual = consumer.ran; + expect(actual).toBe(expected); + }); + + it('reports why', async () => { + const { stages } = await run([stage(new FakeTool('Find', { writes: ['a.ts\n'] }), '|'), xargs(), stage(new FakeTool('TsHover'))], options()); + + const expected = 'refused'; + const actual = stages[1]?.ended.kind; + expect(actual).toBe(expected); + }); +}); + +describe('an Xargs stage reading more than may be held', () => { + it('stops the stage that was producing', async () => { + const producer = new FakeTool('Find', { endless: true }); + + await run([stage(producer, '|'), xargs(), stage(new FakeTool('Delete', { takesListIn: 'files' }))], options({ hold: 128 })); + + const expected = true; + const actual = producer.stopped; + expect(actual).toBe(expected); + }); + + it('does not run the stage it would have fed', async () => { + const consumer = new FakeTool('Delete', { takesListIn: 'files' }); + + await run([stage(new FakeTool('Find', { endless: true }), '|'), xargs(), stage(consumer)], options({ hold: 128 })); + + const expected = false; + const actual = consumer.ran; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/orchestrate-core/tsconfig.check.json b/packages/orchestrate-core/tsconfig.check.json new file mode 100644 index 00000000..657cdc8a --- /dev/null +++ b/packages/orchestrate-core/tsconfig.check.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "incremental": true, + "skipLibCheck": true, + "composite": false, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/orchestrate-core/tsconfig.json b/packages/orchestrate-core/tsconfig.json new file mode 100644 index 00000000..bb021979 --- /dev/null +++ b/packages/orchestrate-core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@shellicar/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/orchestrate-core/tsup.config.ts b/packages/orchestrate-core/tsup.config.ts new file mode 100644 index 00000000..7a031ecf --- /dev/null +++ b/packages/orchestrate-core/tsup.config.ts @@ -0,0 +1,41 @@ +import versionPlugin from '@shellicar/build-version/esbuild'; +import { Strategies } from '@shellicar/build-version/types'; +import { defineConfig, type Options } from 'tsup'; + +const esbuildPlugins = [versionPlugin({ strategies: [Strategies.git({ packageName: 'orchestrate-core' }), Strategies.fallback('0.1.0')] })]; + +const commonOptions = (config: Options) => + ({ + bundle: true, + clean: true, + dts: true, + entry: ['src/entry/*.ts'], + esbuildPlugins, + esbuildOptions: (options) => { + options.chunkNames = 'chunks/[name]-[hash]'; + options.entryNames = '[name]'; + }, + keepNames: true, + minify: false, + removeNodeProtocol: false, + platform: 'node', + sourcemap: true, + splitting: true, + target: 'node24', + treeshake: false, + watch: config.watch, + tsconfig: 'tsconfig.json', + }) satisfies Options; + +export default defineConfig((config) => [ + { + ...commonOptions(config), + format: 'esm', + outDir: 'dist/esm', + }, + { + ...commonOptions(config), + format: 'cjs', + outDir: 'dist/cjs', + }, +]); diff --git a/packages/orchestrate-core/vitest.config.ts b/packages/orchestrate-core/vitest.config.ts new file mode 100644 index 00000000..ae8680ec --- /dev/null +++ b/packages/orchestrate-core/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.spec.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43a9a0de..9f39f5c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,8 +61,8 @@ importers: specifier: workspace:^ version: link:../../packages/claude-sdk-tools '@shellicar/core-di': - specifier: ^5.0.0-alpha.3 - version: 5.0.0-alpha.3 + specifier: ^5.0.0-alpha.5 + version: 5.0.0-alpha.5 ansi-regex: specifier: 6.2.2 version: 6.2.2 @@ -132,8 +132,8 @@ importers: specifier: ^5.7.0 version: 5.7.0 '@shellicar/core-di': - specifier: ^5.0.0-alpha.3 - version: 5.0.0-alpha.3 + specifier: ^5.0.0-alpha.5 + version: 5.0.0-alpha.5 ansi-regex: specifier: 6.2.2 version: 6.2.2 @@ -187,8 +187,8 @@ importers: specifier: workspace:^ version: link:../claude-core '@shellicar/core-di': - specifier: ^5.0.0-alpha.3 - version: 5.0.0-alpha.3 + specifier: ^5.0.0-alpha.5 + version: 5.0.0-alpha.5 zod: specifier: ^4.4.3 version: 4.4.3 @@ -239,11 +239,14 @@ importers: specifier: workspace:^ version: link:../claude-sdk '@shellicar/core-di': - specifier: ^5.0.0-alpha.3 - version: 5.0.0-alpha.3 + specifier: ^5.0.0-alpha.5 + version: 5.0.0-alpha.5 '@shellicar/exec-core': specifier: workspace:^ version: link:../exec-core + '@shellicar/orchestrate-core': + specifier: workspace:^ + version: link:../orchestrate-core diff: specifier: ^8.0.4 version: 8.0.4 @@ -345,8 +348,8 @@ importers: specifier: ^1.29.0 version: 1.29.0(supports-color@7.2.0)(zod@4.4.3) '@shellicar/core-di': - specifier: ^5.0.0-alpha.3 - version: 5.0.0-alpha.3 + specifier: ^5.0.0-alpha.5 + version: 5.0.0-alpha.5 devDependencies: '@shellicar/build-clean': specifier: ^1.3.6 @@ -479,8 +482,8 @@ importers: specifier: ^1.29.0 version: 1.29.0(supports-color@7.2.0)(zod@4.4.3) '@shellicar/core-di': - specifier: ^5.0.0-alpha.3 - version: 5.0.0-alpha.3 + specifier: ^5.0.0-alpha.5 + version: 5.0.0-alpha.5 zod: specifier: ^4.4.3 version: 4.4.3 @@ -544,8 +547,8 @@ importers: specifier: workspace:^ version: link:../claude-sdk-tools '@shellicar/core-di': - specifier: ^5.0.0-alpha.3 - version: 5.0.0-alpha.3 + specifier: ^5.0.0-alpha.5 + version: 5.0.0-alpha.5 '@shellicar/typescript-config': specifier: workspace:^ version: link:../typescript-config @@ -568,6 +571,39 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5)(yaml@2.9.0)) + packages/orchestrate-core: + devDependencies: + '@shellicar/build-clean': + specifier: ^1.3.6 + version: 1.3.6(esbuild@0.28.1)(rolldown@1.0.3)(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5)(yaml@2.9.0)) + '@shellicar/build-version': + specifier: ^2.0.0 + version: 2.0.0(esbuild@0.28.1)(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5)(yaml@2.9.0)) + '@shellicar/typescript-config': + specifier: workspace:^ + version: link:../typescript-config + '@tsconfig/node24': + specifier: ^24.0.4 + version: 24.0.4 + '@types/node': + specifier: ^25.9.5 + version: 25.9.5 + esbuild: + specifier: ^0.28.1 + version: 0.28.1 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.22)(supports-color@7.2.0)(tsx@4.22.5)(typescript@5.9.3)(yaml@2.9.0) + tsx: + specifier: ^4.22.5 + version: 4.22.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5)(yaml@2.9.0)) + packages/typescript-config: devDependencies: '@tsconfig/node24': @@ -2175,11 +2211,11 @@ packages: webpack: optional: true - '@shellicar/core-di-engine@5.0.0-alpha.2': - resolution: {integrity: sha512-dvOMTi5WkMTOcdiVN7NKdyApDCFilpgOTwa9b1aF+U7V+eDbveYZ52Kj6vMaD6O7CA5ixAQ73Az+fgbnNGBrIA==} + '@shellicar/core-di-engine@5.0.0-alpha.5': + resolution: {integrity: sha512-EjG7e76uFd+7KpQ8jn9PoiVTHkulSl/pzmx8o8bEAzL7SNk14pzmHf/4+foqrKYCOisPgCLdWmOWwLnWrTgdYg==} - '@shellicar/core-di@5.0.0-alpha.3': - resolution: {integrity: sha512-l8KV/xZsEaPeL6+nA4F4uQNdEF1Fg6dZjztb9HOWfu/ZBMnPMS52E7aRCKnv16VylCW5YewFSgQMklpoTbPUQQ==} + '@shellicar/core-di@5.0.0-alpha.5': + resolution: {integrity: sha512-wzF7o23HcCrGDuRmcx1gwTyU0PFuXxTtTq9I+XozI4bryccKHAC+d393clhtLojOPSy8SXBJJU+sHxvNNNif1A==} '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} @@ -4633,11 +4669,11 @@ snapshots: esbuild: 0.28.1 vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5)(yaml@2.9.0) - '@shellicar/core-di-engine@5.0.0-alpha.2': {} + '@shellicar/core-di-engine@5.0.0-alpha.5': {} - '@shellicar/core-di@5.0.0-alpha.3': + '@shellicar/core-di@5.0.0-alpha.5': dependencies: - '@shellicar/core-di-engine': 5.0.0-alpha.2 + '@shellicar/core-di-engine': 5.0.0-alpha.5 '@so-ric/colorspace@1.1.6': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 57dde9a2..94b288b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,5 +22,5 @@ overrides: hono@<4.12.25: ^4.12.25 minimumReleaseAgeExclude: - - '@shellicar/core-di@5.0.0-alpha.3' - - '@shellicar/core-di-engine@5.0.0-alpha.2' + - '@shellicar/core-di@5.0.0-alpha.5' + - '@shellicar/core-di-engine@5.0.0-alpha.5' diff --git a/schema/sdk-config.schema.json b/schema/sdk-config.schema.json index 44af66e9..9e46b7a1 100644 --- a/schema/sdk-config.schema.json +++ b/schema/sdk-config.schema.json @@ -526,6 +526,110 @@ } } }, + "policy": { + "default": [ + { + "path": "$PWD", + "operations": { + "fs.read": "allow", + "fs.list": "allow" + } + } + ], + "description": "The unified Tools V2 approval policy — an ordered list of rules, first match wins. Replaces permissions/tools.rules/tools.blockedCommands for anything going through Orchestrate; not yet consulted for V1 tools. Omitted or invalid (as a whole) falls back to the built-in default, which reproduces the current permissions/ExecV3-rules behaviour as-is.", + "type": "array", + "items": { + "type": "object", + "properties": { + "tool": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "allOf": { + "type": "array", + "items": { + "type": "string" + } + }, + "anyOf": { + "type": "array", + "items": { + "type": "string" + } + }, + "suffix": { + "type": "string" + }, + "basename": { + "type": "array", + "items": { + "type": "string" + } + }, + "maxLength": { + "type": "number" + } + } + } + ] + } + }, + "path": { + "type": "string" + }, + "default": { + "type": "string", + "enum": [ + "allow", + "ask", + "deny" + ] + }, + "operations": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "allow", + "ask", + "deny" + ] + } + }, + "message": { + "type": "string" + } + } + } + }, "input": { "default": { "escFastPath": true diff --git a/scripts/src/tool-schema-sizes.ts b/scripts/src/tool-schema-sizes.ts index cf8636b8..911ef6cd 100644 --- a/scripts/src/tool-schema-sizes.ts +++ b/scripts/src/tool-schema-sizes.ts @@ -17,10 +17,8 @@ import { toWireTool } from '@shellicar/claude-sdk'; import { createAppTools } from '@shellicar/claude-sdk-cli/src/createAppTools.js'; import { ISecrets } from '@shellicar/claude-sdk-cli/src/secrets/Secrets.js'; import { IEnvProvider, StaticRulesConfigProvider } from '@shellicar/claude-sdk-tools/ExecV3'; -import type { ITypeScriptService } from '@shellicar/claude-sdk-tools/TsService'; // Stubs — handlers are never invoked here; only name/description/schema/examples matter. -const stubTs = null as unknown as ITypeScriptService; class StubObjectStore extends IObjectStore { public set(): void {} @@ -84,7 +82,6 @@ const stubFs = null as unknown as IFileSystem; const { tools } = createAppTools({ fs: stubFs, - tsServer: stubTs, toolsConfig: { exec: false, execV2: true, execV3: true }, rulesProvider: new StaticRulesConfigProvider(), objects: new StubObjectStore(),