Promote main to release - #2547
Merged
Merged
Conversation
Moves Copse from Apache-2.0 to the GNU Affero General Public License v3. Anyone may still use, study, modify and redistribute Copse — including commercially. What changes is that a distributed modification, or one made available to users over a network, must ship its complete corresponding source under the same terms. That keeps Copse open source under the OSD while closing the closed-rebrand path a permissive licence leaves open, and it preserves the option to sell proprietary exceptions later. ## The switch is prospective Releases up to and including `0.1.0-beta.8` went out under Apache-2.0, and [Apache-2.0 §2](https://www.apache.org/licenses/LICENSE-2.0#patent) grants a *perpetual, irrevocable* licence — so that grant stands for those versions and cannot be withdrawn. The README records the boundary explicitly so there is no ambiguity about which release carries which terms. Exposure at the time of the switch: 0 forks, 1 star, and pull requests are collaborators-only. ## Dependency compatibility — checked, not assumed Going copyleft means every dependency must be GPL-compatible. Verified before making the change: | Dependency | Licence | Status | |---|---|---| | `@anthropic-ai/sandbox-runtime` | Apache-2.0 | One-way compatible with GPLv3 | | `@novnc/novnc` | MPL-2.0 | Sources carry **no** "Incompatible With Secondary Licenses" notice, so §3.3 permits it in a Larger Work under the GNU licences | | `@nationaldesignstudio/rampart` | CC-BY-4.0 | [Listed by the FSF as GPLv3-compatible](https://www.fsf.org/blogs/licensing/cc-by-4-0-and-cc-by-sa-4-0-added-to-our-list-of-free-licenses) | | Electron, Monaco, xterm, vendored Cursor plugins | MIT | Compatible | | `packages/extract-zip` | BSD-2-Clause | Unchanged — fork of a third-party package, keeps its own licence | The noVNC check was the one real risk: an MPL-2.0 project *can* opt out of GPL compatibility, but only by actively attaching the notice to its file headers. noVNC's headers say only `Licensed under MPL 2.0 (see LICENSE.txt)`. ## Attribution that was missing There was no copyright notice anywhere in the repository — no `author`, no `build.copyright` for the macOS bundle, and Apache-2.0's appendix was never filled in. Enforcing any licence starts with being able to show who holds the copyright, so this adds it. ## Contributions `CONTRIBUTING.md` gains a DCO sign-off requirement (`git commit -s`) so future contributions carry a provenance record. No CLA yet, deliberately. A CLA grants rights *beyond* the inbound licence and is what would enable selling proprietary exceptions; a DCO does not. Since PRs are currently collaborators-only there is nothing inbound to cover, and the deadline for adding one is the first outside contribution, not a date. ## Changes - `LICENSE` — full AGPL-3.0 text, verified word-for-word against the SPDX canonical text - `package.json` — `license`, plus new `author` and `build.copyright` - 9 workspace packages — `Apache-2.0` → `AGPL-3.0-only` - `README.md` — licence section with copyright, plain-English summary, and the relicensing boundary - `THIRD_PARTY_NOTICES.md` — updated, including why noVNC's MPL and rampart's CC-BY remain compatible - `CONTRIBUTING.md` — DCO section - Three stale "Copse is Apache-2.0" statements in `docs/` ## Verification - `oxfmt --check .` passes across 2,782 files - All 11 `package.json` files parse and report the expected licence - No test or script pins the licence value - The site's "Free & open source" claims stay accurate — AGPL-3.0 is OSI-approved, so no marketing copy needed changing ## Note before merging Two things worth a second opinion, since neither is reversible in the way code is: 1. **`AGPL-3.0-only` vs `AGPL-3.0-or-later`.** I chose `-only`, which is the usual pick for projects planning to sell exceptions — it avoids automatically adopting a future AGPLv4 you haven't read. Easy to change if you'd rather follow the FSF's `-or-later` recommendation. 2. **This commit is not DCO signed-off**, since the sign-off is a personal certification and shouldn't be made on your behalf. Worth signing off your own commits from here on, given the policy this PR introduces. A solicitor's read of a one-page licence change is cheap relative to what rests on it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01D6MCpnuHT1yLDRW5JNSi9F --- _Generated by [Claude Code](https://claude.ai/code/session_01D6MCpnuHT1yLDRW5JNSi9F)_ Co-authored-by: Claude <noreply@anthropic.com>
## Summary - Adds `packages/store-kit` (`@copse/store-kit`): `copse-paths`, `cached-store`, `persistent-store`, `secret-cipher`, `keyring-cipher`, `os-keyring`, `secret-migration`, `secret-write-policy`, `api-key-readability`, `storage-schema`, `user-data-migration`, `zip-reader`, and `zip-archive` moved from `src/main/services/storage/` with their eleven test files. Depends on `@copse/std`, `@napi-rs/keyring`, and `zod`; no host-app imports. - **Deduplicates the profile-root defaults.** Three packages had each reimplemented them, because none could import the app's resolver: `@copse/thread-store`'s `defaultWorkspaceRoot`, and `@copse/hooks-dialects`' and `@copse/plugin-sdk`'s `defaultDataRoot`. All three now delegate to `copseWorkspaceDir` / `copseDataRoot` here, so `COPSE_DIR` and `COPSE_WORKSPACE_DIR` resolve identically everywhere rather than by three copies agreeing. That was the main reason to do this fold before the remaining ones. - The storage files of the same names are one-line re-exports, so the app's importers are unchanged. `settings`, `storage`, `knowledge-store`, `long-task-tracker`, `project-namespace`, `plan-window-history`, `usage-ledger`, and the `electron-store` backend stay in the app. - The `cached-store` stub had no remaining importers (`persistent-store` was its only one, and it moved too) and was deleted; the comment in `storage.ts` that pointed at it now names the package. Second of the small folds tracked in #2313. Part of #2303. Independent of #2349 (`@copse/procwatch`), which is also on `main`. ## Why draft Opened as a draft to secure the work: it was uncommitted in a `.claude/worktrees/` checkout, which gets cleaned up. Local gates below are green; mark ready once CI agrees. ## Validation - `pnpm test -- workspace-package-invariants pnpm-workspace-invariants module-boundaries stdout-protocol-guard settings-read-keys packages/store-kit/ packages/thread-store/ packages/hooks-dialects/ packages/plugin-sdk/ src/main/services/storage/` — 448 pass - `pnpm run typecheck`, `pnpm run check:dead-code`, `pnpm run build` — clean - `pnpm run lint`, `pnpm test` (full unit suite) — reported in a follow-up comment - Build plumbing and an equal-behaviour move; no user-visible change, so no visual evidence is required. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…sites) (#2420) Follow-on from #1330, now that #2419 has landed. **104 asserted predicates, down from 118.** First of five follow-ups; the rest are listed at the bottom. ## The pattern Sixteen sites spelled "is this a usable string?" four different ways, each as an inline `x is string` assertion: ```ts .filter((part): part is string => Boolean(part)) .filter((p): p is string => typeof p === 'string' && p.length > 0) .filter((g): g is string => typeof g === 'string' && g.trim().length > 0) .filter((block): block is string => block !== undefined && block.trim().length > 0) ``` An inline predicate is the one shape `docs/type-safety.md`'s "an exported predicate needs a test" rule can't reach — there's nothing to import, so nothing to test. Naming it is the only remedy available, and `@copse/std` is where #1332 already put `isDefined` / `isNonNull` for exactly this reason. ## Why two helpers and not one **`isNonEmptyString`** accepts `' '`; **`isNonBlankString`** does not. The four spellings above are not one function — collapsing them into a single helper would have silently changed behaviour at nine sites. Neither is named as *the* default, deliberately: a call site has to pick, and the name then says at a glance what the old body made you read the condition to work out. That already caught one lie — `nonEmptyString` in `terminal-bench-steering.mts` was the blank-rejecting one, so its name meant the opposite of the codebase's other `length > 0` checks. ## `Boolean(part)` is only equivalent under a condition `Boolean(x)` and `isNonEmptyString(x)` agree exactly when nothing in the array is a truthy non-string — a `5` would be kept by one and dropped by the other, and the types cannot see the difference because the old annotation claimed `x is string` either way. So I checked rather than assumed. Every receiver at the six `Boolean()` sites: | site | element type | | --- | --- | | `agent-errors.ts` ×2, `settings-dialog.ts`, `semantic-index.ts` | `(string \| null)[]` | | `agent-service.ts`, `model-picker.ts` | `(string \| undefined)[]` | | `agent-model-identity.ts` | `readonly (string \| null \| undefined)[]` | | `cx.ts` | `(string \| false \| null \| undefined)[]` | | `workspace-trust.ts` | `any[]` | No numerics, so the swap is exact. The test pins that equivalence directly rather than describing it: ```ts assert.deepEqual(values.filter(isNonEmptyString), values.filter((value) => Boolean(value))) ``` `workspace-trust.ts` is worth a look — it reads `any[]` off storage, and it is the site where #2419 measured that *deleting* the annotation degrades to `any` and trips `no-unsafe-*`. A named predicate narrows `any → string` explicitly, so it fixes what deletion broke. ## Verification - **No type moved.** Every named binding and signature in the 15 touched files resolves to a byte-identical type before and after. The one exception is the local `nonEmptyString` this deletes. (The call-expression diff #2419 used doesn't work here — removing `Boolean()` changes the number of calls in a file — so this compares declarations by name instead.) - **The tests can fail.** A `return true` body turns 8 of the 16 new cases red. - `pnpm run check` end to end on the rebased branch: **8,604 passing / 0 failing**. No visual eval: `cx.ts`, `model-picker.ts` and `settings-dialog.ts` are class-name and detail-string joins whose output is unchanged — same predicate, same strings, unchanged DOM. ## Also `firstNonEmptyString` in `@copse/std` had its own inline copy of this check; it now uses `isNonEmptyString`, so the package states the concept once. Two sites in this family are deliberately left alone: `github-ci-service.ts` narrows the *object* (`row is GhPrCheckRow & { name: string }`), not the string, and `sync-thread-branch.ts` is domain logic wearing a predicate. ## The rest of the follow-ups | | sites | | | --- | --- | --- | | **this PR** | 16 | shared string-presence predicates | | next | 8 | finish #1332's `isRecord` sweep — it matched on the name, so `scripts/` copies and the `isUnknownRecord` / `isDetail` aliases survived | | then | 4 | `keyOf()` for `k in RECORD` predicates | | then | 4 | contract tests for the `typeof x === 'function'` guards, which assert a call contract nothing checks | | then | ~10 | one-off conversions | | separately | ~50 | structural boundary parsers → schemas. Behaviour-affecting, wants its own issue | 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ --- _Generated by [Claude Code](https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ)_ Co-authored-by: Claude <noreply@anthropic.com>
…now also carries #2422 (#2421) Follow-on from #1330. **97 asserted predicates, down from 104** for this PR's own commit; **95** with #2422 included. > **This branch now contains two changes.** #2422 was merged into it rather than into `main`, so its commit is here as well and the diff below is the pair. #2422's own description is the review material for the `keyOf` half — it is the one with the settings-gate bug in it. Everything under the headings below is this PR's commit only. > > Still stacked on #2420 — retarget to `main` once that merges. #2423 has been retargeted onto this branch. ## What #1332 missed, and why #1332 consolidated 22 copies onto `@copse/std` and reported one definition left standing. Seven survived, because the sweep matched the **identifier**: five live in `scripts/`, and two are spelled `isUnknownRecord`. ``` scripts/lib/cloud-hosts.mts isRecord scripts/lib/edited-file-check.mts isRecord scripts/lib/terminal-bench-task-image.mts isRecord scripts/lib/terminal-bench-tasks.mts isRecord scripts/skillsbench-agent-lib.mts isRecord src/main/services/acp/session-update-adapter.ts isUnknownRecord src/main/services/remote/cursor-agent-discovery.ts isUnknownRecord ``` Not a criticism of that PR — it's the failure mode of any name-based sweep, and it's the reason the inventory from #2419 keys on *shape* instead. These seven were sitting in it. ## The behaviour question Five of the seven omit `!Array.isArray(value)`, so they accept arrays where the canonical one doesn't. That's a real difference, so I checked each rather than repeating #1332's argument on faith. Every use is `isRecord(v)` followed by a **named string-key read**: | file | what follows the check | | --- | --- | | `edited-file-check.mts` | `source[key]` for `'file_path'`, `'notebook_path'`, … | | `terminal-bench-task-image.mts` | `value['Id']`, `image['RepoDigests']` | | `terminal-bench-tasks.mts` | `value['schemaVersion']`, `value['name' \| 'image' \| 'configSha256']` | | `skillsbench-agent-lib.mts` | `!(name in args)` before the read | | `cursor-agent-discovery.ts` | `rec['id']`, `rec['items']`, `repoRec['url']`, … | An array has none of those keys, so it already fell through to the same `undefined` / `null` / throw. The narrowing has no reachable behaviour change. ## Two files kept their predicate — both were traps, not copies **`scripts/perf-report.mts`** had an `isRecord` that reads `t` and `name` and narrows to a trace-line interface: ```ts function isRecord(value: unknown): value is Record_ { if (typeof value !== 'object' || value === null) return false const t: unknown = Reflect.get(value, 't') const name: unknown = Reflect.get(value, 'name') return typeof t === 'number' && typeof name === 'string' } ``` A sweep matching on the name — this one, next time — would have replaced it and made the parser accept every object. It's now `isTraceLine`, with `Record_` renamed to `TraceLine` to match, and a comment saying why the name matters. **`src/main/services/diagnostics/perf-ipc.ts#isDetail`** claims its values are `string | number | boolean | undefined` and only checks the container is an object. That one is a genuine lie, but fixing it drops diagnostics: this is the debug perf branch, and rejecting an object-valued detail would silently lose data rather than fix anything. Left as-is, with the gap written down rather than implied. Say the word if you'd rather it were tightened. ## Verification - **No type moved.** Every declaration in the nine touched files resolves identically before and after, except the seven deletions and the `Record_ → TraceLine` rename. - `pnpm run check` end to end: **8,604 passing / 0 failing**. No renderer files touched by this commit, so no visual eval. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ --------- Co-authored-by: Claude <noreply@anthropic.com>
) Follow-on from #1330. **85 asserted predicates, down from 95.** Last of the mechanical five. > **Unstacked.** #2421 (which carried #2422) has merged, so this now sits directly on `main` and the earlier note about retargeting is spent. The merge commit resolves two conflicts, both caused by this branch having already converted what `main` later carried: `edited-file-check.mts` keeps the `isNonNull` import it needs, and the inventory ratchet was **regenerated from the merged tree** rather than reconciled by hand — 85 in 53 files, which is what the table below claims. > > The diff also carries **five screenshot baselines** from #2523, which the screenshot-review flow merged into this branch. I'd flagged these as ones I didn't think this diff caused, and offered to drop them; CI has since answered it, so no action is needed. On the merged head all **eight e2e shards pass** against those baselines, and the screenshot job now reports *"no changed screenshot candidates for `bfec6c4c30d8`"* — the built app renders to match them and the branch is screenshot-stable. Keeping them is correct. ## What converts **Six inline annotations deleted**, so the compiler infers the predicate they were asserting — `block.type === 'text'` in `agent-service` and `plugin-model-turn`, `typeof name === 'string'` in three `gh-cli-backend` filters, and one in `semantic-index`. These were excluded from #2419's batch because their *file* showed a type difference somewhere; measured individually, nothing moves. **Four presence checks** that #1332's `isNonNull` / `isDefined` sweep didn't reach, because they live in `scripts/`: ```ts - .filter((value): value is string => value !== null) + .filter(isNonNull) ``` ## Two candidates dropped after measuring Both are worth knowing about, because both look convertible and aren't: - **`plugin-model-turn.ts`'s image filter** widens from `{ type: 'image'; dataUrl: string }[]` to one carrying `detail?`. The runtime value is identical — the predicate only filters — but the annotation was projecting the field away, and the declared shape is part of the plugin payload. Left alone rather than quietly changing what a plugin sees. - **`api-protocol.mts`**: `ts.isInterfaceDeclaration(s) && s.name.text === 'ApiClient'` degrades to `Statement | undefined`. The second conjunct reads a property that only exists once the first has narrowed, and inference won't carry that across. ## The other half: a "what stays" section in the docs The inventory has been shrinking for four PRs, which invites the reading that zero is the target. It isn't, and `docs/type-safety.md` now says so. Two categories on the list are **finished work**: - **The shared helpers themselves** — `isRecord`, `memberOf`, `keyOf`, `isDefined`, `isNonNull`, `isNonEmptyString`, `isNonBlankString`, `matchesFallbackType`. Each is one audited assertion with a real test standing in for the dozens it replaced. Taking them off the list would hide the assertions the codebase actually leans on. - **Provenance guards over a callable** — `isToolFactory`, `isRawExecute`, `isDynamicImport`, `isCreateGuard`. These narrow `unknown` to a specific function *signature* from `typeof v === 'function'`, which establishes only that it is callable. I went looking for a fix and there isn't one: the values come from user-authored tool modules and an optional dependency, so no check can see the signature. The contract is enforced where the function is called — a `try`/`catch` that isolates the failure to one file — not where it is narrowed. A generic `isCallable<T>()` would only make the unchecked half convenient, which is the opposite of what the rest of this work does. That leaves the structural parsers over `unknown` as the genuine remainder, and the answer there is a decoder rather than a better predicate. The ratchet's header comment also stops asserting a number that decays: it now says where the list started (183 in 113 files) and where it stands (85 in 53), and notes the second figure is enforced by the two tests below rather than remembered. Both sides had carried the stale line unchanged since #2419. ## Verification - **No type moved.** Every declaration in the ten touched files resolves byte-identically before and after. - `pnpm run check` end to end, re-run after the merge onto current `main`: **8,610 passing / 0 failing**. - CI green on `bfec6c4c3`: `CI Passed`, `check`, `build`, all eight `e2e` shards, `screenshot-artifacts`. One renderer-adjacent file (`agent-service.ts`) is touched; it's a text-block filter with identical output and unchanged DOM, so no visual eval beyond the e2e run above. ## Where this leaves #1330 | | asserted predicates | | --- | --- | | `main` before #2419 | 183 | | after #2419 | 118 | | after #2420 → #2421 (+#2422) → this | **85** | The remaining 85 are the shared helpers (8), the callable guards (4), and ~70 structural boundary parsers — `command-hook-runner` (12), `spine-schema` (9), `settings`, `knowledge-store`, `automation-service`, the renderer seed parsers. That group is filed as #2424: it turned out to be two unrelated problems, and neither is a schema sweep. The lint half of "stop this recurring" is #2524, which is independent of this and sits on `main` — it overlaps only in `docs/type-safety.md`, where both add a section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: copse-release-bot[bot] <304038887+copse-release-bot[bot]@users.noreply.github.com> Co-authored-by: jonathanKingston <338988+jonathanKingston@users.noreply.github.com>
…2524) Follow-on from #1330. This is the lint half of the answer to "can we stop this recurring" — plus the second bug the question turned up. `in` walks the prototype chain, so `key in RECORD` — where the key did not come from a literal — answers **true** for the eight members every object literal inherits: `toString`, `constructor`, `valueOf`, `hasOwnProperty`, `__proto__`, `isPrototypeOf`, `propertyIsEnumerable`, `toLocaleString`. ## The bug #2422 fixed one allowlist written that way (`settings:set`). Asking how many others there were found `plugins:setSetting`, and it is the worse of the two: ```ts const declared = pack?.manifest.settings if (!declared || !(key in declared)) { throw new Error(`pack "${pluginId}" declares no setting "${key}"`) } await storageUpdate(pluginSettingsKey(pluginId), (raw) => { const current: Record<string, unknown> = isRecord(raw) ? { ...raw } : {} current[key] = value return current }) ``` `key` comes straight off the renderer. All eight inherited names passed the gate, and unlike the `settings:set` case nothing downstream threw first — **the write happened**. The comment immediately above that check says it exists to stop a pack's bag growing arbitrary keys forever, which is precisely what it permitted. The new test loops the eight names and asserts both the rejection and that nothing persisted. On the old body it fails: ``` AssertionError: Missing expected rejection: inherited key toString was accepted ``` Severity, stated plainly: writing `toString` into a pack's own settings bag is a durable junk key, not a privilege escalation — the bag is per-pack and the value is read back through the pack's own declared schema. It is a gate that did not gate. ## The rule ```js 'no-restricted-syntax': [ 'error', { selector: "BinaryExpression[operator='in'][left.type!='Literal']", message: … }, ] ``` Only a **dynamic** key is restricted. `'kind' in payload` is the ordinary way to discriminate an object union and the tree has **248** of those, so a blanket ban on `in` would bury the ten that mattered. Counted with tsc rather than grep, so `for (const k in o)` and the word in a string never count. Tests are exempt. Several use `in` deliberately to demonstrate the behaviour the rule forbids — `member-of.test.ts` asserts `key in SCHEMAS === true` for all eight next to `keyOf(SCHEMAS)(key) === false`. A test that cannot state the wrong answer cannot pin the right one. All nine remaining dynamic-key sites are tests. Verified against a probe file: `key in rec` and `rec.a in rec` are flagged, `'a' in rec` is not. ## The other nine sites All converted to `Object.hasOwn`. Only `prototypes/side-highlight/proto.js` was reachable by input — it reads `location.hash` — and none of the rest can produce a wrong answer today: `MODEL_CARDS`/`MODEL_INTELLECT_RAW`/`PUBLISHERS` are looked up with ids that came from the same tables, and `api-protocol.mts`/`persistence.ts` compare two objects built the same way. They are each one caller away from mattering, and the rule doesn't have a "this one is fine" mode. | file | what it gated | | --- | --- | | `packages/llm/src/model-cards.ts` (×2) | model-card id lookup | | `packages/llm/src/model-intellect.ts` | intellect table lookup | | `scripts/sync-model-cards.mts` | publisher validation | | `scripts/lib/api-protocol.mts` | added-channel diff | | `scripts/skillsbench-agent-lib.mts` (×2) | tool-arg property reads | | `src/renderer/controller/persistence.ts` | removed-key patch | | `prototypes/side-highlight/proto.js` | `location.hash` → treatment | ## Verification - `pnpm run check` end to end on this base: **8,611 passing / 0 failing**. - The plugin-service regression test confirmed failing on the old body before the fix. - Rebased onto `main` after #2421 landed; re-ran the full gate on the rebased base. No renderer-visible change (`persistence.ts` is a patch-shape helper with the same output), so no visual eval. ## Docs `docs/type-safety.md` gains a "Never use a dynamic key with `in`" section; `AGENTS.md`'s type-safety paragraph names it in one clause. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ --- _Generated by [Claude Code](https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ)_ Co-authored-by: Claude <noreply@anthropic.com>
Problem 2 of #2424 — the last group that issue called out. **78 asserted predicates, down from 85** (independent of #2528, which takes the other 12). > **Not the zod migration #2424 proposed.** I measured that route first and it cannot deliver its own benefit safely. Details below, because the reasoning is the substance of this PR. ## The constraint, with a mechanism A rejected line is not merely skipped. `parseSpineEntries` keeps its raw text, but `rebuildSpinePreservingNonMessageLines` collects blob refs **only from lines it could parse**, and `pruneStaleFiles` then `unlinkSync`s every blob outside that set: ```ts if (entry.line?.type === 'hook_run') preservedRefs.push(...hookRunBlobRefs(entry.line)) … pruneStaleFiles(dir, files, preservedRefs) // unlinkSync for anything not in `keep` ``` So tightening any check here **deletes** the stdout, stderr, decision-detail and plan-artifact files that older threads still point at, on that thread's next save. Any change had to be provably no-stricter. ## Why zod was the wrong tool here The advertised benefit is `z.infer` — the type derives from the schema, so they can't disagree. That is unavailable at no-stricter strictness: - `SpineMessageLine` declares ~20 fields; the predicate validates **5**. To stay no-stricter the schema must leave the other 15 as `z.unknown().optional()`, and `z.infer` then yields `unknown` where the interface declares `ModelParameters`, `TurnOutcome`, `ThreadReview`… so the result is still not assignable and **still needs an assertion**. Net: 9 assertions → 9, plus 9 schemas and a zod parse on the thread-load path. - Getting `z.infer` to work means validating those fields — exactly the tightening that triggers the deletion above. - `z.object` also **strips unknown keys** (verified: `{a,unknownFuture}` → `{a}`), which would silently drop forward-compat fields a newer Copse wrote. ## What this does instead Each parser is derived from a per-field table, and the compiler checks it two ways: ```ts type RequiredFieldChecks<T, Loose extends keyof T = never> = { [K in RequiredKeys<T>]: FieldCheck<T, K, Loose> } ``` - a required field with no entry → *"Property 'x' is missing"* - an entry whose body proves the wrong type → *"Type predicate 'x is string' is not assignable to 'value is number'"* Each check is a bare arrow, so TypeScript **infers** its predicate from the body rather than taking an assertion's word — the #1330 mechanism, applied per field. Each line predicate then becomes an annotated `const` binding a single call, which the compiler checks (TS2322) instead of accepting. `Loose` names the fields deliberately not fully proven, so every tolerance appears **in the table's own type** rather than being an absence a reader has to notice. ## It found a real bug immediately `TurnOutcome.stopReason` is declared as a **nine-literal union** but has only ever been validated as `typeof === 'string'`. `isTurnOutcome` was returning `true` — and claiming `value is TurnOutcome` — for outcomes whose `stopReason` is not in the type. The compiler rejected my faithful transcription of it, which is the whole point of the exercise. I did **not** tighten it: a newer Copse can write a stop reason this build has no literal for, and rejecting that line drops the turn outcome from the transcript. It is declared `Loose` with that reasoning recorded. ## Proof that nothing moved A 1035-line corpus — every single-field mutation (delete, wrong type, null, empty, extra key) of a valid line of each of the 7 types, plus malformed, unknown-type and non-object input — run through `parseSpineLine` before and after: ``` corpus 1035 lines, accepted 158, rejected 877 IDENTICAL on all 1035 lines ``` Compared on accept/reject **and the exact returned object**, so a stripped or added field would fail too. Re-verified after the lint-driven redesign below. ## Two lint findings kept, not suppressed - **`no-unnecessary-condition`** on the phase/result rule. It was right in a way worth reading: the rule ran on the *narrowed* value, whose `started` arm declares `result?: never`, so TypeScript believed the check was dead — while at runtime that field is exactly what needs inspecting. Cross-field rules now read the raw record. A validator handed an already-validated value is the wrong shape. - **`no-unnecessary-type-parameters`** on the `unchecked<V>()` wrapper I first used for tolerated fields. Moving the tolerance into the table's type instead removed the wrapper — and with it the last hand-written assertion of the set. ## Verification - `pnpm run check` end to end: **8,620 passing / 0 failing**. - 300 thread-store tests pass; 9 new contract tests in `spine-field-checks.test.ts` pin the tolerances as behaviour (absent vs malformed `toolCalls`, unvalidated elements, an out-of-union `stopReason`, optional-only-when-present, unknown-field round trip, the phase/result correlation). - One of those tests caught **my own** wrong expectation — a non-array `toolCalls` was always rejected, only an absent one is defaulted — which the differential run had already proven. No renderer change, so no visual eval. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ --- _Generated by [Claude Code](https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ)_ Co-authored-by: Claude <noreply@anthropic.com>
) Problem 1 of #2424. **73 asserted predicates, down from 85.** > **This is not the fix #2424 proposed, and the issue was wrong.** I filed it saying the twelve predicates needed a discriminated-union change to `HookEventPayloads`, gated behind a decisions-log entry in `docs/plans/hooks-and-feature-packs.md`. Measuring first showed a fix that is local to one file, needs no type-model change, no decisions-log entry, and is *more* type-safe than the union proposal. The issue has been corrected. ## Why the predicates existed `run` takes the hook and payload as two separate parameters: ```ts async run<E extends HookEventName>(hook: CommandHook<E>, payload: HookEventPayloads[E], …) ``` Gating on `hook.event` narrows the hook and leaves `payload` at its unresolved indexed-access type. TypeScript has no way to carry a correlation across two parameters, so twelve predicates existed to restate one the compiler could not see. They restated it badly. With nothing but key presence to go on, siblings collided: ```ts 'status' in payload && !('subagentType' in payload) // stop, avoiding subagentStop 'filePath' in payload && !('applied' in payload) // beforeDiffApply, avoiding afterDiffApply ``` Those negative clauses were load-bearing, absent from the types, and unchecked — a predicate asserts `payload is T` and the compiler takes its word. ## The fix A per-event table. Indexing it with `hook.event` keeps both sides on the same `E`, so the compiler resolves the pair itself: ```ts const EVENT_DISPATCH: { [E in HookEventName]?: EventDispatch<E> } = { … } const dispatch = EVENT_DISPATCH[hook.event] if (!dispatch) return ABSTAIN ``` ## Measured, not assumed I probed four designs against `tsc` before writing any of it: | design | result | | --- | --- | | today's shape — gate on `hook.event`, read `payload` | **error** — confirms the premise | | payload carries its own `event`, widen to the union | works, but needs every payload + fire site changed | | build the correlated union locally from a generic `E` | **error** — TS can't prove the assignment | | **per-event table indexed by `hook.event`** | **works, no platform change** | The discriminated-union option also fails on design grounds independently of cost: `emit` already takes the event name, so every fire site would repeat it. The table is **strictly stronger** than the predicates. Verified by sabotaging the real file: - wiring `stop` to `marshalSubagentStopRequest` → *"Property `subagentType` is missing in type `StopPayload` but required in type `SubagentStopPayload`"* — the exact mistake the hand-written negative clause was guarding - `sessionStarted:` instead of `sessionStart:` → *"…does not exist in type… Did you mean to write `sessionStart`?"* That is execution-guidance rule 3 of `docs/plans/hooks-and-feature-packs.md`: *prefer a compile error over a review comment over a runtime check, in that order.* ## Why no decisions-log entry No semantic change. The twelve runtime checks could only ever have fired on a payload that did not match its own event, which the in-process fire sites never construct. An event with no table entry abstains exactly as the unwired branch did — the existing `compaction` abstain test covers that path unchanged. Module layout is untouched (rule 4): everything stays in `packages/hooks-dialects`. ## Verification - `pnpm run check` end to end: **8,611 passing / 0 failing**. - 465 hook tests pass, including the adapter and failClosed contract tests. - The runner loses **91 lines** (522 → 431); twelve near-identical 12-line blocks become one dispatch plus a table. No renderer change, so no visual eval. ## Relationship to #2531 Problem 2 of #2424 (`spine-schema.ts`, 9 predicates) is now done separately in #2531. The two are **independent** — either can merge first, neither is stacked on the other, and they touch disjoint source trees (`packages/hooks-dialects` here, `packages/thread-store` there). They do both edit `scripts/type-predicate-inventory.test.ts`, so **whichever merges second will conflict there**. Resolve it by regenerating the list from the merged tree rather than hand-picking a side, and update the header comment's count. Together they land at **66 in 52 files**, down from 85 — verified by a local trial merge rather than by adding the two arms, which would give 67 and be wrong (the two PRs each remove a predicate the other's count also reflects). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ Co-authored-by: Claude <noreply@anthropic.com>
…-minor-patch group (#2540) Bumps the npm-minor-patch group with 1 update: [@wdio/local-runner](https://github.com/webdriverio/webdriverio/tree/HEAD/packages/wdio-local-runner). Updates `@wdio/local-runner` from 9.31.3 to 9.31.5 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/webdriverio/webdriverio/releases">@wdio/local-runner's releases</a>.</em></p> <blockquote> <h2>v9.31.5 (2026-08-29)</h2> <h4>:rocket: New Feature</h4> <ul> <li><code>wdio-utils</code>, <code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15538">#15538</a> feat: UNSTABLE Review Multi-remote <code>select</code> implementations and remove filter (<a href="https://github.com/dprevost-LMI"><code>@dprevost-LMI</code></a>)</li> </ul> </li> </ul> <h4>:bug: Bug Fix</h4> <ul> <li><code>wdio-utils</code>, <code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15538">#15538</a> feat: UNSTABLE Review Multi-remote <code>select</code> implementations and remove filter (<a href="https://github.com/dprevost-LMI"><code>@dprevost-LMI</code></a>)</li> </ul> </li> <li><code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15535">#15535</a> fix(webdriverio): handle BiDi no such node errors as stale (<a href="https://github.com/Fnine59"><code>@Fnine59</code></a>)</li> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15537">#15537</a> fix(webdriverio): require all classic cookie filter attributes (<a href="https://github.com/ML642"><code>@ML642</code></a>)</li> </ul> </li> </ul> <h4>:memo: Documentation</h4> <ul> <li><code>wdio-protocols</code>, <code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15536">#15536</a> Docs/actions api guidance (<a href="https://github.com/ML642"><code>@ML642</code></a>)</li> </ul> </li> </ul> <h4>Committers: 3</h4> <ul> <li>David Prevost (<a href="https://github.com/dprevost-LMI"><code>@dprevost-LMI</code></a>)</li> <li>Fnine59 (<a href="https://github.com/Fnine59"><code>@Fnine59</code></a>)</li> <li>Martsin Lazouski (<a href="https://github.com/ML642"><code>@ML642</code></a>)</li> </ul> <h2>v9.31.4 (2026-08-27)</h2> <h4>:rocket: New Feature</h4> <ul> <li><code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15532">#15532</a> feat: Beta ElementArray under feature flag for <code>multiRemote.$$()</code> e.g. MultiRemoteElement[] (<a href="https://github.com/dprevost-LMI"><code>@dprevost-LMI</code></a>)</li> </ul> </li> <li><code>wdio-allure-reporter</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15278">#15278</a> feat(wdio-allure-reporter): add support for global errors and global attachments (<a href="https://github.com/todti"><code>@todti</code></a>)</li> </ul> </li> </ul> <h4>:bug: Bug Fix</h4> <ul> <li><code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15478">#15478</a> fix(webdriverio): track document element per context (<a href="https://github.com/Badisi"><code>@Badisi</code></a>)</li> </ul> </li> <li><code>webdriver</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15516">#15516</a> fix(webdriver): serialize IPv6 BiDi WebSocket candidates correctly (<a href="https://github.com/ML642"><code>@ML642</code></a>)</li> </ul> </li> </ul> <h4>:memo: Documentation</h4> <ul> <li><code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15531">#15531</a> docs(webdriverio): document the $$ async iterator methods (<a href="https://github.com/devangpratap"><code>@devangpratap</code></a>)</li> </ul> </li> </ul> <h4>Committers: 5</h4> <ul> <li>Alex (<a href="https://github.com/todti"><code>@todti</code></a>)</li> <li>David Prevost (<a href="https://github.com/dprevost-LMI"><code>@dprevost-LMI</code></a>)</li> <li>Martsin Lazouski (<a href="https://github.com/ML642"><code>@ML642</code></a>)</li> <li><a href="https://github.com/Badisi"><code>@Badisi</code></a></li> <li><a href="https://github.com/devangpratap"><code>@devangpratap</code></a></li> </ul> </blockquote> </details> <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/webdriverio/webdriverio/blob/main/CHANGELOG.md">@wdio/local-runner's changelog</a>.</em></p> <blockquote> <h2>v9.31.5 (2026-08-29)</h2> <h4>:rocket: New Feature</h4> <ul> <li><code>wdio-utils</code>, <code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15538">#15538</a> feat: UNSTABLE Review Multi-remote <code>select</code> implementations and remove filter (<a href="https://github.com/dprevost-LMI"><code>@dprevost-LMI</code></a>)</li> </ul> </li> </ul> <h4>:bug: Bug Fix</h4> <ul> <li><code>wdio-utils</code>, <code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15538">#15538</a> feat: UNSTABLE Review Multi-remote <code>select</code> implementations and remove filter (<a href="https://github.com/dprevost-LMI"><code>@dprevost-LMI</code></a>)</li> </ul> </li> <li><code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15535">#15535</a> fix(webdriverio): handle BiDi no such node errors as stale (<a href="https://github.com/Fnine59"><code>@Fnine59</code></a>)</li> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15537">#15537</a> fix(webdriverio): require all classic cookie filter attributes (<a href="https://github.com/ML642"><code>@ML642</code></a>)</li> </ul> </li> </ul> <h4>:memo: Documentation</h4> <ul> <li><code>wdio-protocols</code>, <code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15536">#15536</a> Docs/actions api guidance (<a href="https://github.com/ML642"><code>@ML642</code></a>)</li> </ul> </li> </ul> <h4>Committers: 3</h4> <ul> <li>David Prevost (<a href="https://github.com/dprevost-LMI"><code>@dprevost-LMI</code></a>)</li> <li>Fnine59 (<a href="https://github.com/Fnine59"><code>@Fnine59</code></a>)</li> <li>Martsin Lazouski (<a href="https://github.com/ML642"><code>@ML642</code></a>)</li> </ul> <h2>v9.31.4 (2026-08-27)</h2> <h4>:rocket: New Feature</h4> <ul> <li><code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15532">#15532</a> feat: Beta ElementArray under feature flag for <code>multiRemote.$()</code> e.g. MultiRemoteElement[] (<a href="https://github.com/dprevost-LMI"><code>@dprevost-LMI</code></a>)</li> </ul> </li> <li><code>wdio-allure-reporter</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15278">#15278</a> feat(wdio-allure-reporter): add support for global errors and global attachments (<a href="https://github.com/todti"><code>@todti</code></a>)</li> </ul> </li> </ul> <h4>:bug: Bug Fix</h4> <ul> <li><code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15478">#15478</a> fix(webdriverio): track document element per context (<a href="https://github.com/Badisi"><code>@Badisi</code></a>)</li> </ul> </li> <li><code>webdriver</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15516">#15516</a> fix(webdriver): serialize IPv6 BiDi WebSocket candidates correctly (<a href="https://github.com/ML642"><code>@ML642</code></a>)</li> </ul> </li> </ul> <h4>:memo: Documentation</h4> <ul> <li><code>webdriverio</code> <ul> <li><a href="https://redirect.github.com/webdriverio/webdriverio/pull/15531">#15531</a> docs(webdriverio): document the $ async iterator methods (<a href="https://github.com/devangpratap"><code>@devangpratap</code></a>)</li> </ul> </li> </ul> <h4>Committers: 5</h4> <ul> <li>Alex (<a href="https://github.com/todti"><code>@todti</code></a>)</li> <li>David Prevost (<a href="https://github.com/dprevost-LMI"><code>@dprevost-LMI</code></a>)</li> <li>Martsin Lazouski (<a href="https://github.com/ML642"><code>@ML642</code></a>)</li> <li><a href="https://github.com/Badisi"><code>@Badisi</code></a></li> <li><a href="https://github.com/devangpratap"><code>@devangpratap</code></a></li> </ul> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/webdriverio/webdriverio/commit/4f39140405a3a54a44c391f97a6de1fc3af2d33d"><code>4f39140</code></a> v9.31.5</li> <li><a href="https://github.com/webdriverio/webdriverio/commit/69c45e316f75ba2e40e83ef7475106ece26cf2e2"><code>69c45e3</code></a> v9.31.4</li> <li>See full diff in <a href="https://github.com/webdriverio/webdriverio/commits/v9.31.5/packages/wdio-local-runner">compare view</a></li> </ul> </details> <br /> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore <dependency name> major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore <dependency name> minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore <dependency name>` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore <dependency name>` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore <dependency name> <ignore condition>` will remove the ignore condition of the specified dependency and ignore conditions </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.8 to 4.37.9. <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/github/codeql-action/releases">github/codeql-action's releases</a>.</em></p> <blockquote> <h2>v4.37.9</h2> <ul> <li>Update default CodeQL bundle version to <a href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.4">2.26.4</a>. <a href="https://redirect.github.com/github/codeql-action/pull/4106">#4106</a></li> </ul> </blockquote> </details> <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action's changelog</a>.</em></p> <blockquote> <h2>4.37.9 - 26 Aug 2026</h2> <ul> <li>Update default CodeQL bundle version to <a href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.4">2.26.4</a>. <a href="https://redirect.github.com/github/codeql-action/pull/4106">#4106</a></li> </ul> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/github/codeql-action/commit/cdf488f595d80d6e07e03d4674febd5ab45fa938"><code>cdf488f</code></a> Merge pull request <a href="https://redirect.github.com/github/codeql-action/issues/4107">#4107</a> from github/update-v4.37.9-920ba7cd1</li> <li><a href="https://github.com/github/codeql-action/commit/7243f38558d187dde99730d224bb47aa26a95306"><code>7243f38</code></a> Update changelog for v4.37.9</li> <li><a href="https://github.com/github/codeql-action/commit/920ba7cd1596037e042122c00381eb16b397d68e"><code>920ba7c</code></a> Merge pull request <a href="https://redirect.github.com/github/codeql-action/issues/4106">#4106</a> from github/update-bundle/codeql-bundle-v2.26.4</li> <li><a href="https://github.com/github/codeql-action/commit/ecfa6e16817b8f490bc9a59baa391baf4fa3e3c2"><code>ecfa6e1</code></a> Add changelog note</li> <li><a href="https://github.com/github/codeql-action/commit/adcdf4a70d247343cf9c29e0f7a6658b51c3a2b1"><code>adcdf4a</code></a> Update default bundle to codeql-bundle-v2.26.4</li> <li><a href="https://github.com/github/codeql-action/commit/486fec2a3ea2626afcd8c7e9208b4f515078dd7e"><code>486fec2</code></a> Merge pull request <a href="https://redirect.github.com/github/codeql-action/issues/4099">#4099</a> from github/update-supported-enterprise-server-versions</li> <li><a href="https://github.com/github/codeql-action/commit/134624c67b20869c2aaa36dafa726375b78a5d76"><code>134624c</code></a> Merge pull request <a href="https://redirect.github.com/github/codeql-action/issues/4101">#4101</a> from github/dependabot/npm_and_yarn/npm-minor-457d82...</li> <li><a href="https://github.com/github/codeql-action/commit/ff43db8f982a368288f117354fb8d046e937124c"><code>ff43db8</code></a> Merge pull request <a href="https://redirect.github.com/github/codeql-action/issues/4103">#4103</a> from github/mergeback/v4.37.8-to-main-db488dde</li> <li><a href="https://github.com/github/codeql-action/commit/4605e03a74cf891614c4d76f82384a16c1c11816"><code>4605e03</code></a> Rebuild</li> <li><a href="https://github.com/github/codeql-action/commit/099c869cad6bf3b88657154d4ae47ffed27e632d"><code>099c869</code></a> Update changelog and version after v4.37.8</li> <li>Additional commits viewable in <a href="https://github.com/github/codeql-action/compare/v4.37.8...v4.37.9">compare view</a></li> </ul> </details> <br /> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@napi-rs/keyring](https://github.com/Brooooooklyn/keyring-node) from 1.3.0 to 2.0.0. <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/Brooooooklyn/keyring-node/releases">@napi-rs/keyring's releases</a>.</em></p> <blockquote> <h2>v2.0.0</h2> <h2>⚠️ Breaking Changes</h2> <p>This release changes how credential store <strong>errors</strong> are reported. Success and "not found" results are unchanged, but provider failures that were previously silenced now throw (sync API) or reject (async API).</p> <h3>1. Reads: <code>getPassword()</code> / <code>getSecret()</code></h3> <p>Previously, <strong>any</strong> failure reading the credential store — a locked keychain, denied access, an OS error — was swallowed and returned as <code>null</code> / <code>undefined</code>, indistinguishable from "no credential stored".</p> <p>Now only a genuinely missing credential (<code>NoEntry</code>) returns the absent result. Every other error throws or rejects:</p> <table> <thead> <tr> <th>Scenario</th> <th>v1.x</th> <th>v2.0.0</th> </tr> </thead> <tbody> <tr> <td>Credential exists</td> <td>value</td> <td>value (unchanged)</td> </tr> <tr> <td>Credential missing</td> <td><code>null</code> / <code>undefined</code></td> <td><code>null</code> / <code>undefined</code> (unchanged)</td> </tr> <tr> <td>Store locked / inaccessible / OS error</td> <td><code>null</code> / <code>undefined</code>⚠️ </td> <td><strong>throws / rejects</strong></td> </tr> </tbody> </table> <h3>2. Deletes: <code>deleteCredential()</code> / <code>deletePassword()</code></h3> <p>Previously, <strong>any</strong> delete failure returned <code>false</code>, so a failed delete looked identical to "credential was already gone" — leaving callers unable to tell whether the secret was actually removed.</p> <p>Now <code>false</code> only means the credential did not exist (<code>NoEntry</code>). A failed delete throws or rejects:</p> <table> <thead> <tr> <th>Scenario</th> <th>v1.x</th> <th>v2.0.0</th> </tr> </thead> <tbody> <tr> <td>Credential deleted</td> <td><code>true</code></td> <td><code>true</code> (unchanged)</td> </tr> <tr> <td>No credential to delete</td> <td><code>false</code></td> <td><code>false</code> (unchanged)</td> </tr> <tr> <td>Delete failed (locked store, OS error)</td> <td><code>false</code>⚠️ </td> <td><strong>throws / rejects</strong></td> </tr> </tbody> </table> <p>A <code>false</code> result now guarantees the credential is absent from the store.</p> <h3>3. TypeScript: async <code>deletePassword()</code> return type</h3> <p>The async <code>deletePassword()</code> alias is now correctly declared as <code>Promise<boolean></code> instead of <code>Promise<unknown></code>. This is a narrowing and is source-compatible for typical usage, but code that treated the result as <code>unknown</code> may need a small type adjustment.</p> <p>These changes apply to both <code>Entry</code> (sync) and <code>AsyncEntry</code> (async), including the <code>deletePassword()</code> aliases.</p> <hr /> <h2>🔧 Migration Guide</h2> <h3>If you only check for absent credentials — no change needed</h3> <pre lang="ts"><code>// Still works exactly as before: null means "not stored" const password = await entry.getPassword() if (password === null) { // no credential stored } </code></pre> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/Brooooooklyn/keyring-node/commit/f3449416a1b4bf11b0570f0a49395aacc84c8608"><code>f344941</code></a> 2.0.0</li> <li><a href="https://github.com/Brooooooklyn/keyring-node/commit/648d7ceb9d9dcb37639a0ecd69cc24e2cec6c447"><code>648d7ce</code></a> chore: bump up cross-platform-actions/action action to v1.5.0 (<a href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/140">#140</a>)</li> <li><a href="https://github.com/Brooooooklyn/keyring-node/commit/5df8cf5284d6179a4247317bcbce4b435ed8e276"><code>5df8cf5</code></a> fix: propagate credential store errors instead of erasing them into false/abs...</li> <li><a href="https://github.com/Brooooooklyn/keyring-node/commit/a8709c1381e604c22b9a01b78277a51d65447a69"><code>a8709c1</code></a> chore: bump up cross-platform-actions/action action to v1.4.0 (<a href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/139">#139</a>)</li> <li><a href="https://github.com/Brooooooklyn/keyring-node/commit/58edf338fcb8ade10d71b6b08a5a7a5dd30cc140"><code>58edf33</code></a> fix: preserve non-missing password read errors (<a href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/136">#136</a>)</li> <li><a href="https://github.com/Brooooooklyn/keyring-node/commit/8e1e4184419e14cec65206ded9e3737be05ec081"><code>8e1e418</code></a> chore: bump up Yarn to v4.18.0 (<a href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/135">#135</a>)</li> <li><a href="https://github.com/Brooooooklyn/keyring-node/commit/df1f9b5f730ef58d5f66ca56f70ee6823852b830"><code>df1f9b5</code></a> chore: bump up chalk version to v6 (<a href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/134">#134</a>)</li> <li><a href="https://github.com/Brooooooklyn/keyring-node/commit/3e7bcc484fd213da420ce0788370f0bcd0279357"><code>3e7bcc4</code></a> chore: bump up typescript version to v7 (<a href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/132">#132</a>)</li> <li><a href="https://github.com/Brooooooklyn/keyring-node/commit/9ded330e3586555b5cba726cd9602fbb5efadcf3"><code>9ded330</code></a> chore: bump up actions/setup-node action to v7 (<a href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/133">#133</a>)</li> <li><a href="https://github.com/Brooooooklyn/keyring-node/commit/f330874629298929eda4c4729d1987c7449b51ca"><code>f330874</code></a> chore: bump up Yarn to v4.17.1 (<a href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/131">#131</a>)</li> <li>Additional commits viewable in <a href="https://github.com/Brooooooklyn/keyring-node/compare/v1.3.0...v2.0.0">compare view</a></li> </ul> </details> <br /> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
## Summary - add an explicit, off-by-default macOS permission for Copse's native `git_commit` subprocess to reach the single Unix socket named by `SSH_AUTH_SOCK` - preserve the existing filesystem and internet-deny sandbox, and replace path-configured SSH signing keys with an inline public identity so the private key and `.ssh` directory remain unreadable - pin a one-line sandbox-runtime patch so the documented per-spawn `allowUnixSockets` override reaches the macOS seatbelt profile - explain the Git-hook/agent capability boundary in Settings and the shell-permissions documentation Closes [#2320](#2320). The dependency patch can be removed after [anthropics/sandbox-runtime#420](anthropics/sandbox-runtime#420) ships in a release. ## Security boundary The grant is macOS-only, requires explicit user consent, validates that `SSH_AUTH_SOCK` is an absolute normalized socket, and applies only to the native commit spawn. Network domains remain denied. Git hooks inherit the commit process and can ask ssh-agent to use any loaded key, so the UI recommends `ssh-add -c`. ## Validation - `pnpm test -- git-commit-signing` — 12 passed - `pnpm test` — 8,555 passed - `pnpm run typecheck` - `pnpm run lint` - `pnpm run format:check` - `pnpm run check:dead-code` - `pnpm run check:oracle` - `pnpm run check:e2e-syntax` - `pnpm run demo:site:check` - `pnpm run build` - `pnpm install --frozen-lockfile --ignore-scripts` - focused Electron e2e: 2 passed - `git diff --check` ## Visual evidence  Co-Authored-By: Copse <noreply@copse.dev> Copse-Models: acp:codex-acp#gpt-5.6-sol, acp:claude-acp#opus[1m] --------- Co-authored-by: Copse <noreply@copse.dev> Co-authored-by: copse-release-bot[bot] <304038887+copse-release-bot[bot]@users.noreply.github.com> Co-authored-by: jonathanKingston <338988+jonathanKingston@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Daily automated promotion of
mainto the release-readyreleasebranch.The repository ruleset requires the full
CI Passedgate. This PR willmerge automatically when that gate is green; failures leave it open
until the underlying issue is fixed on
main.