diff --git a/Cargo.lock b/Cargo.lock index 296196d63..31b5bf573 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4623,9 +4623,9 @@ dependencies = [ [[package]] name = "pdf-inspector" -version = "1.14.2" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e024ae242c514e2adf6aee186678e0eabdc2e5ecfbb2159186881b4498593cb" +checksum = "71d004766b3256499a00dc15c05d99872a24ab6d95ee34b6409eaaec4b793b3a" dependencies = [ "env_logger", "include_dir", @@ -4880,7 +4880,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "17.3.5" +version = "17.3.7" dependencies = [ "anyhow", "ast-grep-core", @@ -4949,7 +4949,7 @@ dependencies = [ [[package]] name = "pi-builtins" -version = "17.3.5" +version = "17.3.7" dependencies = [ "ansi-width", "anyhow", @@ -5016,7 +5016,7 @@ dependencies = [ "rustix 1.1.4", "self_cell", "serde_json", - "similar 3.1.2", + "similar 3.2.0", "smallvec", "strum", "strum_macros", @@ -5034,19 +5034,19 @@ dependencies = [ [[package]] name = "pi-iso" -version = "17.3.5" +version = "17.3.7" dependencies = [ "async-trait", "libc", "parking_lot", - "similar 3.1.2", + "similar 3.2.0", "tokio", "windows-sys 0.61.2", ] [[package]] name = "pi-natives" -version = "17.3.5" +version = "17.3.7" dependencies = [ "anyhow", "arboard", @@ -5118,7 +5118,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "17.3.5" +version = "17.3.7" dependencies = [ "anyhow", "brush-core", @@ -5146,7 +5146,7 @@ dependencies = [ [[package]] name = "pi-voice" -version = "17.3.5" +version = "17.3.7" dependencies = [ "audiopus_sys", "bytes", @@ -5161,7 +5161,7 @@ dependencies = [ [[package]] name = "pi-walker" -version = "17.3.5" +version = "17.3.7" dependencies = [ "dashmap", "globset", @@ -6178,9 +6178,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "similar" -version = "3.1.2" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ee016af5d736b69fc89e19254540fa4b5f5492853fb5503920f084011c78b6" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" dependencies = [ "bstr", ] @@ -9145,9 +9145,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "4.0.0" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 7da5d4b32..904e6e101 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ resolver = "3" [workspace.package] -version = "17.3.5" +version = "17.3.7" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/bun.lock b/bun.lock index d0f4353b9..c4260194b 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "17.3.5", + "version": "17.3.7", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", @@ -40,7 +40,7 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "17.3.5", + "version": "17.3.7", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/omptype": "catalog:", @@ -63,7 +63,7 @@ }, "packages/catalog": { "name": "@oh-my-pi/pi-catalog", - "version": "17.3.5", + "version": "17.3.7", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/omptype": "catalog:", @@ -76,7 +76,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "17.3.5", + "version": "17.3.7", "bin": { "omp": "src/cli.ts", }, @@ -133,7 +133,7 @@ }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "17.3.5", + "version": "17.3.7", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -177,7 +177,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "17.3.5", + "version": "17.3.7", "bin": { "mnemopi": "src/cli.ts", }, @@ -203,7 +203,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "17.3.5", + "version": "17.3.7", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", @@ -211,7 +211,7 @@ }, "packages/omptype": { "name": "@oh-my-pi/omptype", - "version": "17.3.5", + "version": "17.3.7", "devDependencies": { "@ark/attest": "0.56.3", "@ark/schema": "0.56.2", @@ -224,7 +224,7 @@ }, "packages/snapcompact": { "name": "@oh-my-pi/snapcompact", - "version": "17.3.5", + "version": "17.3.7", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", @@ -238,7 +238,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "17.3.5", + "version": "17.3.7", "bin": { "omp-stats": "./src/index.ts", }, @@ -263,7 +263,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "17.3.5", + "version": "17.3.7", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -299,7 +299,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "17.3.5", + "version": "17.3.7", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", }, @@ -309,7 +309,7 @@ }, "packages/wire": { "name": "@oh-my-pi/pi-wire", - "version": "17.3.5", + "version": "17.3.7", "devDependencies": { "@types/bun": "catalog:", }, @@ -347,19 +347,19 @@ "@bufbuild/protoc-gen-es": "^2.12.1", "@huggingface/transformers": "^4.2.0", "@napi-rs/cli": "3.7.2", - "@oh-my-pi/hashline": "17.3.5", - "@oh-my-pi/omp-stats": "17.3.5", - "@oh-my-pi/omptype": "17.3.5", - "@oh-my-pi/pi-agent-core": "17.3.5", - "@oh-my-pi/pi-ai": "17.3.5", - "@oh-my-pi/pi-catalog": "17.3.5", - "@oh-my-pi/pi-coding-agent": "17.3.5", - "@oh-my-pi/pi-mnemopi": "17.3.5", - "@oh-my-pi/pi-natives": "17.3.5", - "@oh-my-pi/pi-tui": "17.3.5", - "@oh-my-pi/pi-utils": "17.3.5", - "@oh-my-pi/pi-wire": "17.3.5", - "@oh-my-pi/snapcompact": "17.3.5", + "@oh-my-pi/hashline": "17.3.7", + "@oh-my-pi/omp-stats": "17.3.7", + "@oh-my-pi/omptype": "17.3.7", + "@oh-my-pi/pi-agent-core": "17.3.7", + "@oh-my-pi/pi-ai": "17.3.7", + "@oh-my-pi/pi-catalog": "17.3.7", + "@oh-my-pi/pi-coding-agent": "17.3.7", + "@oh-my-pi/pi-mnemopi": "17.3.7", + "@oh-my-pi/pi-natives": "17.3.7", + "@oh-my-pi/pi-tui": "17.3.7", + "@oh-my-pi/pi-utils": "17.3.7", + "@oh-my-pi/pi-wire": "17.3.7", + "@oh-my-pi/snapcompact": "17.3.7", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", @@ -471,11 +471,11 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.14.0", "", {}, "sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w=="], - "@bufbuild/protoc-gen-es": ["@bufbuild/protoc-gen-es@2.13.0", "", { "dependencies": { "@bufbuild/protobuf": "2.13.0", "@bufbuild/protoplugin": "2.13.0" }, "bin": { "protoc-gen-es": "bin/protoc-gen-es" } }, "sha512-ylI1vrLksdnXrVZRs9xGxmrQxKGhUm6pPszv26kqBvNiO3qPTktk+hgfwbLISBY4M/reShkT2dFLGT9fbydBXg=="], + "@bufbuild/protoc-gen-es": ["@bufbuild/protoc-gen-es@2.14.0", "", { "dependencies": { "@bufbuild/protobuf": "2.14.0", "@bufbuild/protoplugin": "2.14.0" }, "bin": { "protoc-gen-es": "bin/protoc-gen-es" } }, "sha512-/Rrui0BJWSRONiKGYzqWq9ivGN3+cYkmc47QWITufr7K6yDb8HfssuJUSQZeptyg/rHbHrfDzCUjzoJ2pwuafg=="], - "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.13.0", "", { "dependencies": { "@bufbuild/protobuf": "2.13.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-32eMChKaL/A8Hh5AfMmXSdnuyznN85uoEjoyWiWeRrvtQOtpqX/v1R9PDe0g9vMIgzznK9inMT3CUaal0kjLUQ=="], + "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.14.0", "", { "dependencies": { "@bufbuild/protobuf": "2.14.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-S0TvZhVtTEkZQRj2JsXBCXiib/b0+bLQtJCcn+hj9O/usgbcgxHn4D+ii22Ao3qj0/kKxVYeo7BtAI2dMk+iig=="], "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], @@ -981,7 +981,7 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.13", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww=="], "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], @@ -1023,7 +1023,7 @@ "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -1061,7 +1061,7 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "electron-to-chromium": ["electron-to-chromium@1.5.405", "", {}, "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew=="], + "electron-to-chromium": ["electron-to-chromium@1.5.406", "", {}, "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg=="], "emnapi": ["emnapi@1.11.3", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-+/ZS90YK/rYfVOHtGLHkGffVsnmD/MAKaBHio+Y4XAtg75RLr4cveV/w0jTkUdLM1CcAlaRgG76mpIemWAlk0A=="], @@ -1149,7 +1149,7 @@ "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], - "json-with-bigint": ["json-with-bigint@3.5.10", "", {}, "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w=="], + "json-with-bigint": ["json-with-bigint@3.5.11", "", {}, "sha512-WvkM9Hfb9kqzCcbsvpwfWDvdfGZDhYuCoaCwHRe5q3LtULKkbrI/L6JYcN8owflgA3di5dP2yVAV2NVCXkgtkA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 182de7a34..04637bcd5 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -257,7 +257,7 @@ fn create_windows_napi_tokio_runtime() -> Option { /// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in /// `packages/natives/native/index.js` (which derives the name from /// `package.json#version`). -#[napi(js_name = "__piNativesV17_3_5")] +#[napi(js_name = "__piNativesV17_3_7")] pub const fn pi_natives_version_sentinel() {} /// Native module entry point: install crash diagnostics before any tool can diff --git a/docs/extensions.md b/docs/extensions.md index 28b56768c..9b67b972d 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -123,6 +123,7 @@ Core methods: - `setModel`, `getThinkingLevel`, `setThinkingLevel` - `getServiceTiers`, `setServiceTier` - `registerProvider` +- `registerFileWriteFallback`, `registerFileDeleteFallback` - `events` (shared event bus) `getServiceTiers()` returns a detached snapshot of the session's live per-family tier map. `setServiceTier(family, tier)` changes one family for subsequent requests; pass `undefined` to clear that session override. OpenAI accepts `auto`, `default`, `flex`, `scale`, or `priority`; Anthropic accepts `priority`; Google accepts `flex` or `priority`. Changes made while a response is streaming do not alter that in-flight request. @@ -370,6 +371,137 @@ pi.registerTool({ `tool_call`/`tool_result` intercept all tools once the registry is wrapped in `sdk.ts`, including built-ins and extension/custom tools. `ToolDefinition` also supports optional `hidden`, `defaultInactive`, `loadMode` (`"discoverable"` by default, or `"essential"`), `deferrable`, `approval` (`"exec"` by default), `strict`, `mcpServerName`, `mcpToolName`, `renderCall`, and `renderResult` fields. +### File write fallback (`registerFileWriteFallback`) + +`write`, `edit` and `apply_patch` perform the real byte-write to an ordinary file +path through one shared primitive +(`file ? file.write(content) : Bun.write(dst, content)`). When that primitive fails +with a permission error (`EPERM`/`EACCES`/`EROFS` — every other error, such as +`EISDIR`, is unaffected), the coding agent consults handlers registered +via `pi.registerFileWriteFallback` before giving up: + +```ts +import type { FileWriteFallbackHandler } from "@oh-my-pi/pi-coding-agent"; + +const writeThroughBroker: FileWriteFallbackHandler = async (req, ctx) => { + // req: { dst: string; content: string; cause: unknown } + const ok = await myPrivilegedWriter.write(req.dst, req.content); + return ok; +}; + +pi.registerFileWriteFallback(writeThroughBroker); +``` + +Handlers run in registration order; the first one to resolve `true` counts as the +bytes being durably on disk, and the native tool continues exactly as if its own +write had succeeded — including recording its file snapshot under the real +destination path, so a later hashline `edit` on that path keeps working. A +throwing handler is logged and skipped in favor of the next one — per handler, so a +later handler registered by the same extension still runs; if every handler +returns `false` (or none are registered), the original error is rethrown +unchanged. Intended for a host that embeds the agent inside a sandbox denying +direct filesystem writes but exposing a privileged write channel. + +`req.dst` is the **symlink-resolved** destination, not the path the tool was given. +The kernel follows every component above the last, so `ws/link/file` under a +`ws/link -> /elsewhere` link lands outside `ws` while still looking in-workspace, and +a prefix allowlist in your handler would pass on that innocent-looking path. For a +write the final component is followed too, so it is resolved as well; for a delete it +is not, because `unlink` removes a link rather than what it points at (so a delete +`req.dst` may itself name a link). Treat `req.dst` as authoritative and do not +re-derive the target from anything else. When the real destination cannot be +established — a dangling final link, or an ancestor this process may not resolve — no +handler is consulted at all and the original error is rethrown, because there is no +destination to hand a privileged writer. + +Two details matter when the destination is outside what the host allows: + +- **A missing parent directory.** `Bun.write` creates missing parents itself, and + when that `mkdir` is the operation being denied it reports the subsequent + `open()`'s `ENOENT` rather than the denial. The agent redoes the `mkdir` + explicitly to recover the real errno, so this still reaches a handler — with + `req.cause` set to the `mkdir` denial. In that case `req.dst`'s parent does not + exist yet and the handler is responsible for creating it. An `ENOENT` with a + genuinely creatable or invalid parent is not diverted. (`apply_patch` creates the + parent as a separate step before writing; that `mkdir` tolerates a denial when a + fallback is registered, so the write still reaches the handler.) +- **A hashline `MV`.** `edit`'s move writes its destination directly rather than + through the LSP writethrough. It is routed to the same handlers, and the source + unlink goes to the delete seam below, so a move out of a directory you cannot + write completes too. + +This is deliberately not an interception of every write the agent can make. A +permission error from these surfaces as it does today, with no handler consulted: + +- `write` to an archive member (`foo.zip:entry`) or to a SQLite row. Neither is a + byte-write to `dst`: an archive rewrite reads the whole archive, replaces one + entry, writes a temp file and renames over the original, so what lands is a whole + binary container rather than the string the tool was handed; a SQLite write is a + row operation inside the database engine with no byte payload at all. Brokering + either needs a different request shape than "these bytes belong at this path". +- The ACP bridge's `writeTextFile`, which hands the write to a remote client. +- The `lsp` tool's own writes: applying a workspace edit or code action, and the + Biome formatter, which writes the buffer and then shells out to `biome format + --write` — a subprocess write no in-process seam can reach. + +### File delete fallback (`registerFileDeleteFallback`) + +Removing a file is a different primitive from writing one, and it has its own seam: + +```ts +pi.registerFileDeleteFallback(async (req, ctx) => { + // req: { dst; cause; confirmedFile; sessionId } — no `content`. + return await myPrivilegedWriter.unlink(req.dst); +}); +``` + +It covers `edit`'s `REM`, the source side of a hashline `MV`, and `apply_patch`'s +delete op, and follows the same rules as the write seam: same permission codes, first +`true` wins, a throwing handler is skipped, the original error is rethrown if none +succeed, and nothing happens at all when no handler is registered. Two differences: + +- **`ENOENT` is never diverted.** Nothing is created on the way to an unlink, so a + missing file genuinely is missing — `REM` turns it into a not-found error. +- **A handler must unlink, never remove recursively.** `unlink` on a directory reports + `EPERM` on macOS, which is indistinguishable from a sandbox denial by error code + alone, so the seam `lstat`s the target and refuses to divert a directory. But when + the target's own metadata sits behind the same boundary that denied the unlink — + the common sandbox case — that check cannot be resolved, and `req.dst` may then be a + directory. `req.confirmedFile` is `true` only when the seam positively established + the target is a plain regular file; a symlink reports `false` too, since unlinking a + link is fine but resolving it acts on something else entirely. A privileged helper + that recursively removes `req.dst`, or realpaths it first, would act far outside + what a tool that only ever removes one file asked for. + +**Registering for deletes is deliberately separate from registering for writes.** A +write handler brokers `req.content` to `req.dst`; if a delete request reached it, the +missing content invites brokering an empty write and *truncating* the file that was +meant to be removed. A write-only handler therefore never sees a delete. + +Two lifecycle constraints, which apply to both seams: + +- **Register during extension load** (from the default factory), like other + `register*` calls. Handlers are installed when `ExtensionRunner.initialize` runs; + an extension that registered nothing by then is skipped entirely, so a first + registration made later never takes effect. The `ctx` a handler receives is built + per invocation, not captured at install time, so `ctx.cwd` and `ctx.hasUI` describe + the session as it is when the mutation is denied — a workspace change (`/move`) is + reflected in the next request rather than pinned to load time. +- **The registries are process-wide.** A process can host several sessions (a subagent + gets its own runner), so a handler may be consulted for a denied write or delete + from any session in the process — not only the one whose extension registered it. + This is deliberate: a subagent spawned with restricted tools loads no extensions of + its own, and a host that registers once in its top-level session still expects its + subagents' writes brokered. `req.sessionId` names the session that issued the + mutation (`undefined` when it did not come from a tool call), and + `ctx.sessionManager.getSessionId()` names the handler's own — compare them to make + the decision per session. It matters most before prompting: `ctx.ui` belongs to the + handler's session, not necessarily to the one being asked about. Handlers are + removed on `session_shutdown`. + +With nothing registered none of this engages: the primitive runs exactly as it did +before and performs no extra syscalls. + ## UI integration points `ctx.ui` implements the `ExtensionUIContext` interface. Support differs by mode. diff --git a/docs/provider-quirks.md b/docs/provider-quirks.md index 674342249..08a1a4c60 100644 --- a/docs/provider-quirks.md +++ b/docs/provider-quirks.md @@ -1566,7 +1566,7 @@ xAI Grok OAuth provides subscription-backed access (SuperGrok / X Premium+) to x - **Usage Tracking**: `xaiOauthUsageProvider` (`packages/ai/src/usage/xai-oauth.ts`) queries `https://cli-chat-proxy.grok.com/v1/billing` (`validateXAIBillingEndpoint` pins to HTTPS `*.grok.com`) with header `X-XAI-Token-Auth: xai-grok-cli` (`getXAICliBillingHeaders`). Only accepts valid OAuth bearer credentials. Probes legacy weekly credits (`?format=credits`, `parseWeeklyBillingConfig` for `creditUsagePercent` and `productUsage`) and unified monthly quota (`parseMonthlyBillingConfig` for `monthlyLimit` and `used`), plus positive `onDemandCap` / `onDemandUsed` limits. ### Catalog model handling -- **Curated Models & Static Seed**: `XAI_OAUTH_CURATED_MODELS` (`packages/catalog/src/provider-models/openai-compat.ts`) defines static models (`grok-build`, `grok-build-0.1`, `grok-4.3`, `grok-4.5`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast`) with zero cost (`cost: 0`). Default model is `grok-4.3` (`descriptors.ts`). `buildXaiOAuthStaticSeed` seeds `ModelRegistry` synchronously at boot so `modelRoles.default = "xai-oauth/"` works before dynamic refresh. +- **Curated Models & Static Seed**: `XAI_OAUTH_CURATED_MODELS` (`packages/catalog/src/provider-models/openai-compat.ts`) defines static models (`grok-build`, `grok-build-0.1`, `grok-4.3`, `grok-4.5`, `grok-4.6`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast`) with zero cost (`cost: 0`). Default model is `grok-4.6` (`descriptors.ts`). `buildXaiOAuthStaticSeed` seeds `ModelRegistry` synchronously at boot so `modelRoles.default = "xai-oauth/"` works before dynamic refresh. - **Dynamic Curation Overlay**: `applyXAIOAuthCuration` (`openai-compat.ts`, `xaiOAuthModelManagerOptions`) filters non-chat prefixes (`grok-imagine-`, `grok-stt-`, `grok-voice-`), overlays curated context windows (up to 2M), sets `maxTokens` equal to `contextWindow`, preserves image capabilities and reasoning flags, and injects missing curated models. - **Reference Resolution Exclusion**: `isZeroCostXaiOAuthCandidate` (`packages/catalog/src/identity/reference.ts`) excludes zero-cost subscription entries from reference index matching so subscription pricing and limits do not override public/paid Grok references. diff --git a/nix/bun.nix b/nix/bun.nix index de70e28b3..edfeb293c 100644 --- a/nix/bun.nix +++ b/nix/bun.nix @@ -157,17 +157,17 @@ url = "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.8.tgz"; hash = "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA=="; }; - "@bufbuild/protobuf@2.13.0" = fetchurl { - url = "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.13.0.tgz"; - hash = "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="; + "@bufbuild/protobuf@2.14.0" = fetchurl { + url = "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.14.0.tgz"; + hash = "sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w=="; }; - "@bufbuild/protoc-gen-es@2.13.0" = fetchurl { - url = "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.13.0.tgz"; - hash = "sha512-ylI1vrLksdnXrVZRs9xGxmrQxKGhUm6pPszv26kqBvNiO3qPTktk+hgfwbLISBY4M/reShkT2dFLGT9fbydBXg=="; + "@bufbuild/protoc-gen-es@2.14.0" = fetchurl { + url = "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.14.0.tgz"; + hash = "sha512-/Rrui0BJWSRONiKGYzqWq9ivGN3+cYkmc47QWITufr7K6yDb8HfssuJUSQZeptyg/rHbHrfDzCUjzoJ2pwuafg=="; }; - "@bufbuild/protoplugin@2.13.0" = fetchurl { - url = "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.13.0.tgz"; - hash = "sha512-32eMChKaL/A8Hh5AfMmXSdnuyznN85uoEjoyWiWeRrvtQOtpqX/v1R9PDe0g9vMIgzznK9inMT3CUaal0kjLUQ=="; + "@bufbuild/protoplugin@2.14.0" = fetchurl { + url = "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.14.0.tgz"; + hash = "sha512-S0TvZhVtTEkZQRj2JsXBCXiib/b0+bLQtJCcn+hj9O/usgbcgxHn4D+ii22Ao3qj0/kKxVYeo7BtAI2dMk+iig=="; }; "@emnapi/core@1.11.2" = fetchurl { url = "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz"; @@ -1186,9 +1186,9 @@ url = "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz"; hash = "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="; }; - "baseline-browser-mapping@2.11.13" = fetchurl { - url = "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz"; - hash = "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ=="; + "baseline-browser-mapping@2.11.14" = fetchurl { + url = "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz"; + hash = "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww=="; }; "before-after-hook@4.0.0" = fetchurl { url = "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz"; @@ -1278,9 +1278,9 @@ url = "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz"; hash = "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="; }; - "content-type@2.0.0" = fetchurl { - url = "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz"; - hash = "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="; + "content-type@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz"; + hash = "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="; }; "convert-source-map@2.0.0" = fetchurl { url = "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz"; @@ -1354,9 +1354,9 @@ url = "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz"; hash = "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="; }; - "electron-to-chromium@1.5.405" = fetchurl { - url = "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz"; - hash = "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew=="; + "electron-to-chromium@1.5.406" = fetchurl { + url = "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.406.tgz"; + hash = "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg=="; }; "emnapi@1.11.3" = fetchurl { url = "https://registry.npmjs.org/emnapi/-/emnapi-1.11.3.tgz"; @@ -1538,9 +1538,9 @@ url = "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz"; hash = "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="; }; - "json-with-bigint@3.5.10" = fetchurl { - url = "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.10.tgz"; - hash = "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w=="; + "json-with-bigint@3.5.11" = fetchurl { + url = "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.11.tgz"; + hash = "sha512-WvkM9Hfb9kqzCcbsvpwfWDvdfGZDhYuCoaCwHRe5q3LtULKkbrI/L6JYcN8owflgA3di5dP2yVAV2NVCXkgtkA=="; }; "json5@2.2.3" = fetchurl { url = "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"; diff --git a/package.json b/package.json index 6d4b9a45b..48070965b 100644 --- a/package.json +++ b/package.json @@ -23,19 +23,19 @@ "@bufbuild/protoc-gen-es": "^2.12.1", "@huggingface/transformers": "^4.2.0", "@napi-rs/cli": "3.7.2", - "@oh-my-pi/hashline": "17.3.5", - "@oh-my-pi/omp-stats": "17.3.5", - "@oh-my-pi/omptype": "17.3.5", - "@oh-my-pi/pi-agent-core": "17.3.5", - "@oh-my-pi/pi-ai": "17.3.5", - "@oh-my-pi/pi-catalog": "17.3.5", - "@oh-my-pi/pi-coding-agent": "17.3.5", - "@oh-my-pi/pi-mnemopi": "17.3.5", - "@oh-my-pi/pi-natives": "17.3.5", - "@oh-my-pi/pi-tui": "17.3.5", - "@oh-my-pi/pi-utils": "17.3.5", - "@oh-my-pi/pi-wire": "17.3.5", - "@oh-my-pi/snapcompact": "17.3.5", + "@oh-my-pi/hashline": "17.3.7", + "@oh-my-pi/omp-stats": "17.3.7", + "@oh-my-pi/omptype": "17.3.7", + "@oh-my-pi/pi-agent-core": "17.3.7", + "@oh-my-pi/pi-ai": "17.3.7", + "@oh-my-pi/pi-catalog": "17.3.7", + "@oh-my-pi/pi-coding-agent": "17.3.7", + "@oh-my-pi/pi-mnemopi": "17.3.7", + "@oh-my-pi/pi-natives": "17.3.7", + "@oh-my-pi/pi-tui": "17.3.7", + "@oh-my-pi/pi-utils": "17.3.7", + "@oh-my-pi/pi-wire": "17.3.7", + "@oh-my-pi/snapcompact": "17.3.7", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", diff --git a/packages/agent/package.json b/packages/agent/package.json index 875878cfc..dcd9d3b65 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-agent-core", - "version": "17.3.5", + "version": "17.3.7", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 09ddc8abd..0fdde853c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,11 @@ ### Fixed - Fixed OpenAI Completions, Amazon Bedrock, and Cursor providers ignoring `onPayload` replacement payloads. The hook now transforms the actual request body sent upstream on these providers, matching the Anthropic/Gemini/OpenAI Responses replacement contract. `devin-agent` still does not fire the hook (its payload is a protobuf object). +## [17.3.7] - 2026-08-17 + +### Changed + +- Send the `omp/` User-Agent on xAI chat (`xai` and `xai-oauth`) unless the request already set its own. ## [17.3.5] - 2026-08-16 diff --git a/packages/ai/package.json b/packages/ai/package.json index 0c487719b..8b367bcf6 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "17.3.5", + "version": "17.3.7", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 82441706b..e9f4f4417 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -2698,7 +2698,7 @@ export class AuthStorage { /** * Whether a request could resolve a key for this provider, including * cross-provider env aliases (`xai-oauth` borrowing `XAI_API_KEY`). - * Use this for explicit model preflight (`xai-oauth/grok-4.5`); use + * Use this for explicit model preflight (`xai-oauth/grok-4.6`); use * {@link hasAuth} for auto-availability so the default picker stays on * paid `xai` when only `XAI_API_KEY` is set. */ @@ -2732,8 +2732,8 @@ export class AuthStorage { * `getEnvApiKey("xai-oauth")` also accepts `XAI_API_KEY` so an explicit * `xai-oauth/…` stream can still borrow the paid key. Availability and * origin must not: otherwise an API-key-only setup marks SuperGrok as - * signed in and `pickDefaultAvailableModel` prefers `xai-oauth/grok-4.5` - * over paid `xai/grok-4.5`. + * signed in and `pickDefaultAvailableModel` prefers `xai-oauth/grok-4.6` + * over paid `xai/grok-4.6`. */ #hasDedicatedEnvAuth(provider: string): boolean { if (provider === "xai-oauth") { diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index f7b16f00e..34a4bbae0 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -31,6 +31,7 @@ import { parseStreamingJsonThrottled, stringifyJson, structuredCloneJSON, + USER_AGENT, } from "@oh-my-pi/pi-utils"; import * as AIError from "../error"; import { @@ -315,6 +316,10 @@ export function resolveOpenAIRequestSetup( if (options.defaultBaseUrl !== undefined) { baseUrl = baseUrl ?? ($env.OPENAI_BASE_URL?.trim() || options.defaultBaseUrl); } + // Attribute xAI traffic as omp unless a User-Agent is already set. + if (model.provider === "xai" || model.provider === "xai-oauth") { + setHeaderIfAbsent(headers, "User-Agent", USER_AGENT); + } const requestHeaders = { ...headers }; // A keyless provider (`auth: none` in models.yml) resolves to the `N/A` // sentinel rather than a real key. Injecting `Authorization: Bearer N/A` diff --git a/packages/ai/test/issue-4593-repro.test.ts b/packages/ai/test/issue-4593-repro.test.ts index e79f4f1e1..47e9494ec 100644 --- a/packages/ai/test/issue-4593-repro.test.ts +++ b/packages/ai/test/issue-4593-repro.test.ts @@ -16,7 +16,9 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build"; // timer IS the unit under test), but never guess durations: the simulated local // work completes only once the watchdog has demonstrably reached an expired // deadline and consulted the local-work probe, so the tests stay causal on a -// loaded machine. Budgets are a few milliseconds. +// loaded machine. Budgets are tens of milliseconds — wide enough that a noisy +// virtualized CI runner cannot make a single scheduling hiccup span a full +// idle budget. function createModel(): Model<"bedrock-converse-stream"> { return buildModel({ @@ -71,7 +73,7 @@ describe("idle watchdog local-work deferral (issue #4593)", () => { let idleFired = false; const items: string[] = []; for await (const item of iterateWithIdleTimeout(source(), { - idleTimeoutMs: 5, + idleTimeoutMs: 50, errorMessage: "stalled", onIdle: () => { idleFired = true; @@ -104,7 +106,7 @@ describe("idle watchdog local-work deferral (issue #4593)", () => { let error: Error | undefined; try { for await (const item of iterateWithIdleTimeout(source(), { - idleTimeoutMs: 5, + idleTimeoutMs: 50, errorMessage: "stalled", hasPendingLocalWork: () => { workDone.resolve(); @@ -131,8 +133,8 @@ describe("idle watchdog local-work deferral (issue #4593)", () => { } const items: string[] = []; for await (const item of iterateWithIdleTimeout(source(), { - idleTimeoutMs: 5, - firstItemTimeoutMs: 5, + idleTimeoutMs: 50, + firstItemTimeoutMs: 50, errorMessage: "stalled", firstItemErrorMessage: "first event timed out", hasPendingLocalWork: () => { @@ -179,7 +181,7 @@ describe("idle watchdog local-work deferral (issue #4593)", () => { }, }); - const stream = streamBedrock(createModel(), baseContext, { streamIdleTimeoutMs: 5 }); + const stream = streamBedrock(createModel(), baseContext, { streamIdleTimeoutMs: 50 }); const result = await stream.result(); expect(providerSignal?.aborted).toBe(false); diff --git a/packages/ai/test/openai-compat-user-agent.test.ts b/packages/ai/test/openai-compat-user-agent.test.ts new file mode 100644 index 000000000..71d0690f4 --- /dev/null +++ b/packages/ai/test/openai-compat-user-agent.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "bun:test"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, FetchImpl, Model, ModelSpec } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { USER_AGENT } from "@oh-my-pi/pi-utils"; +import { resolveOpenAIRequestSetup } from "../src/providers/openai-shared"; + +const context: Context = { + messages: [{ role: "user", content: "ping", timestamp: 0 }], +}; + +function createResponsesSse(): Response { + return new Response( + `data: ${JSON.stringify({ + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "msg_1", role: "assistant", content: [] }, + })}\n\n` + + `data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}\n\n` + + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "ok" })}\n\n` + + `data: ${JSON.stringify({ + type: "response.output_item.done", + output_index: 0, + item: { type: "message", id: "msg_1", role: "assistant", content: [{ type: "output_text", text: "ok" }] }, + })}\n\n` + + `data: ${JSON.stringify({ + type: "response.completed", + response: { + status: "completed", + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + })}\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); +} + +function createChatSse(): Response { + return new Response( + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: "ok" }, finish_reason: null }] })}\n\n` + + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n` + + `data: [DONE]\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); +} + +function xaiResponsesModel(provider: "xai" | "xai-oauth" = "xai"): Model<"openai-responses"> { + return buildModel({ + id: "grok-4.6", + name: "Grok 4.6", + api: "openai-responses", + provider, + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 500_000, + maxTokens: 8192, + } as ModelSpec<"openai-responses">); +} + +function openaiCompletionsModel(): Model<"openai-completions"> { + return buildModel({ + id: "gpt-4o-mini", + name: "GPT-4o Mini", + api: "openai-completions", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4096, + } as ModelSpec<"openai-completions">); +} + +async function captureStreamHeaders( + run: (fetch: FetchImpl) => AsyncIterable<{ type: string }>, + sse: Response, +): Promise<{ url: string; userAgent: string | null }> { + let url = ""; + let userAgent: string | null = null; + const fetchMock: FetchImpl = async (input, init) => { + url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + userAgent = new Headers(init?.headers).get("user-agent"); + return sse; + }; + for await (const event of run(fetchMock)) { + if (event.type === "done" || event.type === "error") break; + } + return { url, userAgent }; +} + +describe("resolveOpenAIRequestSetup User-Agent", () => { + test("sets omp User-Agent on xAI when none is provided", () => { + for (const provider of ["xai", "xai-oauth"] as const) { + const setup = resolveOpenAIRequestSetup( + { provider, id: "grok-4.6", baseUrl: "https://api.x.ai/v1" }, + { apiKey: "sk-test", messages: [] }, + ); + expect(setup.headers["User-Agent"]).toBe(USER_AGENT); + expect(setup.requestHeaders["User-Agent"]).toBe(USER_AGENT); + } + }); + + test("does not set User-Agent on other OpenAI-wire providers", () => { + for (const provider of ["openai", "deepseek"] as const) { + const setup = resolveOpenAIRequestSetup( + { provider, id: "m", baseUrl: "https://api.example/v1" }, + { apiKey: "sk-test", messages: [] }, + ); + expect(setup.headers["User-Agent"]).toBeUndefined(); + expect(setup.requestHeaders["User-Agent"]).toBeUndefined(); + } + }); + + test("does not override a caller-supplied xAI User-Agent", () => { + const setup = resolveOpenAIRequestSetup( + { + provider: "xai", + id: "grok-4.6", + baseUrl: "https://api.x.ai/v1", + headers: { "User-Agent": "custom-xai-client/1.0" }, + }, + { apiKey: "sk-test", messages: [] }, + ); + expect(setup.headers["User-Agent"]).toBe("custom-xai-client/1.0"); + }); + + test("does not override a lowercase xAI user-agent header", () => { + const setup = resolveOpenAIRequestSetup( + { + provider: "xai", + id: "grok-4.6", + baseUrl: "https://api.x.ai/v1", + headers: { "user-agent": "custom-xai-client/1.0" }, + }, + { apiKey: "sk-test", messages: [] }, + ); + expect(setup.headers["user-agent"]).toBe("custom-xai-client/1.0"); + expect(setup.headers["User-Agent"]).toBeUndefined(); + }); +}); + +describe("xAI stream User-Agent", () => { + test("xAI Responses POST sends omp User-Agent", async () => { + const captured = await captureStreamHeaders( + fetch => streamOpenAIResponses(xaiResponsesModel(), context, { apiKey: "sk-test", fetch }), + createResponsesSse(), + ); + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.userAgent).toBe(USER_AGENT); + expect(captured.userAgent).toMatch(/^omp\/\d+\.\d+\.\d+$/); + }); + + test("xAI OAuth Responses POST sends omp User-Agent", async () => { + const captured = await captureStreamHeaders( + fetch => streamOpenAIResponses(xaiResponsesModel("xai-oauth"), context, { apiKey: "sk-test", fetch }), + createResponsesSse(), + ); + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.userAgent).toBe(USER_AGENT); + }); + + test("OpenAI Completions POST does not send omp User-Agent", async () => { + const captured = await captureStreamHeaders( + fetch => streamOpenAICompletions(openaiCompletionsModel(), context, { apiKey: "sk-test", fetch }), + createChatSse(), + ); + expect(captured.url).toBe("https://api.openai.com/v1/chat/completions"); + expect(captured.userAgent).toBeNull(); + }); +}); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 9d2da00bc..288b1940c 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [17.3.6] - 2026-08-17 + +### Changed + +- Changed the paid xAI (XAI_API_KEY) and SuperGrok (xai-oauth) default models to grok-4.6. + +### Fixed + +- Raised the GPT-5.6 Sol/Terra/Luna context window on the Codex transport (openai-codex) from 372K to 1M tokens: OpenAI enabled the 1M window for subscription Codex on 2026-08-16, but the Codex model registry still reports the stale 272,000, so discovery now floors these SKUs at 1,000,000 instead of trusting the reported value ([openai/codex#38917](https://github.com/openai/codex/issues/38917)). + ## [17.3.5] - 2026-08-16 ### Added diff --git a/packages/catalog/package.json b/packages/catalog/package.json index 247bef560..d6da8a8d3 100644 --- a/packages/catalog/package.json +++ b/packages/catalog/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-catalog", - "version": "17.3.5", + "version": "17.3.7", "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/catalog/scripts/generated-policies.ts b/packages/catalog/scripts/generated-policies.ts index caaeee24e..2ca015337 100644 --- a/packages/catalog/scripts/generated-policies.ts +++ b/packages/catalog/scripts/generated-policies.ts @@ -145,7 +145,7 @@ const CODEX_GPT_5_4_PRIORITY_BY_VARIANT: Partial> nano: 2, }; -const CODEX_GPT_5_6_372K_MODEL_IDS: Record = { +const CODEX_GPT_5_6_1M_MODEL_IDS: Record = { "gpt-5.6-luna": true, "gpt-5.6-sol": true, "gpt-5.6-terra": true, @@ -536,12 +536,12 @@ function applyOpenAICatalogPolicy(model: ModelSpec, parsedModel: OpenAIMode model.contextWindow = 272000; } } - // GPT-5.6 luna/sol/terra on the Codex transport: OpenAI's Codex model - // registry declares context_window = max_context_window = 372000, but Codex - // discovery omits `context_window` for these SKUs and falls back to - // DEFAULT_CONTEXT_WINDOW (272000, src/discovery/codex.ts), which regressed - // the bundled hard capacity (#5705). Pin the true 372K input window. - if (model.api === "openai-codex-responses" && CODEX_GPT_5_6_372K_MODEL_IDS[model.id]) { - model.contextWindow = 372000; + // GPT-5.6 luna/sol/terra on the Codex transport: OpenAI enabled a 1M-token + // window for subscription Codex (2026-08-16), but the Codex model registry + // still reports the stale 272000 (openai/codex#38917), so floor the bundled + // window at 1,000,000. Daybreak aliases are excluded — the registry actively + // reports their true window. + if (model.api === "openai-codex-responses" && CODEX_GPT_5_6_1M_MODEL_IDS[model.id]) { + model.contextWindow = Math.max(model.contextWindow ?? 0, 1_000_000); } } diff --git a/packages/catalog/src/discovery/codex.ts b/packages/catalog/src/discovery/codex.ts index 6cbc32293..9c950ee14 100644 --- a/packages/catalog/src/discovery/codex.ts +++ b/packages/catalog/src/discovery/codex.ts @@ -9,13 +9,19 @@ const DEFAULT_MODEL_LIST_PATHS = ["/codex/models", "/models"] as const; const DEFAULT_CONTEXT_WINDOW = 272_000; const DEFAULT_MAX_TOKENS = 128_000; /** - * GPT-5.6 luna/sol/terra hard context capacity. Codex discovery omits - * `context_window` for these SKUs, so the generic {@link DEFAULT_CONTEXT_WINDOW} - * (272000) would understate the real window — OpenAI's Codex model registry - * declares context_window = max_context_window = 372000 (#5705). Used as the - * fallback only when upstream reports no value. + * Fallback for GPT-5.6-family SKUs when upstream omits `context_window`: the + * generic {@link DEFAULT_CONTEXT_WINDOW} (272000) understates the registry's + * former 372000 hard capacity (#5705). */ const GPT_5_6_CONTEXT_WINDOW = 372_000; +/** + * OpenAI enabled a 1M-token window for subscription Codex on GPT-5.6 + * luna/sol/terra (2026-08-16), but the Codex model registry still reports the + * stale 272000 — so the reported value must be floored, not just defaulted + * (openai/codex#38917; Codex CLI override `model_context_window = 1000000`). + */ +const GPT_5_6_1M_CONTEXT_WINDOW = 1_000_000; +const CODEX_GPT_5_6_1M_SLUGS: ReadonlySet = new Set(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]); const CODEX_REMOTE_COMPACTION = { enabled: true, api: "openai-codex-responses", @@ -224,14 +230,18 @@ function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCo } const name = toNonEmptyString(payload.display_name) ?? slug; - // Codex discovery omits `context_window` for GPT-5.6 luna/sol/terra; the - // generic 272000 fallback understates their real 372000 window (#5705). + // Codex discovery historically omitted `context_window` for GPT-5.6-family + // SKUs (#5705); luna/sol/terra additionally floor the reported value because + // the registry still declares the pre-1M 272000 window. const parsed = parseKnownModel(slug); const fallbackContextWindow = parsed.family === "openai" && semverEqual(parsed.version, "5.6") ? GPT_5_6_CONTEXT_WINDOW : DEFAULT_CONTEXT_WINDOW; - const contextWindow = toPositiveInt(payload.context_window) ?? fallbackContextWindow; + const reportedContextWindow = toPositiveInt(payload.context_window) ?? fallbackContextWindow; + const contextWindow = CODEX_GPT_5_6_1M_SLUGS.has(slug) + ? Math.max(reportedContextWindow, GPT_5_6_1M_CONTEXT_WINDOW) + : reportedContextWindow; const maxTokens = Math.min(DEFAULT_MAX_TOKENS, contextWindow); const reasoning = supportsReasoning(payload.default_reasoning_level, payload.supported_reasoning_levels); const input = normalizeInputModalities(payload.input_modalities); diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index fb3fdbce0..e7215b13a 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -9533,6 +9533,96 @@ "supportsDisplay": true } }, + "global.openai.gpt-5.6-luna": { + "id": "global.openai.gpt-5.6-luna", + "name": "GPT-5.6 Luna (Global)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.22, + "output": 1.32, + "cacheRead": 0.022, + "cacheWrite": 0.275 + }, + "contextWindow": 1050000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + }, + "global.openai.gpt-5.6-sol": { + "id": "global.openai.gpt-5.6-sol", + "name": "GPT-5.6 Sol (Global)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5.5, + "output": 33, + "cacheRead": 0.55, + "cacheWrite": 6.875 + }, + "contextWindow": 1050000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + }, + "global.openai.gpt-5.6-terra": { + "id": "global.openai.gpt-5.6-terra", + "name": "GPT-5.6 Terra (Global)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 2.2, + "output": 13.2, + "cacheRead": 0.22, + "cacheWrite": 2.75 + }, + "contextWindow": 1050000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + }, "google.gemma-3-27b-it": { "id": "google.gemma-3-27b-it", "name": "Google Gemma 3 27B Instruct", @@ -17022,7 +17112,7 @@ "cursor": { "claude-4-sonnet": { "id": "claude-4-sonnet", - "name": "Sonnet 4", + "name": "Claude Sonnet 4", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17061,7 +17151,7 @@ }, "claude-4.5-opus-high": { "id": "claude-4.5-opus-high", - "name": "Opus 4.5", + "name": "Claude Opus 4.5", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17100,7 +17190,7 @@ }, "claude-4.5-sonnet": { "id": "claude-4.5-sonnet", - "name": "Sonnet 4.5", + "name": "Claude Sonnet 4.5", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17139,7 +17229,7 @@ }, "claude-4.6-opus-high": { "id": "claude-4.6-opus-high", - "name": "Opus 4.6 1M", + "name": "Claude Opus 4.6 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17177,7 +17267,7 @@ }, "claude-4.6-opus-max": { "id": "claude-4.6-opus-max", - "name": "Opus 4.6 1M Max", + "name": "Claude Opus 4.6 1M Max", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17216,7 +17306,7 @@ }, "claude-4.6-sonnet-medium": { "id": "claude-4.6-sonnet-medium", - "name": "Sonnet 4.6 1M", + "name": "Claude Sonnet 4.6 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17254,7 +17344,7 @@ }, "claude-fable-5-high": { "id": "claude-fable-5-high", - "name": "Fable 5 1M (NO ZDR)", + "name": "Claude Fable 5 1M (NO ZDR)", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17293,7 +17383,7 @@ }, "claude-fable-5-low": { "id": "claude-fable-5-low", - "name": "Fable 5 1M Low (NO ZDR)", + "name": "Claude Fable 5 1M Low (NO ZDR)", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17332,7 +17422,7 @@ }, "claude-fable-5-max": { "id": "claude-fable-5-max", - "name": "Fable 5 1M Max (NO ZDR)", + "name": "Claude Fable 5 1M Max (NO ZDR)", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17371,7 +17461,7 @@ }, "claude-fable-5-medium": { "id": "claude-fable-5-medium", - "name": "Fable 5 1M Medium (NO ZDR)", + "name": "Claude Fable 5 1M Medium (NO ZDR)", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17410,7 +17500,7 @@ }, "claude-fable-5-xhigh": { "id": "claude-fable-5-xhigh", - "name": "Fable 5 1M Extra High (NO ZDR)", + "name": "Claude Fable 5 1M Extra High (NO ZDR)", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17449,7 +17539,7 @@ }, "claude-opus-4-7-high": { "id": "claude-opus-4-7-high", - "name": "Opus 4.7 1M High", + "name": "Claude Opus 4.7 1M High", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17488,7 +17578,7 @@ }, "claude-opus-4-7-high-fast": { "id": "claude-opus-4-7-high-fast", - "name": "Opus 4.7 1M High Fast", + "name": "Claude Opus 4.7 1M High Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17527,7 +17617,7 @@ }, "claude-opus-4-7-low": { "id": "claude-opus-4-7-low", - "name": "Opus 4.7 1M Low", + "name": "Claude Opus 4.7 1M Low", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17566,7 +17656,7 @@ }, "claude-opus-4-7-low-fast": { "id": "claude-opus-4-7-low-fast", - "name": "Opus 4.7 1M Low Fast", + "name": "Claude Opus 4.7 1M Low Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17605,7 +17695,7 @@ }, "claude-opus-4-7-max": { "id": "claude-opus-4-7-max", - "name": "Opus 4.7 1M Max", + "name": "Claude Opus 4.7 1M Max", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17644,7 +17734,7 @@ }, "claude-opus-4-7-max-fast": { "id": "claude-opus-4-7-max-fast", - "name": "Opus 4.7 1M Max Fast", + "name": "Claude Opus 4.7 1M Max Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17683,7 +17773,7 @@ }, "claude-opus-4-7-medium": { "id": "claude-opus-4-7-medium", - "name": "Opus 4.7 1M Medium", + "name": "Claude Opus 4.7 1M Medium", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17722,7 +17812,7 @@ }, "claude-opus-4-7-medium-fast": { "id": "claude-opus-4-7-medium-fast", - "name": "Opus 4.7 1M Medium Fast", + "name": "Claude Opus 4.7 1M Medium Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17761,7 +17851,7 @@ }, "claude-opus-4-7-xhigh": { "id": "claude-opus-4-7-xhigh", - "name": "Opus 4.7 1M", + "name": "Claude Opus 4.7 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17800,7 +17890,7 @@ }, "claude-opus-4-7-xhigh-fast": { "id": "claude-opus-4-7-xhigh-fast", - "name": "Opus 4.7 1M Fast", + "name": "Claude Opus 4.7 1M Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17839,7 +17929,7 @@ }, "claude-opus-4-8-high": { "id": "claude-opus-4-8-high", - "name": "Opus 4.8 1M", + "name": "Claude Opus 4.8 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17878,7 +17968,7 @@ }, "claude-opus-4-8-high-fast": { "id": "claude-opus-4-8-high-fast", - "name": "Opus 4.8 1M Fast", + "name": "Claude Opus 4.8 1M Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17917,7 +18007,7 @@ }, "claude-opus-4-8-low": { "id": "claude-opus-4-8-low", - "name": "Opus 4.8 1M Low", + "name": "Claude Opus 4.8 1M Low", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17956,7 +18046,7 @@ }, "claude-opus-4-8-low-fast": { "id": "claude-opus-4-8-low-fast", - "name": "Opus 4.8 1M Low Fast", + "name": "Claude Opus 4.8 1M Low Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17995,7 +18085,7 @@ }, "claude-opus-4-8-max": { "id": "claude-opus-4-8-max", - "name": "Opus 4.8 1M Max", + "name": "Claude Opus 4.8 1M Max", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18034,7 +18124,7 @@ }, "claude-opus-4-8-max-fast": { "id": "claude-opus-4-8-max-fast", - "name": "Opus 4.8 1M Max Fast", + "name": "Claude Opus 4.8 1M Max Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18073,7 +18163,7 @@ }, "claude-opus-4-8-medium": { "id": "claude-opus-4-8-medium", - "name": "Opus 4.8 1M Medium", + "name": "Claude Opus 4.8 1M Medium", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18112,7 +18202,7 @@ }, "claude-opus-4-8-medium-fast": { "id": "claude-opus-4-8-medium-fast", - "name": "Opus 4.8 1M Medium Fast", + "name": "Claude Opus 4.8 1M Medium Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18151,7 +18241,7 @@ }, "claude-opus-4-8-xhigh": { "id": "claude-opus-4-8-xhigh", - "name": "Opus 4.8 1M Extra High", + "name": "Claude Opus 4.8 1M Extra High", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18190,7 +18280,7 @@ }, "claude-opus-4-8-xhigh-fast": { "id": "claude-opus-4-8-xhigh-fast", - "name": "Opus 4.8 1M Extra High Fast", + "name": "Claude Opus 4.8 1M Extra High Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18229,7 +18319,7 @@ }, "claude-opus-5-high": { "id": "claude-opus-5-high", - "name": "Opus 5 1M", + "name": "Claude Opus 5 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18268,7 +18358,7 @@ }, "claude-opus-5-high-fast": { "id": "claude-opus-5-high-fast", - "name": "Opus 5 1M Fast", + "name": "Claude Opus 5 1M Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18307,7 +18397,7 @@ }, "claude-opus-5-low": { "id": "claude-opus-5-low", - "name": "Opus 5 1M Low", + "name": "Claude Opus 5 1M Low", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18346,7 +18436,7 @@ }, "claude-opus-5-low-fast": { "id": "claude-opus-5-low-fast", - "name": "Opus 5 1M Low Fast", + "name": "Claude Opus 5 1M Low Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18385,7 +18475,7 @@ }, "claude-opus-5-medium": { "id": "claude-opus-5-medium", - "name": "Opus 5 1M Medium", + "name": "Claude Opus 5 1M Medium", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18424,7 +18514,7 @@ }, "claude-opus-5-medium-fast": { "id": "claude-opus-5-medium-fast", - "name": "Opus 5 1M Medium Fast", + "name": "Claude Opus 5 1M Medium Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18463,7 +18553,7 @@ }, "claude-opus-5-thinking-max": { "id": "claude-opus-5-thinking-max", - "name": "Opus 5 1M Max Thinking", + "name": "Claude Opus 5 1M Max Thinking", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18486,7 +18576,7 @@ }, "claude-opus-5-thinking-max-fast": { "id": "claude-opus-5-thinking-max-fast", - "name": "Opus 5 1M Max Thinking Fast", + "name": "Claude Opus 5 1M Max Thinking Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18509,7 +18599,7 @@ }, "claude-opus-5-thinking-xhigh": { "id": "claude-opus-5-thinking-xhigh", - "name": "Opus 5 1M Extra High Thinking", + "name": "Claude Opus 5 1M Extra High Thinking", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18532,7 +18622,7 @@ }, "claude-opus-5-thinking-xhigh-fast": { "id": "claude-opus-5-thinking-xhigh-fast", - "name": "Opus 5 1M Extra High Thinking Fast", + "name": "Claude Opus 5 1M Extra High Thinking Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18555,7 +18645,7 @@ }, "claude-sonnet-5-high": { "id": "claude-sonnet-5-high", - "name": "Sonnet 5 1M", + "name": "Claude Sonnet 5 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18594,7 +18684,7 @@ }, "claude-sonnet-5-low": { "id": "claude-sonnet-5-low", - "name": "Sonnet 5 1M Low", + "name": "Claude Sonnet 5 1M Low", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18633,7 +18723,7 @@ }, "claude-sonnet-5-max": { "id": "claude-sonnet-5-max", - "name": "Sonnet 5 1M Max", + "name": "Claude Sonnet 5 1M Max", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18672,7 +18762,7 @@ }, "claude-sonnet-5-medium": { "id": "claude-sonnet-5-medium", - "name": "Sonnet 5 1M Medium", + "name": "Claude Sonnet 5 1M Medium", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18711,7 +18801,7 @@ }, "claude-sonnet-5-xhigh": { "id": "claude-sonnet-5-xhigh", - "name": "Sonnet 5 1M Extra High", + "name": "Claude Sonnet 5 1M Extra High", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -29507,6 +29597,34 @@ ] } }, + "Qwen/Qwen3.8-2.4T-A95B": { + "id": "Qwen/Qwen3.8-2.4T-A95B", + "name": "Qwen3.8 2.4T A95B", + "api": "openai-completions", + "provider": "huggingface", + "baseUrl": "https://router.huggingface.co/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 2.5, + "output": 6.25, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "stepfun-ai/Step-3.5-Flash": { "id": "stepfun-ai/Step-3.5-Flash", "name": "Step 3.5 Flash", @@ -30131,9 +30249,9 @@ "text" ], "cost": { - "input": 0.0675, - "output": 0.135, - "cacheRead": 0.0135, + "input": 0.0603, + "output": 0.1206, + "cacheRead": 0.01206, "cacheWrite": 0 }, "contextWindow": 262144, @@ -30219,13 +30337,13 @@ "image" ], "cost": { - "input": 2.8, - "output": 14, + "input": 2.6, + "output": 13, "cacheRead": 0.29, "cacheWrite": 0 }, - "contextWindow": 1048576, - "maxTokens": 1048576, + "contextWindow": 974842, + "maxTokens": 974842, "thinking": { "mode": "effort", "efforts": [ @@ -32620,7 +32738,7 @@ "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 393216, + "maxTokens": 384000, "thinking": { "mode": "effort", "efforts": [ @@ -33592,7 +33710,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 16384, "thinking": { "mode": "effort", "efforts": [ @@ -36503,7 +36621,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 228000, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -36699,7 +36817,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -39669,8 +39787,8 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 40960, - "maxTokens": 16384, + "contextWindow": 131072, + "maxTokens": 8192, "thinking": { "mode": "effort", "efforts": [ @@ -40416,8 +40534,8 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 65536, + "contextWindow": 131072, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -40440,7 +40558,7 @@ "image" ], "cost": { - "input": 0.15, + "input": 0.14, "output": 1, "cacheRead": 0.05, "cacheWrite": 0 @@ -40734,7 +40852,7 @@ "cost": { "input": 0.45, "output": 3.2, - "cacheRead": 0, + "cacheRead": 0.05, "cacheWrite": 0 }, "contextWindow": 262144, @@ -41422,9 +41540,9 @@ "text" ], "cost": { - "input": 0.132, - "output": 0.528, - "cacheRead": 0.033, + "input": 0.14, + "output": 0.58, + "cacheRead": 0.035, "cacheWrite": 0 }, "contextWindow": 262144, @@ -42768,7 +42886,7 @@ "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 131072, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -42780,6 +42898,26 @@ ] } }, + "z-ai/glm-5.2:free": { + "id": "z-ai/glm-5.2:free", + "name": "GLM 5.2 (free)", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 131072, + "supportsComputerUse": false + }, "z-ai/glm-5v-turbo": { "id": "z-ai/glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -45222,7 +45360,8 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-128k-vision-preview": { "id": "moonshot-v1-128k-vision-preview", @@ -45242,7 +45381,8 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-32k": { "id": "moonshot-v1-32k", @@ -45261,7 +45401,8 @@ "cacheWrite": 0 }, "contextWindow": 32768, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-32k-vision-preview": { "id": "moonshot-v1-32k-vision-preview", @@ -45281,7 +45422,8 @@ "cacheWrite": 0 }, "contextWindow": 32768, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-8k": { "id": "moonshot-v1-8k", @@ -45300,7 +45442,8 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-8k-vision-preview": { "id": "moonshot-v1-8k-vision-preview", @@ -45320,7 +45463,8 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-auto": { "id": "moonshot-v1-auto", @@ -45339,7 +45483,8 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false } }, "nanogpt": { @@ -48816,7 +48961,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "deepseek/deepseek-v4-flash-0731-cheaper:thinking": { "id": "deepseek/deepseek-v4-flash-0731-cheaper:thinking", @@ -48843,7 +48989,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "deepseek/deepseek-v4-flash-0731:thinking": { "id": "deepseek/deepseek-v4-flash-0731:thinking", @@ -49032,7 +49179,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "deepseek/deepseek-v4-pro-cheaper:thinking": { "id": "deepseek/deepseek-v4-pro-cheaper:thinking", @@ -49059,7 +49207,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "deepseek/deepseek-v4-pro:thinking": { "id": "deepseek/deepseek-v4-pro:thinking", @@ -49151,9 +49300,39 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "dots-studio/dots-3-note-preview": { + "id": "dots-studio/dots-3-note-preview", + "name": "Dots3-Note Preview", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.1, + "output": 0.2, + "cacheRead": 0.05, + "cacheWrite": 0 + }, + "contextWindow": 393216, + "maxTokens": 393216, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, "dots-studio/dots-3-note-preview:free": { "id": "dots-studio/dots-3-note-preview:free", - "name": "Dots3-Note Preview", + "name": "Dots3-Note Preview (free)", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -49179,7 +49358,8 @@ "high", "xhigh" ] - } + }, + "supportsComputerUse": false }, "doubao-1-5-thinking-pro-250415": { "id": "doubao-1-5-thinking-pro-250415", @@ -60271,6 +60451,35 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "qwen3.5-0.8b": { + "id": "qwen3.5-0.8b", + "name": "Qwen3.5 0.8B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.06, + "output": 0.12, + "cacheRead": 0.03, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "qwen3.5-122b-a10b": { "id": "qwen3.5-122b-a10b", "name": "Qwen3.5 122B A10B", @@ -61328,6 +61537,35 @@ ] } }, + "qwen3.5-2b": { + "id": "qwen3.5-2b", + "name": "Qwen3.5 2B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.08, + "output": 0.16, + "cacheRead": 0.04, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "qwen3.5-35b-a3b": { "id": "qwen3.5-35b-a3b", "name": "Qwen3.5 35B A3B", @@ -61377,6 +61615,35 @@ ] } }, + "qwen3.5-4b": { + "id": "qwen3.5-4b", + "name": "Qwen3.5 4B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.1, + "output": 0.2, + "cacheRead": 0.05, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "qwen3.5-flash": { "id": "qwen3.5-flash", "name": "qwen3.5-flash", @@ -61642,6 +61909,64 @@ ] } }, + "qwen3.8-27b": { + "id": "qwen3.8-27b", + "name": "Qwen3.8 27B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.4, + "output": 3, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "qwen3.8-27b:thinking": { + "id": "qwen3.8-27b:thinking", + "name": "Qwen3.8 27B Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.4, + "output": 3, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "qwen3.8-max": { "id": "qwen3.8-max", "name": "Qwen3.8 Max", @@ -62730,6 +63055,60 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "TEE/deepseek-v4-pro-0813": { + "id": "TEE/deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813 TEE", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0.132, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 1048576, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + } + }, + "TEE/deepseek-v4-pro-0813:thinking": { + "id": "TEE/deepseek-v4-pro-0813:thinking", + "name": "DeepSeek V4 Pro 0813 Thinking TEE", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0.132, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 1048576, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + } + }, "TEE/deepseek-v4-pro:thinking": { "id": "TEE/deepseek-v4-pro:thinking", "name": "DeepSeek V4 Pro Thinking TEE", @@ -67022,7 +67401,7 @@ }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", + "name": "Gemma 4 26B A4B", "api": "openai-completions", "provider": "novita", "baseUrl": "https://api.novita.ai/openai/v1", @@ -76174,7 +76553,7 @@ "api": "openai-codex-responses", "v2StreamingEnabled": true }, - "contextWindow": 372000, + "contextWindow": 1000000, "maxTokens": 128000, "preferWebsockets": true, "useResponsesLite": true, @@ -76213,7 +76592,7 @@ "api": "openai-codex-responses", "v2StreamingEnabled": true }, - "contextWindow": 372000, + "contextWindow": 1000000, "maxTokens": 128000, "preferWebsockets": true, "useResponsesLite": true, @@ -76252,7 +76631,7 @@ "api": "openai-codex-responses", "v2StreamingEnabled": true }, - "contextWindow": 372000, + "contextWindow": 1000000, "maxTokens": 128000, "preferWebsockets": true, "useResponsesLite": true, @@ -76459,7 +76838,7 @@ "opencode-go": { "deepseek-v4-flash": { "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash (2x usage)", + "name": "DeepSeek V4 Flash", "api": "openai-responses", "provider": "opencode-go", "baseUrl": "https://opencode.ai/zen/go/v1", @@ -76468,9 +76847,9 @@ "text" ], "cost": { - "input": 0.07, - "output": 0.14, - "cacheRead": 0.0014, + "input": 0.22, + "output": 0.66, + "cacheRead": 0.007, "cacheWrite": 0 }, "contextWindow": 1000000, @@ -76503,9 +76882,9 @@ "text" ], "cost": { - "input": 0.435, - "output": 0.87, - "cacheRead": 0.003625, + "input": 0.66, + "output": 1.98, + "cacheRead": 0.022, "cacheWrite": 0 }, "contextWindow": 1000000, @@ -77130,9 +77509,9 @@ "qwen3.7-max": { "id": "qwen3.7-max", "name": "Qwen3.7 Max", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "opencode-go", - "baseUrl": "https://opencode.ai/zen/go", + "baseUrl": "https://opencode.ai/zen/go/v1", "reasoning": true, "input": [ "text" @@ -77146,22 +77525,21 @@ "contextWindow": 1000000, "maxTokens": 65536, "thinking": { - "mode": "budget", + "mode": "effort", "efforts": [ "minimal", "low", "medium", - "high", - "xhigh" + "high" ] } }, "qwen3.7-plus": { "id": "qwen3.7-plus", "name": "Qwen3.7 Plus", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "opencode-go", - "baseUrl": "https://opencode.ai/zen/go", + "baseUrl": "https://opencode.ai/zen/go/v1", "reasoning": true, "input": [ "text", @@ -77176,22 +77554,21 @@ "contextWindow": 1000000, "maxTokens": 65536, "thinking": { - "mode": "budget", + "mode": "effort", "efforts": [ "minimal", "low", "medium", - "high", - "xhigh" + "high" ] } }, "qwen3.8-max": { "id": "qwen3.8-max", "name": "Qwen3.8 Max", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "opencode-go", - "baseUrl": "https://opencode.ai/zen/go", + "baseUrl": "https://opencode.ai/zen/go/v1", "reasoning": true, "input": [ "text", @@ -77206,13 +77583,12 @@ "contextWindow": 1000000, "maxTokens": 131072, "thinking": { - "mode": "budget", + "mode": "effort", "efforts": [ "minimal", "low", "medium", - "high", - "xhigh" + "high" ] } } @@ -79790,12 +80166,12 @@ "text" ], "cost": { - "input": 0.0675, - "output": 0.135, - "cacheRead": 0.0135, + "input": 0.060300000000000006, + "output": 0.12060000000000001, + "cacheRead": 0.01206, "cacheWrite": 0 }, - "contextWindow": 1048576, + "contextWindow": 1310720, "maxTokens": 262144, "thinking": { "mode": "effort", @@ -79882,13 +80258,13 @@ "image" ], "cost": { - "input": 2.8, - "output": 14, + "input": 2.6, + "output": 13, "cacheRead": 0.29, "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 1048576, + "maxTokens": 974842, "thinking": { "mode": "effort", "efforts": [ @@ -82102,9 +82478,9 @@ "text" ], "cost": { - "input": 0.06426, - "output": 0.12852, - "cacheRead": 0.012852, + "input": 0.08259999999999999, + "output": 0.16519999999999999, + "cacheRead": 0.01652, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -82136,7 +82512,7 @@ "cacheRead": 0.028, "cacheWrite": 0 }, - "contextWindow": 1048576, + "contextWindow": 1310720, "maxTokens": 393216, "thinking": { "mode": "effort", @@ -82189,13 +82565,13 @@ "text" ], "cost": { - "input": 1.1680000000000001, - "output": 2.3360000000000003, - "cacheRead": 0.09855000000000001, + "input": 1.32, + "output": 3.9600000000000004, + "cacheRead": 0.044, "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 393216, + "maxTokens": 384000, "thinking": { "mode": "effort", "efforts": [ @@ -82216,9 +82592,9 @@ "text" ], "cost": { - "input": 0.435, - "output": 0.87, - "cacheRead": 0.003625, + "input": 1.32, + "output": 3.9600000000000004, + "cacheRead": 0.044, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -83268,7 +83644,7 @@ }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", + "name": "Gemma 4 26B A4B", "api": "openrouter", "provider": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", @@ -83278,13 +83654,13 @@ "image" ], "cost": { - "input": 0.12, - "output": 0.39999999999999997, + "input": 0.07, + "output": 0.33999999999999997, "cacheRead": 0.049999999999999996, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 16384, "thinking": { "mode": "effort", "efforts": [ @@ -85134,9 +85510,9 @@ "image" ], "cost": { - "input": 0.65, - "output": 3.41, - "cacheRead": 0.15, + "input": 0.95, + "output": 4, + "cacheRead": 0.16, "cacheWrite": 0 }, "contextWindow": 262144, @@ -85519,11 +85895,11 @@ "cost": { "input": 0.049999999999999996, "output": 0.19999999999999998, - "cacheRead": 0.024999999999999998, + "cacheRead": 0.03, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 228000, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -85759,13 +86135,13 @@ "text" ], "cost": { - "input": 0.09999999999999999, - "output": 0.25, - "cacheRead": 0.049999999999999996, + "input": 0.08, + "output": 0.19999999999999998, + "cacheRead": 0.04, "cacheWrite": 0 }, "contextWindow": 1000000, - "maxTokens": 262144, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -89562,13 +89938,13 @@ "text" ], "cost": { - "input": 0.12, - "output": 0.5, + "input": 0.13, + "output": 0.52, "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 16384, + "maxTokens": 8192, "thinking": { "mode": "effort", "efforts": [ @@ -90028,8 +90404,8 @@ "image" ], "cost": { - "input": 0.26, - "output": 1.04, + "input": 0.21, + "output": 1.9, "cacheRead": 0.09999999999999999, "cacheWrite": 0 }, @@ -90460,13 +90836,13 @@ "image" ], "cost": { - "input": 0.3, - "output": 2, + "input": 0.28900000000000003, + "output": 2.4, "cacheRead": 0.03, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 65536, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -90491,7 +90867,7 @@ "image" ], "cost": { - "input": 0.15, + "input": 0.14, "output": 1, "cacheRead": 0.049999999999999996, "cacheWrite": 0 @@ -90799,7 +91175,7 @@ "cost": { "input": 0.44999999999999996, "output": 3.1999999999999997, - "cacheRead": 0, + "cacheRead": 0.049999999999999996, "cacheWrite": 0 }, "contextWindow": 262144, @@ -92491,13 +92867,13 @@ "text" ], "cost": { - "input": 0.46199999999999997, - "output": 1.452, - "cacheRead": 0.0858, + "input": 1.19, + "output": 3.74, + "cacheRead": 0.221, "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 131072, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -93187,6 +93563,33 @@ ] } }, + "deepseek-ai/DeepSeek-V4-Pro-0813": { + "id": "deepseek-ai/DeepSeek-V4-Pro-0813", + "name": "DeepSeek V4 Pro 0813", + "api": "openai-completions", + "provider": "together", + "baseUrl": "https://api.together.xyz/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0.13, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 384000, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + } + }, "essentialai/Rnj-1-Instruct": { "id": "essentialai/Rnj-1-Instruct", "name": "Rnj-1 Instruct", @@ -94025,9 +94428,9 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 1.32, + "output": 3.96, + "cacheRead": 0.044, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -98784,7 +99187,7 @@ "cost": { "input": 2, "output": 6, - "cacheRead": 0.25, + "cacheRead": 0.19999999999999998, "cacheWrite": 0 }, "contextWindow": 262144, @@ -98801,6 +99204,37 @@ }, "supportsComputerUse": false }, + "alibaba/qwen3.8-27b": { + "id": "alibaba/qwen3.8-27b", + "name": "Qwen3.8 27B", + "api": "anthropic-messages", + "provider": "vercel-ai-gateway", + "baseUrl": "https://ai-gateway.vercel.sh", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262133, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "supportsComputerUse": false + }, "alibaba/qwen3.8-max": { "id": "alibaba/qwen3.8-max", "name": "Qwen 3.8 Max", @@ -99894,9 +100328,9 @@ "text" ], "cost": { - "input": 0.19999999999999998, - "output": 0.39999999999999997, - "cacheRead": 0.04, + "input": 0.13, + "output": 0.26, + "cacheRead": 0.028, "cacheWrite": 0 }, "contextWindow": 1000000, @@ -99924,9 +100358,9 @@ "text" ], "cost": { - "input": 0.19999999999999998, - "output": 0.39999999999999997, - "cacheRead": 0.04, + "input": 0.13, + "output": 0.26, + "cacheRead": 0.028, "cacheWrite": 0 }, "contextWindow": 1000000, @@ -100469,7 +100903,7 @@ }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", + "name": "Gemma 4 26B A4B", "api": "anthropic-messages", "provider": "vercel-ai-gateway", "baseUrl": "https://ai-gateway.vercel.sh", @@ -102165,6 +102599,36 @@ }, "supportsComputerUse": false }, + "nvidia/nemotron-3.5-lightning": { + "id": "nvidia/nemotron-3.5-lightning", + "name": "Nvidia Nemotron 3.5 Lightning", + "api": "anthropic-messages", + "provider": "vercel-ai-gateway", + "baseUrl": "https://ai-gateway.vercel.sh", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 131072, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "supportsComputerUse": false + }, "nvidia/nemotron-nano-12b-v2-vl": { "id": "nvidia/nemotron-nano-12b-v2-vl", "name": "Nemotron Nano 12B v2 VL", @@ -106420,6 +106884,7 @@ }, "contextWindow": 2000000, "maxTokens": 2000000, + "supportsComputerUse": false, "compat": { "includeEncryptedReasoning": true, "filterReasoningHistory": false, @@ -106447,6 +106912,7 @@ }, "contextWindow": 2000000, "maxTokens": 2000000, + "supportsComputerUse": false, "compat": { "includeEncryptedReasoning": true, "filterReasoningHistory": false, @@ -106473,16 +106939,6 @@ }, "contextWindow": 2000000, "maxTokens": 2000000, - "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": true, - "filterReasoningHistory": false, - "supportsImageDetailOriginal": false, - "omitReasoningEffort": false, - "supportsReasoningEffort": true - }, "thinking": { "mode": "effort", "efforts": [ @@ -106495,6 +106951,17 @@ "effortMap": { "minimal": "low" } + }, + "supportsComputerUse": false, + "compat": { + "reasoningEffortMap": { + "minimal": "low" + }, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, + "supportsImageDetailOriginal": false, + "omitReasoningEffort": false, + "supportsReasoningEffort": true } }, "grok-4.3": { @@ -106516,18 +106983,6 @@ }, "contextWindow": 1000000, "maxTokens": 1000000, - "compat": { - "reasoningEffortMap": { - "minimal": "low", - "xhigh": "high", - "max": "high" - }, - "includeEncryptedReasoning": true, - "filterReasoningHistory": false, - "supportsImageDetailOriginal": false, - "omitReasoningEffort": false, - "supportsReasoningEffort": true - }, "thinking": { "mode": "effort", "efforts": [ @@ -106539,6 +106994,19 @@ "effortMap": { "minimal": "low" } + }, + "supportsComputerUse": false, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, + "supportsImageDetailOriginal": false, + "omitReasoningEffort": false, + "supportsReasoningEffort": true } }, "grok-4.5": { @@ -106560,18 +107028,6 @@ }, "contextWindow": 500000, "maxTokens": 500000, - "compat": { - "reasoningEffortMap": { - "minimal": "low", - "xhigh": "high", - "max": "high" - }, - "includeEncryptedReasoning": true, - "filterReasoningHistory": false, - "supportsImageDetailOriginal": false, - "omitReasoningEffort": false, - "supportsReasoningEffort": true - }, "thinking": { "mode": "effort", "efforts": [ @@ -106583,6 +107039,19 @@ "effortMap": { "minimal": "low" } + }, + "supportsComputerUse": false, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, + "supportsImageDetailOriginal": false, + "omitReasoningEffort": false, + "supportsReasoningEffort": true } }, "grok-4.6": { @@ -106604,16 +107073,6 @@ }, "contextWindow": 500000, "maxTokens": 500000, - "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": true, - "filterReasoningHistory": false, - "supportsImageDetailOriginal": false, - "omitReasoningEffort": false, - "supportsReasoningEffort": true - }, "thinking": { "mode": "effort", "efforts": [ @@ -106626,6 +107085,17 @@ "effortMap": { "minimal": "low" } + }, + "supportsComputerUse": false, + "compat": { + "reasoningEffortMap": { + "minimal": "low" + }, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, + "supportsImageDetailOriginal": false, + "omitReasoningEffort": false, + "supportsReasoningEffort": true } }, "grok-build": { @@ -106647,6 +107117,7 @@ }, "contextWindow": 512000, "maxTokens": 512000, + "supportsComputerUse": false, "compat": { "includeEncryptedReasoning": true, "filterReasoningHistory": false, @@ -106674,6 +107145,7 @@ }, "contextWindow": 256000, "maxTokens": 256000, + "supportsComputerUse": false, "compat": { "includeEncryptedReasoning": true, "filterReasoningHistory": false, @@ -106700,6 +107172,7 @@ }, "contextWindow": 200000, "maxTokens": 200000, + "supportsComputerUse": false, "compat": { "includeEncryptedReasoning": true, "filterReasoningHistory": false, @@ -107676,7 +108149,8 @@ "defaultLevel": "max", "requiresEffort": true }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "glm-5v-turbo": { "id": "glm-5v-turbo", @@ -109391,7 +109865,7 @@ }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", + "name": "Gemma 4 26B A4B", "api": "openai-completions", "provider": "zenmux", "baseUrl": "https://zenmux.ai/api/v1", diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts index dc1d2afc3..c7bd9ad29 100644 --- a/packages/catalog/src/provider-models/descriptors.ts +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -478,13 +478,13 @@ export const CATALOG_PROVIDERS = [ }, { id: "xai", - defaultModel: "grok-4.5", + defaultModel: "grok-4.6", envVars: ["XAI_API_KEY"], createModelManagerOptions: (config: ModelManagerConfig) => xaiModelManagerOptions(config), }, { id: "xai-oauth", - defaultModel: "grok-4.5", + defaultModel: "grok-4.6", envVars: ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], createModelManagerOptions: (config: ModelManagerConfig) => xaiOAuthModelManagerOptions(config), catalogDiscovery: { diff --git a/packages/catalog/test/codex-discovery.test.ts b/packages/catalog/test/codex-discovery.test.ts index 17fa6cb64..7cf08217e 100644 --- a/packages/catalog/test/codex-discovery.test.ts +++ b/packages/catalog/test/codex-discovery.test.ts @@ -103,7 +103,7 @@ describe("Codex model discovery", () => { expect(legacy?.useResponsesLite).toBeUndefined(); }); - it("falls back to the 372K window for GPT-5.6 SKUs when upstream omits context_window (#5705)", async () => { + it("floors GPT-5.6 luna/sol/terra at the 1M window when upstream omits context_window (#5705)", async () => { const fetchFn: typeof fetch = Object.assign( async () => new Response( @@ -138,7 +138,7 @@ describe("Codex model discovery", () => { }); const sol = result?.models.find(model => model.id === "gpt-5.6-sol"); - expect(sol?.contextWindow).toBe(372_000); + expect(sol?.contextWindow).toBe(1_000_000); const legacy = result?.models.find(model => model.id === "gpt-5.5"); expect(legacy?.contextWindow).toBe(272_000); }); @@ -195,7 +195,7 @@ describe("Codex model discovery", () => { expect(red.cost).toEqual({ input: 12.5, output: 75, cacheRead: 1.25, cacheWrite: 15.625 }); }); - it("honors context_window when upstream actively reports it for GPT-5.6 SKUs", async () => { + it("floors stale reported windows for GPT-5.6 luna/sol/terra and honors reports above the floor", async () => { const fetchFn: typeof fetch = Object.assign( async () => new Response( @@ -210,6 +210,15 @@ describe("Codex model discovery", () => { input_modalities: ["text", "image"], supported_in_api: true, }, + { + slug: "gpt-5.6-terra", + display_name: "GPT-5.6-Terra", + context_window: 1_050_000, + default_reasoning_level: "medium", + supported_reasoning_levels: ["low", "medium", "high"], + input_modalities: ["text", "image"], + supported_in_api: true, + }, { slug: "gpt-5.5", display_name: "GPT-5.5", @@ -231,8 +240,13 @@ describe("Codex model discovery", () => { fetchFn, }); + // Registry still reports the pre-1M 272000 for sol; the floor must win. const sol = result?.models.find(model => model.id === "gpt-5.6-sol"); - expect(sol?.contextWindow).toBe(272_000); + expect(sol?.contextWindow).toBe(1_000_000); + // Reports above the floor are honored as-is. + const terra = result?.models.find(model => model.id === "gpt-5.6-terra"); + expect(terra?.contextWindow).toBe(1_050_000); + // Non-floored SKUs keep the actively reported value. const legacy = result?.models.find(model => model.id === "gpt-5.5"); expect(legacy?.contextWindow).toBe(272_000); }); diff --git a/packages/catalog/test/generated-policies.test.ts b/packages/catalog/test/generated-policies.test.ts index 88f6fd27a..45674a517 100644 --- a/packages/catalog/test/generated-policies.test.ts +++ b/packages/catalog/test/generated-policies.test.ts @@ -120,9 +120,9 @@ describe("generated model policies", () => { expect(models[4]?.cost.longContext).toBeUndefined(); }); - it("pins GPT-5.6 Codex-transport context window to the 372K hard capacity (#5705)", () => { + it("floors GPT-5.6 Codex-transport context windows at 1M (openai/codex#38917)", () => { const models: ModelSpec[] = [ - // Codex discovery underreports these via DEFAULT_CONTEXT_WINDOW=272000. + // Codex discovery/registry still reports the stale 272000 for these. createSpec({ id: "gpt-5.6-luna", api: "openai-codex-responses", @@ -155,9 +155,9 @@ describe("generated model policies", () => { applyGeneratedModelPolicies(models); - expect(models[0]?.contextWindow).toBe(372000); - expect(models[1]?.contextWindow).toBe(372000); - expect(models[2]?.contextWindow).toBe(372000); + expect(models[0]?.contextWindow).toBe(1_000_000); + expect(models[1]?.contextWindow).toBe(1_000_000); + expect(models[2]?.contextWindow).toBe(1_000_000); expect(models[3]?.contextWindow).toBe(1050000); expect(models[4]?.contextWindow).toBe(272000); }); diff --git a/packages/catalog/test/issue-887-repro.test.ts b/packages/catalog/test/issue-887-repro.test.ts deleted file mode 100644 index 2fac1dbee..000000000 --- a/packages/catalog/test/issue-887-repro.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Repro for #887 — OpenCode Go: Minimax M2.7 (and Qwen3.5/3.6 Plus) return 404 - * because the resolver routes them to anthropic-messages /v1/messages while - * the OpenCode Go gateway only serves them at /v1/chat/completions. - * - * stencil.so declares these ids with `provider.npm = "@ai-sdk/anthropic"`, - * which by default would resolve to anthropic-messages on opencode-go. The - * descriptor must override these specific ids to openai-completions so that - * regenerated models.json keeps the correct routing. - */ -import { describe, expect, test } from "bun:test"; -import { - MODELS_DEV_PROVIDER_DESCRIPTORS, - type ModelsDevModel, - opencodeGoModelManagerOptions, -} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; - -const OPENCODE_GO_BASE = "https://opencode.ai/zen/go/v1"; - -describe("opencode-go resolver routes 404-ing ids to openai-completions (issue #887)", () => { - const descriptor = MODELS_DEV_PROVIDER_DESCRIPTORS.find(d => d.providerId === "opencode-go"); - - // Per upstream stencil.so (verified 2026-05-02 against - // https://stencil.so/api.json["opencode-go"].models), these three ids carry - // `provider.npm = "@ai-sdk/anthropic"`. The naive @ai-sdk/anthropic rule - // would route them to /v1/messages on opencode.ai/zen/go which 404s. - const npmAnthropic: ModelsDevModel = { provider: { npm: "@ai-sdk/anthropic" }, tool_call: true }; - - test.each([["minimax-m2.7"], ["qwen3.5-plus"], ["qwen3.6-plus"]])( - "%s resolves to openai-completions on /v1/chat/completions", - modelId => { - const resolved = descriptor?.resolveApi?.(modelId, npmAnthropic); - expect(resolved).toEqual({ api: "openai-completions", baseUrl: OPENCODE_GO_BASE }); - }, - ); - - test("minimax-m2.5 (control: works empirically) also resolves to openai-completions", () => { - // stencil.so currently lists minimax-m2.5 without an explicit provider.npm, - // so it falls through to the default openai-completions resolution. - const m25: ModelsDevModel = { tool_call: true }; - const resolved = descriptor?.resolveApi?.("minimax-m2.5", m25); - expect(resolved).toEqual({ api: "openai-completions", baseUrl: OPENCODE_GO_BASE }); - }); - - test("runtime /v1/models refresh preserves qwen3.7-max Anthropic transport", async () => { - let requestedUrl = ""; - const fetchMock = (async (input: string | Request | URL): Promise => { - requestedUrl = input instanceof Request ? input.url : String(input); - return new Response( - JSON.stringify({ - data: [{ id: "qwen3.7-max", name: "Qwen3.7 Max", context_length: 1000000 }], - }), - { headers: { "content-type": "application/json" } }, - ); - }) as typeof fetch; - - const options = opencodeGoModelManagerOptions({ apiKey: "opencode-test-key", fetch: fetchMock }); - const models = await options.fetchDynamicModels?.(); - const qwenMax = models?.find(model => model.id === "qwen3.7-max"); - - expect(requestedUrl).toBe("https://opencode.ai/zen/go/v1/models"); - expect(qwenMax?.api).toBe("anthropic-messages"); - expect(qwenMax?.baseUrl).toBe("https://opencode.ai/zen/go"); - }); -}); diff --git a/packages/catalog/test/xai-api-key-responses.test.ts b/packages/catalog/test/xai-api-key-responses.test.ts index c11849cec..97b75def6 100644 --- a/packages/catalog/test/xai-api-key-responses.test.ts +++ b/packages/catalog/test/xai-api-key-responses.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { resolveProviderModels } from "@oh-my-pi/pi-catalog/model-manager"; import { getBundledModels } from "@oh-my-pi/pi-catalog/models"; -import { CATALOG_PROVIDERS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { CATALOG_PROVIDERS, DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; import { xaiModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; @@ -37,13 +37,18 @@ describe("paid xai (XAI_API_KEY) Responses contract", () => { it("registers xai on the catalog Responses discovery path", () => { const entry = CATALOG_PROVIDERS.find(provider => provider.id === "xai"); expect(entry, "xai catalog descriptor").toBeDefined(); - expect(entry!.defaultModel).toBe("grok-4.5"); + expect(entry!.defaultModel).toBe("grok-4.6"); + expect(DEFAULT_MODEL_PER_PROVIDER.xai).toBe("grok-4.6"); + expect( + getBundledModels("xai").find(model => model.id === "grok-4.6"), + "xai/grok-4.6 must be bundled for the default", + ).toBeDefined(); expect(entry!.envVars).toContain("XAI_API_KEY"); const options = xaiModelManagerOptions({ apiKey: "test-key" }); expect(options.providerId).toBe("xai"); expect(options.fetchDynamicModels, "live /v1/models overlay").toBeTypeOf("function"); expect(options.dropCachedModelIdsOnStaticMismatch).toEqual(getBundledModels("xai").map(model => model.id)); - expect(options.dropCachedModelIdsOnStaticMismatch).toContain("grok-4.5"); + expect(options.dropCachedModelIdsOnStaticMismatch).toContain("grok-4.6"); }); it("bundles every paid xai chat model on openai-responses", () => { diff --git a/packages/catalog/test/xai-oauth-bundle.test.ts b/packages/catalog/test/xai-oauth-bundle.test.ts index 534cc488c..8a16a7a55 100644 --- a/packages/catalog/test/xai-oauth-bundle.test.ts +++ b/packages/catalog/test/xai-oauth-bundle.test.ts @@ -18,11 +18,11 @@ describe("xai-oauth bundled catalog (regression)", () => { (MODELS_JSON as unknown as Record>>)["xai-oauth"] ?? {}; const seed = buildXaiOAuthStaticSeed(); - it("defaults SuperGrok selection to grok-4.5", () => { + it("defaults SuperGrok selection to grok-4.6", () => { const entry = CATALOG_PROVIDERS.find(provider => provider.id === "xai-oauth"); - expect(entry?.defaultModel).toBe("grok-4.5"); - expect(DEFAULT_MODEL_PER_PROVIDER["xai-oauth"]).toBe("grok-4.5"); - expect(bundled["grok-4.5"], "xai-oauth/grok-4.5 must be bundled for the default").toBeDefined(); + expect(entry?.defaultModel).toBe("grok-4.6"); + expect(DEFAULT_MODEL_PER_PROVIDER["xai-oauth"]).toBe("grok-4.6"); + expect(bundled["grok-4.6"], "xai-oauth/grok-4.6 must be bundled for the default").toBeDefined(); }); it("bundles every curated id", () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4901d84f7..2f7b500ff 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +## [17.3.7] - 2026-08-17 + +### Changed + +- Send the `omp/` User-Agent on xAI chat (`xai` and `xai-oauth`) unless the request already set its own ([#8840](https://github.com/can1357/oh-my-pi/pull/8840) by [@Jaaneek](https://github.com/Jaaneek)). + +## [17.3.6] - 2026-08-17 + +### Added + +- Added `ExtensionAPI.registerFileWriteFallback(handler)` and `ExtensionAPI.registerFileDeleteFallback(handler)`, letting an extension supply a fallback writer or deleter that is consulted when a native `write`, `edit`, or `apply_patch` byte-write or unlink is denied with a permission error (`EPERM`/`EACCES`/`EROFS`) — for hosts that embed the agent inside a sandbox that denies direct filesystem access but exposes a privileged channel. The brokered path is symlink-resolved so a handler's allowlist sees the real destination, a destination that cannot be resolved is not brokered at all, and `req.sessionId` names the session that issued the mutation so a handler sharing the process-wide registry can enforce policy per session. See [`docs/extensions.md`](../../docs/extensions.md). + +### Changed + +- Updated the default model for XAI_API_KEY (xai) and SuperGrok OAuth (xai-oauth) to grok-4.6. Automatic model selection continues to prefer paid xai/grok-4.6 when only XAI_API_KEY is set, with xai-oauth/grok-4.6 still available explicitly. + +### Fixed + +- Fixed `omp stats` and `/stats` dashboards being unreachable from container hosts by accepting an explicit `--host` bind address while preserving the `127.0.0.1` default. + ## [17.3.5] - 2026-08-16 ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index b37fbc91f..1e50e4435 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-coding-agent", - "version": "17.3.5", + "version": "17.3.7", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/coding-agent/src/cli/stats-cli.ts b/packages/coding-agent/src/cli/stats-cli.ts index fc453afbf..7f99557da 100644 --- a/packages/coding-agent/src/cli/stats-cli.ts +++ b/packages/coding-agent/src/cli/stats-cli.ts @@ -5,7 +5,7 @@ */ import { truncateToWidth } from "@oh-my-pi/pi-tui/utils"; -import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils"; +import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils"; import chalk from "@oh-my-pi/pi-utils/chalk"; import { openPath } from "../utils/open"; @@ -57,45 +57,11 @@ function shortenSessionFile(p: string): string { export interface StatsCommandArgs { port: number; + host: string; json: boolean; summary: boolean; } -// ============================================================================= -// Argument Parser -// ============================================================================= - -/** - * Parse stats subcommand arguments. - * Returns undefined if not a stats command. - */ -export function parseStatsArgs(args: string[]): StatsCommandArgs | undefined { - if (args.length === 0 || args[0] !== "stats") { - return undefined; - } - - const result: StatsCommandArgs = { - port: 3847, - json: false, - summary: false, - }; - - for (let i = 1; i < args.length; i++) { - const arg = args[i]; - if (arg === "--json" || arg === "-j") { - result.json = true; - } else if (arg === "--summary" || arg === "-s") { - result.summary = true; - } else if ((arg === "--port" || arg === "-p") && i + 1 < args.length) { - result.port = parseInt(args[++i], 10); - } else if (arg.startsWith("--port=")) { - result.port = parseInt(arg.split("=")[1], 10); - } - } - - return result; -} - function formatCost(n: number): string { if (n < 0.01) return `$${n.toFixed(4)}`; if (n < 1) return `$${n.toFixed(3)}`; @@ -112,9 +78,8 @@ function normalizePremiumRequests(n: number): number { export async function runStatsCommand(cmd: StatsCommandArgs): Promise { // Lazy import to avoid loading stats module when not needed - const { getDashboardStats, syncAllSessions, getTotalMessageCount, startServer, closeDb } = await import( - "@oh-my-pi/omp-stats" - ); + const { closeDb, formatStatsDashboardUrl, getDashboardStats, getTotalMessageCount, startServer, syncAllSessions } = + await import("@oh-my-pi/omp-stats"); // Sync session files first const progress = createSyncProgressReporter(); @@ -136,8 +101,8 @@ export async function runStatsCommand(cmd: StatsCommandArgs): Promise { } // Start the dashboard server - const { hostname, port } = await startServer(cmd.port); - const url = `http://${hostname}:${port}`; + const { hostname, port } = await startServer(cmd.port, cmd.host); + const url = formatStatsDashboardUrl(hostname, port); console.log(chalk.green(`Dashboard available at: ${url}`)); // Open browser @@ -197,34 +162,3 @@ async function printStatsSummary(): Promise { console.log(""); } - -// ============================================================================= -// Help -// ============================================================================= - -export function printStatsHelp(): void { - console.log(`${chalk.bold(`${APP_NAME} stats`)} - AI Usage Statistics Dashboard - -${chalk.bold("Usage:")} - ${APP_NAME} stats [options] - -${chalk.bold("Options:")} - -p, --port Port for the dashboard server (default: 3847) - -j, --json Output stats as JSON and exit - -s, --summary Print summary to console and exit - -h, --help Show this help message - -${chalk.bold("Examples:")} - ${APP_NAME} stats # Start dashboard server - ${APP_NAME} stats --json # Print stats as JSON - ${APP_NAME} stats --summary # Print summary to console - ${APP_NAME} stats --port 8080 # Start on custom port - -${chalk.bold("Metrics:")} - - Total requests and error rate - - Token usage (input, output, cache) - - Cost breakdown - - Average duration and time to first token (TTFT) - - Tokens per second throughput -`); -} diff --git a/packages/coding-agent/src/commands/stats.ts b/packages/coding-agent/src/commands/stats.ts index a52800c80..626ce8b64 100644 --- a/packages/coding-agent/src/commands/stats.ts +++ b/packages/coding-agent/src/commands/stats.ts @@ -4,13 +4,15 @@ import { Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { statsHelp as commandHelp } from "../cli/command-help"; -import { runStatsCommand, type StatsCommandArgs } from "../cli/stats-cli"; -import { initTheme } from "../modes/theme/theme"; +import type { StatsCommandArgs } from "../cli/stats-cli"; +import * as statsCli from "../cli/stats-cli"; +import * as theme from "../modes/theme/theme"; export default class Stats extends Command { static description = commandHelp.description; static flags = { port: Flags.integer({ char: "p", description: "Port for the dashboard server", default: 3847 }), + host: Flags.string({ description: "Host to bind", default: "127.0.0.1" }), json: Flags.boolean({ char: "j", description: "Output stats as JSON", default: false }), summary: Flags.boolean({ char: "s", description: "Print summary to console", default: false }), }; @@ -20,11 +22,12 @@ export default class Stats extends Command { const cmd: StatsCommandArgs = { port: flags.port, + host: flags.host ?? "127.0.0.1", json: flags.json, summary: flags.summary, }; - await initTheme(); - await runStatsCommand(cmd); + await theme.initTheme(); + await statsCli.runStatsCommand(cmd); } } diff --git a/packages/coding-agent/src/edit/hashline/filesystem.ts b/packages/coding-agent/src/edit/hashline/filesystem.ts index 10dbdd283..3dea49458 100644 --- a/packages/coding-agent/src/edit/hashline/filesystem.ts +++ b/packages/coding-agent/src/edit/hashline/filesystem.ts @@ -25,6 +25,7 @@ import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client"; import type { ToolSession } from "../../tools"; import { routeWriteThroughBridge } from "../../tools/acp-bridge"; import { assertEditableFileContent } from "../../tools/auto-generated-guard"; +import { deleteFileWithFallback, writeFileWithFallback } from "../../tools/file-write-fallback"; import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation"; import { isInternalUrlPath } from "../../tools/path-utils"; import { enforcePlanModeWrite, resolvePlanPath, targetsLocalSandbox } from "../../tools/plan-mode-guard"; @@ -153,7 +154,7 @@ export class HashlineFilesystem extends Filesystem { enforcePlanModeWrite(this.session, relativePath, { op: "delete" }); const absolutePath = this.resolveAbsolute(relativePath); try { - await fs.rm(absolutePath); + await deleteFileWithFallback(absolutePath); } catch (error) { if (isEnoent(error)) throw new NotFoundError(relativePath, error); throw error; @@ -173,8 +174,13 @@ export class HashlineFilesystem extends Filesystem { const fromAbsolute = this.resolveAbsolute(fromRelative); const toAbsolute = this.resolveAbsolute(toRelative); if (content !== undefined) { - await Bun.write(toAbsolute, content); - await fs.rm(fromAbsolute); + // The one `edit` write that does not pass through the writethrough, so it + // routes to the fallback seam directly. `patcher.ts` always supplies + // `content` for a hashline `MV`, making this the live branch. The source + // unlink is a separate primitive with its own seam, so a move out of the + // workspace and a move out of denied territory both complete. + await writeFileWithFallback(toAbsolute, content); + await deleteFileWithFallback(fromAbsolute); } else { await fs.rename(fromAbsolute, toAbsolute); } diff --git a/packages/coding-agent/src/edit/modes/patch.ts b/packages/coding-agent/src/edit/modes/patch.ts index feadc35fa..a507e64b6 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -20,6 +20,12 @@ import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client"; import type { ToolSession } from "../../tools"; import { routeWriteThroughBridge } from "../../tools/acp-bridge"; import { assertEditableFile } from "../../tools/auto-generated-guard"; +import { + deleteFileWithFallback, + hasFileWriteFallback, + isPermissionDeniedError, + writeFileWithFallback, +} from "../../tools/file-write-fallback"; import { invalidateFsScanAfterDelete, invalidateFsScanAfterRename, @@ -111,6 +117,26 @@ export interface ApplyPatchOptions { // Default File System // ═══════════════════════════════════════════════════════════════════════════ +/** + * Create a patch target's parent directory, tolerating a permission denial when a + * file-write fallback is registered. + * + * `apply_patch` mkdirs the parent before writing, so under a sandbox that denies + * the out-of-tree path this throws before the write — and therefore before + * {@link writeFileWithFallback} — is ever reached, leaving the fallback unable to + * broker a `create` or a rename-move into a new directory. Swallowing only a + * permission denial, and only with a handler installed, hands control to the write, + * which reports the denial through the seam. Without a handler the error propagates + * exactly as before. + */ +async function mkdirAllowingFallback(dir: string): Promise { + try { + await fs.promises.mkdir(dir, { recursive: true }); + } catch (error) { + if (!hasFileWriteFallback() || !isPermissionDeniedError(error)) throw error; + } +} + /** Default filesystem implementation using Bun APIs */ export const defaultFileSystem: FileSystem = { async exists(path: string): Promise { @@ -123,13 +149,13 @@ export const defaultFileSystem: FileSystem = { return fs.promises.readFile(path); }, async write(path: string, content: string): Promise { - await Bun.write(path, await serializeEditFileText(path, path, content)); + await writeFileWithFallback(path, await serializeEditFileText(path, path, content)); }, async delete(path: string): Promise { - await fs.promises.unlink(path); + await deleteFileWithFallback(path); }, async mkdir(path: string): Promise { - await fs.promises.mkdir(path, { recursive: true }); + await mkdirAllowingFallback(path); }, }; @@ -1753,7 +1779,7 @@ class LspFileSystem implements FileSystem { } async delete(path: string): Promise { - await this.#getFile(path).unlink(); + await deleteFileWithFallback(path, this.#getFile(path)); if (this.session.enableLsp ?? true) { await notifyWorkspaceWatchedFiles( this.session.cwd, @@ -1764,7 +1790,7 @@ class LspFileSystem implements FileSystem { } async mkdir(path: string): Promise { - await fs.promises.mkdir(path, { recursive: true }); + await mkdirAllowingFallback(path); } getDiagnostics(): FileDiagnosticsResult | undefined { diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index 4b1c04651..b0fb283d3 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -28,6 +28,7 @@ import { execCommand } from "../../exec/exec"; // Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe. import * as PiCodingAgent from "../../index"; import type { CustomMessagePayload } from "../../session/messages"; +import type { FileDeleteFallbackHandler, FileWriteFallbackHandler } from "../../tools/file-write-fallback"; import { EventBus } from "../../utils/event-bus"; import * as TypeBox from "../legacy-typebox"; import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/legacy-pi-compat"; @@ -183,6 +184,14 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime { for (const listener of this.extension.toolRegistrationListeners ?? []) listener(tool.name); } + registerFileWriteFallback(handler: FileWriteFallbackHandler): void { + this.extension.fileWriteFallbackHandlers.push(handler); + } + + registerFileDeleteFallback(handler: FileDeleteFallbackHandler): void { + this.extension.fileDeleteFallbackHandlers.push(handler); + } + registerCommand( name: string, options: { @@ -320,6 +329,8 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension tools: new Map(), toolRegistrationListeners: new Set(), assistantThinkingRenderers: [], + fileWriteFallbackHandlers: [], + fileDeleteFallbackHandlers: [], messageRenderers: new Map(), commands: new Map(), flags: new Map(), diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 9378eb494..60e8189d2 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -19,6 +19,7 @@ import type { MemoryRuntimeContext } from "../../memory-backend"; import { type Theme, theme } from "../../modes/theme/theme"; import type { AsyncJobSnapshot } from "../../session/agent-session"; import type { SessionManager } from "../../session/session-manager"; +import { addFileDeleteFallback, addFileWriteFallback } from "../../tools/file-write-fallback"; import type { BranchHandler, NavigateTreeHandler, NewSessionHandler } from "../session-handler-types"; import { ManagedTimers } from "./managed-timers"; import { createExtensionModelQuery } from "./model-api"; @@ -375,10 +376,12 @@ export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled: export type ShutdownHandler = () => void; /** - * Emit `session_shutdown` and clear timers owned by an extension runner. + * Emit `session_shutdown`, dispose file-write-fallback registrations, and clear + * timers owned by an extension runner. * - * Returns whether any shutdown handlers were present. Timer cleanup runs even - * when a handler fails so extension background work cannot outlive its host. + * Returns whether any shutdown handlers were present. Fallback disposal and timer + * cleanup run even when a handler fails so extension background work — and a + * fallback bound to this session's context — cannot outlive its host. */ export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner | undefined): Promise { if (!extensionRunner) return false; @@ -389,6 +392,7 @@ export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner }); return true; } finally { + extensionRunner.disposeFileFallbacks(); extensionRunner.clearManagedTimers(); } } @@ -486,6 +490,23 @@ export class ExtensionRunner { #managedTimers = new ManagedTimers((event, error, stack) => this.emitError({ extensionPath: "", event, error, stack }), ); + /** + * Disposers for the trampolines installed via {@link addFileWriteFallback} and + * {@link addFileDeleteFallback} — one per extension per seam it registered for. + * Installed during {@link initialize} (after the UI/runtime context is live, so + * the bound handler sees a working `ctx.ui`) and drained by + * {@link disposeFileFallbacks} on session shutdown so a handler from a + * torn-down session can never fire for a later one sharing the same process. + * + * Each trampoline re-reads its extension's handler list at call time rather than + * closing over a snapshot, matching how `ext.handlers` is re-read on every emit, + * so an extension that already had a handler for that seam at `initialize` picks + * up later additions to it. A seam the extension registered NOTHING for gets no + * trampoline at all, which keeps the registry empty for a host with no fallbacks; + * the cost is that a first registration for that seam after `initialize` never + * takes effect, which is why the API documents load-time registration. + */ + #fileFallbackDisposers: Array<() => void> = []; /** * Dedup markers for `tool_call` emission, keyed `${toolCallId}:${toolName}`. * The agent loop emits `tool_call` at arg-prep time (before scheduling and @@ -609,6 +630,18 @@ export class ExtensionRunner { return this.sessionManager.getCwd(); } + /** + * Stable id of the session this runner serves. Read through `sessionManager` + * for the same reason as {@link cwd}: it is this session's own, never a + * process-global, so a subagent runner reports itself and not its parent. + * + * Used to attribute a denied file write or delete to the session that issued + * it, since the fallback registry those handlers live in is process-wide. + */ + get sessionId(): string { + return this.sessionManager.getSessionId(); + } + initialize( actions: ExtensionActions, contextActions: ExtensionContextActions, @@ -666,6 +699,75 @@ export class ExtensionRunner { this.#mode = mode; this.#initialized = true; + // Re-initialize (e.g. a mode switch rewiring UI/runtime actions) must not + // accumulate duplicate global registrations — drop the prior generation before + // installing this one's trampolines. + this.disposeFileFallbacks(); + for (const ext of this.extensions) { + // Nothing registered by this extension means no trampoline, so a host with + // no fallback-registering extension leaves the seam genuinely empty and + // `hasFileWriteFallback()`/`hasFileDeleteFallback()` false — the invariant + // the whole feature rests on. Each seam is checked separately, so an + // extension that only brokers writes never appears in the delete registry. + if (ext.fileWriteFallbackHandlers.length === 0 && ext.fileDeleteFallbackHandlers.length === 0) continue; + // One trampoline per extension per seam, not per handler: the list is walked + // at mutation time so a handler this extension adds later still takes effect, + // and `createContext()` takes no extension argument, so within one invocation + // a single context is all any of this extension's handlers would have + // received anyway. + // + // The context is built PER INVOCATION rather than captured here, matching + // every other dispatch site. `createContext()` materializes `cwd` and + // `hasUI` as values, so a trampoline holding one context for the life of the + // session would keep handing handlers the workspace this runner initialized + // in — wrong the moment `SessionManager.moveTo()` relocates the session + // (`/move`), and a handler that scopes or prompts against `ctx.cwd` would + // then allow the old workspace and deny the new one. A denied mutation is a + // rare path, so the extra object costs nothing that matters. + // + // Isolation is per HANDLER, not per extension. The registry only sees one + // trampoline per extension, so a throw escaping this loop would advance the + // registry to the NEXT extension and skip every later handler this one + // registered — breaking both the documented "a throwing handler is skipped" + // contract and registration order for a backup-handler setup. + if (ext.fileWriteFallbackHandlers.length > 0) { + this.#fileFallbackDisposers.push( + addFileWriteFallback(async req => { + const ctx = this.createContext(); + for (const handler of ext.fileWriteFallbackHandlers) { + try { + if (await handler(req, ctx)) return true; + } catch (error) { + logger.warn("Extension file write fallback handler threw; trying next handler", { + extension: ext.path, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return false; + }), + ); + } + if (ext.fileDeleteFallbackHandlers.length > 0) { + this.#fileFallbackDisposers.push( + addFileDeleteFallback(async req => { + const ctx = this.createContext(); + for (const handler of ext.fileDeleteFallbackHandlers) { + try { + if (await handler(req, ctx)) return true; + } catch (error) { + logger.warn("Extension file delete fallback handler threw; trying next handler", { + extension: ext.path, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return false; + }), + ); + } + } + // Drain events buffered by emitCredentialDisabled() before initialize ran. The // spread adds the `type` discriminator — `event` is the pi-ai shape (no `type`). // Deferred by one microtask so callers that register an onError listener @@ -1098,6 +1200,16 @@ export class ExtensionRunner { this.#managedTimers.clearAll(); } + /** + * Remove every file write and delete fallback this runner installed into the + * process-wide registries. Called on session shutdown (and before reinstalling + * on a re-{@link initialize}) so a handler bound to a torn-down session's + * context can never fire for another session sharing this process. + */ + disposeFileFallbacks(): void { + for (const dispose of this.#fileFallbackDisposers.splice(0)) dispose(); + } + createCommandContext(): ExtensionCommandContext { return { ...this.createContext(), diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 6dcf2f129..da80737d3 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -76,6 +76,7 @@ import type { WriteToolInput, } from "../../tools"; import type { ApprovalMode } from "../../tools/approval"; +import type { FileDeleteFallbackHandler, FileWriteFallbackHandler } from "../../tools/file-write-fallback"; import type { EventBus } from "../../utils/event-bus"; import type { AgentEndEvent, @@ -1256,6 +1257,63 @@ export interface ExtensionAPI { /** Register a tool that the LLM can call. */ registerTool(tool: ToolDefinition): void; + /** + * Register a fallback writer consulted when a native `write`/`edit` byte-write is + * denied with a permission error (`EPERM`/`EACCES`/`EROFS`). Every other write + * error is unaffected. Handlers run in registration order; the first one to + * resolve `true` counts as the bytes being durably on disk, and the native tool + * continues as if its own write had succeeded — including recording its file + * snapshot under the real destination path, so a later hashline `edit` on that + * path keeps working. Intended for a host embedding the agent inside a sandbox + * that denies direct filesystem writes but exposes a privileged write channel. + * + * A denial that `Bun.write` masks as `ENOENT` — a write into a directory the host + * may not create — also diverts here, with `req.dst`'s parent absent and the + * handler responsible for creating it. + * + * `req.dst` is symlink-RESOLVED: the path the failed write itself acted on, not + * the one the tool was given. A link anywhere in a lexical path redirects the + * bytes while still passing a prefix allowlist, so treat `req.dst` as + * authoritative. A destination that cannot be resolved is never brokered. + * + * Call this during extension load, like the other `register*` methods: handlers + * are installed when the runner initializes, so an extension that has registered + * none by then is skipped and a first registration made later never takes effect. + * + * The underlying registry is process-wide, so a handler may be consulted for a + * denied write from any session in the process, not only its own. + * `req.sessionId` names the session that issued the write and + * `ctx.sessionManager.getSessionId()` names the handler's own; compare them + * before prompting, because `ctx.ui` belongs to the latter. See + * `docs/extensions.md`. + */ + registerFileWriteFallback(handler: FileWriteFallbackHandler): void; + + /** + * Register a fallback deleter consulted when a native `edit`/`apply_patch` unlink is + * denied with a permission error (`EPERM`/`EACCES`/`EROFS`). Covers `edit`'s `REM`, + * the source side of a hashline `MV`, and `apply_patch`'s delete op. Return `true` + * once `dst` is gone from disk. + * + * A handler MUST remove `dst` with a plain unlink and MUST NOT fall back to a + * recursive removal. `unlink` on a directory reports `EPERM` on Darwin, so the seam + * checks the target before diverting — but when the target's own metadata is behind + * the same boundary that denied the unlink, which is the common sandbox case, that + * check cannot be resolved and `dst` may be a directory. `req.confirmedFile` says + * which situation the handler is in. + * + * `req.dst` resolves every component ABOVE the last, for the same reason the + * write seam resolves all of them; the last is left alone because `unlink` + * removes a link rather than its target, so `req.dst` may name a link. + * + * Separate from {@link registerFileWriteFallback} on purpose. A write handler + * brokers `req.content` to `req.dst`, so a delete request reaching it with no + * content invites brokering an empty write and truncating the file instead of + * removing it. Registering for deletes is therefore an explicit opt-in, and the + * same load-time and process-wide notes above apply. + */ + registerFileDeleteFallback(handler: FileDeleteFallbackHandler): void; + // ========================================================================= // Command, Shortcut, Flag Registration // ========================================================================= @@ -1632,6 +1690,8 @@ export interface Extension { tools: Map>; toolRegistrationListeners?: Set; assistantThinkingRenderers: AssistantThinkingRenderer[]; + fileWriteFallbackHandlers: FileWriteFallbackHandler[]; + fileDeleteFallbackHandlers: FileDeleteFallbackHandler[]; messageRenderers: Map; commands: Map; flags: Map; diff --git a/packages/coding-agent/src/extensibility/extensions/wrapper.ts b/packages/coding-agent/src/extensibility/extensions/wrapper.ts index 5dd3d33e4..a4aa75dc8 100644 --- a/packages/coding-agent/src/extensibility/extensions/wrapper.ts +++ b/packages/coding-agent/src/extensibility/extensions/wrapper.ts @@ -14,6 +14,7 @@ import type { Settings } from "../../config/settings"; import type { Theme } from "../../modes/theme/theme"; import { type ApprovalMode, formatApprovalPrompt, resolveApproval, truncateForPrompt } from "../../tools/approval"; import { defaultLoadModeForToolName } from "../../tools/essential-tools"; +import { withFileMutationSession } from "../../tools/file-write-fallback"; import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input"; import { applyToolProxy } from "../tool-proxy"; import type { ExtensionRunner } from "./runner"; @@ -349,7 +350,15 @@ export class ExtensionToolWrapper + this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context), + ); } catch (err) { executionError = err instanceof Error ? err : new Error(String(err)); result = { diff --git a/packages/coding-agent/src/lsp/writethrough.ts b/packages/coding-agent/src/lsp/writethrough.ts index ec8b90785..959cafeda 100644 --- a/packages/coding-agent/src/lsp/writethrough.ts +++ b/packages/coding-agent/src/lsp/writethrough.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs"; import { isEnoent, logger, once, untilAborted } from "@oh-my-pi/pi-utils"; import type { BunFile } from "bun"; +import { isPermissionDeniedError, writeFileWithFallback } from "../tools/file-write-fallback"; import { FileChangeType, notifyWorkspaceWatchedFiles } from "./client"; import { getServersForFile } from "./config"; import { @@ -66,11 +67,7 @@ export async function writethroughNoop( _batch?: LspWritethroughBatchRequest, _getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined, ): Promise { - if (file) { - await file.write(content); - } else { - await Bun.write(dst, content); - } + await writeFileWithFallback(dst, content, file); return undefined; } @@ -78,6 +75,12 @@ interface PendingWritethrough { dst: string; file?: BunFile; changeType: FileChangeType; + /** + * The bytes this entry committed. The flush prefers a fresh read of `dst` so + * post-processing sees whatever else in the batch touched the file, and falls + * back to these when that read is denied. + */ + content: string; } interface RunLspWritethroughOptions { @@ -288,7 +291,7 @@ async function runLspWritethrough( const contentAlreadyWritten = runOptions?.contentAlreadyWritten ?? false; let finalContent = content; - const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value)); + const writeContent = async (value: string) => writeFileWithFallback(dst, value, file); const getWritePromise = once(() => contentAlreadyWritten && finalContent === content ? Promise.resolve() : writeContent(finalContent), ); @@ -458,9 +461,16 @@ async function flushWritethroughBatch( try { content = await fs.promises.readFile(entry.dst, "utf8"); } catch (error) { - if (!isEnoent(error)) throw error; - bundle?.finalize(undefined); - continue; + if (isEnoent(error)) { + bundle?.finalize(undefined); + continue; + } + // A brokered write lands bytes this process may not be able to read + // back: a sandbox that denies the write commonly denies the read too. + // Failing here would fail a flush whose every write succeeded, so the + // content this entry committed stands in for the unreadable file. + if (!isPermissionDeniedError(error)) throw error; + content = entry.content; } const deferredInner = bundle && @@ -552,7 +562,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions } const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions); - state.entries.set(dst, { dst, file, changeType }); + state.entries.set(dst, { dst, file, changeType, content }); if (!batch.flush) return undefined; writethroughBatches.delete(batch.id); diff --git a/packages/coding-agent/src/slash-commands/builtin-session.ts b/packages/coding-agent/src/slash-commands/builtin-session.ts index 456a712e7..a799725dd 100644 --- a/packages/coding-agent/src/slash-commands/builtin-session.ts +++ b/packages/coding-agent/src/slash-commands/builtin-session.ts @@ -336,7 +336,7 @@ export const BUILTIN_SESSION_SLASH_COMMANDS: ReadonlyArray = [ { name: "stats", description: "Launch the local stats dashboard", - inlineHint: "[--port ]", + inlineHint: "[--port ] [--host ]", allowArgs: true, handle: async (command, runtime) => { const parsed = parseStatsDashboardArgs(command.args); diff --git a/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts b/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts index a22da28a6..569ef25b7 100644 --- a/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts +++ b/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts @@ -11,6 +11,7 @@ interface StatsDashboardServer { export interface StatsDashboardArgs { port: number; + host: string; } export interface StatsDashboardLaunchResult { @@ -20,7 +21,7 @@ export interface StatsDashboardLaunchResult { let activeStatsServer: StatsDashboardServer | undefined; -const STATS_DASHBOARD_USAGE = "Usage: /stats [--port ]"; +const STATS_DASHBOARD_USAGE = "Usage: /stats [--port ] [--host ]"; function parsePort(value: string | undefined): number | string { if (!value) return `Missing port. ${STATS_DASHBOARD_USAGE}`; @@ -33,6 +34,7 @@ function parsePort(value: string | undefined): number | string { export function parseStatsDashboardArgs(args: string): StatsDashboardArgs | { error: string } { const tokens = args.split(/\s+/).filter(Boolean); let port = DEFAULT_STATS_DASHBOARD_PORT; + let host = "127.0.0.1"; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; @@ -48,28 +50,40 @@ export function parseStatsDashboardArgs(args: string): StatsDashboardArgs | { er port = parsed; continue; } + if (token === "--host") { + const value = tokens[++i]; + if (!value) return { error: `Missing host. ${STATS_DASHBOARD_USAGE}` }; + host = value; + continue; + } + if (token.startsWith("--host=")) { + const value = token.slice("--host=".length); + if (!value) return { error: `Missing host. ${STATS_DASHBOARD_USAGE}` }; + host = value; + continue; + } return { error: `Unknown option: ${token}. ${STATS_DASHBOARD_USAGE}` }; } - return { port }; + return { port, host }; } export async function launchStatsDashboard(args: StatsDashboardArgs): Promise { const { processed, files } = await stats.syncAllSessions(); const total = await stats.getTotalMessageCount(); - let requestedPortIgnored = false; + let requestedAddressIgnored = false; if (!activeStatsServer) { - activeStatsServer = await stats.startServer(args.port); - } else if (args.port !== activeStatsServer.port) { - requestedPortIgnored = true; + activeStatsServer = await stats.startServer(args.port, args.host); + } else if (args.port !== activeStatsServer.port || args.host !== activeStatsServer.hostname) { + requestedAddressIgnored = true; } - const url = `http://${activeStatsServer.hostname}:${activeStatsServer.port}`; + const url = stats.formatStatsDashboardUrl(activeStatsServer.hostname, activeStatsServer.port); openUtils.openPath(url); - const serverLine = requestedPortIgnored - ? `Dashboard already running at: ${url} (requested port ${args.port} ignored)` + const serverLine = requestedAddressIgnored + ? `Dashboard already running at: ${url} (requested ${args.host}:${args.port} ignored)` : `Dashboard available at: ${url}`; return { diff --git a/packages/coding-agent/src/tools/file-write-fallback.ts b/packages/coding-agent/src/tools/file-write-fallback.ts new file mode 100644 index 000000000..dd7b14fa9 --- /dev/null +++ b/packages/coding-agent/src/tools/file-write-fallback.ts @@ -0,0 +1,467 @@ +/** + * In-session fallbacks for permission-denied file writes and deletes. + * + * A host that embeds the agent inside an OS sandbox can grant a path mid-session + * but cannot apply that grant to an in-process write, because the write happens + * in the agent process under a profile fixed at launch. This module gives such a + * host a seam to intercept a denied mutation and perform it through a privileged + * channel, without reimplementing `write`/`edit` semantics: the native tool still + * records its own snapshot under the real destination path once the fallback + * reports success, so a follow-up hashline `edit` on that path keeps working. + * + * Writes and deletes have SEPARATE registries. A write handler brokers `content` + * to `dst`, so a delete request reaching it with no content invites brokering an + * empty write and truncating the file it was asked to remove. Opting into deletes + * is therefore explicit; see {@link addFileDeleteFallback}. + * + * ## What is routed + * + * The byte-write that `write`, `edit` and `apply_patch` perform on an ordinary + * file path goes through the same two-line primitive + * (`file ? file.write(content) : Bun.write(dst, content)`). It has four call + * sites, and all of them route here: + * + * - `writethroughNoop` and `runLspWritethrough`'s `writeContent` (`lsp/writethrough.ts`), + * the `WritethroughCallback` that `write` and `edit` both write through. + * `apply_patch` reaches it too: `LspFileSystem.write` (`edit/modes/patch.ts`), + * which it always injects, delegates to the same callback. + * - `HashlineFilesystem.move` (`edit/hashline/filesystem.ts`) — a hashline `MV` + * destination, the one `edit` write that does not pass through the writethrough. + * - `defaultFileSystem.write` (`edit/modes/patch.ts`), only the default parameter + * for external `applyPatch` callers and tests. + * + * `apply_patch` also creates a missing parent directory before writing, via its + * filesystem's `mkdir`. That `mkdir` consults {@link hasFileWriteFallback} so a + * denial there falls through to the write and reaches a handler, instead of + * throwing before the seam is ever consulted. + * + * The unlink that `edit` and `apply_patch` perform routes to the separate delete + * seam ({@link deleteFileWithFallback}) at four sites: `HashlineFilesystem.delete` + * (`edit`'s `REM`) and `HashlineFilesystem.move`'s source unlink, plus + * `LspFileSystem.delete` and `defaultFileSystem.delete` for `apply_patch`. + * + * ## What is NOT routed + * + * This is deliberately not an exhaustive interception of every syscall the tools + * can make. A permission error from any of these surfaces as it does today: + * + * - `write` to an archive member (`foo.zip:entry`) or a SQLite row. Neither is a + * byte-write to `dst`: an archive member rewrite reads the whole archive, sets + * one entry, writes a temp file and renames over the original, so the bytes on + * disk are a whole binary container rather than the string the tool was given; + * a SQLite write is a row operation inside the database engine with no byte + * payload at all. Brokering either needs a different request shape than + * "these exact bytes belong at this path". + * - `acp-bridge.ts`'s `bridge.writeTextFile` — a remote-client transport. + * - Removing a DIRECTORY is never the intent: the delete seam refuses to divert a + * target it can confirm is one, and reports `confirmedFile: false` when the + * target's metadata is behind the same boundary and the check cannot be resolved. + * - The `lsp` tool's own writes: applying a workspace edit or a code action + * (`lsp/edits.ts`), and the Biome formatter, which writes the buffer and then + * shells out to `biome format --write` (`lsp/clients/biome-client.ts`) — a + * subprocess write no in-process seam can reach anyway. + * + * ## Diverting + * + * Only a permission boundary diverts — `EPERM`, `EACCES`, `EROFS`, plus the one + * case where Bun hides such a denial behind an `ENOENT` (see + * {@link classifyWriteFailure}). Every other error rethrows untouched. + * + * With no handler registered this module is inert: the primitive runs exactly as + * it did before, a failure rethrows from the same place, and no extra syscalls + * are performed. + * + * ## The path a handler is given + * + * A handler is more privileged than the syscall that just failed, so it is never + * handed the lexical path the tool used. A lexical path is not a destination: the + * kernel follows every component above the last, so `ws/link/file` under a + * `ws/link -> /elsewhere` link lands outside `ws` while still looking + * in-workspace. That defeats the defence a helper author reaches for first, since + * a prefix allowlist passes on the link's own path — and for writes the final + * component is followed too, so a plain `ws/link` is enough. + * + * `req.dst` is therefore resolved through {@link resolveSyscallTarget} to the path + * the failed syscall itself acted on: fully for a write, and up to the last + * component for a delete, since `unlink` removes a link rather than following it. + * Resolving rather than refusing also closes the TOCTOU window, because the + * handler no longer traverses a link the agent could re-point after the check. + * + * A path that cannot be canonicalized — a dangling final link, or an ancestor + * whose own resolution is denied — is not brokered at all. "Where would this + * land" has no answer there, and a privileged writer is the wrong place to guess. + * That narrows the seam for a sandbox that also hides the ancestors of a denied + * path, which is the honest cost of not handing over an unverifiable target. + * + * That refusal is load-bearing for more than symlink safety, and relaxing it needs + * care. `apply_patch`'s `create` and rename-destination refuse to overwrite, and + * they decide that with `Bun.file(dst).exists()`, which reports `false` when the + * parent hides the target's metadata rather than distinguishing "absent" from + * "unknown". The non-overwrite contract holds today only because the same denied + * `lstat` that fools that check also stops this seam from brokering — a privileged + * writer, the one party that could enforce exclusivity itself, is never handed the + * path. Broker an unverifiable destination and a `create` starts clobbering a + * protected file it was told not to touch; a request field carrying explicit + * exclusive-create intent would be the prerequisite for that change. + * + * ## Scope of the registry + * + * Handlers live in one process-wide list, and a process can host several sessions + * (a subagent gets its own `ExtensionRunner`). A handler is therefore consulted + * for denied mutations from ANY session in the process, not only the one whose + * extension registered it. Filtering by session here would be wrong: a subagent + * spawned with `restrictToolNames` loads no extensions of its own, so scoping + * would leave its denied writes with nothing to broker them, and a host that + * registers once in its top-level session expects subagent writes covered. + * + * So the request names its origin instead, and the policy stays with the party + * that owns it. `req.sessionId` is the session that issued the mutation (see + * {@link withFileMutationSession}); a handler compares it with + * `ctx.sessionManager.getSessionId()` to decide. That matters most for a handler + * that prompts: `ctx.ui` belongs to the session whose extension registered the + * handler, which is not necessarily the session being asked about. + * + * Each list is iterated over a snapshot, because a concurrent session shutdown + * splices the live array and a `for` over it would skip whichever handler shifted + * into the hole. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { isEnoent, isFsError, logger } from "@oh-my-pi/pi-utils"; +import type { BunFile } from "bun"; +import type { ExtensionContext } from "../extensibility/extensions/types"; +import { resolveSyscallTarget } from "./path-utils"; + +/** A denied write, captured for a registered fallback to retry through a privileged channel. */ +export interface FileWriteFallbackRequest { + /** + * Absolute, symlink-resolved path to write the bytes to. + * + * This is where the failed in-process write would itself have landed, which is + * not necessarily the path the tool was given: `open` follows every component, + * so a link anywhere in that path redirects the bytes. Resolving it here is + * what lets a handler's allowlist see the real destination instead of a + * lexically innocent path, so a handler MUST treat this as authoritative and + * MUST NOT re-derive the target from anything else. + */ + dst: string; + /** + * Session the denied write was issued from, or `undefined` when the mutation + * did not happen inside a tool call (an external `applyPatch` caller, a test). + * + * The registry is process-wide, so a handler can be consulted for a write from + * a session other than the one whose extension registered it. Compare this with + * `ctx.sessionManager.getSessionId()` to tell the two apart — a handler that + * prompts through `ctx.ui` needs to, since that UI belongs to ITS session and + * not necessarily to the one being asked about. + */ + sessionId: string | undefined; + /** The exact bytes the tool intended to write. */ + content: string; + /** + * The error that proves the write hit a permission boundary. Usually the write's + * own `EPERM`/`EACCES`/`EROFS`; for a write into a directory the host may not + * create, the denial raised by creating that directory, in which case `dst`'s + * parent may not exist yet and the handler is responsible for creating it. + */ + cause: unknown; +} + +/** Extension-authored handler. Return `true` once `content` is durably on disk at `dst`. */ +export type FileWriteFallbackHandler = (req: FileWriteFallbackRequest, ctx: ExtensionContext) => Promise; + +/** A handler already bound to its owning extension's live context. */ +type BoundFileWriteFallbackHandler = (req: FileWriteFallbackRequest) => Promise; + +/** A denied unlink, captured for a registered fallback to perform through a privileged channel. */ +export interface FileDeleteFallbackRequest { + /** + * Absolute, symlink-resolved path the unlink was denied for. + * + * Every component ABOVE the last is resolved, so a handler cannot be walked + * outside its allowed roots through a link in the path. The last component is + * deliberately NOT resolved, because `unlink` removes a link itself rather + * than its target — which is also why this may still name a symlink. + */ + dst: string; + /** The `EPERM`/`EACCES`/`EROFS` that proves the unlink hit a permission boundary. */ + cause: unknown; + /** + * Whether `dst` was confirmed to be a plain regular file before diverting. + * + * `false` means the seam could not establish that, either because the target's + * own metadata is behind the same boundary that denied the unlink — the common + * sandbox case, since `unlink` on a directory also reports `EPERM` on Darwin — + * or because `dst` is a symlink. + * + * A handler MUST remove `dst` with a plain unlink. It MUST NOT remove it + * recursively, and MUST NOT resolve the path first: when this is `false` the + * target may be a directory, and resolving a symlink would delete whatever it + * points at instead of the link. + */ + confirmedFile: boolean; + /** See {@link FileWriteFallbackRequest.sessionId}. */ + sessionId: string | undefined; +} + +/** Extension-authored handler. Return `true` once `dst` is gone from disk. */ +export type FileDeleteFallbackHandler = (req: FileDeleteFallbackRequest, ctx: ExtensionContext) => Promise; + +/** A handler already bound to its owning extension's live context. */ +type BoundFileDeleteFallbackHandler = (req: FileDeleteFallbackRequest) => Promise; + +const PERMISSION_DENIED_CODES: Record = { EPERM: true, EACCES: true, EROFS: true }; +const PERMISSION_DENIED_MESSAGE = /\b(EPERM|EACCES|EROFS)\b/; + +/** True for `EPERM`, `EACCES`, and `EROFS` — the sandbox-boundary write failures this seam exists for. */ +export function isPermissionDeniedError(error: unknown): boolean { + // A structured `code` is authoritative. Checking the message as well would + // misclassify any error whose path contains one of these names, and Bun embeds + // the full path in its fs error messages (`ENOENT: ..., open '/x/EACCES/y'`). + if (isFsError(error)) return PERMISSION_DENIED_CODES[error.code] === true; + // Some write paths (e.g. a bridged transport) surface the denial as a plain + // Error with no structured `code`, leaving only the message to go on. + return error instanceof Error && PERMISSION_DENIED_MESSAGE.test(error.message); +} + +const fallbackHandlers: BoundFileWriteFallbackHandler[] = []; + +/** Whether any fallback is registered. Lets a caller skip work that only this seam needs. */ +export function hasFileWriteFallback(): boolean { + return fallbackHandlers.length > 0; +} + +/** + * Append a fallback writer, consulted in registration order when a direct write is + * permission-denied. Returns a disposer that removes this exact registration; the + * runner calls it on session shutdown so no handler outlives its session. + */ +export function addFileWriteFallback(handler: BoundFileWriteFallbackHandler): () => void { + fallbackHandlers.push(handler); + return () => { + const index = fallbackHandlers.indexOf(handler); + if (index !== -1) fallbackHandlers.splice(index, 1); + }; +} + +const deleteFallbackHandlers: BoundFileDeleteFallbackHandler[] = []; + +/** Whether any delete fallback is registered. */ +export function hasFileDeleteFallback(): boolean { + return deleteFallbackHandlers.length > 0; +} + +/** + * Append a fallback deleter, consulted in registration order when a direct unlink is + * permission-denied. Deliberately a separate registry from + * {@link addFileWriteFallback}: a write handler brokers `content` to `dst`, and + * handing it a request with no content would let it "broker" an empty write and + * truncate the file it was asked to remove. Opting in is explicit for that reason. + */ +export function addFileDeleteFallback(handler: BoundFileDeleteFallbackHandler): () => void { + deleteFallbackHandlers.push(handler); + return () => { + const index = deleteFallbackHandlers.indexOf(handler); + if (index !== -1) deleteFallbackHandlers.splice(index, 1); + }; +} + +const mutationSessionStorage = new AsyncLocalStorage(); + +/** + * Name the session whose tool call is about to run, so a denied mutation inside it + * can tell a handler where the request came from. + * + * Entered once per tool call by `ExtensionToolWrapper` (`extensibility/extensions/ + * wrapper.ts`), which `sdk.ts` puts around the whole tool registry whenever an + * `ExtensionRunner` exists — so the component that owns the handlers is the one + * naming its own session, and no caller has to thread an `AgentToolContext` + * through for attribution to work. + * + * That covers the deferred LSP write batch too: a batch id belongs to one + * assistant turn of one session, and its flush is awaited inside a tool call of + * that same session, so a write performed during a later call of the group is + * still attributed to the session that issued it. + * + * Deliberately NOT a general "current session" accessor: nothing else enters this + * scope, so outside a tool call it is empty by design — an external `applyPatch` + * caller reports `undefined` rather than borrowing someone else's identity. + */ +export function withFileMutationSession(sessionId: string | undefined, fn: () => T): T { + // With nothing registered no scope is entered, keeping the seam's inertness + // promise: a stock host pays one length check per tool call and no more. + if (sessionId === undefined || (fallbackHandlers.length === 0 && deleteFallbackHandlers.length === 0)) return fn(); + return mutationSessionStorage.run(sessionId, fn); +} + +/** + * Remove a file, consulting registered delete fallbacks when the unlink is denied. + * + * Unlike the write path there is no masked-`ENOENT` case to see through: nothing is + * created on the way, so an `ENOENT` here means the file genuinely is not there and + * must propagate — `edit`'s `REM` turns it into a `NotFoundError`. + */ +export async function deleteFileWithFallback(dst: string, file?: BunFile): Promise { + try { + if (file) { + await file.unlink(); + } else { + await fs.unlink(dst); + } + } catch (error) { + if (deleteFallbackHandlers.length === 0 || !isPermissionDeniedError(error)) throw error; + // A handler is more privileged than the unlink that just failed, so it is told + // which path really gets removed, not the lexical one the tool used. `unlink` + // follows every component ABOVE the last, so `ws/link/victim` under a + // `ws/link -> /elsewhere` link removes a file outside `ws` while a helper's + // prefix allowlist still passes. The final component is deliberately left + // unresolved: `unlink` removes the link itself, never its target. + const target = await resolveSyscallTarget(dst, false); + if (target === null) throw error; + // `unlink` on a directory reports EPERM on Darwin (EISDIR on Linux), which is + // indistinguishable from a sandbox denial by code alone, so check the target + // before diverting: asking a privileged deleter to remove a DIRECTORY on + // behalf of a tool that only ever removes one file would far exceed the + // intent. `lstat` rather than `stat`, so the link itself is judged — removing + // a symlink is a legitimate file removal, and following it here would ask the + // wrong question. + const stat = await fs.lstat(target).catch((statError: unknown) => { + // A sandbox that denies the unlink usually denies the target's metadata + // too, so a denied `lstat` is expected here and must still divert — it + // just leaves the question unresolved, which `confirmedFile` reports. + // Any OTHER `lstat` failure is not something this seam should paper over. + if (isPermissionDeniedError(statError)) return null; + throw error; + }); + if (stat?.isDirectory()) throw error; + // A symlink is safe to unlink but NOT safe to resolve: a helper that + // realpaths `dst` for auditing, or removes it recursively, would act on the + // link's target instead. Only a plain regular file is a confirmed file. + const confirmedFile = stat?.isFile() ?? false; + // The process-wide registry can hand this to a handler from another session, + // so the request names the one that issued it. + const sessionId = mutationSessionStorage.getStore(); + // Snapshot: a concurrent session shutdown splices the live array, and + // iterating it directly would skip whichever handler shifted into the hole. + for (const handler of [...deleteFallbackHandlers]) { + try { + if (await handler({ dst: target, cause: error, confirmedFile, sessionId })) return; + } catch (handlerError) { + logger.warn("File delete fallback handler threw; trying next handler", { + dst: target, + error: handlerError instanceof Error ? handlerError.message : String(handlerError), + }); + } + } + // Always the ORIGINAL error, never a handler's, so behaviour matches a host + // with no fallback registered. + throw error; + } +} + +/** + * Outcome of inspecting a failed primitive write. `denied` diverts to the + * registered handlers, `retry` repeats the write because this call repaired the + * cause, and `rethrow` leaves the original error alone. + */ +type WriteFailureKind = { kind: "denied"; cause: unknown } | { kind: "retry" } | { kind: "rethrow" }; + +/** + * Decide whether a failed write hit a permission boundary. + * + * `Bun.write` and `BunFile.write` create missing parent directories themselves, + * but when that `mkdir` is the thing being denied they report the subsequent + * `open()`'s `ENOENT` rather than the denial — so a sandboxed write into a new + * out-of-tree directory is indistinguishable from an ordinary missing path. + * Redoing the `mkdir` explicitly recovers the real errno, and because it runs + * through the same enforcement path as the write it sees kernel-level denials + * (Seatbelt, LSM) that a `stat`/`access` probe would report as writable. + * + * Only called with at least one handler registered, so a stock host never pays + * for this. + */ +async function classifyWriteFailure(dst: string, error: unknown): Promise { + if (isPermissionDeniedError(error)) return { kind: "denied", cause: error }; + if (!isEnoent(error)) return { kind: "rethrow" }; + try { + await fs.mkdir(path.dirname(dst), { recursive: true }); + } catch (mkdirError) { + // A denied `mkdir` is the boundary the write hid; anything else (`ENOTDIR` + // for a file used as a directory, ...) is a genuine bad path. + if (isPermissionDeniedError(mkdirError)) return { kind: "denied", cause: mkdirError }; + return { kind: "rethrow" }; + } + // The parent exists now, so the `ENOENT` was a lost race rather than a + // boundary. Any directory just created stays, matching what a permitted + // `Bun.write` would have left behind; removing it could race a concurrent + // writer that legitimately needs it. + return { kind: "retry" }; +} + +export async function writeFileWithFallback(dst: string, content: string, file?: BunFile): Promise { + // Attempt 0 is the plain write. The single retry is reachable only when the + // first failure turned out to be a parent-directory race this call repaired, + // which bounds the loop at two writes. + for (let attempt = 0; ; attempt++) { + try { + if (file) { + await file.write(content); + } else { + await Bun.write(dst, content); + } + return; + } catch (error) { + if (fallbackHandlers.length === 0) throw error; + // On the second attempt a `retry` verdict can no longer change the + // outcome, so skip the probe and let the error stand unless it is a + // denial the handlers should see. + const failure = + attempt === 0 + ? await classifyWriteFailure(dst, error) + : isPermissionDeniedError(error) + ? ({ kind: "denied", cause: error } as const) + : ({ kind: "rethrow" } as const); + if (failure.kind === "retry") continue; + if (failure.kind === "denied") { + // A handler is more privileged than the write that just failed, so it is + // told where the bytes would REALLY have landed rather than the lexical + // path the tool used. `open` follows EVERY component, so `ws/link/file` + // under a `ws/link -> /elsewhere` link writes outside `ws` while still + // looking in-workspace — which defeats the defence a helper author + // reaches for first, since a prefix allowlist passes on the link's own + // path. Resolving closes that, and closes the TOCTOU window with it: the + // helper no longer traverses a link the agent could re-point after the + // check. A path that cannot be canonicalized is not brokered at all, + // because "where would this land" then has no answer to hand over. + const target = await resolveSyscallTarget(dst, true); + // Snapshot: a concurrent session shutdown splices the live array, and + // iterating it directly would skip whichever handler shifted into the hole. + if (target !== null) { + // The process-wide registry can hand this to a handler from another + // session, so the request names the one that issued it. + const sessionId = mutationSessionStorage.getStore(); + for (const handler of [...fallbackHandlers]) { + try { + if (await handler({ dst: target, content, cause: failure.cause, sessionId })) return; + } catch (handlerError) { + logger.warn("File write fallback handler threw; trying next handler", { + dst: target, + error: handlerError instanceof Error ? handlerError.message : String(handlerError), + }); + } + } + } + } + // Always the ORIGINAL error, never a handler's, so behaviour matches a + // host with no fallback registered. When the real boundary was recovered + // from behind a masked `ENOENT`, attach it so the denial is not lost: + // without this the caller is told `ENOENT` for a path this code has + // already proven is `EACCES`. + if (failure.kind === "denied" && failure.cause !== error && error instanceof Error && error.cause == null) { + error.cause = failure.cause; + } + throw error; + } + } +} diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 0890fe1fd..4c0c4401c 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -86,6 +86,7 @@ export * from "./debug"; export * from "./essential-tools"; export * from "./eval"; export * from "./eval-backends"; +export * from "./file-write-fallback"; export * from "./gh"; export * from "./glob"; export * from "./grep"; diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index 696f5799a..df153ee27 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -618,6 +618,85 @@ function isSymlink(target: string): boolean { } } +/** + * Resolve the path a syscall on `filePath` would really act on, or `null` when + * that cannot be established. + * + * A lexical path is not a destination. The kernel follows every component above + * the last, so `ws/link/file` under a `ws/link -> /elsewhere` link lands outside + * `ws` while still looking relative and `..`-free. Handing such a path to a + * privileged helper defeats the defence a helper author reaches for first — a + * prefix allowlist passes, because the link sits inside the allowed root while + * its target does not. Callers that hand a path to something more privileged + * than the syscall that just failed resolve it here first. + * + * Rejecting symlinked components outright is not an option: `/var` and `/tmp` + * are links on macOS, so every path under `os.tmpdir()` traverses one. They are + * resolved instead, and only a path whose real destination cannot be established + * is refused, because "where would this land" then has no answer to hand over. + * {@link confineToWorkspace} refuses an unresolvable link for the same reason. + * + * @param followFinal `true` for a syscall that follows a link at the final + * component (`open`, so every write), `false` for one that acts on the link + * itself (`unlink`) and therefore needs it left alone. + */ +export async function resolveSyscallTarget(filePath: string, followFinal: boolean): Promise { + const target = path.resolve(filePath); + if (followFinal) { + const real = await tryRealpathAsync(target); + if (real !== null) return real; + // `realpath` also fails on a DANGLING link, which a write follows to a place + // this cannot name, and on a path whose ancestor may not be searched. Neither + // is proof the final component is a plain name, and only proof continues. + if (!(await isProvenNotSymlink(target))) return null; + } + // Walk up to the deepest ancestor that does resolve, then re-apply the + // components below it. A resolved ancestor vouches for the ones above it, so + // re-applying them lexically matches what the kernel would have done. + const tail: string[] = [path.basename(target)]; + let ancestor = path.dirname(target); + for (;;) { + const real = await tryRealpathAsync(ancestor); + if (real !== null) return path.join(real, ...tail.reverse()); + // This component is about to be re-applied lexically without a resolved + // ancestor vouching for it, which is exactly the escape being closed — so it + // has to prove itself. `realpath` fails here for a component that does not + // exist yet AND for one inside a directory the caller may not search (the + // usual shape when a sandbox hides a denied path), and the second still + // permits `lstat`. + if (!(await isProvenNotSymlink(ancestor))) return null; + const parent = path.dirname(ancestor); + // Ran past the filesystem root: `realpath("/")` cannot fail, so only a + // filesystem disappearing mid-walk gets here. + if (parent === ancestor) return null; + tail.push(path.basename(ancestor)); + ancestor = parent; + } +} + +async function tryRealpathAsync(target: string): Promise { + try { + // `fs.promises.realpath` has no `.native` variant under Bun, unlike its sync + // counterpart; the JS implementation resolves links identically. + return await fs.promises.realpath(target); + } catch { + return null; + } +} + +/** + * Whether `target` is known NOT to redirect. A path that does not exist cannot + * redirect anything, and nothing below it exists either; any other `lstat` + * failure leaves the question unanswered, which is not proof. + */ +async function isProvenNotSymlink(target: string): Promise { + try { + return !(await fs.promises.lstat(target)).isSymbolicLink(); + } catch (error) { + return isEnoent(error); + } +} + export function formatPathRelativeToCwd( filePath: string, cwd: string, diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index e344bcbb6..6de740abe 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -3739,6 +3739,8 @@ describe("ExtensionRunner", () => { handlers: new Map([["input", [async (...args: unknown[]) => handler(args[0] as InputEvent)]]]), tools: new Map(), assistantThinkingRenderers: [], + fileWriteFallbackHandlers: [], + fileDeleteFallbackHandlers: [], messageRenderers: new Map(), commands: new Map(), flags: new Map(), diff --git a/packages/coding-agent/test/fixtures/changelog-bundle-fallback-probe.ts b/packages/coding-agent/test/fixtures/changelog-bundle-fallback-probe.ts index bd7cdd408..0b96348d4 100644 --- a/packages/coding-agent/test/fixtures/changelog-bundle-fallback-probe.ts +++ b/packages/coding-agent/test/fixtures/changelog-bundle-fallback-probe.ts @@ -1,4 +1,3 @@ -import { VERSION } from "@oh-my-pi/pi-utils"; import { parseChangelog } from "../../src/utils/changelog"; const missingPackageChangelogPath = process.argv[2]; @@ -8,9 +7,13 @@ if (!missingPackageChangelogPath) { const entries = await parseChangelog(missingPackageChangelogPath); const latest = entries[0]; +// The callers compare this output against a from-source run of the same probe, +// so the only local contract is "the bundled fallback asset parsed into a real +// release entry" — not that the latest entry matches the package version (a +// release may legally ship without coding-agent changelog content). const version = latest ? `${latest.major}.${latest.minor}.${latest.patch}` : undefined; -if (version !== VERSION || !latest?.content.startsWith(`## [${VERSION}]`)) { - throw new Error(`Unexpected latest changelog release: ${JSON.stringify({ version, expected: VERSION })}`); +if (version === undefined || !latest?.content.startsWith(`## [${version}]`)) { + throw new Error(`Bundled changelog fallback did not parse a release entry: ${JSON.stringify({ version })}`); } process.stdout.write(JSON.stringify({ version, entries: entries.length })); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 6d0e2d86d..8b6cedd52 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { type Api, Effort, type Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models"; import { expandRoleAlias, @@ -439,30 +440,11 @@ describe("pickDefaultAvailableModel", () => { }); test("prefers SuperGrok over paid xAI when both defaults are present", () => { - const paid = buildModel({ - id: "grok-4.5", - name: "Grok 4.5", - api: "openai-responses", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, - contextWindow: 500000, - maxTokens: 500000, - }); - const oauth = buildModel({ - id: "grok-4.5", - name: "Grok 4.5", - api: "openai-responses", - provider: "xai-oauth", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 500000, - maxTokens: 500000, - }); + const paid = getBundledModel("xai", DEFAULT_MODEL_PER_PROVIDER.xai); + const oauth = getBundledModel("xai-oauth", DEFAULT_MODEL_PER_PROVIDER["xai-oauth"]); + if (!paid || !oauth) { + throw new Error("Expected bundled xAI provider defaults"); + } expect(pickDefaultAvailableModel([paid, oauth])?.provider).toBe("xai-oauth"); expect(pickDefaultAvailableModel([paid])?.provider).toBe("xai"); diff --git a/packages/coding-agent/test/rpc-client.restart.test.ts b/packages/coding-agent/test/rpc-client.restart.test.ts index e6f56b9d1..eff4df35d 100644 --- a/packages/coding-agent/test/rpc-client.restart.test.ts +++ b/packages/coding-agent/test/rpc-client.restart.test.ts @@ -1,7 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import * as path from "node:path"; import { RpcClient } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-client"; -import { ptree, TempDir } from "@oh-my-pi/pi-utils"; +import { type ChildProcess, ptree, TempDir } from "@oh-my-pi/pi-utils"; const MOCK_AGENT = path.join(import.meta.dir, "fixtures", "mock-rpc-agent.ts"); @@ -243,4 +243,41 @@ describe("RpcClient lifecycle (issue #4079 B)", () => { "Agent process exited with code 23. Stderr: fixture worker failed", ); }); + + test("start() rejects instead of hanging when a pre-ready worker closes stdout and never exits", async () => { + // The worker outlives its own stdout, so start() cannot learn an exit code + // and must still fail: it waits a bounded time for the exit, then reports + // the stream end. A regression stalls until the 30s ready timeout, which + // this test's own timeout catches. + let resolveExit: ((exitCode: number) => void) | undefined; + let killCalls = 0; + const fakeChild = { + stdout: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + stdin: { write: () => 0, flush: () => 0 }, + exited: new Promise(resolve => { + resolveExit = resolve; + }), + peekStderr: () => "worker went quiet", + kill() { + killCalls += 1; + resolveExit?.(0); + }, + }; + const spawn = spyOn(ptree, "spawn").mockImplementation(() => fakeChild as unknown as ChildProcess); + + try { + using client = new RpcClient({ cliPath: MOCK_AGENT, terminationGraceMs: 10 }); + await expect(client.start()).rejects.toThrow( + "Agent output stream ended before ready. Stderr: worker went quiet", + ); + // The failed start must also reap the orphan rather than leak it. + expect(killCalls).toBe(1); + } finally { + spawn.mockRestore(); + } + }, 5_000); }); diff --git a/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts b/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts index e3715ad18..6504aa9a3 100644 --- a/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts +++ b/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts @@ -506,6 +506,8 @@ describe("createAgentSession credential_disabled subscription", () => { ]), tools: new Map(), assistantThinkingRenderers: [], + fileWriteFallbackHandlers: [], + fileDeleteFallbackHandlers: [], messageRenderers: new Map(), commands: new Map(), flags: new Map(), diff --git a/packages/coding-agent/test/sdk-file-write-fallback-extension.test.ts b/packages/coding-agent/test/sdk-file-write-fallback-extension.test.ts new file mode 100644 index 000000000..383fc182c --- /dev/null +++ b/packages/coding-agent/test/sdk-file-write-fallback-extension.test.ts @@ -0,0 +1,586 @@ +/** + * End-to-end proof of the `registerFileWriteFallback` seam: a REAL extension, + * loaded through the REAL loader/runner pipeline, registered on a REAL + * `createAgentSession` session, intercepting a REAL EACCES raised by the + * kernel for a genuinely-unwritable destination — not a fake resolver + * standing in for the extension path. + * + * Permission denial is simulated without a sandbox, since real permission + * bits behave differently depending on whether the destination already + * exists: + * - `write` targets a NEW file inside a directory chmod'd `0o500` (no write + * bit). Creating a file needs write permission on the *directory*, so this + * raises a real EACCES on `Bun.write`. + * - `edit` overwrites an EXISTING file chmod'd `0o400` (no write bit). Bun + * opens the existing inode directly for the rewrite, so a locked + * *directory* alone does NOT block it (verified empirically) — only a + * locked *file* does. + */ +import { afterAll, afterEach, beforeAll, describe, expect, it, spyOn } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { + ExtensionActions, + ExtensionContextActions, + ExtensionFactory, + ExtensionRunner, +} from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; +import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "@oh-my-pi/pi-coding-agent/sdk"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import type { FileWriteFallbackRequest } from "@oh-my-pi/pi-coding-agent/tools/file-write-fallback"; +import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; + +/** + * Drives `ExtensionRunner.initialize` with no-op stubs, mirroring what a mode + * controller (interactive/RPC/ACP/print/subagent) does after + * `createAgentSession` returns. Without this, `registerFileWriteFallback` + * handlers never install: `ExtensionRunner` binds them to a live `ctx` inside + * `initialize`, not at extension-load time. + */ +function initializeRunnerForTest(runner: ExtensionRunner | undefined): void { + if (!runner) return; + const actions: ExtensionActions = { + sendMessage: () => {}, + sendUserMessage: () => {}, + appendEntry: () => {}, + setLabel: () => {}, + getActiveTools: () => [], + getAllTools: () => [], + setActiveTools: async () => {}, + getCommands: () => [], + setModel: async () => false, + getThinkingLevel: () => undefined, + setThinkingLevel: () => {}, + getSessionName: () => undefined, + setSessionName: async () => {}, + }; + const contextActions: ExtensionContextActions = { + getModel: () => undefined, + isIdle: () => true, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: async () => {}, + getSystemPrompt: () => [], + }; + runner.initialize(actions, contextActions); +} + +function resultText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((b): b is { type: "text"; text: string } => b.type === "text" && typeof b.text === "string") + .map(b => b.text) + .join("\n"); +} + +const HASHLINE_HEADER_LINE = /^\[([^#\r\n]+)#([0-9A-F]{4})\]$/; + +describe("registerFileWriteFallback end-to-end (real extension, real session)", () => { + const tempDirs: string[] = []; + const lockedDirs: string[] = []; + let modelRegistry!: ModelRegistry; + let registryAuthDir: string; + + const makeTempDir = (): string => { + const created = path.join(os.tmpdir(), `pi-file-write-fallback-e2e-${Snowflake.next()}`); + fs.mkdirSync(created, { recursive: true }); + // The seam brokers a symlink-RESOLVED path, and `os.tmpdir()` sits under `/var` + // — itself a link — on macOS. Canonicalizing the fixture up front keeps a + // handler's `req.dst` comparable to the path a test built. + const tempDir = fs.realpathSync.native(created); + tempDirs.push(tempDir); + return tempDir; + }; + + /** Tighten a mode and register it for restoration in `afterEach`, not in the test body. */ + const lock = (target: string, mode: number): void => { + lockedDirs.push(target); + fs.chmodSync(target, mode); + }; + + // Mode bits do not constrain a privileged user, so `chmod` denies nothing as root + // and every expectation that depends on a real denial would fail for a reason + // unrelated to the seam. Root is real for a Docker-based local run and for a + // self-hosted CI runner. `getuid` is undefined on Windows, where these modes are + // not enforced either. + const itDenied = it.skipIf(process.platform === "win32" || process.getuid?.() === 0); + + const baseOptions = (tempDir: string, extensions: ExtensionFactory[]): CreateAgentSessionOptions => ({ + cwd: tempDir, + agentDir: tempDir, + modelRegistry, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated(), + model: getBundledModel("openai", "gpt-4o-mini"), + disableExtensionDiscovery: true, + extensions, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + rules: [], + workspaceTree: { rootPath: tempDir, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] }, + }); + + beforeAll(async () => { + registryAuthDir = path.join(os.tmpdir(), `pi-file-write-fallback-e2e-auth-${Snowflake.next()}`); + fs.mkdirSync(registryAuthDir, { recursive: true }); + modelRegistry = new ModelRegistry(await discoverAuthStorage(registryAuthDir)); + }); + + afterAll(() => { + removeSyncWithRetries(registryAuthDir); + }); + + // Restore every mode this file tightened BEFORE removing the trees. A test that + // throws before its own `finally` would otherwise leave a 0o500 directory behind, + // and `removeSyncWithRetries` only retries on Windows — on macOS/Linux it throws + // EACCES, aborting this loop after `splice(0)` already emptied the list, which + // strands every remaining temp dir for the rest of the run. + afterEach(() => { + for (const dir of lockedDirs.splice(0)) { + try { + fs.chmodSync(dir, 0o700); + } catch { + // Already gone, or never created: nothing to restore. + } + } + for (const tempDir of tempDirs.splice(0)) { + removeSyncWithRetries(tempDir); + } + }); + + itDenied( + "write: a permission-denied create succeeds through a registered fallback, and a follow-up hashline edit on the real path works", + async () => { + const tempDir = makeTempDir(); + const lockedDir = path.join(tempDir, "locked-write"); + fs.mkdirSync(lockedDir, { recursive: true }); + lock(lockedDir, 0o500); // no write bit: creating a file here needs dir-write + + const received: FileWriteFallbackRequest[] = []; + const ownSessionIds: string[] = []; + const factory: ExtensionFactory = pi => { + pi.registerFileWriteFallback(async (req, ctx) => { + received.push(req); + ownSessionIds.push(ctx.sessionManager.getSessionId()); + // Stand-in for an out-of-process privileged broker: this test's own + // user cannot write into `lockedDir`, so relax the permission bit + // just long enough to place the exact bytes the tool intended, then + // restore it — proving the handler alone determined success, not + // some ambient permission the tool already had. + fs.chmodSync(lockedDir, 0o700); + try { + fs.writeFileSync(req.dst, req.content); + } finally { + fs.chmodSync(lockedDir, 0o500); + } + return true; + }); + }; + + const { session } = await createAgentSession(baseOptions(tempDir, [factory])); + initializeRunnerForTest(session.extensionRunner); + + try { + const writeTool = session.getToolByName("write") as AgentTool | undefined; + expect(writeTool).toBeDefined(); + + const targetPath = path.join(lockedDir, "new-file.txt"); + const content = "export const value = 42;\n"; + + const writeResult = await writeTool!.execute("call-write-1", { path: targetPath, content }); + + // (i) the tool call succeeds + expect(writeResult.isError).not.toBe(true); + // (ii) the fallback received the exact intended bytes and the real destination path + expect(received).toHaveLength(1); + expect(received[0]?.dst).toBe(targetPath); + expect(received[0]?.content).toBe(content); + // (ii-b) and it can tell WHOSE write it was: the registry is process-wide, so + // the request names the issuing session and `ctx` names the handler's own. + // Both defined and equal here, which only holds if the tool-execution scope + // that carries the session id is actually entered. + expect(ownSessionIds[0]).toMatch(/./); + expect(received[0]?.sessionId).toBe(ownSessionIds[0]); + expect(fs.readFileSync(targetPath, "utf8")).toBe(content); + + const headerLine = resultText(writeResult).split("\n")[0] ?? ""; + expect(HASHLINE_HEADER_LINE.test(headerLine)).toBe(true); + + // (iii) a subsequent hashline edit on the SAME real path works — this only + // holds if the write tool recorded its snapshot under `targetPath` itself + // (not a temp path the fallback happened to route through). + const editTool = session.getToolByName("edit") as AgentTool | undefined; + expect(editTool).toBeDefined(); + const editInput = `${headerLine}\nPUT 1-1:\n+export const value = 43;\n`; + const editResult = await editTool!.execute("call-edit-1", { input: editInput }); + + expect(editResult.isError).not.toBe(true); + expect(fs.readFileSync(targetPath, "utf8")).toBe("export const value = 43;\n"); + } finally { + fs.chmodSync(lockedDir, 0o700); + await session.dispose(); + } + }, + ); + + itDenied( + "edit: a permission-denied overwrite of an existing file succeeds through a registered fallback", + async () => { + const tempDir = makeTempDir(); + const targetPath = path.join(tempDir, "existing.txt"); + const originalContent = "export const enabled = false;\n"; + fs.writeFileSync(targetPath, originalContent); + lock(targetPath, 0o400); // no write bit on the file itself + + const received: FileWriteFallbackRequest[] = []; + const factory: ExtensionFactory = pi => { + pi.registerFileWriteFallback(async req => { + received.push(req); + fs.chmodSync(targetPath, 0o600); + try { + fs.writeFileSync(req.dst, req.content); + } finally { + fs.chmodSync(targetPath, 0o400); + } + return true; + }); + }; + + const { session } = await createAgentSession(baseOptions(tempDir, [factory])); + initializeRunnerForTest(session.extensionRunner); + + try { + // A prior `read` is required to seed the file-snapshot tag the hashline + // edit addresses, mirroring how the model would discover an existing + // file's current tag before patching it. + const readTool = session.getToolByName("read") as AgentTool | undefined; + expect(readTool).toBeDefined(); + const readResult = await readTool!.execute("call-read-1", { path: targetPath }); + const readHeaderLine = resultText(readResult).split("\n")[0] ?? ""; + expect(HASHLINE_HEADER_LINE.test(readHeaderLine)).toBe(true); + + const editTool = session.getToolByName("edit") as AgentTool | undefined; + expect(editTool).toBeDefined(); + const editInput = `${readHeaderLine}\nPUT 1-1:\n+export const enabled = true;\n`; + const editResult = await editTool!.execute("call-edit-2", { input: editInput }); + + expect(editResult.isError).not.toBe(true); + expect(received).toHaveLength(1); + expect(received[0]?.dst).toBe(targetPath); + expect(received[0]?.content).toBe("export const enabled = true;\n"); + expect(fs.readFileSync(targetPath, "utf8")).toBe("export const enabled = true;\n"); + } finally { + fs.chmodSync(targetPath, 0o600); + await session.dispose(); + } + }, + ); + + itDenied("edit: a hashline MV into an unwritable directory succeeds through a registered fallback", async () => { + // `MV` is the one `edit` write that never passes through the LSP writethrough + // (`HashlineFilesystem.move` writes the destination directly), so it needs its + // own end-to-end proof that the seam covers it. + const tempDir = makeTempDir(); + const sourcePath = path.join(tempDir, "source.txt"); + fs.writeFileSync(sourcePath, "export const stage = 1;\n"); + const lockedDir = path.join(tempDir, "locked-move"); + fs.mkdirSync(lockedDir, { recursive: true }); + lock(lockedDir, 0o500); + const destPath = path.join(lockedDir, "moved.txt"); + + const received: FileWriteFallbackRequest[] = []; + const factory: ExtensionFactory = pi => { + pi.registerFileWriteFallback(async req => { + received.push(req); + fs.chmodSync(lockedDir, 0o700); + try { + fs.writeFileSync(req.dst, req.content); + } finally { + fs.chmodSync(lockedDir, 0o500); + } + return true; + }); + }; + + const { session } = await createAgentSession(baseOptions(tempDir, [factory])); + initializeRunnerForTest(session.extensionRunner); + + try { + const readTool = session.getToolByName("read") as AgentTool | undefined; + expect(readTool).toBeDefined(); + const readResult = await readTool!.execute("call-read-mv", { path: sourcePath }); + const readHeaderLine = resultText(readResult).split("\n")[0] ?? ""; + expect(HASHLINE_HEADER_LINE.test(readHeaderLine)).toBe(true); + + const editTool = session.getToolByName("edit") as AgentTool | undefined; + expect(editTool).toBeDefined(); + const editInput = [readHeaderLine, "PUT 1-1:", "+export const stage = 2;", `MV ${destPath}`, ""].join("\n"); + const editResult = await editTool!.execute("call-edit-mv", { input: editInput }); + + expect(editResult.isError).not.toBe(true); + expect(received).toHaveLength(1); + expect(received[0]?.dst).toBe(destPath); + expect(received[0]?.content).toBe("export const stage = 2;\n"); + expect(fs.readFileSync(destPath, "utf8")).toBe("export const stage = 2;\n"); + // `move` unlinks the source after the destination lands. + expect(fs.existsSync(sourcePath)).toBe(false); + } finally { + fs.chmodSync(lockedDir, 0o700); + await session.dispose(); + } + }); + + it("edit: a hashline MV with no handler registered behaves exactly as before", async () => { + // Guards the seam's inertness claim on the one site that now reaches it + // outside the writethrough: with nothing registered, a plain MV must still + // move the file and this package has no other coverage for that path. + const tempDir = makeTempDir(); + const sourcePath = path.join(tempDir, "plain-source.txt"); + fs.writeFileSync(sourcePath, "export const stage = 1;\n"); + const destPath = path.join(tempDir, "nested", "plain-dest.txt"); + + const { session } = await createAgentSession(baseOptions(tempDir, [])); + initializeRunnerForTest(session.extensionRunner); + + try { + const readTool = session.getToolByName("read") as AgentTool | undefined; + const readResult = await readTool!.execute("call-read-plain", { path: sourcePath }); + const readHeaderLine = resultText(readResult).split("\n")[0] ?? ""; + expect(HASHLINE_HEADER_LINE.test(readHeaderLine)).toBe(true); + + const editTool = session.getToolByName("edit") as AgentTool | undefined; + const editInput = [readHeaderLine, "PUT 1-1:", "+export const stage = 2;", `MV ${destPath}`, ""].join("\n"); + const editResult = await editTool!.execute("call-edit-plain-mv", { input: editInput }); + + expect(editResult.isError).not.toBe(true); + expect(fs.readFileSync(destPath, "utf8")).toBe("export const stage = 2;\n"); + expect(fs.existsSync(sourcePath)).toBe(false); + } finally { + await session.dispose(); + } + }); + + itDenied("edit: a permission-denied REM succeeds through a registered delete fallback", async () => { + // `REM` unlinks the file, which is a different primitive from the byte-write and + // has its own seam. A write fallback must NOT be consulted for it: a write + // handler brokers `content` to `dst`, so a delete arriving there would truncate + // the file instead of removing it. + const tempDir = makeTempDir(); + const lockedDir = path.join(tempDir, "locked-rem"); + fs.mkdirSync(lockedDir, { recursive: true }); + const targetPath = path.join(lockedDir, "doomed.txt"); + fs.writeFileSync(targetPath, "export const stage = 1;\n"); + lock(lockedDir, 0o500); + + const deleted: string[] = []; + const writeCalls: string[] = []; + const factory: ExtensionFactory = pi => { + pi.registerFileWriteFallback(async req => { + writeCalls.push(req.dst); + return false; + }); + pi.registerFileDeleteFallback(async req => { + deleted.push(req.dst); + fs.chmodSync(lockedDir, 0o700); + try { + fs.rmSync(req.dst); + } finally { + fs.chmodSync(lockedDir, 0o500); + } + return true; + }); + }; + + const { session } = await createAgentSession(baseOptions(tempDir, [factory])); + initializeRunnerForTest(session.extensionRunner); + + try { + const readTool = session.getToolByName("read") as AgentTool | undefined; + const readResult = await readTool!.execute("call-read-rem", { path: targetPath }); + const readHeaderLine = resultText(readResult).split("\n")[0] ?? ""; + expect(HASHLINE_HEADER_LINE.test(readHeaderLine)).toBe(true); + + const editTool = session.getToolByName("edit") as AgentTool | undefined; + const editResult = await editTool!.execute("call-edit-rem", { + input: [readHeaderLine, "REM", ""].join("\n"), + }); + + expect(editResult.isError).not.toBe(true); + expect(deleted).toEqual([targetPath]); + expect(writeCalls).toEqual([]); + expect(fs.existsSync(targetPath)).toBe(false); + } finally { + fs.chmodSync(lockedDir, 0o700); + await session.dispose(); + } + }); + + itDenied("write: a throwing handler does not skip later handlers from the SAME extension", async () => { + // The registry sees ONE trampoline per extension, so per-handler isolation has to + // live inside that trampoline. Without it, a throw from the first handler escapes + // to the registry, which advances to the next EXTENSION — so every later handler + // this extension registered is skipped, breaking both the documented "a throwing + // handler is skipped" rule and registration order for a backup-handler setup. + const tempDir = makeTempDir(); + const lockedDir = path.join(tempDir, "locked-order"); + fs.mkdirSync(lockedDir, { recursive: true }); + lock(lockedDir, 0o500); + + const order: string[] = []; + const factory: ExtensionFactory = pi => { + pi.registerFileWriteFallback(async () => { + order.push("throws"); + throw new Error("first handler blew up"); + }); + pi.registerFileWriteFallback(async () => { + order.push("declines"); + return false; + }); + pi.registerFileWriteFallback(async req => { + order.push("brokers"); + fs.chmodSync(lockedDir, 0o700); + try { + fs.writeFileSync(req.dst, req.content); + } finally { + fs.chmodSync(lockedDir, 0o500); + } + return true; + }); + }; + + const { session } = await createAgentSession(baseOptions(tempDir, [factory])); + initializeRunnerForTest(session.extensionRunner); + + try { + const targetPath = path.join(lockedDir, "ordered.txt"); + const content = "export const value = 3;\n"; + const writeTool = session.getToolByName("write") as AgentTool | undefined; + const writeResult = await writeTool!.execute("call-write-order", { path: targetPath, content }); + + expect(writeResult.isError).not.toBe(true); + expect(order).toEqual(["throws", "declines", "brokers"]); + expect(fs.readFileSync(targetPath, "utf8")).toBe(content); + } finally { + fs.chmodSync(lockedDir, 0o700); + await session.dispose(); + } + }); + + itDenied("write: a handler sees the session's CURRENT cwd, not the one captured at init", async () => { + // Handlers are installed once, at `ExtensionRunner.initialize`, but every other + // extension dispatch builds its `ExtensionContext` per call — and `createContext` + // materializes `cwd` as a value. A trampoline holding one context for the life of + // the session would keep reporting the workspace it initialized in, so a handler + // that scopes or prompts against `ctx.cwd` would allow the old workspace and deny + // the new one after a `/move`. + const tempDir = makeTempDir(); + const lockedDir = path.join(tempDir, "locked-cwd"); + fs.mkdirSync(lockedDir, { recursive: true }); + lock(lockedDir, 0o500); + + const seenCwds: string[] = []; + const factory: ExtensionFactory = pi => { + pi.registerFileWriteFallback(async (req, ctx) => { + seenCwds.push(ctx.cwd); + fs.chmodSync(lockedDir, 0o700); + try { + fs.writeFileSync(req.dst, req.content); + } finally { + fs.chmodSync(lockedDir, 0o500); + } + return true; + }); + }; + + const { session } = await createAgentSession(baseOptions(tempDir, [factory])); + initializeRunnerForTest(session.extensionRunner); + + // Stands in for `SessionManager.moveTo()` without relocating real session files: + // `getCwd()` is the session's own source of truth for its workspace, and it is + // what `ExtensionRunner.cwd` reads. + const moved = path.join(tempDir, "moved-workspace"); + fs.mkdirSync(moved, { recursive: true }); + const cwdSpy = spyOn(session.sessionManager, "getCwd").mockReturnValue(moved); + + try { + const writeTool = session.getToolByName("write") as AgentTool | undefined; + const writeResult = await writeTool!.execute("call-write-cwd", { + path: path.join(lockedDir, "after-move.txt"), + content: "export const value = 4;\n", + }); + + expect(writeResult.isError).not.toBe(true); + expect(seenCwds).toEqual([moved]); + } finally { + cwdSpy.mockRestore(); + fs.chmodSync(lockedDir, 0o700); + await session.dispose(); + } + }); + + itDenied( + "edit: a hashline MV OUT of an undeletable directory removes the source through the delete seam", + async () => { + // `HashlineFilesystem.move` writes the destination and then unlinks the source as + // two separate primitives. Moving INTO a locked directory only exercises the + // write seam, because the source sits in the writable workspace. This is the + // mirror case, and the only end-to-end cover for the source-unlink site: before + // the delete seam the destination landed and the unlink threw, so the move failed + // with the original left behind. + const tempDir = makeTempDir(); + const lockedDir = path.join(tempDir, "locked-source"); + fs.mkdirSync(lockedDir, { recursive: true }); + const sourcePath = path.join(lockedDir, "escaping.txt"); + fs.writeFileSync(sourcePath, "export const stage = 1;\n"); + const destPath = path.join(tempDir, "escaped.txt"); + lock(lockedDir, 0o500); + + const deleted: string[] = []; + const factory: ExtensionFactory = pi => { + pi.registerFileDeleteFallback(async req => { + deleted.push(req.dst); + fs.chmodSync(lockedDir, 0o700); + try { + fs.rmSync(req.dst); + } finally { + fs.chmodSync(lockedDir, 0o500); + } + return true; + }); + }; + + const { session } = await createAgentSession(baseOptions(tempDir, [factory])); + initializeRunnerForTest(session.extensionRunner); + + try { + const readTool = session.getToolByName("read") as AgentTool | undefined; + const readResult = await readTool!.execute("call-read-mv-out", { path: sourcePath }); + const readHeaderLine = resultText(readResult).split("\n")[0] ?? ""; + expect(HASHLINE_HEADER_LINE.test(readHeaderLine)).toBe(true); + + const editTool = session.getToolByName("edit") as AgentTool | undefined; + const editResult = await editTool!.execute("call-edit-mv-out", { + input: [readHeaderLine, "PUT 1-1:", "+export const stage = 2;", `MV ${destPath}`, ""].join("\n"), + }); + + expect(editResult.isError).not.toBe(true); + expect(deleted).toEqual([sourcePath]); + expect(fs.readFileSync(destPath, "utf8")).toBe("export const stage = 2;\n"); + expect(fs.existsSync(sourcePath)).toBe(false); + } finally { + fs.chmodSync(lockedDir, 0o700); + await session.dispose(); + } + }, + ); +}); diff --git a/packages/coding-agent/test/stats-cli.test.ts b/packages/coding-agent/test/stats-cli.test.ts new file mode 100644 index 000000000..0283ad10f --- /dev/null +++ b/packages/coding-agent/test/stats-cli.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as statsCli from "../src/cli/stats-cli"; +import Stats from "../src/commands/stats"; +import * as theme from "../src/modes/theme/theme"; +import { parseStatsDashboardArgs } from "../src/slash-commands/helpers/stats-dashboard"; + +const TEST_CONFIG = { bin: "omp", version: "0.0.0-test", commands: new Map() }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("stats dashboard host arguments", () => { + it("forwards the real omp stats flags to the dashboard runner", async () => { + vi.spyOn(theme, "initTheme").mockResolvedValue(); + const runStatsCommand = vi.spyOn(statsCli, "runStatsCommand").mockResolvedValue(); + const command = new Stats(["--host", "::", "--port", "3850"], TEST_CONFIG); + + await command.run(); + + expect(runStatsCommand).toHaveBeenCalledWith({ + port: 3850, + host: "::", + json: false, + summary: false, + }); + }); + + it("keeps the slash command loopback-only unless a host is requested", () => { + expect(parseStatsDashboardArgs("")).toEqual({ port: 3847, host: "127.0.0.1" }); + expect(parseStatsDashboardArgs("--host 0.0.0.0")).toEqual({ port: 3847, host: "0.0.0.0" }); + }); +}); diff --git a/packages/coding-agent/test/tools/browser-relay-daemon.test.ts b/packages/coding-agent/test/tools/browser-relay-daemon.test.ts index 96dcf1e61..8dd8abf4f 100644 --- a/packages/coding-agent/test/tools/browser-relay-daemon.test.ts +++ b/packages/coding-agent/test/tools/browser-relay-daemon.test.ts @@ -163,5 +163,11 @@ try { rescue.close(); await fs.rm(home, { recursive: true, force: true }); } - }, 30_000); + // Budget must exceed the sum of the bounds inside the test: two 15s marker waits + // plus the 5s shutdown probe are 35s of legitimate waiting, so a 30s cap let a + // loaded runner kill the test mid-`waitUntil` and report only "timed out after + // 30000ms" instead of the marker assertion that actually failed. Each consumer is + // a cold `bun` process importing the daemon module graph, so the spawns are slow + // exactly when the machine is busy. + }, 60_000); }); diff --git a/packages/coding-agent/test/tools/file-write-fallback.test.ts b/packages/coding-agent/test/tools/file-write-fallback.test.ts new file mode 100644 index 000000000..50efeb016 --- /dev/null +++ b/packages/coding-agent/test/tools/file-write-fallback.test.ts @@ -0,0 +1,796 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { applyPatch } from "@oh-my-pi/pi-coding-agent/edit/modes/patch"; +import { + addFileDeleteFallback, + addFileWriteFallback, + deleteFileWithFallback, + isPermissionDeniedError, + withFileMutationSession, + writeFileWithFallback, +} from "@oh-my-pi/pi-coding-agent/tools/file-write-fallback"; + +/** Mimics a Node/Bun filesystem error with a structured `code`, without touching a real fs. */ +function fsError(code: string, message = `${code}: simulated`): NodeJS.ErrnoException { + const error = new Error(message) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +describe("isPermissionDeniedError", () => { + it("is true for EPERM, EACCES, and EROFS error codes", () => { + expect(isPermissionDeniedError(fsError("EPERM"))).toBe(true); + expect(isPermissionDeniedError(fsError("EACCES"))).toBe(true); + expect(isPermissionDeniedError(fsError("EROFS"))).toBe(true); + }); + + it("is false for unrelated error codes", () => { + expect(isPermissionDeniedError(fsError("ENOENT"))).toBe(false); + expect(isPermissionDeniedError(fsError("EISDIR"))).toBe(false); + expect(isPermissionDeniedError(fsError("ENOSPC"))).toBe(false); + }); + + it("is false for a plain error with no code or matching message", () => { + expect(isPermissionDeniedError(new Error("something else went wrong"))).toBe(false); + expect(isPermissionDeniedError("not an error")).toBe(false); + expect(isPermissionDeniedError(undefined)).toBe(false); + }); + + it("defensively matches a permission code embedded only in the message", () => { + // A bridged/transport write can surface a denial as a plain Error with no code. + expect(isPermissionDeniedError(new Error("write failed: EACCES permission denied"))).toBe(true); + }); + + it("trusts a structured code over a permission name appearing in the path", () => { + // Bun embeds the full path in fs error messages, so a directory literally named + // EACCES would otherwise make an ordinary missing-path ENOENT look like a denial + // and divert a write that should just fail. + expect(isPermissionDeniedError(fsError("ENOENT", "ENOENT: no such file, open '/repo/EACCES/x.txt'"))).toBe(false); + }); +}); + +describe("writeFileWithFallback", () => { + const disposers: Array<() => void> = []; + afterEach(() => { + for (const dispose of disposers.splice(0)) dispose(); + }); + + /** A `BunFile`-shaped stub whose `.write()` always fails with `error`. */ + function denyingFile(error: unknown): { write: (content: string) => Promise } { + return { + write: async () => { + throw error; + }, + }; + } + + it("diverts a permission-denied write to a registered handler", async () => { + const seen: Array<{ dst: string; content: string }> = []; + disposers.push( + addFileWriteFallback(async req => { + seen.push({ dst: req.dst, content: req.content }); + return true; + }), + ); + + await writeFileWithFallback("/denied/path.txt", "payload", denyingFile(fsError("EACCES")) as never); + + expect(seen).toEqual([{ dst: "/denied/path.txt", content: "payload" }]); + }); + + it("names the session that issued the write, and reports none outside a tool call", async () => { + // The registry is process-wide, so a handler can be asked about a write from a + // session other than its own. Without this it cannot tell the difference, which + // is what makes a per-session decision (or a prompt through the right session's + // UI) impossible. + const seen: Array = []; + disposers.push( + addFileWriteFallback(async req => { + seen.push(req.sessionId); + return true; + }), + ); + + await withFileMutationSession("session-a", () => + writeFileWithFallback("/denied/path.txt", "payload", denyingFile(fsError("EACCES")) as never), + ); + // No scope: an external `applyPatch` caller is not attributable to a session, + // and inventing one would be worse than saying so. + await writeFileWithFallback("/denied/path.txt", "payload", denyingFile(fsError("EACCES")) as never); + + expect(seen).toEqual(["session-a", undefined]); + }); + + it("rethrows a non-permission error without consulting any handler", async () => { + let called = false; + disposers.push( + addFileWriteFallback(async () => { + called = true; + return true; + }), + ); + + await expect( + writeFileWithFallback("/denied/path.txt", "payload", denyingFile(fsError("EISDIR")) as never), + ).rejects.toMatchObject({ code: "EISDIR" }); + expect(called).toBe(false); + }); + + it("retries an ENOENT at most once when the parent turns out to be creatable", async () => { + // A creatable parent means the ENOENT was a race, not a boundary: the helper + // creates the directory and repeats the write. This stub keeps failing, which + // pins the retry at exactly one extra attempt instead of spinning. + const root = await fs.mkdtemp(path.join(os.tmpdir(), "fallback-race-")); + let attempts = 0; + let handlerCalled = false; + disposers.push( + addFileWriteFallback(async () => { + handlerCalled = true; + return true; + }), + ); + const file = { + write: async () => { + attempts += 1; + throw fsError("ENOENT"); + }, + }; + + await expect( + writeFileWithFallback(path.join(root, "fresh", "path.txt"), "payload", file as never), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(attempts).toBe(2); + expect(handlerCalled).toBe(false); + // The repair is the reason the retry happened, so it must be observable. + expect((await fs.stat(path.join(root, "fresh"))).isDirectory()).toBe(true); + + await fs.rm(root, { recursive: true, force: true }); + }); + + it("rethrows the ORIGINAL error when the handler returns false", async () => { + const cause = fsError("EACCES"); + disposers.push(addFileWriteFallback(async () => false)); + + await expect(writeFileWithFallback("/denied/path.txt", "payload", denyingFile(cause) as never)).rejects.toBe( + cause, + ); + }); + + it("rethrows the ORIGINAL error when every handler throws", async () => { + const cause = fsError("EACCES"); + disposers.push( + addFileWriteFallback(async () => { + throw new Error("handler blew up"); + }), + ); + + await expect(writeFileWithFallback("/denied/path.txt", "payload", denyingFile(cause) as never)).rejects.toBe( + cause, + ); + }); + + it("falls through a throwing handler to the next registered handler", async () => { + disposers.push( + addFileWriteFallback(async () => { + throw new Error("first handler blew up"); + }), + ); + let secondCalled = false; + disposers.push( + addFileWriteFallback(async () => { + secondCalled = true; + return true; + }), + ); + + await writeFileWithFallback("/denied/path.txt", "payload", denyingFile(fsError("EACCES")) as never); + + expect(secondCalled).toBe(true); + }); + + it("invokes handlers in registration order and stops at the first success", async () => { + const order: string[] = []; + disposers.push( + addFileWriteFallback(async () => { + order.push("first"); + return false; + }), + ); + disposers.push( + addFileWriteFallback(async () => { + order.push("second"); + return true; + }), + ); + disposers.push( + addFileWriteFallback(async () => { + order.push("third"); + return true; + }), + ); + + await writeFileWithFallback("/denied/path.txt", "payload", denyingFile(fsError("EACCES")) as never); + + expect(order).toEqual(["first", "second"]); + }); + + it("stops receiving writes once its disposer runs", async () => { + let calls = 0; + // Registered through `disposers` as well: if an assertion below throws, afterEach + // still removes the handler. A leaked registration is process-global and would + // silently swallow denied writes in every later test file. + const dispose = addFileWriteFallback(async () => { + calls += 1; + return true; + }); + disposers.push(dispose); + + await writeFileWithFallback("/denied/path.txt", "payload", denyingFile(fsError("EACCES")) as never); + expect(calls).toBe(1); + + dispose(); + + await expect( + writeFileWithFallback("/denied/path.txt", "payload", denyingFile(fsError("EACCES")) as never), + ).rejects.toMatchObject({ code: "EACCES" }); + expect(calls).toBe(1); + }); + + // A privileged user is not constrained by mode bits, so `chmod 0o500` denies + // nothing and every expectation here would fail for a reason unrelated to this + // seam. Root is real for a Docker-based local run and for a self-hosted runner. + describe.skipIf(process.getuid?.() === 0)("against real kernel permissions", () => { + let root = ""; + + beforeEach(async () => { + // Canonical from the start: the seam hands handlers a symlink-resolved path, + // and `os.tmpdir()` is under `/var` — itself a link — on macOS, so a lexical + // fixture path would differ from the brokered one for a reason unrelated to + // what these tests are about. + root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "fallback-kernel-"))); + }); + afterEach(async () => { + // Restore the mode first: a 0o500 directory cannot be emptied. + await fs.chmod(path.join(root, "locked"), 0o700).catch(() => {}); + await fs.rm(root, { recursive: true, force: true }); + }); + + /** A directory the current user may traverse and read, but not create inside. */ + async function lockedDir(): Promise { + const dir = path.join(root, "locked"); + await fs.mkdir(dir); + await fs.chmod(dir, 0o500); + return dir; + } + + it("diverts a real EACCES from creating a file in an unwritable directory", async () => { + const dst = path.join(await lockedDir(), "new.txt"); + const seen: Array<{ dst: string; content: string; code: unknown }> = []; + disposers.push( + addFileWriteFallback(async req => { + seen.push({ dst: req.dst, content: req.content, code: (req.cause as NodeJS.ErrnoException).code }); + return true; + }), + ); + + await writeFileWithFallback(dst, "payload"); + + expect(seen).toEqual([{ dst, content: "payload", code: "EACCES" }]); + }); + + it("unmasks a denied parent mkdir that Bun reports as ENOENT", async () => { + // Bun's write creates missing parents itself and, when that mkdir is denied, + // surfaces the open()'s ENOENT instead of the denial. Without unmasking, a + // sandboxed write into a new out-of-tree directory never reaches a handler. + const dst = path.join(await lockedDir(), "sub", "new.txt"); + const seen: Array<{ dst: string; content: string; code: unknown }> = []; + disposers.push( + addFileWriteFallback(async req => { + seen.push({ dst: req.dst, content: req.content, code: (req.cause as NodeJS.ErrnoException).code }); + return true; + }), + ); + + await writeFileWithFallback(dst, "payload"); + + expect(seen).toEqual([{ dst, content: "payload", code: "EACCES" }]); + }); + + it("attaches the recovered denial as `cause` when no handler takes the write", async () => { + // The thrown error stays the ENOENT Bun reported, so behaviour matches a host + // with no fallback registered. But this code has already proven the real + // boundary is EACCES, and discarding that would hand the caller back exactly + // the misleading errno this module exists to see through. + const dst = path.join(await lockedDir(), "sub", "new.txt"); + disposers.push(addFileWriteFallback(async () => false)); + + await expect(writeFileWithFallback(dst, "payload")).rejects.toMatchObject({ + code: "ENOENT", + cause: { code: "EACCES" }, + }); + }); + + it("leaves an ENOENT alone when a path component is a file rather than a directory", async () => { + const blocker = path.join(root, "blocker"); + await Bun.write(blocker, "not a directory"); + let called = false; + disposers.push( + addFileWriteFallback(async () => { + called = true; + return true; + }), + ); + + await expect(writeFileWithFallback(path.join(blocker, "child.txt"), "payload")).rejects.toMatchObject({ + code: expect.stringMatching(/^(ENOTDIR|ENOENT)$/), + }); + expect(called).toBe(false); + }); + + it("brokers the RESOLVED target for a write through a symlink", async () => { + // The escape this closes: the agent creates a link inside a directory the + // sandbox permits, pointing at a target it does not. The in-process write + // follows the link, so the kernel denied the TARGET — but a handler given + // the LINK would pass its own prefix allowlist, because the link sits inside + // the allowed root while its target does not. The handler is told where the + // bytes would really land, so its allowlist judges the real destination. + const secretDir = path.join(root, "off-limits"); + await fs.mkdir(secretDir); + const secret = path.join(secretDir, "authorized_keys"); + await Bun.write(secret, "original\n"); + await fs.chmod(secret, 0o400); + await fs.chmod(secretDir, 0o500); + + const link = path.join(root, "innocent-link"); + await fs.symlink(secret, link); + + const seen: string[] = []; + disposers.push( + addFileWriteFallback(async req => { + seen.push(req.dst); + // Declining stands in for the allowlist refusal a real helper makes. + return false; + }), + ); + + try { + await expect(writeFileWithFallback(link, "pwned\n")).rejects.toMatchObject({ + code: expect.stringMatching(/^(EACCES|EPERM)$/), + }); + expect(seen).toEqual([secret]); + expect(await Bun.file(secret).text()).toBe("original\n"); + } finally { + await fs.chmod(secretDir, 0o700); + await fs.chmod(secret, 0o600); + } + }); + + it("resolves a symlinked ANCESTOR, not just a link at the last component", async () => { + // `lstat(dst)` alone judges only the final component, so `ws/link/file` under + // a `ws/link -> /outside` link is a lexically innocent path whose bytes land + // outside. Every component above the last is followed by the kernel, so the + // handler has to be told the resolved path for this shape too. + const outside = path.join(root, "off-limits"); + await fs.mkdir(outside); + const victim = path.join(outside, "secret.txt"); + await Bun.write(victim, "original\n"); + await fs.chmod(victim, 0o400); + await fs.chmod(outside, 0o500); + + const linkDir = path.join(root, "innocent-dir"); + await fs.symlink(outside, linkDir); + + const seen: string[] = []; + disposers.push( + addFileWriteFallback(async req => { + seen.push(req.dst); + return false; + }), + ); + + try { + await expect(writeFileWithFallback(path.join(linkDir, "secret.txt"), "pwned\n")).rejects.toMatchObject({ + code: expect.stringMatching(/^(EACCES|EPERM)$/), + }); + expect(seen).toEqual([victim]); + expect(await Bun.file(victim).text()).toBe("original\n"); + } finally { + await fs.chmod(outside, 0o700); + await fs.chmod(victim, 0o600); + } + }); + + it("refuses to broker a write through a dangling symlink", async () => { + // `realpath` cannot name where a dangling link points, and the write follows + // it, so there is no destination to hand a privileged writer. Refusing is the + // only honest answer, and it is the one `confineToWorkspace` already gives. + const dir = path.join(root, "locked"); + await fs.mkdir(dir); + const dangling = path.join(dir, "dangling"); + await fs.symlink(path.join(dir, "nowhere"), dangling); + await fs.chmod(dir, 0o500); + + let called = false; + disposers.push( + addFileWriteFallback(async () => { + called = true; + return true; + }), + ); + + await expect(writeFileWithFallback(dangling, "payload")).rejects.toMatchObject({ + code: expect.stringMatching(/^(EACCES|EPERM)$/), + }); + expect(called).toBe(false); + }); + + it("refuses to broker a write whose own metadata is behind the boundary", async () => { + // A sandbox that denies the write often hides the target's metadata too, so + // the final component cannot be shown to be a plain name rather than a link — + // and `open` follows a link there. The delete seam keeps working in this shape + // because `unlink` never follows the last component; a write cannot. + const opaque = path.join(root, "opaque"); + await fs.mkdir(opaque); + await fs.chmod(opaque, 0o000); + + let called = false; + disposers.push( + addFileWriteFallback(async () => { + called = true; + return true; + }), + ); + + try { + await expect(writeFileWithFallback(path.join(opaque, "new.txt"), "payload")).rejects.toMatchObject({ + code: expect.stringMatching(/^(EACCES|EPERM)$/), + }); + expect(called).toBe(false); + } finally { + await fs.chmod(opaque, 0o700); + } + }); + }); + + // `apply_patch` creates a missing parent before writing, so a denial there used + // to throw before the write — and therefore before the seam — was ever reached. + describe.skipIf(process.getuid?.() === 0)("apply_patch into a denied new directory", () => { + let root = ""; + let locked = ""; + + beforeEach(async () => { + root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "fallback-patch-"))); + locked = path.join(root, "locked"); + await fs.mkdir(locked); + await fs.chmod(locked, 0o500); + }); + afterEach(async () => { + await fs.chmod(locked, 0o700).catch(() => {}); + await fs.rm(root, { recursive: true, force: true }); + }); + + it("reaches a registered handler with the bytes for the created file", async () => { + const brokered: Array<{ dst: string; content: string }> = []; + disposers.push( + addFileWriteFallback(async req => { + brokered.push({ dst: req.dst, content: req.content }); + return true; + }), + ); + + const target = path.join(locked, "sub", "new.txt"); + const result = await applyPatch({ path: target, op: "create", diff: "hello\n" }, { cwd: root }); + + expect(result.change).toMatchObject({ type: "create", path: target }); + expect(brokered).toEqual([{ dst: target, content: "hello\n" }]); + }); + + it("still fails when no handler is registered", async () => { + const target = path.join(locked, "sub", "new.txt"); + await expect(applyPatch({ path: target, op: "create", diff: "hello\n" }, { cwd: root })).rejects.toMatchObject( + { + code: expect.stringMatching(/^(EACCES|EPERM)$/), + }, + ); + }); + + it("never brokers an exclusive create whose destination cannot be proven absent", async () => { + // `apply_patch`'s `create` refuses to overwrite, and it decides that with + // `Bun.file(dst).exists()`, which reports `false` when the parent hides the + // target's metadata instead of distinguishing "absent" from "unknown". The + // non-overwrite contract survives regardless, because the same denied `lstat` + // that fools the existence check also stops the seam from brokering: a + // privileged writer is never handed a destination whose identity is unproven, + // and it is the only party that could have enforced exclusivity itself. + // + // Those are two independent guards in two files, so this pins the pair. If the + // seam is ever relaxed to broker an unverifiable path, a `create` would start + // silently clobbering a protected file it was told not to touch. + const opaque = path.join(root, "opaque"); + await fs.mkdir(opaque); + const victim = path.join(opaque, "victim.txt"); + await Bun.write(victim, "original\n"); + await fs.chmod(opaque, 0o000); + + let called = false; + disposers.push( + addFileWriteFallback(async () => { + called = true; + return true; + }), + ); + + try { + // The premise: the existence check cannot see the file it must not clobber. + expect(await Bun.file(victim).exists()).toBe(false); + + await expect( + applyPatch({ path: victim, op: "create", diff: "clobbered\n" }, { cwd: root }), + ).rejects.toMatchObject({ code: expect.stringMatching(/^(EACCES|EPERM)$/) }); + expect(called).toBe(false); + } finally { + await fs.chmod(opaque, 0o700); + } + expect(await Bun.file(victim).text()).toBe("original\n"); + }); + }); +}); + +describe("deleteFileWithFallback", () => { + const disposers: Array<() => void> = []; + afterEach(() => { + for (const dispose of disposers.splice(0)) dispose(); + }); + + it("rethrows ENOENT without consulting a handler", async () => { + // `edit`'s REM turns this into a NotFoundError, so it must not be diverted. + let called = false; + disposers.push( + addFileDeleteFallback(async () => { + called = true; + return true; + }), + ); + + await expect(deleteFileWithFallback("/nonexistent/nope.txt")).rejects.toMatchObject({ code: "ENOENT" }); + expect(called).toBe(false); + }); + + it("does not consult a registered WRITE handler", async () => { + // A write handler brokers `content` to `dst`. If a delete reached it, brokering + // a request with no content would truncate the file instead of removing it. + let writeCalled = false; + disposers.push( + addFileWriteFallback(async () => { + writeCalled = true; + return true; + }), + ); + + await expect(deleteFileWithFallback("/nonexistent/nope.txt")).rejects.toMatchObject({ code: "ENOENT" }); + expect(writeCalled).toBe(false); + }); + + describe.skipIf(process.getuid?.() === 0)("against real kernel permissions", () => { + let root = ""; + let locked = ""; + + beforeEach(async () => { + // Canonical from the start; see the write-side note above. + root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "fallback-del-"))); + locked = path.join(root, "locked"); + await fs.mkdir(locked); + }); + afterEach(async () => { + await fs.chmod(locked, 0o700).catch(() => {}); + await fs.rm(root, { recursive: true, force: true }); + }); + + /** A file whose containing directory denies the unlink. */ + async function lockedFile(name = "victim.txt"): Promise { + const target = path.join(locked, name); + await Bun.write(target, "payload"); + await fs.chmod(locked, 0o500); + return target; + } + + it("diverts a real denied unlink to a registered handler, naming its session", async () => { + const target = await lockedFile(); + const seen: Array<{ dst: string; code: unknown; sessionId: string | undefined }> = []; + disposers.push( + addFileDeleteFallback(async req => { + seen.push({ dst: req.dst, code: (req.cause as NodeJS.ErrnoException).code, sessionId: req.sessionId }); + return true; + }), + ); + + await withFileMutationSession("session-del", () => deleteFileWithFallback(target)); + + expect(seen).toEqual([ + { dst: target, code: expect.stringMatching(/^(EACCES|EPERM)$/), sessionId: "session-del" }, + ]); + }); + + it("rethrows the ORIGINAL error when the handler declines", async () => { + const target = await lockedFile(); + disposers.push(addFileDeleteFallback(async () => false)); + + await expect(deleteFileWithFallback(target)).rejects.toMatchObject({ + code: expect.stringMatching(/^(EACCES|EPERM)$/), + }); + }); + + it("refuses to divert a directory it can confirm, reporting confirmedFile on files", async () => { + // On Darwin `unlink` on a directory fails EPERM, which by code alone looks + // exactly like a sandbox denial. Brokering it would ask a privileged deleter + // to remove a whole directory for a tool that only ever removes one file. + const dir = path.join(root, "a-directory"); + await fs.mkdir(dir); + const seen: boolean[] = []; + disposers.push( + addFileDeleteFallback(async req => { + seen.push(req.confirmedFile); + return true; + }), + ); + + await expect(deleteFileWithFallback(dir)).rejects.toMatchObject({ + code: expect.stringMatching(/^(EPERM|EISDIR)$/), + }); + expect(seen).toEqual([]); + expect((await fs.lstat(dir)).isDirectory()).toBe(true); + + // A file under a directory that denies the unlink but still permits lstat + // resolves the check, so the handler is told the target is a real file. + const target = await lockedFile("confirmed.txt"); + await deleteFileWithFallback(target); + expect(seen).toEqual([true]); + }); + + it("still diverts, unresolved, when the target's own metadata is denied", async () => { + // A sandbox that denies the unlink usually denies the metadata too, so the + // directory check cannot run. The write must still reach a handler — that is + // the whole point of the seam — but the handler has to be TOLD the check was + // unresolved, or it may recursively remove a path that is really a directory. + const opaque = path.join(root, "opaque"); + await fs.mkdir(opaque); + const victim = path.join(opaque, "buried.txt"); + await Bun.write(victim, "payload"); + await fs.chmod(opaque, 0o000); + + const seen: Array<{ dst: string; confirmedFile: boolean }> = []; + disposers.push( + addFileDeleteFallback(async req => { + seen.push({ dst: req.dst, confirmedFile: req.confirmedFile }); + return true; + }), + ); + + try { + await deleteFileWithFallback(victim); + expect(seen).toEqual([{ dst: victim, confirmedFile: false }]); + } finally { + await fs.chmod(opaque, 0o700); + } + }); + + it("rethrows a non-permission lstat failure rather than diverting", async () => { + // `ENOTDIR` from a path component that is a file is a genuinely bad path, not + // a boundary, so the seam must not paper over it by consulting a handler. + const blocker = path.join(root, "not-a-dir"); + await Bun.write(blocker, "payload"); + let called = false; + disposers.push( + addFileDeleteFallback(async () => { + called = true; + return true; + }), + ); + + await expect(deleteFileWithFallback(path.join(blocker, "child.txt"))).rejects.toMatchObject({ + code: "ENOTDIR", + }); + expect(called).toBe(false); + }); + + it("resolves a symlinked ANCESTOR before brokering a delete", async () => { + // `unlink` follows every component above the last, so a link in the path + // removes a file outside the allowed root while the lexical path still looks + // contained. The handler must be told which file actually disappears. + const outside = path.join(root, "off-limits"); + await fs.mkdir(outside); + const victim = path.join(outside, "keep.txt"); + await Bun.write(victim, "keep me"); + await fs.chmod(outside, 0o500); + + const linkDir = path.join(root, "innocent-dir"); + await fs.symlink(outside, linkDir); + + const seen: Array<{ dst: string; confirmedFile: boolean }> = []; + disposers.push( + addFileDeleteFallback(async req => { + seen.push({ dst: req.dst, confirmedFile: req.confirmedFile }); + return false; + }), + ); + + try { + await expect(deleteFileWithFallback(path.join(linkDir, "keep.txt"))).rejects.toMatchObject({ + code: expect.stringMatching(/^(EACCES|EPERM)$/), + }); + expect(seen).toEqual([{ dst: victim, confirmedFile: true }]); + expect(await Bun.file(victim).text()).toBe("keep me"); + } finally { + await fs.chmod(outside, 0o700); + } + }); + + it("leaves the LAST component unresolved, reporting confirmedFile false for a link", async () => { + // `unlink` removes the link itself, so resolving the final component would + // name the wrong file. Diverting is still right — unlinking a link is a + // legitimate file removal — but a handler that realpaths `dst` for auditing, + // or removes it recursively, would act on the link's TARGET, a directory tree + // here. So the link is brokered as itself, and `confirmedFile` is false even + // though `lstat` succeeded. + const targetDir = path.join(root, "link-target-dir"); + await fs.mkdir(targetDir); + await Bun.write(path.join(targetDir, "keep.txt"), "keep me"); + const link = path.join(locked, "dir-link"); + await fs.symlink(targetDir, link); + await fs.chmod(locked, 0o500); + + const seen: Array<{ dst: string; confirmedFile: boolean }> = []; + disposers.push( + addFileDeleteFallback(async req => { + seen.push({ dst: req.dst, confirmedFile: req.confirmedFile }); + return true; + }), + ); + + await deleteFileWithFallback(link); + + expect(seen).toEqual([{ dst: link, confirmedFile: false }]); + // The target must be untouched: the seam only ever asked for the link. + expect(await Bun.file(path.join(targetDir, "keep.txt")).text()).toBe("keep me"); + }); + + it("diverts a denied unlink issued through a BunFile handle", async () => { + // `LspFileSystem.delete` is the only caller that passes a `BunFile`, and it is + // covered only transitively, so the `file.unlink()` branch would otherwise + // never be exercised directly. + const target = await lockedFile("via-handle.txt"); + const seen: string[] = []; + disposers.push( + addFileDeleteFallback(async req => { + seen.push(req.dst); + return true; + }), + ); + + await deleteFileWithFallback(target, Bun.file(target)); + + expect(seen).toEqual([target]); + }); + + it("routes an apply_patch delete op through the seam", async () => { + const target = await lockedFile("doomed.txt"); + const removed: string[] = []; + disposers.push( + addFileDeleteFallback(async req => { + await fs.chmod(locked, 0o700); + await fs.unlink(req.dst); + removed.push(req.dst); + return true; + }), + ); + + const result = await applyPatch({ path: target, op: "delete" }, { cwd: root }); + + expect(result.change).toMatchObject({ type: "delete", path: target }); + expect(removed).toEqual([target]); + expect(await Bun.file(target).exists()).toBe(false); + }); + }); +}); diff --git a/packages/coding-agent/test/tools/lsp-batching.test.ts b/packages/coding-agent/test/tools/lsp-batching.test.ts index 73bf14ce3..e0f44bc5b 100644 --- a/packages/coding-agent/test/tools/lsp-batching.test.ts +++ b/packages/coding-agent/test/tools/lsp-batching.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; import { createLspWritethrough } from "@oh-my-pi/pi-coding-agent/lsp"; import * as lspConfig from "@oh-my-pi/pi-coding-agent/lsp/config"; import type { LinterClient, ServerConfig } from "@oh-my-pi/pi-coding-agent/lsp/types"; +import { addFileWriteFallback } from "@oh-my-pi/pi-coding-agent/tools/file-write-fallback"; import { TempDir } from "@oh-my-pi/pi-utils"; function createFormatter(format: (filePath: string, content: string) => Promise): ServerConfig { @@ -180,3 +182,61 @@ describe("createLspWritethrough batching", () => { expect(await Bun.file(filePath).text()).toBe("const single = true;\n"); }); }); + +// A privileged user is not constrained by mode bits: a 0o000 file stays both +// writable and readable, so the write would never be denied and the seam under +// test would never engage. +describe.skipIf(process.getuid?.() === 0)("createLspWritethrough batching with a brokered write", () => { + let tempDir: TempDir; + let root = ""; + const disposers: Array<() => void> = []; + + beforeEach(async () => { + tempDir = TempDir.createSync("@omp-lsp-batch-broker-"); + // The seam hands handlers a symlink-resolved path and `os.tmpdir()` sits + // under `/var` — itself a link — on macOS, so a lexical fixture root would + // differ from the brokered path for a reason unrelated to this test. + root = await fs.realpath(tempDir.path()); + }); + + afterEach(async () => { + for (const dispose of disposers.splice(0)) dispose(); + vi.restoreAllMocks(); + await fs.chmod(path.join(root, "opaque.ts"), 0o600).catch(() => {}); + tempDir.removeSync(); + }); + + it("flushes a batch whose brokered destination cannot be read back", async () => { + vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined }); + vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([]); + const writethrough = createLspWritethrough(root, { enableFormat: true, enableDiagnostics: true }); + + // Denied for writing and for reading at once, which is what a sandbox that + // hides a path produces: the direct write fails, a privileged helper lands + // the bytes, and this process still cannot read them back. + const opaque = path.join(root, "opaque.ts"); + await Bun.write(opaque, "const before = true;\n"); + await fs.chmod(opaque, 0o000); + + const brokered: Array<{ dst: string; content: string }> = []; + disposers.push( + addFileWriteFallback(async req => { + brokered.push({ dst: req.dst, content: req.content }); + await fs.chmod(req.dst, 0o600); + await Bun.write(req.dst, req.content); + await fs.chmod(req.dst, 0o000); + return true; + }), + ); + + const sibling = path.join(root, "sibling.ts"); + const batchId = `brokered-${Date.now()}`; + await writethrough(opaque, "const after = true;\n", undefined, undefined, { id: batchId, flush: false }); + await writethrough(sibling, "const other = true;\n", undefined, undefined, { id: batchId, flush: true }); + + expect(brokered).toEqual([{ dst: opaque, content: "const after = true;\n" }]); + expect(await Bun.file(sibling).text()).toBe("const other = true;\n"); + await fs.chmod(opaque, 0o400); + expect(await Bun.file(opaque).text()).toBe("const after = true;\n"); + }); +}); diff --git a/packages/coding-agent/test/utils/changelog-static-import.test.ts b/packages/coding-agent/test/utils/changelog-static-import.test.ts index b4d60c94d..2403295f6 100644 --- a/packages/coding-agent/test/utils/changelog-static-import.test.ts +++ b/packages/coding-agent/test/utils/changelog-static-import.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { VERSION } from "@oh-my-pi/pi-utils"; import type { BunPlugin } from "bun"; import { resolveBundledChangelogPath } from "../../src/utils/changelog"; @@ -115,8 +114,8 @@ describe("changelog static import resources", () => { unrelatedCwd, ); - expect(result.version).toBe(VERSION); expect(result.entries).toBe(sourceResult.entries); + expect(result.version).toBe(sourceResult.version); } finally { await fs.rm(tempDir, { force: true, recursive: true }); } @@ -147,8 +146,8 @@ describe("changelog static import resources", () => { expect(buildOutput.success, buildOutput.logs.map(log => log.message).join("\n")).toBe(true); const result = await runProbe([binaryPath, missingPackageChangelogPath], unrelatedCwd); - expect(result.version).toBe(VERSION); expect(result.entries).toBe(sourceResult.entries); + expect(result.version).toBe(sourceResult.version); } finally { await fs.rm(tempDir, { force: true, recursive: true }); } diff --git a/packages/hashline/package.json b/packages/hashline/package.json index 12859b824..e5fd3de0b 100644 --- a/packages/hashline/package.json +++ b/packages/hashline/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/hashline", - "version": "17.3.5", + "version": "17.3.7", "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/mnemopi/package.json b/packages/mnemopi/package.json index ce0b8e7e0..92428a2d4 100644 --- a/packages/mnemopi/package.json +++ b/packages/mnemopi/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-mnemopi", - "version": "17.3.5", + "version": "17.3.7", "description": "Local SQLite memory engine for Oh My Pi agents", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index c9dc87c07..c283bd5db 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -279,7 +279,7 @@ export declare function __ompInstallTokioRuntime(): void * `packages/natives/native/index.js` (which derives the name from * `package.json#version`). */ -export declare function __piNativesV17_3_5(): void +export declare function __piNativesV17_3_7(): void /** * Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index ab0fae547..a3fda8388 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -29,7 +29,7 @@ export const Shell = nativeBindings.Shell; // functions export const __ompInstallTokioRuntime = nativeBindings.__ompInstallTokioRuntime; -export const __piNativesV17_3_5 = nativeBindings.__piNativesV17_3_5; +export const __piNativesV17_3_7 = nativeBindings.__piNativesV17_3_7; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; export const astMatch = nativeBindings.astMatch; diff --git a/packages/natives/package.json b/packages/natives/package.json index 0a4cbdd28..b39c3b3f0 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-natives", - "version": "17.3.5", + "version": "17.3.7", "description": "Native Rust bindings for PDF conversion, audio, WebRTC, grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", "type": "module", "homepage": "https://omp.sh", diff --git a/packages/omptype/package.json b/packages/omptype/package.json index 52bc6992a..b6746e6b1 100644 --- a/packages/omptype/package.json +++ b/packages/omptype/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omptype", - "version": "17.3.5", + "version": "17.3.7", "description": "ArkType-compatible runtime schema validation with lazy JIT compilation", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/snapcompact/package.json b/packages/snapcompact/package.json index 5e2451bcb..c108fe3df 100644 --- a/packages/snapcompact/package.json +++ b/packages/snapcompact/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/snapcompact", - "version": "17.3.5", + "version": "17.3.7", "description": "Bitmap-frame context compression for vision-capable LLMs", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 35177dc9a..c85203736 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [17.3.6] - 2026-08-17 + +### Fixed + +- Fixed the stats dashboard being unreachable from container hosts by accepting an explicit `--host` bind address while preserving loopback-only binding and same-origin API access by default. + ## [17.3.0] - 2026-08-13 ### Added diff --git a/packages/stats/package.json b/packages/stats/package.json index 732771f68..0fc4fd1e4 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omp-stats", - "version": "17.3.5", + "version": "17.3.7", "description": "Local observability dashboard for pi AI usage statistics", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/stats/src/index.ts b/packages/stats/src/index.ts index cf7e42d5a..e20134654 100755 --- a/packages/stats/src/index.ts +++ b/packages/stats/src/index.ts @@ -4,7 +4,7 @@ import { parseArgs } from "node:util"; import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils"; import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator"; import { closeDb } from "./db"; -import { startServer } from "./server"; +import { formatStatsDashboardUrl, startServer } from "./server"; export { getDashboardStats, @@ -17,7 +17,7 @@ export { } from "./aggregator"; export { closeDb } from "./db"; export { getGainDashboardStats } from "./gain-aggregator"; -export { startServer } from "./server"; +export { formatStatsDashboardUrl, startServer } from "./server"; export type { GainDashboardStats, GainSource, @@ -96,19 +96,42 @@ async function printStats(): Promise { console.log(""); } -/** - * Main CLI entry point. - */ -async function main(): Promise { +/** Parsed arguments for the standalone `omp-stats` entry point. */ +export interface StandaloneStatsArgs { + port: number; + host: string; + json: boolean; + sync: boolean; + help: boolean; +} + +/** Parse the standalone `omp-stats` arguments used by the production entry point. */ +export function parseStandaloneStatsArgs(args: string[]): StandaloneStatsArgs { const { values } = parseArgs({ + args, options: { port: { type: "string", short: "p", default: "3847" }, + host: { type: "string", default: "127.0.0.1" }, json: { type: "boolean", short: "j", default: false }, sync: { type: "boolean", short: "s", default: false }, help: { type: "boolean", short: "h", default: false }, }, allowPositionals: true, }); + return { + port: parseInt(values.port || "3847", 10), + host: values.host || "127.0.0.1", + json: values.json ?? false, + sync: values.sync ?? false, + help: values.help ?? false, + }; +} + +/** + * Main CLI entry point. + */ +async function main(): Promise { + const values = parseStandaloneStatsArgs(process.argv.slice(2)); if (values.help) { console.log(` @@ -119,6 +142,7 @@ Usage: Options: -p, --port Port for the dashboard server (default: 3847) + --host Host to bind (default: 127.0.0.1) -j, --json Output stats as JSON and exit -s, --sync Sync session files and show summary -h, --help Show this help message @@ -126,7 +150,7 @@ Options: Examples: omp-stats # Start dashboard server omp-stats --json # Print stats as JSON - omp-stats --port 8080 # Start on custom port + omp-stats --host 0.0.0.0 # Explicitly expose on all IPv4 interfaces omp-stats --sync # Sync and show summary `); return; @@ -171,9 +195,8 @@ Examples: } // Start server - const port = parseInt(values.port || "3847", 10); - const { hostname, port: actualPort } = await startServer(port); - console.log(`Dashboard available at: http://${hostname}:${actualPort}`); + const { port: actualPort } = await startServer(values.port, values.host); + console.log(`Dashboard available at: ${formatStatsDashboardUrl(values.host, actualPort)}`); console.log("Press Ctrl+C to stop\n"); // Keep process running diff --git a/packages/stats/src/port-conflict.ts b/packages/stats/src/port-conflict.ts index 74b2273a1..7c5c874d0 100644 --- a/packages/stats/src/port-conflict.ts +++ b/packages/stats/src/port-conflict.ts @@ -17,23 +17,28 @@ interface PortHolder { /** Header stamped on every dashboard response so reuse probes can identify us. */ export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard"; +/** Header recording the server's requested bind host so reuse cannot change its exposure scope. */ +export const STATS_DASHBOARD_HOSTNAME_HEADER = "x-omp-stats-hostname"; -/** Identity-header value for dashboards enforcing loopback-only, same-origin access. */ -export const STATS_DASHBOARD_SECURITY_VERSION = "2"; +/** Identity-header value for dashboards enforcing an explicit bind host and same-origin access. */ +export const STATS_DASHBOARD_SECURITY_VERSION = "3"; /** IPv4 loopback address shared by the dashboard server and reuse probe. */ export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1"; type StatsDashboardProbe = "reusable" | "occupied" | "unreachable"; -async function probeStatsDashboard(port: number): Promise { +async function probeStatsDashboard(port: number, hostname: string): Promise { + const probeHostname = hostname === "0.0.0.0" ? STATS_DASHBOARD_HOSTNAME : hostname === "::" ? "::1" : hostname; + const urlHostname = probeHostname.includes(":") ? `[${probeHostname}]` : probeHostname; try { - const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${port}/api/stats/models`, { + const response = await fetch(`http://${urlHostname}:${port}/api/stats/models`, { signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS), }); const reusable = response.status === 200 && response.headers.get(STATS_DASHBOARD_HEADER) === STATS_DASHBOARD_SECURITY_VERSION && + response.headers.get(STATS_DASHBOARD_HOSTNAME_HEADER) === hostname && !response.headers.has("Access-Control-Allow-Origin"); await response.body?.cancel(); return reusable ? "reusable" : "occupied"; @@ -248,16 +253,16 @@ async function reclaimStatsPort(port: number): Promise<"retry"> { * The preflight is needed on platforms that permit wildcard and loopback-specific * listeners to coexist on one port. */ -export async function prepareStatsPort(port: number): Promise<"retry" | "reuse"> { +export async function prepareStatsPort(port: number, hostname = STATS_DASHBOARD_HOSTNAME): Promise<"retry" | "reuse"> { if (port === 0) return "retry"; - const probe = await probeStatsDashboard(port); + const probe = await probeStatsDashboard(port, hostname); if (probe === "reusable") return "reuse"; if (probe === "occupied") return reclaimStatsPort(port); return "retry"; } /** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */ -export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> { - if ((await probeStatsDashboard(port)) === "reusable") return "reuse"; +export async function recoverStatsPort(port: number, hostname = STATS_DASHBOARD_HOSTNAME): Promise<"retry" | "reuse"> { + if ((await probeStatsDashboard(port, hostname)) === "reusable") return "reuse"; return reclaimStatsPort(port); } diff --git a/packages/stats/src/server.ts b/packages/stats/src/server.ts index 33dd1097d..b33cfe86e 100644 --- a/packages/stats/src/server.ts +++ b/packages/stats/src/server.ts @@ -26,6 +26,7 @@ import { recoverStatsPort, STATS_DASHBOARD_HEADER, STATS_DASHBOARD_HOSTNAME, + STATS_DASHBOARD_HOSTNAME_HEADER, STATS_DASHBOARD_SECURITY_VERSION, } from "./port-conflict"; @@ -306,10 +307,16 @@ async function handleStatic(requestPath: string): Promise { return new Response("Not Found", { status: 404 }); } -function createDashboardServer(port: number) { +/** Format a dashboard origin, including brackets required by IPv6 literals. */ +export function formatStatsDashboardUrl(hostname: string, port: number): string { + const urlHostname = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname; + return `http://${urlHostname}:${port}`; +} + +function createDashboardServer(port: number, hostname: string) { const server = Bun.serve({ port, - hostname: STATS_DASHBOARD_HOSTNAME, + hostname, async fetch(req) { const url = new URL(req.url); const path = url.pathname; @@ -318,6 +325,7 @@ function createDashboardServer(port: number) { // recognize this dashboard without allowing cross-origin API reads. const dashboardHeaders: Record = { [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION, + [STATS_DASHBOARD_HOSTNAME_HEADER]: hostname, }; if (req.method === "OPTIONS") { @@ -358,37 +366,40 @@ function createDashboardServer(port: number) { /** * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener. */ -export async function startServer(port = 3847): Promise<{ hostname: string; port: number; stop: () => void }> { +export async function startServer( + port = 3847, + hostname = STATS_DASHBOARD_HOSTNAME, +): Promise<{ hostname: string; port: number; stop: () => void }> { await ensureClientBuild(); - const preparation = await prepareStatsPort(port); + const preparation = await prepareStatsPort(port, hostname); if (preparation === "reuse") { - return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} }; + return { hostname, port, stop: () => {} }; } try { - const server = createDashboardServer(port); + const server = createDashboardServer(port, hostname); return { - hostname: STATS_DASHBOARD_HOSTNAME, + hostname, port: server.port ?? port, stop: () => server.stop(), }; } catch (error) { if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error; - const recovery = await recoverStatsPort(port); + const recovery = await recoverStatsPort(port, hostname); if (recovery === "reuse") { - return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} }; + return { hostname, port, stop: () => {} }; } try { - const server = createDashboardServer(port); + const server = createDashboardServer(port, hostname); return { - hostname: STATS_DASHBOARD_HOSTNAME, + hostname, port: server.port ?? port, stop: () => server.stop(), }; } catch (retryError) { - throw new Error(`Failed to start stats dashboard on port ${port} after reclaiming it.`, { + throw new Error(`Failed to start stats dashboard on ${hostname}:${port} after reclaiming it.`, { cause: retryError, }); } diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index ae92163bc..35657952f 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -4,6 +4,7 @@ import { connect, type Subprocess } from "bun"; import { STATS_DASHBOARD_HEADER, STATS_DASHBOARD_HOSTNAME, + STATS_DASHBOARD_HOSTNAME_HEADER, STATS_DASHBOARD_SECURITY_VERSION, } from "../src/port-conflict"; import { startServer } from "../src/server"; @@ -25,6 +26,15 @@ async function tcpConnects(hostname: string, port: number): Promise { return false; } } +function getNonLoopbackHostname(): string | undefined { + const interfaces = networkInterfaces(); + for (const name in interfaces) { + for (const address of interfaces[name] ?? []) { + if (address.family === "IPv4" && !address.internal) return address.address; + } + } + return undefined; +} const holderProcesses: Array> = []; @@ -77,26 +87,34 @@ describe("startServer access", () => { const response = await fetch(`http://${server.hostname}:${server.port}/api/stats/models`); expect(response.status).toBe(200); expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); + expect(response.headers.get(STATS_DASHBOARD_HOSTNAME_HEADER)).toBe(STATS_DASHBOARD_HOSTNAME); expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); await response.body?.cancel(); - let nonLoopbackHostname: string | undefined; - const interfaces = networkInterfaces(); - for (const name in interfaces) { - const addresses = interfaces[name] ?? []; - for (const address of addresses) { - if (address.family === "IPv4" && !address.internal) { - nonLoopbackHostname = address.address; - break; - } - } - if (nonLoopbackHostname) break; - } - expect(nonLoopbackHostname).toBeDefined(); + const nonLoopbackHostname = getNonLoopbackHostname(); expect(await tcpConnects(server.hostname, server.port)).toBe(true); - if (nonLoopbackHostname) { - expect(await tcpConnects(nonLoopbackHostname, server.port)).toBe(false); - } + if (nonLoopbackHostname) expect(await tcpConnects(nonLoopbackHostname, server.port)).toBe(false); + } finally { + server.stop(); + } + }); + + it("serves non-loopback requests only when explicitly requested", async () => { + const nonLoopbackHostname = getNonLoopbackHostname(); + if (!nonLoopbackHostname) return; + + const server = await startServer(0, "0.0.0.0"); + + try { + expect(server.hostname).toBe("0.0.0.0"); + expect(await tcpConnects(nonLoopbackHostname, server.port)).toBe(true); + + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${server.port}/api/stats/models`); + expect(response.status).toBe(200); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); + expect(response.headers.get(STATS_DASHBOARD_HOSTNAME_HEADER)).toBe("0.0.0.0"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + await response.body?.cancel(); } finally { server.stop(); } @@ -110,7 +128,12 @@ describe("startServer port conflicts", () => { hostname: STATS_DASHBOARD_HOSTNAME, fetch: request => new URL(request.url).pathname === "/api/stats/models" - ? Response.json([], { headers: { [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION } }) + ? Response.json([], { + headers: { + [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION, + [STATS_DASHBOARD_HOSTNAME_HEADER]: STATS_DASHBOARD_HOSTNAME, + }, + }) : new Response("dashboard"), }); diff --git a/packages/stats/test/stats-cli.test.ts b/packages/stats/test/stats-cli.test.ts new file mode 100644 index 000000000..ccd6b19de --- /dev/null +++ b/packages/stats/test/stats-cli.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { parseStandaloneStatsArgs } from "../src/index"; +import { formatStatsDashboardUrl } from "../src/server"; + +describe("standalone stats CLI", () => { + it("keeps the production parser loopback-only by default", () => { + expect(parseStandaloneStatsArgs([])).toEqual({ + port: 3847, + host: "127.0.0.1", + json: false, + sync: false, + help: false, + }); + }); + + it("forwards explicit bind hosts and formats IPv6 dashboard URLs", () => { + expect(parseStandaloneStatsArgs(["--host", "::", "--port", "3850"])).toMatchObject({ + port: 3850, + host: "::", + }); + expect(formatStatsDashboardUrl("::", 3850)).toBe("http://[::]:3850"); + expect(formatStatsDashboardUrl("2001:db8::1", 3850)).toBe("http://[2001:db8::1]:3850"); + }); +}); diff --git a/packages/tui/package.json b/packages/tui/package.json index 6857e7489..bfe8250de 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-tui", - "version": "17.3.5", + "version": "17.3.7", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/utils/package.json b/packages/utils/package.json index 0739653db..2c4ca9622 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-utils", - "version": "17.3.5", + "version": "17.3.7", "description": "Shared utilities for pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/wire/package.json b/packages/wire/package.json index 957ee9488..3d0f91dbd 100644 --- a/packages/wire/package.json +++ b/packages/wire/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-wire", - "version": "17.3.5", + "version": "17.3.7", "description": "Shared wire protocol types for Oh My Pi packages", "homepage": "https://omp.sh", "author": "Can Boluk",