From 865607235f0270563db9804e6b56b4272fdb35ae Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 23 Oct 2026 19:12:43 +0530 Subject: [PATCH 01/11] fix: release review follow-ups for v0.12.8 - `run`: the stdin timeout notice says input sent after the wait is ignored - `uninstall`: on Windows, say the removal commands are for PowerShell - telemetry: the `error_message` type comment describes the fixed-vocabulary mask - docs: `check` policy/semantic fail closed on statements without a plan; `schema_inspect` value profiles, the `values` switch and that sampled values reach the model provider; `client_connected` in the telemetry table; `/integrations` and `datamate_manager` in linked projects; plain `run` prompt arguments are no longer quoted; the Windows `.cmd` alias goes through cmd.exe Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- docs/docs/data-engineering/tools/schema-tools.md | 5 +++++ docs/docs/reference/telemetry.md | 1 + docs/docs/reference/windows-wsl.md | 4 +++- docs/docs/usage/check.md | 2 ++ docs/docs/usage/cli.md | 4 ++++ packages/opencode/src/altimate/telemetry/index.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 2 +- packages/opencode/src/cli/cmd/uninstall.ts | 8 ++++++-- 8 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/docs/data-engineering/tools/schema-tools.md b/docs/docs/data-engineering/tools/schema-tools.md index e58922797b..866c0286d3 100644 --- a/docs/docs/data-engineering/tools/schema-tools.md +++ b/docs/docs/data-engineering/tools/schema-tools.md @@ -31,6 +31,11 @@ Table: ANALYTICS.PUBLIC.ORDERS - `table` (required): Table name (schema-qualified: `schema.table` or just `table`) - `schema_name` (optional): Schema to search in - `warehouse` (optional): Connection name +- `values` (optional, default `true`): Append a value profile per column + +On SQLite, DuckDB and PostgreSQL connections, and for MongoDB collections, `schema_inspect` also profiles each column's values from a bounded sample (10,000 rows, or 100 documents for MongoDB; at most 40 columns): distinct count, null share, the most frequent values with their counts, whether the values are unique in the sample, and, for short text, the common value shapes. MongoDB nested fields are listed by their dotted paths. The profile shows the stored format, such as case, prefixes or padding, so filters match real data. + +The profile contains real cell values, which are sent to your model provider with the rest of the tool output. For tables that hold personal or sensitive data, ask for `values: false` to get the column list only. --- diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 4168488bb1..0f32d31dcd 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -44,6 +44,7 @@ We collect the following categories of events: | `core_failure` | An internal tool error occurs (tool name, category, error class, truncated error message, PII-safe input signature, and optionally masked arguments — no raw values or credentials) | | `first_launch` | Fired once on the first CLI run after an install or upgrade, triggered by a marker file the installer wrote — the installers themselves send nothing and contact no telemetry endpoint. Contains the installed version, `is_upgrade`, and `install_method` (`curl`, `powershell`, `npm`, `vscode-extension`, `local` for `install --binary`, or `unknown` for markers written before the field existed). `vscode-extension` starts appearing only once an extension build containing the marker write ships, so a zero share for it means the extension has not rolled out yet rather than no extension installs. No PII. **Reading `is_upgrade`:** it means "this machine had run altimate-code before", probed as whether `~/.altimate/machine-id` already existed — *not* "a binary was already present". A reinstall onto a machine that ever ran the CLI reports `is_upgrade: true`, and `altimate uninstall` leaves `machine-id` in place, so a metric excluding upgrades counts installs **per previously-unseen machine** and undercounts reinstalls onto known ones. (`is_upgrade` is a boolean in the event schema; it arrives in Application Insights `customDimensions` as a string, so KQL filters read `tostring(customDimensions.is_upgrade) != "true"`.) Delivery is at-most-once: the marker is deleted before the event flushes, so a process that dies first loses that install rather than re-firing it every launch. Local `--binary` installs report `version: "local"`. | | `startup_ready` | Fired once per process when the top-level command can serve its first request or frame (`serve` listening, TUI transport resolved, `run` about to send its first prompt). Contains the command name, `duration_ms` since process start, and `fresh_install` (true when this process also emitted a non-upgrade `first_launch`). No PII. | +| `client_connected` | Fired once per process when the first client attaches to a running server, which separates "the backend started but no client connected" from "connected but never sent a message". Contains `surface` (the launching command: `serve`, `run` or `tui`, also sent on `startup_ready`) and `since_start_ms`. No PII. | | `event_loop_stall` | A 250 ms monitor tick fired more than 1 s late, meaning the event loop was blocked for that long (for example an in-process dependency install). Contains the command name, `thread` (`main` or `worker`), `blocked_ms`, and `since_start_ms`. Capped at 20 per thread (the main thread and the TUI server worker each keep their own counter). No PII. | | `altimate_base_registration` | Timing and outcome of each Altimate Base registration, whether triggered automatically at startup, by an explicit picker selection, or through the `POST /altimate/base/register` route an IDE calls (`origin: "server"`) (concurrent calls share one result, and a still-valid cached credential reports `success` without a network round trip): `result` (`success`, `network`, `http`, `response`, `cancelled`, `configuration`, or `error`), `duration_ms`, and the HTTP `status` when the result is `http`. Distinct from `altimate_base_register_result`, which records the onboarding-flow outcome. No gateway URL, response body, credential, or error text is included. | | `task_outcome_signal` | Behavioral quality signal at session end — accepted, error, abandoned, or cancelled. Includes tool count, step count, duration, and last tool category. No user content. | diff --git a/docs/docs/reference/windows-wsl.md b/docs/docs/reference/windows-wsl.md index 890e62fccf..07429cd093 100644 --- a/docs/docs/reference/windows-wsl.md +++ b/docs/docs/reference/windows-wsl.md @@ -23,7 +23,9 @@ directory to your user PATH (open a new terminal afterwards). It also writes an `altimate-code` alias there: `altimate-code.cmd` for cmd.exe and PowerShell, and an extensionless shim for Git Bash. Inside WSL, install the Linux build with `curl -fsSL https://www.altimate.sh/install | bash`; the Windows shim refuses to -run there. The installer +run there. The `.cmd` alias passes its arguments through cmd.exe, which expands +`%VAR%` and interprets unquoted `&`, `|` and `^`, so scripts should call +`altimate` directly. The installer auto-detects AVX2 support and falls back to the baseline build on older CPUs. Options (pass via a script block): diff --git a/docs/docs/usage/check.md b/docs/docs/usage/check.md index f79aedc5dd..05a49a5530 100644 --- a/docs/docs/usage/check.md +++ b/docs/docs/usage/check.md @@ -50,6 +50,8 @@ altimate-code check --fail-on warning | `semantic` | Semantic validation (cartesian products, wrong JOINs) | Optional | No | | `grade` | SQL quality grading with recommendations | Optional | No | +`policy` and `semantic` fail closed on a statement the engine cannot plan, such as a table-valued function (`TABLE(fn())`, Snowflake `LATERAL FLATTEN`, DuckDB `read_csv(...)`). `policy` reports a `policy_not_evaluated` error and treats the statement as blocked, and `semantic` reports it as skipped rather than clean (an error when `--schema` is given, a warning without). Earlier versions rejected these statements as invalid SQL. + Findings usually carry a machine-readable `rule` field (e.g. `unbalanced_quote`, `sql_injection`, `L003`) visible in `--format json` output — see the [Finding Object](#finding-object) schema below. `safety` and `policy` findings always include one; `lint` findings may omit both `rule` and `code` if the engine didn't attach them, so switch on presence rather than assuming. By default, `lint` and `safety` are enabled. Override with `--checks`: diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index 11b2d60124..bfc579398e 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -62,6 +62,8 @@ With workspaces on: - `altimate-code workspace refresh` pulls the workspace's skills (memory is loaded by the next session started in the project); `altimate-code workspace sync` sends memory the workspace has not received; `altimate-code workspace unlink [--yes]` detaches the project (`--yes` is required without a terminal). - These commands accept `--directory`. Exit codes: `0` done, `1` failed (including when the workspace service could not be reached), `2` a request to change (not signed in, a re-link or unlink without `--yes` and no terminal to confirm on, or a `sync` to a link not yet confirmed on this machine), `3` the project is not linked. With `--json`, `ok` is `true` exactly when the exit code is `0`. - `/workspace` in the TUI opens a menu: **Get started** shows what the workspace provides and prompts to try (choosing one puts it in the prompt without sending it; after you link from the TUI, the confirmation shows the same summary and prompts), **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** (when a web URL is available) shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead. +- `/integrations` in the TUI opens the workspace's integration status directly (the same view as `/workspace` → Status). In a project that is not linked it offers the link, and when the workspace engine is missing or too old it offers the install and says which. +- In a project linked to a workspace, integrations come only from the workspace's engine, so the `datamate_manager` tool is not offered there and refuses to run. `datamate-*` MCP servers already in the config still load, and a one-time notice names the file they are in; the config is not edited. - `altimate-code skill publish ` uploads a project skill to the linked workspace; see [Skills](../configure/skills.md#cli-commands). - When the linked workspace has knowledge, the agent is told to check it for best practices and team conventions before answering, without you having to mention the knowledge base. - In an ordinary session the agent is told every turn which workspace the project is linked to — or that none is, or that the link could not be verified just now. In an extension-pinned session it is told the pinned workspace instead, and that it differs from the project's own link. Either way "which workspace am I in?" has an answer, and the agent is told not to confuse it with a Databricks workspace or an IDE workspace folder. @@ -192,6 +194,8 @@ altimate run --no-trace "quick question" `run` reads piped input to the end, however long the command before the pipe takes. One exception: when `run` already has a message or `--command` and its stdin is a socket, which is what an agent's shell tool or a Node `spawn` hands a child, it waits only 3 seconds for the first byte and then continues without stdin. Set `ALTIMATE_RUN_STDIN_WAIT_MS` to change that wait, or to `0` to always wait. A caller that never writes to or closes stdin, and whose stdin is not a socket (for example a pipe on Windows), should redirect it from an empty source: `< /dev/null`, or `< NUL` in `cmd.exe`. +A plain `run` prompt given as several arguments is joined with spaces and reaches the model as typed; arguments are no longer wrapped in quotes. With `--command`, arguments that contain spaces are still quoted so `$1` and `$2` expand to whole values. + For CI pipelines and headless automation, see [CI & Automation](ci-headless.md). ## Trace diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index b28a8db1ba..3bb60e717a 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -942,7 +942,7 @@ export namespace Telemetry { tool_name: string /** Tool category */ tool_category: string - /** Allowlist template of the error first line, max 200 chars: plain lowercase words are kept, identifiers, numbers, quoted values, capitalized names and credential values become */ + /** Template of the error first line, max 200 chars: only words from a fixed vocabulary of common error terms are kept; every other word becomes , and digits and symbols are dropped */ error_message?: string /** Whether a subsequent tool call succeeded (error was recovered) */ recovery_successful: boolean diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 521acce43e..3e3afe3a15 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -571,7 +571,7 @@ export const RunCommand = cmd({ const piped = await readRunStdin(undefined, waitMs, message.trim().length > 0 || !!args.command) if (piped.timedOut) process.stderr.write( - `No input on stdin after ${waitMs / 1000}s; continuing without it. Redirect stdin from /dev/null to skip the wait, or set ALTIMATE_RUN_STDIN_WAIT_MS=0 to wait for it.${EOL}`, + `No input on stdin after ${waitMs / 1000}s; continuing without it (input sent later is ignored). Redirect stdin from /dev/null to skip the wait, or set ALTIMATE_RUN_STDIN_WAIT_MS=0 to wait for it.${EOL}`, ) message += "\n" + piped.text } diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 017d290b42..87b7a6db8e 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -342,8 +342,12 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar if (method === "curl" && targets.binary) { UI.empty() - prompts.log.message("To finish removing the binary, run:") - // altimate_change start — platform-native commands, plus the installer's other files + // altimate_change start — platform-native commands (named shell on Windows), plus the installer's other files + prompts.log.message( + process.platform === "win32" + ? "To finish removing the binary, run in PowerShell:" + : "To finish removing the binary, run:", + ) prompts.log.info(` ${removeFileCommand(targets.binary, process.platform)}`) for (const file of targets.installerFiles) { prompts.log.info(` ${removeFileCommand(file, process.platform, true)}`) From 0c9af6c6ecd94dc5a817a5a12ff68dbc62d1f3c2 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 23 Oct 2026 19:21:40 +0530 Subject: [PATCH 02/11] fix(check): tell the user to review a statement policy cannot evaluate The `policy_not_evaluated` and skipped-semantics messages now end with what to do: review the statement by hand, naming `TABLE(...)`, `LATERAL FLATTEN` and file readers such as `read_csv(...)`. Fixes #1585 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- packages/opencode/src/altimate/native/altimate-core.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/native/altimate-core.ts b/packages/opencode/src/altimate/native/altimate-core.ts index 4089f0e74e..33378bb74d 100644 --- a/packages/opencode/src/altimate/native/altimate-core.ts +++ b/packages/opencode/src/altimate/native/altimate-core.ts @@ -36,9 +36,9 @@ const PLAN_SKIP_RULES = new Set(["plan_unavailable", "POLICY_SKIPPED"]) const PLAN_SKIP_NOTE = /plan-based analysis was skipped|logical plan unavailable/i export const POLICY_NOT_EVALUATED_MESSAGE = - "policy could not be evaluated for this statement (table-valued function, no plan available); treated as blocked" + "policy could not be evaluated for this statement (table-valued function, no plan available); treated as blocked. Review it by hand: the policy engine cannot check statements built on TABLE(...), LATERAL FLATTEN or file readers such as read_csv(...)" export const SEMANTICS_SKIPPED_MESSAGE = - "semantic analysis skipped (no plan available for this statement, e.g. table-valued function)" + "semantic analysis skipped (no plan available for this statement, e.g. table-valued function); review it by hand" /** Mark a policy result not-allowed when the engine skipped evaluation. */ export function applyPolicySkipGuard(data: Record): Record { From 456968b613be46c0ada762f3f5d677cc96562153 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 23 Oct 2026 19:21:40 +0530 Subject: [PATCH 03/11] feat(schema_inspect): `ALTIMATE_DISABLE_SCHEMA_VALUES` turns value profiles off When set, `schema_inspect` runs no value sampling whatever the model passes as `values`, and says so in its output so the model does not retry. Parsed like `ALTIMATE_DISABLE_WORKSPACE`: only empty, `0` or `false` leave it unset. Documented in the schema tools page and the CLI feature toggles. Refs #1578 (env var only; a per-warehouse setting needs new config) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- .../data-engineering/tools/schema-tools.md | 2 +- docs/docs/usage/cli.md | 1 + packages/core/src/flag/flag.ts | 9 +++++ .../src/altimate/tools/schema-inspect.ts | 6 +++- .../tools/schema-inspect-values.test.ts | 36 ++++++++++++++++++- 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/docs/docs/data-engineering/tools/schema-tools.md b/docs/docs/data-engineering/tools/schema-tools.md index 866c0286d3..2e19106610 100644 --- a/docs/docs/data-engineering/tools/schema-tools.md +++ b/docs/docs/data-engineering/tools/schema-tools.md @@ -35,7 +35,7 @@ Table: ANALYTICS.PUBLIC.ORDERS On SQLite, DuckDB and PostgreSQL connections, and for MongoDB collections, `schema_inspect` also profiles each column's values from a bounded sample (10,000 rows, or 100 documents for MongoDB; at most 40 columns): distinct count, null share, the most frequent values with their counts, whether the values are unique in the sample, and, for short text, the common value shapes. MongoDB nested fields are listed by their dotted paths. The profile shows the stored format, such as case, prefixes or padding, so filters match real data. -The profile contains real cell values, which are sent to your model provider with the rest of the tool output. For tables that hold personal or sensitive data, ask for `values: false` to get the column list only. +The profile contains real cell values, which are sent to your model provider with the rest of the tool output. For tables that hold personal or sensitive data, ask for `values: false` to get the column list only. Set `ALTIMATE_DISABLE_SCHEMA_VALUES=1` to turn value profiles off for every call, whatever the model asks for: no sample query runs, and the output says profiles are turned off so the model does not ask again. Only an empty value, `0` or `false` leaves them on. --- diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index bfc579398e..d0adf50bbd 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -112,6 +112,7 @@ Configuration can be controlled via environment variables: | `ALTIMATE_CLI_DISABLE_PRUNE` | Disable database pruning | | `ALTIMATE_CLI_DISABLE_MODELS_FETCH` | Don't fetch models from models.dev | | `ALTIMATE_DISABLE_WORKSPACE` | Set to `1` to turn off every workspace feature (linking, skill and memory sync, the workspace engine, and the project lookup each session makes). The value is trimmed and read without regard to case; anything other than `0`, `false` or an empty value counts as set. Workspaces are on by default | +| `ALTIMATE_DISABLE_SCHEMA_VALUES` | Set to `1` to turn off `schema_inspect`'s column value profiles for every call, whatever the model passes as `values`, so no cell values are sampled or sent to the model provider. The value is trimmed and read without regard to case; anything other than `0`, `false` or an empty value counts as set | | `ALTIMATE_WORKSPACE_WEB_URL` | Development only: the base URL of a local web app's Workspaces pages, used instead of the tenant's for `link`'s browser hand-off and workspace links. Include the mount path, e.g. `http://acme.localhost:3000/workspaces` | | `ALTIMATE_INTEGRATIONS` | Set to `local` to keep warehouse tools local rather than routing them through a bound workspace's engine | | `ALTIMATE_CODE_SERVE` | Set to `1` by `altimate-code serve` itself, whether the IDE extension or you launched it. Marks that process as the extension's host — so it is the one that reads the pin variables — and is stripped from every child the bash and shell tools start | diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 7cfd34a248..3ecf527eab 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -135,6 +135,15 @@ export const Flag = { get ALTIMATE_INTEGRATIONS_LOCAL() { return process.env["ALTIMATE_INTEGRATIONS"]?.toLowerCase() === "local" }, + /** + * Turns `schema_inspect`'s column value profiles off for every call, whatever the model + * passes as `values`, so no cell values are sampled or sent to the model provider. Same + * fail-closed parsing as ALTIMATE_DISABLE_WORKSPACE: only empty, `0` or `false` leave it unset. + */ + get ALTIMATE_DISABLE_SCHEMA_VALUES() { + const value = env("ALTIMATE_DISABLE_SCHEMA_VALUES")?.trim().toLowerCase() + return value !== undefined && value !== "" && value !== "0" && value !== "false" + }, // altimate_change end // Evaluated at access time (not module load) because tests, the CLI, and diff --git a/packages/opencode/src/altimate/tools/schema-inspect.ts b/packages/opencode/src/altimate/tools/schema-inspect.ts index 981efed1e8..5a181942d5 100644 --- a/packages/opencode/src/altimate/tools/schema-inspect.ts +++ b/packages/opencode/src/altimate/tools/schema-inspect.ts @@ -8,6 +8,7 @@ import { PostConnectSuggestions } from "./post-connect-suggestions" import { resolveInspectionTarget } from "../native/connections/inspection-target" import * as Registry from "../native/connections/registry" import { profileValues } from "./schema-values" +import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { isRecord, normalizeError } from "./response-normalization" import { validateTableName, validateWarehouseName } from "./input-validation" // altimate_change start — workspace precedence @@ -116,7 +117,10 @@ export const SchemaInspectTool = Tool.define("schema_inspect", { // altimate_change start — progressive disclosure suggestions let output = formatSchema(schemaResult) if (schemaResult.resolution) output = `${schemaResult.resolution}\n\n${output}` - if (args.values !== false) { + if (CoreFlag.ALTIMATE_DISABLE_SCHEMA_VALUES) { + // Says why, so the model does not retry with values:true. + output += "\n\nValue profiles are turned off by ALTIMATE_DISABLE_SCHEMA_VALUES." + } else if (args.values !== false) { const resolvedTable = schemaResult.table ?? args.table const parts = resolvedTable.includes(".") ? resolvedTable.split(".") : [schemaResult.schema_name, resolvedTable] const ref = parts diff --git a/packages/opencode/test/altimate/tools/schema-inspect-values.test.ts b/packages/opencode/test/altimate/tools/schema-inspect-values.test.ts index 2327b0caee..76032cd146 100644 --- a/packages/opencode/test/altimate/tools/schema-inspect-values.test.ts +++ b/packages/opencode/test/altimate/tools/schema-inspect-values.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { afterAll, beforeAll, afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" import { Database } from "bun:sqlite" import { initTool } from "../tool-fixture" import * as Dispatcher from "../../../src/altimate/native/dispatcher" @@ -59,6 +59,7 @@ beforeEach(() => { Registry.reset() Dispatcher.reset() process.env.ALTIMATE_TELEMETRY_DISABLED = "true" + delete process.env.ALTIMATE_DISABLE_SCHEMA_VALUES }) afterAll(() => { @@ -126,6 +127,39 @@ describe("schema_inspect value grounding", () => { const result = await inspect({ values: false }) expect(calls).toHaveLength(0) expect(result.output).not.toContain("Value samples") + expect(result.output).not.toContain("ALTIMATE_DISABLE_SCHEMA_VALUES") + }) + + describe("ALTIMATE_DISABLE_SCHEMA_VALUES", () => { + afterEach(() => { + delete process.env.ALTIMATE_DISABLE_SCHEMA_VALUES + }) + + for (const value of ["1", "true", " YES "]) { + test(`set to ${JSON.stringify(value)} skips the profiling query even with values:true`, async () => { + process.env.ALTIMATE_DISABLE_SCHEMA_VALUES = value + const calls: string[] = [] + install("sqlite", run, calls) + const result = await inspect({ values: true }) + expect(calls).toHaveLength(0) + expect(result.metadata.success).toBe(true) + expect(result.output).toContain("id | INTEGER") + expect(result.output).not.toContain("Value samples") + expect(result.output).toContain("Value profiles are turned off by ALTIMATE_DISABLE_SCHEMA_VALUES.") + }) + } + + for (const value of ["0", "false", ""]) { + test(`set to ${JSON.stringify(value)} behaves as unset`, async () => { + process.env.ALTIMATE_DISABLE_SCHEMA_VALUES = value + const calls: string[] = [] + install("sqlite", run, calls) + const result = await inspect({}) + expectAppendix(result.output) + expect(calls).toHaveLength(1) + expect(result.output).not.toContain("ALTIMATE_DISABLE_SCHEMA_VALUES") + }) + } }) test("a profiling failure degrades to a note and keeps the column list", async () => { From 1ca9c0d1de7f7d3bcf31981290baadf5d1c4ee97 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 23 Oct 2026 19:21:41 +0530 Subject: [PATCH 04/11] fix(storage): say why startup waits on the first-run setup lock After 5 s waiting on another process's `.setup-lock`, print one line to stderr naming the lock file, then keep waiting to the same 60 s limit. Both the sync and async paths; thresholds are injectable for tests. Fixes #1580 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- packages/core/src/database/setup-lock.ts | 50 +++++++++-- .../core/test/database-setup-lock.test.ts | 83 +++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 packages/core/test/database-setup-lock.test.ts diff --git a/packages/core/src/database/setup-lock.ts b/packages/core/src/database/setup-lock.ts index 739e4936dc..bcbcac5cb8 100644 --- a/packages/core/src/database/setup-lock.ts +++ b/packages/core/src/database/setup-lock.ts @@ -20,12 +20,41 @@ const sentinel = (filename: string) => filename + ".setup-lock" const SYNC_TIMEOUT_MS = 60_000 const POLL_MS = 25 const HOLD_TIMEOUT_MS = 60_000 +/** How long to wait silently before telling the user why startup is stalled. */ +export const WAIT_NOTICE_MS = 5_000 -export function acquireSync(filename: string): () => void { +/** Overrides for tests; production callers use the defaults. */ +export interface Options { + /** Wait before printing the notice. */ + noticeAfterMs?: number + /** Total wait before giving up. */ + timeoutMs?: number + /** Receives the notice line. */ + notify?: (line: string) => void +} + +// stderr, not stdout: `run --format json` consumers parse stdout. +const notifyStderr = (line: string) => void process.stderr.write(line + "\n") + +export function waitNotice(filename: string) { + return `Waiting for another altimate-code process to finish setting up the database (lock: ${sentinel(filename)})` +} + +export function acquireSync(filename: string, options: Options = {}): () => void { if (filename === ":memory:" || active.has(filename)) return () => {} - const lock = openSetupLock(sentinel(filename), SYNC_TIMEOUT_MS) + const timeoutMs = options.timeoutMs ?? SYNC_TIMEOUT_MS + const noticeAfterMs = Math.min(options.noticeAfterMs ?? WAIT_NOTICE_MS, timeoutMs) + // Block for the notice threshold first, then announce the wait and block for the remainder. + const lock = openSetupLock(sentinel(filename), noticeAfterMs) try { - lock.exec("BEGIN EXCLUSIVE") + try { + lock.exec("BEGIN EXCLUSIVE") + } catch (error) { + if (!isBusy(error) || noticeAfterMs >= timeoutMs) throw error + ;(options.notify ?? notifyStderr)(waitNotice(filename)) + lock.exec(`PRAGMA busy_timeout = ${timeoutMs - noticeAfterMs}`) + lock.exec("BEGIN EXCLUSIVE") + } } catch (error) { lock.close() throw error @@ -44,15 +73,22 @@ function isBusy(error: unknown) { } /** Runs `body` holding the lock; waiting is async so in-process peers keep making progress. */ -export function hold(filename: string, body: Effect.Effect): Effect.Effect { +export function hold( + filename: string, + body: Effect.Effect, + options: Options = {}, +): Effect.Effect { if (filename === ":memory:") return body let semaphore = semaphores.get(filename) if (!semaphore) { semaphore = Semaphore.makeUnsafe(1) semaphores.set(filename, semaphore) } + const timeoutMs = options.timeoutMs ?? HOLD_TIMEOUT_MS + const noticeAfterMs = options.noticeAfterMs ?? WAIT_NOTICE_MS const acquire = Effect.gen(function* () { const started = Date.now() + let noticed = false while (true) { const lock = openSetupLock(sentinel(filename), 0) const result = yield* Effect.try({ try: () => lock.exec("BEGIN EXCLUSIVE"), catch: (error) => error }).pipe( @@ -60,7 +96,7 @@ export function hold(filename: string, body: Effect.Effect): E Effect.catch((error) => Effect.sync(() => lock.close()).pipe( Effect.flatMap(() => - isBusy(error) && Date.now() - started < HOLD_TIMEOUT_MS ? Effect.succeed(undefined) : Effect.die(error), + isBusy(error) && Date.now() - started < timeoutMs ? Effect.succeed(undefined) : Effect.die(error), ), ), ), @@ -69,6 +105,10 @@ export function hold(filename: string, body: Effect.Effect): E active.add(filename) return result } + if (!noticed && Date.now() - started >= noticeAfterMs) { + noticed = true + ;(options.notify ?? notifyStderr)(waitNotice(filename)) + } // `acquireUseRelease` makes `acquire` uninterruptible; no handle is open while sleeping, so cancel here. yield* Effect.interruptible(Effect.sleep(POLL_MS)) } diff --git a/packages/core/test/database-setup-lock.test.ts b/packages/core/test/database-setup-lock.test.ts new file mode 100644 index 0000000000..cb5a9c4876 --- /dev/null +++ b/packages/core/test/database-setup-lock.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test" +import { Database } from "bun:sqlite" +import path from "path" +import { Effect } from "effect" +import * as SetupLock from "@opencode-ai/core/database/setup-lock" +import { tmpdir } from "./fixture/tmpdir" + +/** Holds the setup lock from a separate connection, as another process would. */ +function holdExternally(filename: string) { + const db = new Database(filename + ".setup-lock", { create: true }) + db.run("BEGIN EXCLUSIVE") + return () => { + db.run("ROLLBACK") + db.close() + } +} + +describe("SetupLock wait notice", () => { + test("defaults to a 5 second threshold", () => { + expect(SetupLock.WAIT_NOTICE_MS).toBe(5_000) + }) + + test("names the lock file", () => { + expect(SetupLock.waitNotice("/data/opencode.db")).toBe( + "Waiting for another altimate-code process to finish setting up the database (lock: /data/opencode.db.setup-lock)", + ) + }) + + test("acquireSync: no notice when the lock is free", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "free-sync.db") + const lines: string[] = [] + const release = SetupLock.acquireSync(filename, { noticeAfterMs: 0, notify: (line) => lines.push(line) }) + release() + expect(lines).toEqual([]) + }) + + test("acquireSync: prints one notice after the threshold, then keeps waiting until the timeout", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "held-sync.db") + const releaseHolder = holdExternally(filename) + const lines: string[] = [] + const started = Date.now() + try { + expect(() => + SetupLock.acquireSync(filename, { noticeAfterMs: 50, timeoutMs: 300, notify: (line) => lines.push(line) }), + ).toThrow(/locked|busy/i) + } finally { + releaseHolder() + } + // The notice did not end the wait: it continued to the full timeout. + expect(Date.now() - started).toBeGreaterThanOrEqual(250) + expect(lines).toEqual([SetupLock.waitNotice(filename)]) + }) + + test("hold: no notice when the lock is free", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "free-hold.db") + const lines: string[] = [] + const result = await Effect.runPromise( + SetupLock.hold(filename, Effect.succeed("ran"), { noticeAfterMs: 0, notify: (line) => lines.push(line) }), + ) + expect(result).toBe("ran") + expect(lines).toEqual([]) + }) + + test("hold: prints one notice after the threshold and acquires once the holder releases", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "held-hold.db") + const releaseHolder = holdExternally(filename) + const lines: string[] = [] + const timer = setTimeout(releaseHolder, 400) + try { + const result = await Effect.runPromise( + SetupLock.hold(filename, Effect.succeed("ran"), { noticeAfterMs: 50, notify: (line) => lines.push(line) }), + ) + expect(result).toBe("ran") + } finally { + clearTimeout(timer) + } + expect(lines).toEqual([SetupLock.waitNotice(filename)]) + }) +}) From 82be62edf0148dbd7c1b593621988024899a037f Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 23 Oct 2026 19:21:41 +0530 Subject: [PATCH 05/11] test: Windows shim parity between installers, SQL function policy, index control - `install` and `install.ps1` must write identical `altimate-code.cmd` and sh shims (fixes #1583) - direct tests for `sql-function-policy` and `schema/index-control` (part of #1581) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- .../altimate/schema-index-control.test.ts | 99 +++++++++ .../tools/sql-function-policy.test.ts | 200 ++++++++++++++++++ .../test/install/install-alias.test.ts | 44 ++++ 3 files changed, 343 insertions(+) create mode 100644 packages/opencode/test/altimate/schema-index-control.test.ts create mode 100644 packages/opencode/test/altimate/tools/sql-function-policy.test.ts diff --git a/packages/opencode/test/altimate/schema-index-control.test.ts b/packages/opencode/test/altimate/schema-index-control.test.ts new file mode 100644 index 0000000000..1e26c045fb --- /dev/null +++ b/packages/opencode/test/altimate/schema-index-control.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { DEFAULT_INDEX_TIMEOUT_MS, awaitIndex, withIndexSignal } from "../../src/altimate/native/schema/index-control" + +const never = () => new Promise(() => {}) + +describe("withIndexSignal", () => { + test("defaults to a 30 second deadline", () => { + expect(DEFAULT_INDEX_TIMEOUT_MS).toBe(30_000) + }) + + test("returns the work result and clears the deadline", async () => { + let seen: AbortSignal | undefined + const result = await withIndexSignal({ timeoutMs: 20 }, async (signal) => { + seen = signal + return "done" + }) + expect(result).toBe("done") + await Bun.sleep(40) + expect(seen?.aborted).toBe(false) + }) + + test("aborts the signal with a TimeoutError after the deadline", async () => { + let seen: AbortSignal | undefined + const error = await withIndexSignal({ timeoutMs: 10 }, (signal) => { + seen = signal + return awaitIndex(signal, never) + }).catch((e) => e) + expect(seen?.aborted).toBe(true) + expect(error).toBeInstanceOf(DOMException) + expect(error.name).toBe("TimeoutError") + expect(error.message).toBe("Schema indexing deadline exceeded.") + }) + + test("propagates a caller abort to the work signal", async () => { + const parent = new AbortController() + const reason = new Error("caller cancelled") + const pending = withIndexSignal({ signal: parent.signal, timeoutMs: 10_000 }, (signal) => awaitIndex(signal, never)) + parent.abort(reason) + await expect(pending).rejects.toBe(reason) + }) + + test("does not abort the caller's signal on timeout", async () => { + const parent = new AbortController() + await withIndexSignal({ signal: parent.signal, timeoutMs: 10 }, (signal) => awaitIndex(signal, never)).catch( + () => {}, + ) + expect(parent.signal.aborted).toBe(false) + }) +}) + +describe("awaitIndex", () => { + test("returns the work result when not aborted", async () => { + const controller = new AbortController() + expect(await awaitIndex(controller.signal, async () => 42)).toBe(42) + }) + + test("rejects without starting work when the signal is already aborted", async () => { + const controller = new AbortController() + const reason = new Error("already aborted") + controller.abort(reason) + let started = false + await expect( + awaitIndex(controller.signal, async () => { + started = true + return 1 + }), + ).rejects.toBe(reason) + expect(started).toBe(false) + }) + + test("rejects with the abort reason while work is still running", async () => { + const controller = new AbortController() + const reason = new Error("stop") + const pending = awaitIndex(controller.signal, never) + controller.abort(reason) + await expect(pending).rejects.toBe(reason) + }) + + test("propagates work failures", async () => { + const controller = new AbortController() + const failure = new Error("index failed") + await expect( + awaitIndex(controller.signal, async () => { + throw failure + }), + ).rejects.toBe(failure) + }) + + test("rejects when the signal aborts as the work resolves", async () => { + const controller = new AbortController() + const reason = new Error("late abort") + await expect( + awaitIndex(controller.signal, async () => { + controller.abort(reason) + return "late" + }), + ).rejects.toBe(reason) + }) +}) diff --git a/packages/opencode/test/altimate/tools/sql-function-policy.test.ts b/packages/opencode/test/altimate/tools/sql-function-policy.test.ts new file mode 100644 index 0000000000..93648e58b9 --- /dev/null +++ b/packages/opencode/test/altimate/tools/sql-function-policy.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test" +import { hasSideEffectFunction, isReadOnlyNestedFunction } from "../../../src/altimate/tools/sql-function-policy" +import { sqlFunctionCalls } from "../../../src/altimate/tools/sql-functions" + +describe("hasSideEffectFunction", () => { + test.each([ + "nextval", + "setval", + "set_config", + "load_extension", + "writefile", + "dblink", + "dblink_exec", + "lo_import", + "lo_unlink", + "checkpoint", + "get_lock", + "release_all_locks", + "sleep", + "benchmark", + "postgres_execute", + "mysql_execute", + "sqlite_execute", + "SYSTEM$CANCEL_QUERY", + ])("flags generic side-effect function %s", (name) => { + expect(hasSideEffectFunction(name)).toBe(true) + }) + + test.each([ + "pg_terminate_backend", + "pg_cancel_backend", + "pg_reload_conf", + "pg_advisory_lock", + "pg_try_advisory_lock", + "pg_advisory_xact_lock_shared", + "pg_notify", + "pg_switch_wal", + "pg_create_logical_replication_slot", + "pg_drop_replication_slot", + "pg_stat_reset", + "pg_stat_reset_single_table_counters", + "pg_stat_statements_reset", + "pg_stat_clear_snapshot", + "pg_write_file", + "pg_backup_start", + ])("flags PostgreSQL side-effect function %s even when unknown pg_* is allowed", (name) => { + expect(hasSideEffectFunction(name)).toBe(true) + expect(hasSideEffectFunction(name, false)).toBe(true) + }) + + test.each([ + "pg_relation_size", + "pg_total_relation_size", + "pg_size_pretty", + "pg_get_viewdef", + "pg_backend_pid", + "pg_is_in_recovery", + "pg_table_is_visible", + "pg_has_role", + "pg_stat_get_live_tuples", + "pg_current_wal_lsn", + "pg_typeof", + ])("allows read-only PostgreSQL inspection function %s", (name) => { + expect(hasSideEffectFunction(name)).toBe(false) + }) + + test.each(["count", "coalesce", "lower", "json_extract", "my_udf"])( + "does not flag non-pg function %s", + (name) => { + expect(hasSideEffectFunction(name)).toBe(false) + }, + ) + + test("unknown pg_* functions are denied by default and allowed when not strict", () => { + expect(hasSideEffectFunction("pg_sleep")).toBe(true) + expect(hasSideEffectFunction("pg_some_future_function")).toBe(true) + expect(hasSideEffectFunction("pg_sleep", false)).toBe(false) + expect(hasSideEffectFunction("pg_some_future_function", false)).toBe(false) + }) + + test("matches function names case-insensitively", () => { + expect(hasSideEffectFunction("NEXTVAL")).toBe(true) + expect(hasSideEffectFunction("NextVal")).toBe(true) + expect(hasSideEffectFunction("PG_TERMINATE_BACKEND")).toBe(true) + expect(hasSideEffectFunction("system$cancel_query")).toBe(true) + expect(hasSideEffectFunction("PG_RELATION_SIZE")).toBe(false) + expect(hasSideEffectFunction("PG_SLEEP")).toBe(true) + }) + + test("takes one bare identifier: schema-qualified or quoted text is not decoded", () => { + // Callers pass the decoded name from sqlFunctionCalls; raw SQL text never matches. + expect(hasSideEffectFunction("public.nextval")).toBe(false) + expect(hasSideEffectFunction('"nextval"')).toBe(false) + expect(hasSideEffectFunction("nextval(")).toBe(false) + expect(hasSideEffectFunction("nextval_safe")).toBe(false) + }) + + test("empty input is not a side-effect function", () => { + expect(hasSideEffectFunction("")).toBe(false) + expect(hasSideEffectFunction("", false)).toBe(false) + }) +}) + +describe("isReadOnlyNestedFunction", () => { + test.each([ + "count", + "sum", + "coalesce", + "lower", + "substring", + "date_trunc", + "json_extract", + "row_number", + "generate_series", + "current_setting", + "cast", + "exists", + "to_regclass", + ])("allows allowlisted function %s", (name) => { + expect(isReadOnlyNestedFunction(name)).toBe(true) + }) + + test.each(["pg_relation_size", "pg_get_viewdef", "pg_table_is_visible", "has_table_privilege", "has_schema_privilege"])( + "allows read-only catalog function %s", + (name) => { + expect(isReadOnlyNestedFunction(name)).toBe(true) + }, + ) + + test.each(["nextval", "set_config", "dblink", "pg_terminate_backend", "pg_stat_reset", "pg_sleep", "sleep"])( + "rejects side-effect function %s", + (name) => { + expect(isReadOnlyNestedFunction(name)).toBe(false) + }, + ) + + test.each(["my_udf", "refresh_materialized", "query", "postgres_query", "eval", "pg_some_future_function"])( + "fails closed for unknown function %s", + (name) => { + expect(isReadOnlyNestedFunction(name)).toBe(false) + }, + ) + + test("allows pg_catalog and information_schema qualifiers only", () => { + expect(isReadOnlyNestedFunction("count", "pg_catalog")).toBe(true) + expect(isReadOnlyNestedFunction("pg_get_viewdef", "pg_catalog")).toBe(true) + expect(isReadOnlyNestedFunction("count", "information_schema")).toBe(true) + expect(isReadOnlyNestedFunction("count", "public")).toBe(false) + expect(isReadOnlyNestedFunction("lower", "analytics")).toBe(false) + expect(isReadOnlyNestedFunction("count", "")).toBe(false) + }) + + test("a catalog qualifier does not make a side-effect function safe", () => { + expect(isReadOnlyNestedFunction("nextval", "pg_catalog")).toBe(false) + expect(isReadOnlyNestedFunction("set_config", "pg_catalog")).toBe(false) + expect(isReadOnlyNestedFunction("my_udf", "pg_catalog")).toBe(false) + }) + + test("expects lowercased names and qualifiers, failing closed otherwise", () => { + // sqlFunctionCalls lowercases both; anything else is treated as unknown. + expect(isReadOnlyNestedFunction("COUNT")).toBe(false) + expect(isReadOnlyNestedFunction("count", "PG_CATALOG")).toBe(false) + expect(isReadOnlyNestedFunction("NEXTVAL")).toBe(false) + }) + + test("empty input is not read-only", () => { + expect(isReadOnlyNestedFunction("")).toBe(false) + expect(isReadOnlyNestedFunction("", "pg_catalog")).toBe(false) + }) +}) + +describe("policy applied to names decoded by sqlFunctionCalls", () => { + const decoded = (sql: string) => sqlFunctionCalls(sql, "postgres")[0] + + test.each([ + ["SELECT nextval('s')", "nextval"], + ["SELECT NEXTVAL('s')", "nextval"], + ['SELECT "nextval"(\'s\')', "nextval"], + ['SELECT "NextVal"(\'s\')', "nextval"], + ["SELECT public.nextval('s')", "nextval"], + ])("%s is a side-effect call", (sql, name) => { + const call = decoded(sql) + expect(call.name).toBe(name) + expect(hasSideEffectFunction(call.name)).toBe(true) + expect(isReadOnlyNestedFunction(call.name, call.qualifier)).toBe(false) + }) + + test("quoted and uppercase allowlisted calls stay read-only", () => { + for (const sql of ['SELECT "COUNT"(*) FROM t', "SELECT COUNT(*) FROM t", 'SELECT "pg_catalog"."count"(*) FROM t']) { + const call = decoded(sql) + expect(isReadOnlyNestedFunction(call.name, call.qualifier)).toBe(true) + } + }) + + test("schema-qualified user functions require write permission", () => { + const call = decoded("SELECT analytics.count(*) FROM t") + expect(call.qualifier).toBe("analytics") + expect(isReadOnlyNestedFunction(call.name, call.qualifier)).toBe(false) + }) +}) diff --git a/packages/opencode/test/install/install-alias.test.ts b/packages/opencode/test/install/install-alias.test.ts index 3c091ef8ba..66c07840c2 100644 --- a/packages/opencode/test/install/install-alias.test.ts +++ b/packages/opencode/test/install/install-alias.test.ts @@ -398,3 +398,47 @@ describe("install.ps1 — altimate-code alias", () => { for (const line of SH_SHIM_LINES) expect(INSTALL_PS1).toContain(`'${line}'`) }) }) + +// The shim text as each installer spells it, decoded to the bytes it writes. Only the +// host language's escaping is undone; anything the decoder does not know fails the match. +// Sources are read with LF endings, as an autocrlf checkout may hand them back with CRLF. +const lf = (text: string) => text.replace(/\r\n/g, "\n") + +function bashShim(name: string) { + const INSTALL_SH = lf(readFileSync(INSTALL_SH_PATH, "utf-8")) + const m = INSTALL_SH.match(new RegExp(`^\\s*write_shim ${name.replace(".", "\\.")} '([^']*)' \\|\\| return 0$`, "m")) + expect(m).not.toBeNull() + // A printf format: only \r, \n and %% may appear, so no other escape or conversion is lost. + const format = m![1] + expect(format.replace(/\\[rn]|%%/g, "")).not.toMatch(/[\\%]/) + return format.replace(/\\r/g, "\r").replace(/\\n/g, "\n").replace(/%%/g, "%") +} + +function ps1CmdShim() { + const m = lf(INSTALL_PS1).match(/^\s*& \$writeShim "altimate-code\.cmd" "((?:[^"`]|`.)*)"$/m) + expect(m).not.toBeNull() + // A double-quoted PowerShell string: no `$` expansion, only `r, `n and `" escapes. + const text = m![1] + expect(text).not.toContain("$") + expect(text.replace(/`[rn"]/g, "")).not.toContain("`") + return text.replace(/`r/g, "\r").replace(/`n/g, "\n").replace(/`"/g, '"') +} + +function ps1ShShim() { + const m = lf(INSTALL_PS1).match(/^\s*& \$writeShim "altimate-code" \(\(@\(\n((?:\s*'(?:[^']|'')*'\n)+)\s*\) -join "`n"\) \+ "`n"\)$/m) + expect(m).not.toBeNull() + const lines = [...m![1].matchAll(/^\s*'((?:[^']|'')*)'$/gm)].map((l) => l[1].replace(/''/g, "'")) + return lines.join("\n") + "\n" +} + +describe("install and install.ps1 — Windows alias shims", () => { + test("write the same altimate-code.cmd", () => { + expect(bashShim("altimate-code.cmd")).toBe(ps1CmdShim()) + expect(ps1CmdShim()).toBe(CMD_SHIM) + }) + + test("write the same altimate-code sh shim", () => { + expect(bashShim("altimate-code")).toBe(ps1ShShim()) + expect(ps1ShShim()).toBe(SH_SHIM) + }) +}) From c5acdca9c23405fff6e119180d308f35b96111f0 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 23 Oct 2026 19:41:31 +0530 Subject: [PATCH 06/11] release: v0.12.8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- CHANGELOG.md | 52 + .../skill/release-v0.12.8-adversarial.test.ts | 894 ++++++++++++++++++ 2 files changed, 946 insertions(+) create mode 100644 packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b2eade7e07..84ea4d3197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,58 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.8] - 2026-10-23 + +**Claude Code can work through your Altimate workspace.** With the Altimate Code plugin installed and the project linked, tasks Claude hands to altimate-code run with the workspace's skills, knowledge and shared memory, and a headless `run` no longer hangs waiting on a stdin nobody writes to. Also in this release: the curl and PowerShell installers add an `altimate-code` command, the analyst can read workspace memory, `learn status` shows whether a lesson keeps being corrected, `schema_inspect` shows what column values look like, and SQL policy checks fail closed on statements they cannot analyze. + +**Heads-up for support:** +- **`run` waits at most 3 s on a socket stdin.** When `run` already has a message or `--command` and stdin is a socket (an agent's shell tool, a Node `spawn`), it waits 3 s for the first byte, then continues without stdin and prints one line on stderr; input sent later is ignored. Files and shell pipelines (`dbt build | altimate-code run …`) are still read to the end. `ALTIMATE_RUN_STDIN_WAIT_MS` changes the wait (`0` waits for EOF), and `< /dev/null` (`< NUL` in cmd.exe) skips it. On Windows a pipe is not a socket, so a caller that never closes stdin should redirect it (#1577). +- **`run` may end with a line about unsent memory**, giving the count and `altimate-code workspace sync`. It goes to stderr; scripts that parse stderr will see it. +- **Plain `run` prompts are no longer quoted.** Arguments containing spaces used to reach the model wrapped in quotes; they now arrive as typed. `--command` keeps its quoting. +- **`check` policy and semantic checks fail closed.** altimate-core 0.9.0 accepts table-valued functions (`TABLE(fn())`, Snowflake `LATERAL FLATTEN`, DuckDB `read_csv(...)`) that 0.8.0 rejected as invalid, but cannot plan them. Policy now reports `policy_not_evaluated` and treats the statement as blocked; semantics reports it as skipped instead of clean. +- **`schema_inspect` sends sampled column values to the model provider** by default (see Added). `values: false` turns it off per call, and `ALTIMATE_DISABLE_SCHEMA_VALUES=1` turns it off for every call. +- **`error_fingerprint` telemetry carries a masked `error_message`**: the first line of the error with every word outside a fixed list of common error terms replaced by ``, at most 200 characters. No identifiers, paths, values or SQL. +- **Read and bash deny rules match equivalent path spellings** (relative, absolute, after `cd`, through a symlink). A deny rule can now stop a command that used to slip past it; allow rules are unchanged. +- **Warehouse connections are kept per project directory.** A `serve` process serving two projects no longer shares one project's connections with the other. Global and project connection files load as before; a running server still does not re-read an edited `connections.json`. +- **`datamate_manager` is not offered in projects linked to a workspace**; integrations there come from the workspace's engine. Existing `datamate-*` entries still load, with a one-time notice naming their file. + +### Added + +- **Claude Code plugin path to workspaces.** The plugin hands work to `altimate-code run` in the linked workspace; the first run in a project pulls the workspace's skills; `workspace list [--json]` lists the workspaces an account can link to. See the Claude Code guide. (#1552) +- **`altimate-code` from the standalone installers.** curl installs link `altimate-code` to `altimate`; PowerShell installs add `altimate-code.cmd` and a Git Bash shim. Re-running the installer adds it to an existing install. Scripts on Windows should call `altimate`, since the `.cmd` shim passes arguments through cmd.exe. (#1562) +- **Column value profiles in `schema_inspect`.** On SQLite, DuckDB, PostgreSQL and MongoDB it adds, per column, distinct count, null share, the most frequent values with counts, uniqueness in the sample and common value shapes, from a bounded sample, so filters match the stored format. MongoDB nested fields are listed by path. (#1542) +- **`ALTIMATE_DISABLE_SCHEMA_VALUES`** turns those value profiles off for every call, whatever the model asks for. (#1578) +- **Lesson recurrence in `learn status`.** It shows whether a lesson that was shown still drew a correction on the same topic, and flags recurring lessons to reflection. (#1556) +- **`/integrations` in the TUI** opens the workspace's integration status directly, or offers the link or the engine install when those are missing. (#1464) +- **Workspace sync state for every client.** When skills and memory were last checked and what changed is recorded and published, so the IDE extension can show it as the TUI sidebar does. (#1429) +- **Analyst data analysis** gets query deadlines, result envelopes, a scratch bridge (`sql_execute` `query_from`) and verification checks. (#1441) +- **`answer_commit` asks once for a missed item.** A commit with fewer fields than the request asked for ("the X and its Y") is rejected once, naming the clause; an unchanged recommit is accepted. (#1553) +- **`client_connected` telemetry** marks the first client to attach to a running server. (#1528) + +### Changed + +- **The analyst can read workspace memory** (read, refresh and audit) and is steered to workspace skills. Saving memory blocks stays with builder; `training_save` still works. (#1570) +- **altimate-core 0.9.0**, with policy and semantic checks failing closed as described above. The messages now say to review such statements by hand. (#1555, #1585) +- **`datamate_manager` is off in linked projects.** (#1431) +- **Plain `run` prompt arguments are no longer quoted.** (#1561) + +### Fixed + +- **Headless `run` no longer hangs under an agent's shell.** (#1552) +- **"database is locked" when several CLI processes start on a fresh data directory.** Setup is serialized with a lock that the OS releases if its holder dies; after 5 s of waiting, startup says which lock it is waiting on. (#1541, #1580) +- **Warehouse connections no longer leak between projects in one server**, including SSH tunnels, which are now owned by the connect attempt that opened them. (#1567) +- **`warehouse_list` with no connections points at the real config locations** and the `ALTIMATE_CODE_CONN_*` variables. (#1559) +- **Workspace memory and link checks match a repo's remote across its URL forms**, using the same comparison as the workspace service. (#1462) +- **A message sent while the assistant is busy attaches the workspace engine.** (#1461) +- **Prompt caching works across working directories.** The environment section moved after the cached instructions, and tool schemas no longer embed the directory. (#1517) +- **Gemini:** tool schemas no longer contain empty-string enum values, which Gemini rejected with a 400 on every request; small calls on Gemini 4 use thinking levels instead of a budget it rejects. (#1532, #1534) +- **Large MongoDB reads are fast again.** Byte-bounded cursors read in 1,000-document batches instead of one document per round trip. (#1480) +- **DuckDB schema indexing** stays in the current catalog, no longer duplicates schemas from attached catalogs, and stops at a 30 s default deadline with partial results. (#1514) +- **The analyst reports tool failures as failures**, accepts valid read-only syntax and retrieves long text in full. (#1448) +- **`run --model` reports a failed provider list as itself**, with the HTTP status, not "Model not found". (#1472) +- **A failed background file scan is handled** instead of surfacing as an unhandled rejection. (#1471) +- **`uninstall` on Windows says its removal commands are for PowerShell.** Found in this release's review. + ## [0.12.7] - 2026-10-13 **Workspace skills and knowledge come first.** In a linked project the agent prefers the workspace's own skills over lookalikes in the repo or your home folder, and checks the workspace's knowledge base for best practices without being asked. Linking now ends with a starter that says what the workspace provides and suggests prompts. Also in this release: opt-in automatic promotion for `learn`, session traces for the IDE extension, and Snowflake reconnects written to `opencode.log`. diff --git a/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts new file mode 100644 index 0000000000..9f83d99818 --- /dev/null +++ b/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts @@ -0,0 +1,894 @@ +/** + * Adversarial coverage for the v0.12.8 release payload (v0.12.7..HEAD): the `run` stdin wait, quoted `run` + * prompt arguments, the uninstall removal command, the telemetry error template, the plan-skip fail-closed + * guards, permission aliases, the answer_commit conjunction check, the unsent-memory exit notice and the + * schema value profile. + * + * Targets the exported pure helpers with hostile inputs: empty/null/undefined, boundary numbers, type + * confusion, shell metacharacters, prototype-pollution keys and error propagation. No timers beyond a + * never-settling promise raced against a 1ms timeout, no network, no module mocks. + */ +import { describe, expect, test } from "bun:test" +import { readRunStdin, stdinWaitMs, STDIN_WAIT_MS, type StdinSource } from "../../src/altimate/cli/run-stdin" +import { joinRunMessage } from "../../src/cli/cmd/run" +import { removeFileCommand } from "../../src/cli/cmd/uninstall" +import { templateErrorMessage, describeToolError } from "../../src/altimate/telemetry/error-detail" +import { + applyPolicySkipGuard, + applySemanticsSkipGuard, + POLICY_NOT_EVALUATED_MESSAGE, + SEMANTICS_SKIPPED_MESSAGE, +} from "../../src/altimate/native/altimate-core" +import { evaluate, evaluateRequest, evaluateRequestPatterns } from "../../src/permission/evaluate" +import { + requestedItems, + conjunctionRejection, + CONJUNCTION_REJECTION_MARKER, +} from "../../src/altimate/tools/answer-commit-conjunction" +import { + unsentMemoryNotice, + unsentMemoryNoticeAfterRun, + type UnsentMemoryDeps, +} from "../../src/altimate/cli/unsent-memory" +import { valueShape, profileDocuments } from "../../src/altimate/tools/schema-values" + +const enc = new TextEncoder() + +function streamOf(chunks: string[]): ReadableStream { + let i = 0 + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) controller.enqueue(enc.encode(chunks[i++])) + else controller.close() + }, + }) +} + +function source(opts: { + file?: boolean + socket?: boolean + all?: string | Error + stream?: () => ReadableStream +}): StdinSource & { readAllCalls: number; streamCalls: number } { + const s = { + readAllCalls: 0, + streamCalls: 0, + isFile: () => opts.file ?? false, + isSocket: () => opts.socket ?? false, + readAll: async () => { + s.readAllCalls++ + if (opts.all instanceof Error) throw opts.all + return opts.all ?? "" + }, + stream: () => { + s.streamCalls++ + return opts.stream!() + }, + } + return s +} + +describe("v0.12.8 adversarial: stdinWaitMs", () => { + const wait = (v: string | undefined) => stdinWaitMs({ ALTIMATE_RUN_STDIN_WAIT_MS: v }) + + test("unset, empty and whitespace fall back to the default", () => { + expect(stdinWaitMs({})).toBe(STDIN_WAIT_MS) + expect(wait(undefined)).toBe(STDIN_WAIT_MS) + expect(wait("")).toBe(STDIN_WAIT_MS) + expect(wait(" \t\n")).toBe(STDIN_WAIT_MS) + }) + + test("negative, NaN, Infinity and non-numeric text fall back to the default", () => { + for (const v of ["-1", "-0.0001", "NaN", "Infinity", "-Infinity", "abc", "12abc", "1e999", "0x", "--5"]) { + expect(wait(v)).toBe(STDIN_WAIT_MS) + } + }) + + test("zero means wait for EOF and is returned as 0", () => { + expect(wait("0")).toBe(0) + expect(wait(" 0 ")).toBe(0) + }) + + test("valid values pass through, trimmed", () => { + expect(wait("1")).toBe(1) + expect(wait(" 250 ")).toBe(250) + expect(wait("1.5")).toBe(1.5) + }) + + test("values above the timer limit are clamped, not wrapped", () => { + expect(wait("2147483647")).toBe(2147483647) + expect(wait("2147483648")).toBe(2147483647) + expect(wait("99999999999999")).toBe(2147483647) + }) + + test("hex and exponent spellings parse as numbers, never as code", () => { + expect(wait("0x10")).toBe(16) + expect(wait("1e3")).toBe(1000) + expect(wait("1;process.exit(1)")).toBe(STDIN_WAIT_MS) + }) +}) + +describe("v0.12.8 adversarial: readRunStdin", () => { + test("unbounded socket is read to EOF without touching the stream", async () => { + const s = source({ socket: true, all: "whole prompt" }) + expect(await readRunStdin(s, 5, false)).toEqual({ text: "whole prompt", timedOut: false }) + expect(s.streamCalls).toBe(0) + }) + + test("waitMs 0 and negative read to EOF", async () => { + for (const ms of [0, -1, -Infinity]) { + const s = source({ socket: true, all: "x" }) + expect(await readRunStdin(s, ms)).toEqual({ text: "x", timedOut: false }) + expect(s.streamCalls).toBe(0) + } + }) + + test("a file is read to EOF even when it also claims to be a socket", async () => { + const s = source({ file: true, socket: true, all: "from file" }) + expect(await readRunStdin(s, 5)).toEqual({ text: "from file", timedOut: false }) + expect(s.streamCalls).toBe(0) + }) + + test("a FIFO (neither file nor socket) is read to EOF", async () => { + const s = source({ all: "piped" }) + expect(await readRunStdin(s, 5)).toEqual({ text: "piped", timedOut: false }) + expect(s.streamCalls).toBe(0) + }) + + test("empty file returns empty text, not a timeout", async () => { + expect(await readRunStdin(source({ file: true, all: "" }), 5)).toEqual({ text: "", timedOut: false }) + }) + + test("readAll errors propagate", async () => { + const s = source({ file: true, all: new Error("EIO") }) + await expect(readRunStdin(s, 5)).rejects.toThrow("EIO") + }) + + test("a socket that sends data returns all chunks, including split multibyte characters", async () => { + const bytes = enc.encode("héllo 世界") + const stream = () => + new ReadableStream({ + start(c) { + // split inside the two-byte "é" and inside a three-byte character + c.enqueue(bytes.slice(0, 2)) + c.enqueue(bytes.slice(2, 9)) + c.enqueue(bytes.slice(9)) + c.close() + }, + }) + const result = await readRunStdin(source({ socket: true, stream }), 60_000) + expect(result).toEqual({ text: "héllo 世界", timedOut: false }) + }) + + test("a socket that closes without writing is empty, not timed out", async () => { + const result = await readRunStdin(source({ socket: true, stream: () => streamOf([]) }), 60_000) + expect(result).toEqual({ text: "", timedOut: false }) + }) + + test("a socket that never writes times out with empty text and releases the stream", async () => { + let cancelled = false + const stream = () => + new ReadableStream({ + pull: () => new Promise(() => {}), + cancel() { + cancelled = true + }, + }) + const result = await readRunStdin(source({ socket: true, stream }), 1) + expect(result).toEqual({ text: "", timedOut: true }) + expect(cancelled).toBe(true) + }) + + test("a stream read error propagates", async () => { + const stream = () => + new ReadableStream({ + start(c) { + c.error(new Error("socket reset")) + }, + }) + await expect(readRunStdin(source({ socket: true, stream }), 60_000)).rejects.toThrow("socket reset") + }) + + test("an error after the first chunk propagates instead of returning partial text", async () => { + let n = 0 + const stream = () => + new ReadableStream({ + pull(c) { + if (n++ === 0) c.enqueue(enc.encode("partial")) + else c.error(new Error("mid-stream failure")) + }, + }) + await expect(readRunStdin(source({ socket: true, stream }), 60_000)).rejects.toThrow("mid-stream failure") + }) +}) + +describe("v0.12.8 adversarial: joinRunMessage", () => { + test("plain mode joins verbatim, with no quoting", () => { + expect(joinRunMessage(["a b", 'c"d'], false)).toBe('a b c"d') + expect(joinRunMessage([], false)).toBe("") + expect(joinRunMessage([""], false)).toBe("") + expect(joinRunMessage(["", ""], false)).toBe(" ") + }) + + test("command mode quotes only arguments containing a space", () => { + expect(joinRunMessage(["plain", "two words"], true)).toBe('plain "two words"') + }) + + test("command mode escapes embedded double quotes inside a quoted argument", () => { + expect(joinRunMessage(['say "hi" now'], true)).toBe('"say \\"hi\\" now"') + }) + + test("command mode leaves a quote-bearing argument without spaces untouched", () => { + expect(joinRunMessage(['a"b'], true)).toBe('a"b') + }) + + test("command mode keeps $1, backticks and single quotes literal", () => { + expect(joinRunMessage(["$1", "`id`", "it's here"], true)).toBe("$1 `id` \"it's here\"") + }) + + test("command mode on empty input and empty strings", () => { + expect(joinRunMessage([], true)).toBe("") + expect(joinRunMessage([""], true)).toBe("") + expect(joinRunMessage(["", "a b"], true)).toBe(' "a b"') + expect(joinRunMessage([" "], true)).toBe('" "') + }) + + test("a lone backslash before a quote is not unescaped (documented limitation of the escaper)", () => { + expect(joinRunMessage(['a \\" b'], true)).toBe('"a \\\\" b"') + }) + + test("newline and tab alone do not trigger quoting", () => { + expect(joinRunMessage(["a\nb", "c\td"], true)).toBe("a\nb c\td") + }) + + test("prototype keys are plain text", () => { + expect(joinRunMessage(["__proto__", "constructor"], true)).toBe("__proto__ constructor") + }) +}) + +describe("v0.12.8 adversarial: removeFileCommand", () => { + test("linux wraps the path in single quotes, with -f only when forced", () => { + expect(removeFileCommand("/usr/local/bin/altimate-code", "linux")).toBe("rm '/usr/local/bin/altimate-code'") + expect(removeFileCommand("/a/b", "linux", true)).toBe("rm -f '/a/b'") + expect(removeFileCommand("/a/b", "darwin", false)).toBe("rm '/a/b'") + }) + + test("a single quote in the path closes, escapes and reopens the quoting", () => { + expect(removeFileCommand("/home/o'brien/x", "linux")).toBe("rm '/home/o'\\''brien/x'") + expect(removeFileCommand("'", "linux")).toBe("rm ''\\'''") + }) + + test("injection attempts stay inside one quoted word", () => { + const hostile = ["/a; rm -rf ~", "/a $(whoami)", "/a `whoami`", "/a$HOME", "/a && curl x | sh", "/a\nrm -rf ~", "/a | b"] + for (const p of hostile) { + const cmd = removeFileCommand(p, "linux") + expect(cmd).toBe(`rm '${p}'`) + } + }) + + test("the only way out of the quotes is a quote, and every quote is neutralised", () => { + const cmd = removeFileCommand("/a'; touch /tmp/pwn; echo '", "linux", true) + expect(cmd).toBe("rm -f '/a'\\''; touch /tmp/pwn; echo '\\'''") + // stripping the escaped-quote sequences must leave balanced quotes + expect((cmd.replaceAll("'\\''", "").match(/'/g) ?? []).length % 2).toBe(0) + }) + + test("spaces and empty path", () => { + expect(removeFileCommand("/Program Files/x y", "linux")).toBe("rm '/Program Files/x y'") + expect(removeFileCommand("", "linux")).toBe("rm ''") + }) + + test("win32 uses PowerShell Remove-Item with a literal path and doubles single quotes", () => { + expect(removeFileCommand("C:\\Users\\me\\altimate-code.exe", "win32")).toBe( + "Remove-Item -LiteralPath 'C:\\Users\\me\\altimate-code.exe' -Force", + ) + expect(removeFileCommand("C:\\Users\\o'brien\\x", "win32")).toBe( + "Remove-Item -LiteralPath 'C:\\Users\\o''brien\\x' -Force", + ) + }) + + test("win32 does not treat $, backtick or semicolon specially and ignores the force flag", () => { + expect(removeFileCommand("C:\\a$env:X`b;c", "win32")).toBe("Remove-Item -LiteralPath 'C:\\a$env:X`b;c' -Force") + expect(removeFileCommand("C:\\a", "win32", true)).toBe(removeFileCommand("C:\\a", "win32", false)) + expect(removeFileCommand("C:\\a", "win32")).not.toContain("rm ") + }) + + test("a non-linux, non-win32 platform behaves like POSIX", () => { + expect(removeFileCommand("/a", "freebsd")).toBe("rm '/a'") + }) +}) + +describe("v0.12.8 adversarial: templateErrorMessage", () => { + const SECRETS = [ + "Zq9xKjwPLm4", + "hunter2ZZtop", + "AKIAIOSFODNN7EXAMPLE", + "xoxb-123456789012-abcdefQQQ", + "Qwxyz.Rtuvw@zork-corp.example", + "/Users/zorblax/qvx.csv", + "frobnitz_ledger", + "9876543210", + ] + + function assertClean(out: string) { + expect(out.length).toBeLessThanOrEqual(200) + expect(out).toMatch(/^[a-z<> ]*$/) + for (const s of SECRETS) { + expect(out.toLowerCase()).not.toContain(s.toLowerCase()) + for (const part of s.split(/[^A-Za-z0-9]+/).filter((p) => p.length > 5)) { + expect(out.toLowerCase()).not.toContain(part.toLowerCase()) + } + } + } + + test("empty string yields empty template", () => { + expect(templateErrorMessage("")).toBe("") + expect(templateErrorMessage(" ")).toBe("") + expect(templateErrorMessage("12345 !!! ???")).toBe("") + }) + + test("credentials, emails, paths and identifiers never survive", () => { + for (const s of SECRETS) { + assertClean(templateErrorMessage(`Failed to connect with token ${s}: connection refused`)) + } + assertClean(templateErrorMessage(`password=${SECRETS[1]} user=${SECRETS[4]} host=${SECRETS[5]}`)) + }) + + test("SQL text and quoted values are replaced", () => { + const out = templateErrorMessage(`syntax error near "SELECT frobnitz_ledger FROM zorblax WHERE ssn = '123-45-6789'"`) + expect(out).not.toContain("frobnitz") + expect(out).not.toContain("zorblax") + expect(out).not.toContain("123") + expect(out).toMatch(/^[a-z<> ]*$/) + }) + + test("numbers and symbols leave a visible gap rather than disappearing", () => { + expect(templateErrorMessage("timeout after 30000 ms")).toBe(templateErrorMessage("timeout after 30000 ms")) + expect(templateErrorMessage("zzqq9876543210zzqq")).toBe("") + expect(templateErrorMessage("zzqq 9876543210 zzqq")).toBe("") + }) + + test("only the first line is used, including with CR, LS and PS separators", () => { + const secret = "frobnitz_ledger" + for (const sep of ["\n", "\r\n", "\r", "\u2028", "\u2029"]) { + const out = templateErrorMessage(`connection refused${sep}stack: ${secret} at /Users/zorblax/x.ts:12`) + expect(out).not.toContain("frobnitz") + expect(out).not.toContain("zorblax") + expect(out).toBe(templateErrorMessage("connection refused")) + } + }) + + test("a secret hidden on a later line is dropped, even when the first line is empty", () => { + expect(templateErrorMessage(`\nAKIAIOSFODNN7EXAMPLE`)).toBe("") + }) + + test("unicode letters outside ASCII become placeholders, never pass through", () => { + const out = templateErrorMessage("Fehler: Größe überschritten 日本語のエラー 🔥") + expect(out).toMatch(/^[a-z<> ]*$/) + expect(out).not.toContain("gr") + expect(out).not.toMatch(/[^\x00-\x7f]/) + }) + + test("case is normalised so vocabulary words in any case template identically", () => { + expect(templateErrorMessage("CONNECTION REFUSED")).toBe(templateErrorMessage("connection refused")) + }) + + test("a 10k-character single line is capped at 200 chars", () => { + const line = "error ".repeat(2000) + "frobnitz_ledger" + const out = templateErrorMessage(line) + expect(out.length).toBeLessThanOrEqual(200) + expect(out).not.toContain("frobnitz") + }) + + test("a 10k-character unbroken secret collapses to one placeholder", () => { + expect(templateErrorMessage("Zq".repeat(5000))).toBe("") + }) + + test("adjacent unknown words collapse into one placeholder", () => { + expect(templateErrorMessage("zzqq yyww xxvv")).toBe("") + }) + + test("prototype-key words are not mistaken for vocabulary", () => { + for (const w of ["__proto__", "constructor", "toString", "hasOwnProperty"]) { + expect(templateErrorMessage(w)).toBe("") + } + }) + + test("describeToolError returns a template and a stable hash that differs from the template", () => { + const raw = "connection refused for AKIAIOSFODNN7EXAMPLE" + const d = describeToolError(raw) + expect(d.errorMessage).toBe(templateErrorMessage(raw)) + expect(d.errorMessage).not.toContain("akia") + expect(typeof d.errorHash).toBe("string") + expect(d.errorHash.length).toBeGreaterThan(0) + expect(describeToolError(raw).errorHash).toBe(d.errorHash) + }) +}) + +describe("v0.12.8 adversarial: plan-skip fail-closed guards", () => { + const clean = () => ({ allowed: true, violations: [] as unknown[], warnings: [] as unknown[] }) + + test("a clean result is returned unchanged", () => { + const data = clean() + const out = applyPolicySkipGuard(data) + expect(out).toBe(data) + expect(out.allowed).toBe(true) + expect(out.violations).toEqual([]) + expect(out.policy_skipped).toBeUndefined() + }) + + test("plan_unavailable and POLICY_SKIPPED warnings, by rule or code, flip allowed to false", () => { + for (const w of [ + { rule: "plan_unavailable" }, + { rule: "POLICY_SKIPPED" }, + { code: "plan_unavailable" }, + { code: "POLICY_SKIPPED" }, + ]) { + const out = applyPolicySkipGuard({ allowed: true, violations: [], warnings: [w] }) + expect(out.allowed).toBe(false) + expect(out.policy_skipped).toBe(true) + expect(out.violations).toEqual([ + { rule: "policy_not_evaluated", severity: "error", message: POLICY_NOT_EVALUATED_MESSAGE }, + ]) + } + }) + + test("existing violations are preserved and the guard violation is appended", () => { + const existing = { rule: "no_select_star", severity: "error" } + const out = applyPolicySkipGuard({ allowed: true, violations: [existing], warnings: [{ rule: "POLICY_SKIPPED" }] }) + expect((out.violations as unknown[])[0]).toBe(existing) + expect(out.violations as unknown[]).toHaveLength(2) + }) + + test("audit.allowed is flipped too, a non-object audit is left alone", () => { + const audit = { allowed: true } + applyPolicySkipGuard({ allowed: true, warnings: [{ rule: "POLICY_SKIPPED" }], audit }) + expect(audit.allowed).toBe(false) + expect(() => applyPolicySkipGuard({ warnings: [{ rule: "POLICY_SKIPPED" }], audit: "x" })).not.toThrow() + expect(() => applyPolicySkipGuard({ warnings: [{ rule: "POLICY_SKIPPED" }], audit: null })).not.toThrow() + }) + + test("unrelated warnings, lookalike rule names and wrong-case names do not flip a clean result", () => { + for (const w of [ + { rule: "unrelated" }, + { rule: "plan_unavailable_x" }, + { rule: "PLAN_UNAVAILABLE" }, + { rule: "policy_skipped" }, + { rule: " POLICY_SKIPPED" }, + { message: "plan_unavailable" }, + {}, + ]) { + const out = applyPolicySkipGuard({ allowed: true, violations: [], warnings: [w] }) + expect(out.allowed).toBe(true) + expect(out.policy_skipped).toBeUndefined() + } + }) + + test("malformed warnings containers and entries do not throw and do not flip the result", () => { + for (const warnings of [undefined, null, "POLICY_SKIPPED", 5, {}, { rule: "POLICY_SKIPPED" }, [null], [undefined], [1], ["POLICY_SKIPPED"]]) { + const out = applyPolicySkipGuard({ allowed: true, warnings }) + expect(out.allowed).toBe(true) + expect(out.policy_skipped).toBeUndefined() + } + }) + + test("a skip with a malformed violations field still blocks and yields an array", () => { + for (const violations of [undefined, null, "bad", 7, {}]) { + const out = applyPolicySkipGuard({ allowed: true, violations, warnings: [{ rule: "POLICY_SKIPPED" }] }) + expect(out.allowed).toBe(false) + expect(Array.isArray(out.violations)).toBe(true) + expect(out.violations as unknown[]).toHaveLength(1) + } + }) + + test("an unrelated warning next to a skip warning still blocks", () => { + const out = applyPolicySkipGuard({ allowed: true, warnings: [{ rule: "x" }, null, { code: "plan_unavailable" }] }) + expect(out.allowed).toBe(false) + }) + + test("an already-disallowed result stays disallowed", () => { + const out = applyPolicySkipGuard({ allowed: false, violations: [], warnings: [{ rule: "POLICY_SKIPPED" }] }) + expect(out.allowed).toBe(false) + }) + + test("a __proto__ key in the data does not pollute Object.prototype", () => { + const data = JSON.parse('{"allowed":true,"warnings":[{"rule":"POLICY_SKIPPED"}],"__proto__":{"polluted":true}}') + applyPolicySkipGuard(data) + expect(({} as Record).polluted).toBeUndefined() + expect(data.allowed).toBe(false) + }) + + test("semantics: a plan-skipped note marks the result skipped", () => { + for (const note of ["plan-based analysis was skipped", "Logical plan unavailable for this statement", "LOGICAL PLAN UNAVAILABLE"]) { + const out = applySemanticsSkipGuard({ valid: true, notes: [note] }) + expect(out.skipped).toBe(true) + expect(out.skip_reason).toBe(SEMANTICS_SKIPPED_MESSAGE) + } + }) + + test("semantics: unrelated, empty or malformed notes do not mark skipped", () => { + for (const notes of [undefined, null, [], ["all good"], "plan-based analysis was skipped", 5, {}, [null], [undefined], [{}]]) { + const data = { valid: true, notes } + const out = applySemanticsSkipGuard(data) + expect(out).toBe(data) + expect(out.skipped).toBeUndefined() + expect(out.skip_reason).toBeUndefined() + } + }) + + test("semantics: a skip note is found among unrelated and malformed notes", () => { + const out = applySemanticsSkipGuard({ notes: [null, 3, "ok", "plan-based analysis was skipped"] }) + expect(out.skipped).toBe(true) + }) +}) + +describe("v0.12.8 adversarial: permission aliases only tighten", () => { + const rule = (action: "allow" | "ask" | "deny", pattern: string, permission = "bash") => ({ + action, + permission, + pattern, + }) + const req = (aliases: unknown, permission = "bash") => + ({ permission, metadata: { permissionAliases: aliases } }) as Parameters[0] + + test("no matching rule defaults to ask", () => { + expect(evaluate("bash", "ls", []).action).toBe("ask") + expect(evaluate("bash", "ls").action).toBe("ask") + }) + + test("an allow rule never matches through an alias", () => { + const r = evaluateRequest(req({ "rm -rf /": [["ls"]] }), "rm -rf /", [rule("allow", "ls")]) + expect(r.action).toBe("ask") + }) + + test("a deny rule matches through an alias and beats the authored allow", () => { + const r = evaluateRequest(req({ safe: [["rm -rf /"]] }), "safe", [rule("allow", "*"), rule("deny", "rm -rf /")]) + expect(r.action).toBe("deny") + }) + + test("rule order still decides: a later authored allow shadows an earlier alias deny", () => { + const r = evaluateRequest(req({ safe: [["rm"]] }), "safe", [rule("deny", "rm"), rule("allow", "safe")]) + expect(r.action).toBe("allow") + }) + + test("a type-confused rule field that stringifies to a skip rule fails closed", () => { + const out = applyPolicySkipGuard({ allowed: true, warnings: [{ rule: ["POLICY_SKIPPED"] }] }) + expect(out.allowed).toBe(false) + }) + + test("an ask via alias tightens an authored allow but not an authored deny", () => { + expect(evaluateRequest(req({ a: [["b"]] }), "a", [rule("allow", "a"), rule("ask", "b")]).action).toBe("ask") + expect(evaluateRequest(req({ a: [["b"]] }), "a", [rule("deny", "a"), rule("ask", "b")]).action).toBe("deny") + }) + + test("an alias-matched allow can not relax an authored ask", () => { + const r = evaluateRequest(req({ a: [["b"]] }), "a", [rule("ask", "a"), rule("allow", "b")]) + expect(r.action).toBe("ask") + }) + + test("empty alias groups and empty alias lists fall back to plain evaluation", () => { + const rules = [rule("allow", "a")] + expect(evaluateRequest(req({ a: [] }), "a", rules).action).toBe("allow") + expect(evaluateRequest(req({ a: [[]] }), "a", rules).action).toBe("allow") + expect(evaluateRequest(req({}), "a", rules).action).toBe("allow") + expect(evaluateRequest(req(undefined), "a", rules).action).toBe("allow") + }) + + test("type-confused aliases are ignored", () => { + const rules = [rule("allow", "a"), rule("deny", "b")] + for (const aliases of [null, "b", 5, true, [["b"]], { a: "b" }, { a: [["b", 1]] }, { a: [null] }, { a: [{}] }, { a: 5 }]) { + expect(evaluateRequest(req(aliases), "a", rules).action).toBe("allow") + } + }) + + test("a malformed group is skipped while a valid group still applies", () => { + const r = evaluateRequest(req({ a: [[1], ["b"]] }), "a", [rule("allow", "a"), rule("deny", "b")]) + expect(r.action).toBe("deny") + }) + + test("multiple alias groups: any deny wins, then any ask", () => { + const rules = [rule("allow", "a"), rule("ask", "x"), rule("deny", "y")] + expect(evaluateRequest(req({ a: [["x"], ["y"]] }), "a", rules).action).toBe("deny") + expect(evaluateRequest(req({ a: [["x"], ["z"]] }), "a", rules).action).toBe("ask") + expect(evaluateRequest(req({ a: [["z"], ["w"]] }), "a", rules).action).toBe("allow") + }) + + test("aliases are looked up for the exact pattern only", () => { + const rules = [rule("allow", "a"), rule("deny", "b")] + expect(evaluateRequest(req({ other: [["b"]] }), "a", rules).action).toBe("allow") + }) + + test("a __proto__ or constructor pattern does not read inherited properties", () => { + const rules = [rule("allow", "*")] + for (const p of ["__proto__", "constructor", "toString", "hasOwnProperty"]) { + expect(evaluateRequest(req({}), p, rules).action).toBe("allow") + } + const polluted = JSON.parse('{"__proto__":[["x"]]}') + expect(evaluateRequest(req(polluted), "anything", [rule("allow", "anything"), rule("deny", "x")]).action).toBe("allow") + }) + + test("a rule whose pattern is __proto__ matches only that literal pattern", () => { + const rules = [rule("deny", "__proto__")] + expect(evaluate("bash", "__proto__", rules).action).toBe("deny") + expect(evaluate("bash", "toString", rules).action).toBe("ask") + }) + + test("rules for other permissions are ignored even when the alias would match", () => { + const r = evaluateRequest(req({ a: [["b"]] }, "read"), "a", [rule("deny", "b", "edit"), rule("allow", "a", "read")]) + expect(r.action).toBe("allow") + }) + + test("last matching rule wins across rulesets", () => { + expect(evaluate("bash", "a", [rule("deny", "a")], [rule("allow", "a")]).action).toBe("allow") + expect(evaluate("bash", "a", [rule("allow", "a")], [rule("deny", "a")]).action).toBe("deny") + }) + + test("evaluateRequestPatterns sorts denied patterns first and keeps others in order", () => { + const request = { + permission: "bash", + metadata: { permissionAliases: { c: [["bad"]] } }, + patterns: ["a", "c", "b"], + } as Parameters[0] + const out = evaluateRequestPatterns(request, [rule("allow", "*"), rule("deny", "bad")]) + expect(out.map((o) => o.pattern)).toEqual(["c", "a", "b"]) + expect(out[0]!.rule.action).toBe("deny") + expect(evaluateRequestPatterns({ ...request, patterns: [] }, [])).toEqual([]) + }) +}) + +describe("v0.12.8 adversarial: answer_commit conjunction", () => { + test("empty and whitespace requests ask for one item", () => { + expect(requestedItems("")).toEqual({ count: 1, joins: [] }) + expect(requestedItems(" \n\n ")).toEqual({ count: 1, joins: [] }) + }) + + test("a request with no conjunction asks for one item", () => { + expect(requestedItems("What is the total revenue for 2024?")).toEqual({ count: 1, joins: [] }) + }) + + test("narrow connectives are counted and quoted", () => { + const r = requestedItems("Find the top customer and report their lifetime value.") + expect(r.count).toBe(2) + expect(r.joins).toHaveLength(1) + expect(r.joins[0]).toMatch(/^and report/) + }) + + test("ranges and constraint clauses do not count", () => { + expect(requestedItems("Sum sales between 2020 and 2022.").count).toBe(1) + expect(requestedItems("List orders and only those shipped.").count).toBe(1) + expect(requestedItems("List orders and excluding refunds.").count).toBe(1) + }) + + test("only the first paragraph is considered", () => { + expect(requestedItems("Total revenue.\n\nAlso list the top customer as well as the worst one.").count).toBe(1) + }) + + test("the join quote is bounded to eight words", () => { + const r = requestedItems("Compute the total and report one two three four five six seven eight nine ten eleven.") + expect(r.joins[0]!.split(" ").length).toBeLessThanOrEqual(8) + }) + + test("a very large input finishes and returns a bounded join list", () => { + const sentence = "Find x and report y. " + const r = requestedItems(sentence.repeat(20000)) + expect(r.count).toBe(2) + expect(requestedItems("a ".repeat(200_000)).count).toBe(1) + expect(requestedItems("and ".repeat(50_000)).count).toBeGreaterThanOrEqual(1) + }) + + test("unicode, RTL and emoji text does not throw", () => { + expect(requestedItems("总收入是多少 and report 利润 🔥").count).toBe(2) + expect(requestedItems("\u202e\u0000 and the \ud83d").count).toBeGreaterThanOrEqual(1) + }) + + test("prototype keys are ordinary text", () => { + expect(requestedItems("__proto__ and report constructor").count).toBe(2) + }) + + const userMsg = (text: string, synthetic = false) => + ({ info: { role: "user" }, parts: [{ type: "text", text, synthetic }] }) as never + let sid = 0 + const session = () => `adv-v0128-${++sid}-${Math.random()}` + + test("no fields argument is never rejected", () => { + expect(conjunctionRejection({}, [userMsg("Find x and report y.")], session())).toBeUndefined() + }) + + test("empty messages and enough fields are not rejected", () => { + expect(conjunctionRejection({ fields: [{ name: "a" }] }, [], session())).toBeUndefined() + expect( + conjunctionRejection({ fields: [{ name: "a" }, { name: "b" }] }, [userMsg("Find x and report y.")], session()), + ).toBeUndefined() + }) + + test("too few fields is rejected once per session and request, names are JSON-escaped", () => { + const s = session() + const messages = [userMsg("Find x and report y.")] + const first = conjunctionRejection({ fields: [{ name: 'a"b' }] }, messages, s) + expect(first).toContain(CONJUNCTION_REJECTION_MARKER) + expect(first).toContain('["a\\"b"]') + expect(first).toContain("1 field ") + expect(conjunctionRejection({ fields: [{ name: "a" }] }, messages, s)).toBeUndefined() + // a different session is advised independently + expect(conjunctionRejection({ fields: [{ name: "a" }] }, messages, session())).toContain(CONJUNCTION_REJECTION_MARKER) + }) + + test("synthetic parts are ignored when reading the request", () => { + const messages = [userMsg("Find x and report y."), userMsg("budget reminder and report more", true)] + // latest user message has only a synthetic part -> empty request -> no rejection + expect(conjunctionRejection({ fields: [{ name: "a" }] }, messages, session())).toBeUndefined() + }) + + test("a prior marker rejection in history suppresses a repeat after a restart", () => { + const messages = [ + userMsg("Find x and report y."), + { + info: { role: "assistant" }, + parts: [ + { type: "tool", tool: "answer_commit", state: { status: "error", error: `${CONJUNCTION_REJECTION_MARKER} earlier` } }, + ], + } as never, + ] + expect(conjunctionRejection({ fields: [{ name: "a" }] }, messages, session())).toBeUndefined() + }) + + test("messages with missing parts do not crash the history scan", () => { + const messages = [userMsg("Find x and report y."), { info: { role: "assistant" }, parts: [{}] } as never] + expect(conjunctionRejection({ fields: [{ name: "a" }] }, messages, session())).toContain(CONJUNCTION_REJECTION_MARKER) + }) +}) + +describe("v0.12.8 adversarial: unsent memory notice", () => { + const binding = { datamateId: 1, datamateName: "n", repoRemote: null, projectPath: "/p", linkedAt: 0 } as never + const report = (over: Record = {}) => + ({ binding, memory: { local: 3, unsynced: 2 }, skillsEnabled: true, skillsSyncedAt: null, ...over }) as never + const deps = (over: Partial): UnsentMemoryDeps => ({ + target: () => ({ directory: "/target", binding }), + status: async () => report(), + ...over, + }) + + test("notice text: unknown, zero, one and many", () => { + expect(unsentMemoryNotice(0)).toBeNull() + expect(unsentMemoryNotice(null)).toContain("Could not confirm") + expect(unsentMemoryNotice(1)).toContain("1 memory on this machine is not in the workspace yet") + expect(unsentMemoryNotice(1)).toContain("send it.") + expect(unsentMemoryNotice(2)).toContain("2 memories on this machine are not in the workspace yet") + expect(unsentMemoryNotice(2)).toContain("send them.") + }) + + test("no mirror target returns null and never calls status", async () => { + let called = false + const out = await unsentMemoryNoticeAfterRun("/f", 5000, deps({ target: () => undefined, status: async () => ((called = true), report()) })) + expect(out).toBeNull() + expect(called).toBe(false) + }) + + test("the target directory wins, the fallback is used when it is undefined", async () => { + const seen: string[] = [] + const status = async (d: string) => (seen.push(d), report()) + await unsentMemoryNoticeAfterRun("/fallback", 5000, deps({ status })) + await unsentMemoryNoticeAfterRun("/fallback", 5000, deps({ target: () => ({ directory: undefined, binding }), status })) + expect(seen).toEqual(["/target", "/fallback"]) + }) + + test("the binding the upload used is passed through", async () => { + let got: unknown + await unsentMemoryNoticeAfterRun("/f", 5000, deps({ status: async (_d, b) => ((got = b), report()) })) + expect(got).toBe(binding) + }) + + test("a status that rejects reports the count as unknown", async () => { + const out = await unsentMemoryNoticeAfterRun("/f", 5000, deps({ status: async () => Promise.reject(new Error("disk")) })) + expect(out).toContain("Could not confirm") + }) + + test("a status that throws synchronously propagates (it is the caller's contract to return a promise)", async () => { + await expect( + unsentMemoryNoticeAfterRun("/f", 5000, deps({ status: (() => { throw new Error("sync") }) as never })), + ).rejects.toThrow("sync") + }) + + test("a status that never settles reports unknown after the timeout", async () => { + const out = await unsentMemoryNoticeAfterRun("/f", 1, deps({ status: () => new Promise(() => {}) })) + expect(out).toContain("Could not confirm") + }) + + test("unreadable memory is unknown even when counts are present", async () => { + const out = await unsentMemoryNoticeAfterRun("/f", 5000, deps({ status: async () => report({ memoryUnreadable: true }) })) + expect(out).toContain("Could not confirm") + }) + + test("no binding or memory off is silent", async () => { + expect(await unsentMemoryNoticeAfterRun("/f", 5000, deps({ status: async () => report({ binding: null }) }))).toBeNull() + expect(await unsentMemoryNoticeAfterRun("/f", 5000, deps({ status: async () => report({ memory: null }) }))).toBeNull() + }) + + test("count 0 is silent, a null count is unknown, a positive count is reported", async () => { + expect(await unsentMemoryNoticeAfterRun("/f", 5000, deps({ status: async () => report({ memory: { local: 1, unsynced: 0 } }) }))).toBeNull() + expect( + await unsentMemoryNoticeAfterRun("/f", 5000, deps({ status: async () => report({ memory: { local: 1, unsynced: null } }) })), + ).toContain("Could not confirm") + expect( + await unsentMemoryNoticeAfterRun("/f", 5000, deps({ status: async () => report({ memory: { local: 5, unsynced: 4 } }) })), + ).toContain("4 memories") + }) + + test("a resolved null report is treated as unknown", async () => { + expect(await unsentMemoryNoticeAfterRun("/f", 5000, deps({ status: async () => null as never }))).toContain("Could not confirm") + }) +}) + +describe("v0.12.8 adversarial: schema value profile", () => { + test("valueShape of empty and punctuation-only strings", () => { + expect(valueShape("")).toBe("") + expect(valueShape("-/ .")).toBe("-/ .") + }) + + test("valueShape collapses runs and keeps punctuation", () => { + expect(valueShape("H01M4/9066")).toBe("A9+A9/9+") + expect(valueShape("abc DEF 123")).toBe("a+ A+ 9+") + expect(valueShape("a")).toBe("a") + expect(valueShape("aB")).toBe("aA") + }) + + test("valueShape handles unicode letters and digits", () => { + expect(valueShape("Éé")).toBe("Aa") + expect(valueShape("日本語")).toBe("a+") + expect(valueShape("٣٤")).toBe("9+") + expect(valueShape("🔥🔥")).toBe("🔥🔥") + }) + + test("valueShape of a very long string stays short and linear", () => { + expect(valueShape("a".repeat(100_000))).toBe("a+") + expect(valueShape("aB1".repeat(10_000)).length).toBe(30_000) + }) + + test("valueShape keeps shell and SQL metacharacters literal and does not execute them", () => { + expect(valueShape("x'; DROP TABLE t;--")).toBe("a'; A+ A+ a;--") + }) + + test("profileDocuments of no documents is empty", () => { + expect(profileDocuments([])).toBe("") + }) + + test("profileDocuments lists values for simple documents", () => { + const out = profileDocuments([{ status: "open" }, { status: "closed" }, { status: "open" }]) + expect(out).toContain("Value samples:") + expect(out).toContain("status") + expect(out).toContain("open") + expect(out).toContain("closed") + }) + + test("profileDocuments with null, mixed types and empty documents does not throw", () => { + const out = profileDocuments([{ a: null }, { a: 1 }, { a: "x" }, {}, { a: [1, "y"] }, { a: { b: null } }]) + expect(out).toContain("Value samples:") + expect(typeof out).toBe("string") + }) + + test("profileDocuments with __proto__ and constructor keys does not pollute prototypes", () => { + const docs = [ + JSON.parse('{"__proto__":{"polluted":"yes"},"constructor":{"prototype":{"polluted2":"yes"}},"name":"a"}'), + JSON.parse('{"__proto__":"str","name":"b"}'), + ] + const out = profileDocuments(docs) + expect(typeof out).toBe("string") + expect(({} as Record).polluted).toBeUndefined() + expect(({} as Record).polluted2).toBeUndefined() + expect(out).toContain("name") + }) + + test("profileDocuments clips very long strings instead of echoing them", () => { + const long = "Q".repeat(50_000) + const out = profileDocuments([{ note: long }, { note: long }]) + expect(out.length).toBeLessThan(5_000) + expect(out).not.toContain(long) + }) + + test("profileDocuments caps the number of profiled fields", () => { + const doc: Record = {} + for (let i = 0; i < 200; i++) doc[`field${String(i).padStart(3, "0")}`] = `v${i}` + const out = profileDocuments([doc]) + expect(out).toContain("more columns not profiled") + }) +}) From 45a9f9acc6b2391b8d549a8c7a140d54c2d46537 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 23 Oct 2026 20:12:36 +0530 Subject: [PATCH 07/11] fix(sql): treat replication-slot copy and sync functions as side effects `pg_copy_logical_replication_slot`, `pg_copy_physical_replication_slot` and `pg_sync_replication_slots` were missing from `SIDE_EFFECT_PG_FUNCTION`, so a top-level call (where unknown `pg_*` functions are allowed) classified as read-only. The create and drop slot functions were already denied. Tests pin these three plus `pg_promote` and `pg_advisory_unlock_all`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- packages/opencode/src/altimate/tools/sql-function-policy.ts | 2 +- .../opencode/test/altimate/tools/sql-function-policy.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/tools/sql-function-policy.ts b/packages/opencode/src/altimate/tools/sql-function-policy.ts index 62da25d9d9..6341156e29 100644 --- a/packages/opencode/src/altimate/tools/sql-function-policy.ts +++ b/packages/opencode/src/altimate/tools/sql-function-policy.ts @@ -18,7 +18,7 @@ const PURE_PG_FUNCTION = new RegExp(`^pg_(?:${PURE_PG_FUNCTIONS})$`, "i") // Explicit mutating families take precedence over broad inspection patterns // (for example, a future pg_stat_reset_* name must not match the *_size getter). const SIDE_EFFECT_PG_FUNCTION = - /^pg_(?:write_file|append_file|file_write|file_rename|file_unlink|try_advisory\w*|advisory_unlock\w*|advisory_lock\w*|advisory_xact\w*|terminate_backend|cancel_backend|reload_conf|rotate_logfile|promote|switch_wal|logical_emit_message|create_\w*_replication_slot|drop_replication_slot|replication_slot_advance|replication_origin_\w+|notify|stat\w*reset\w*|\w*_reset\w*|stat_clear\w*|stat_force\w*|wal_replay_\w+|logical_slot_\w+|replication_slot_\w+|xlog_replay_\w+|log_backend_memory_contexts|create_restore_point|backup_\w+|start_backup|stop_backup)$/i + /^pg_(?:write_file|append_file|file_write|file_rename|file_unlink|try_advisory\w*|advisory_unlock\w*|advisory_lock\w*|advisory_xact\w*|terminate_backend|cancel_backend|reload_conf|rotate_logfile|promote|switch_wal|logical_emit_message|create_\w*_replication_slot|copy_\w*_replication_slot|sync_replication_slots|drop_replication_slot|replication_slot_advance|replication_origin_\w+|notify|stat\w*reset\w*|\w*_reset\w*|stat_clear\w*|stat_force\w*|wal_replay_\w+|logical_slot_\w+|replication_slot_\w+|xlog_replay_\w+|log_backend_memory_contexts|create_restore_point|backup_\w+|start_backup|stop_backup)$/i const SIDE_EFFECT_FUNCTION = /^(?:nextval|setval|set_config|load_extension|writefile|dblink\w*|lo_\w*|lowrite|loread|checkpoint|force_checkpoint|get_lock|release_lock|release_all_locks|is_free_lock|is_used_lock|master_pos_wait|source_pos_wait|sleep|benchmark|postgres_execute|mysql_execute|sqlite_execute|SYSTEM\$\w+)$/i diff --git a/packages/opencode/test/altimate/tools/sql-function-policy.test.ts b/packages/opencode/test/altimate/tools/sql-function-policy.test.ts index 93648e58b9..73030b44f4 100644 --- a/packages/opencode/test/altimate/tools/sql-function-policy.test.ts +++ b/packages/opencode/test/altimate/tools/sql-function-policy.test.ts @@ -37,6 +37,11 @@ describe("hasSideEffectFunction", () => { "pg_switch_wal", "pg_create_logical_replication_slot", "pg_drop_replication_slot", + "pg_copy_logical_replication_slot", + "pg_copy_physical_replication_slot", + "pg_sync_replication_slots", + "pg_promote", + "pg_advisory_unlock_all", "pg_stat_reset", "pg_stat_reset_single_table_counters", "pg_stat_statements_reset", From 26edbc3aa2d3f4e383d6357404666628e8e8cc44 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 23 Oct 2026 20:12:36 +0530 Subject: [PATCH 08/11] fix: release PR review follow-ups - `uninstall`: `removalHeader` and `removeEmptyDirCommand` helpers; Windows prints a PowerShell command for the empty `.opencode` bin directory instead of POSIX `rmdir ... 2>/dev/null`; tests for both platforms - setup lock: a failing notice write can no longer abort the database open; tests release the external holder deterministically and with wider margins - telemetry: correct the `templateErrorMessage` comment; the adversarial test asserts the concrete template and adds a long-value distinct-count case - `schema_inspect` tests restore `ALTIMATE_DISABLE_SCHEMA_VALUES` - docs: which columns get ranked values, "no value-profile query", and how policy vs semantic skips affect `--fail-on` - CHANGELOG: where the lock notice appears, and the two fixes above Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- CHANGELOG.md | 5 +- .../data-engineering/tools/schema-tools.md | 4 +- docs/docs/usage/check.md | 2 +- packages/core/src/database/setup-lock.ts | 11 +++- .../core/test/database-setup-lock.test.ts | 65 +++++++++++++++++-- .../src/altimate/telemetry/error-detail.ts | 2 +- packages/opencode/src/cli/cmd/uninstall.ts | 25 +++++-- .../tools/schema-inspect-values.test.ts | 4 ++ .../opencode/test/cli/uninstall-hint.test.ts | 26 +++++++- .../skill/release-v0.12.8-adversarial.test.ts | 11 +++- 10 files changed, 132 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ea4d3197..4dbec57f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Headless `run` no longer hangs under an agent's shell.** (#1552) -- **"database is locked" when several CLI processes start on a fresh data directory.** Setup is serialized with a lock that the OS releases if its holder dies; after 5 s of waiting, startup says which lock it is waiting on. (#1541, #1580) +- **"database is locked" when several CLI processes start on a fresh data directory.** Setup is serialized with a lock that the OS releases if its holder dies; after 5 s of waiting, CLI commands such as `run` and `serve` print the lock they are waiting on to stderr; the TUI writes it to the log. (#1541, #1580) - **Warehouse connections no longer leak between projects in one server**, including SSH tunnels, which are now owned by the connect attempt that opened them. (#1567) - **`warehouse_list` with no connections points at the real config locations** and the `ALTIMATE_CODE_CONN_*` variables. (#1559) - **Workspace memory and link checks match a repo's remote across its URL forms**, using the same comparison as the workspace service. (#1462) @@ -55,7 +55,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The analyst reports tool failures as failures**, accepts valid read-only syntax and retrieves long text in full. (#1448) - **`run --model` reports a failed provider list as itself**, with the HTTP status, not "Model not found". (#1472) - **A failed background file scan is handled** instead of surfacing as an unhandled rejection. (#1471) -- **`uninstall` on Windows says its removal commands are for PowerShell.** Found in this release's review. +- **The read-only SQL check blocks PostgreSQL replication-slot copy and sync functions.** `pg_copy_logical_replication_slot`, `pg_copy_physical_replication_slot` and `pg_sync_replication_slots` were not on the side-effect list, so a top-level call passed as read-only; the create and drop slot functions were already blocked. Found in this release's review. +- **`uninstall` on Windows gives PowerShell commands, and says so**, including for the empty `.opencode` bin directory, which used a POSIX `rmdir … 2>/dev/null` line. Found in this release's review. ## [0.12.7] - 2026-10-13 diff --git a/docs/docs/data-engineering/tools/schema-tools.md b/docs/docs/data-engineering/tools/schema-tools.md index 2e19106610..b689b02e10 100644 --- a/docs/docs/data-engineering/tools/schema-tools.md +++ b/docs/docs/data-engineering/tools/schema-tools.md @@ -33,9 +33,9 @@ Table: ANALYTICS.PUBLIC.ORDERS - `warehouse` (optional): Connection name - `values` (optional, default `true`): Append a value profile per column -On SQLite, DuckDB and PostgreSQL connections, and for MongoDB collections, `schema_inspect` also profiles each column's values from a bounded sample (10,000 rows, or 100 documents for MongoDB; at most 40 columns): distinct count, null share, the most frequent values with their counts, whether the values are unique in the sample, and, for short text, the common value shapes. MongoDB nested fields are listed by their dotted paths. The profile shows the stored format, such as case, prefixes or padding, so filters match real data. +On SQLite, DuckDB and PostgreSQL connections, and for MongoDB collections, `schema_inspect` also profiles each column's values from a bounded sample (10,000 rows, or 100 documents for MongoDB; at most 40 columns): distinct count, null share and whether the values are unique in the sample. A column with at most 20 distinct values lists all of them with their counts; a text column with more shows its most frequent values with their counts and, for short text, the common value shapes; other columns with more, such as numbers and dates, show only the distinct count and null share. MongoDB nested fields are listed by their dotted paths. The profile shows the stored format, such as case, prefixes or padding, so filters match real data. -The profile contains real cell values, which are sent to your model provider with the rest of the tool output. For tables that hold personal or sensitive data, ask for `values: false` to get the column list only. Set `ALTIMATE_DISABLE_SCHEMA_VALUES=1` to turn value profiles off for every call, whatever the model asks for: no sample query runs, and the output says profiles are turned off so the model does not ask again. Only an empty value, `0` or `false` leaves them on. +The profile contains real cell values, which are sent to your model provider with the rest of the tool output. For tables that hold personal or sensitive data, ask for `values: false` to get the column list only. Set `ALTIMATE_DISABLE_SCHEMA_VALUES=1` to turn value profiles off for every call, whatever the model asks for: no value-profile query runs, and the output says profiles are turned off so the model does not ask again. Only an empty value, `0` or `false` leaves them on. --- diff --git a/docs/docs/usage/check.md b/docs/docs/usage/check.md index 05a49a5530..a07e9a446e 100644 --- a/docs/docs/usage/check.md +++ b/docs/docs/usage/check.md @@ -50,7 +50,7 @@ altimate-code check --fail-on warning | `semantic` | Semantic validation (cartesian products, wrong JOINs) | Optional | No | | `grade` | SQL quality grading with recommendations | Optional | No | -`policy` and `semantic` fail closed on a statement the engine cannot plan, such as a table-valued function (`TABLE(fn())`, Snowflake `LATERAL FLATTEN`, DuckDB `read_csv(...)`). `policy` reports a `policy_not_evaluated` error and treats the statement as blocked, and `semantic` reports it as skipped rather than clean (an error when `--schema` is given, a warning without). Earlier versions rejected these statements as invalid SQL. +`policy` and `semantic` never report a statement the engine cannot plan as clean. Such statements include table-valued functions (`TABLE(fn())`, Snowflake `LATERAL FLATTEN`, DuckDB `read_csv(...)`). `policy` fails closed: it reports a `policy_not_evaluated` error and treats the statement as blocked. `semantic` reports the skip as a finding: an error when `--schema` is given, and a warning without it, which passes the default `--fail-on error`. Earlier versions rejected these statements as invalid SQL. Findings usually carry a machine-readable `rule` field (e.g. `unbalanced_quote`, `sql_injection`, `L003`) visible in `--format json` output — see the [Finding Object](#finding-object) schema below. `safety` and `policy` findings always include one; `lint` findings may omit both `rule` and `code` if the engine didn't attach them, so switch on presence rather than assuming. diff --git a/packages/core/src/database/setup-lock.ts b/packages/core/src/database/setup-lock.ts index bcbcac5cb8..ac289b8c15 100644 --- a/packages/core/src/database/setup-lock.ts +++ b/packages/core/src/database/setup-lock.ts @@ -40,6 +40,13 @@ export function waitNotice(filename: string) { return `Waiting for another altimate-code process to finish setting up the database (lock: ${sentinel(filename)})` } +/** The notice is diagnostic only: a failing stderr write or `notify` must never abort the database open. */ +function announce(filename: string, options: Options) { + try { + ;(options.notify ?? notifyStderr)(waitNotice(filename)) + } catch {} +} + export function acquireSync(filename: string, options: Options = {}): () => void { if (filename === ":memory:" || active.has(filename)) return () => {} const timeoutMs = options.timeoutMs ?? SYNC_TIMEOUT_MS @@ -51,7 +58,7 @@ export function acquireSync(filename: string, options: Options = {}): () => void lock.exec("BEGIN EXCLUSIVE") } catch (error) { if (!isBusy(error) || noticeAfterMs >= timeoutMs) throw error - ;(options.notify ?? notifyStderr)(waitNotice(filename)) + announce(filename, options) lock.exec(`PRAGMA busy_timeout = ${timeoutMs - noticeAfterMs}`) lock.exec("BEGIN EXCLUSIVE") } @@ -107,7 +114,7 @@ export function hold( } if (!noticed && Date.now() - started >= noticeAfterMs) { noticed = true - ;(options.notify ?? notifyStderr)(waitNotice(filename)) + announce(filename, options) } // `acquireUseRelease` makes `acquire` uninterruptible; no handle is open while sleeping, so cancel here. yield* Effect.interruptible(Effect.sleep(POLL_MS)) diff --git a/packages/core/test/database-setup-lock.test.ts b/packages/core/test/database-setup-lock.test.ts index cb5a9c4876..a1bbd8f34e 100644 --- a/packages/core/test/database-setup-lock.test.ts +++ b/packages/core/test/database-setup-lock.test.ts @@ -5,11 +5,14 @@ import { Effect } from "effect" import * as SetupLock from "@opencode-ai/core/database/setup-lock" import { tmpdir } from "./fixture/tmpdir" -/** Holds the setup lock from a separate connection, as another process would. */ +/** Holds the setup lock from a separate connection, as another process would. The release is idempotent. */ function holdExternally(filename: string) { const db = new Database(filename + ".setup-lock", { create: true }) db.run("BEGIN EXCLUSIVE") + let released = false return () => { + if (released) return + released = true db.run("ROLLBACK") db.close() } @@ -49,7 +52,7 @@ describe("SetupLock wait notice", () => { releaseHolder() } // The notice did not end the wait: it continued to the full timeout. - expect(Date.now() - started).toBeGreaterThanOrEqual(250) + expect(Date.now() - started).toBeGreaterThanOrEqual(200) expect(lines).toEqual([SetupLock.waitNotice(filename)]) }) @@ -69,15 +72,67 @@ describe("SetupLock wait notice", () => { const filename = path.join(tmp.path, "held-hold.db") const releaseHolder = holdExternally(filename) const lines: string[] = [] - const timer = setTimeout(releaseHolder, 400) try { const result = await Effect.runPromise( - SetupLock.hold(filename, Effect.succeed("ran"), { noticeAfterMs: 50, notify: (line) => lines.push(line) }), + SetupLock.hold(filename, Effect.succeed("ran"), { + noticeAfterMs: 50, + notify: (line) => { + lines.push(line) + releaseHolder() + }, + }), ) expect(result).toBe("ran") } finally { - clearTimeout(timer) + releaseHolder() } expect(lines).toEqual([SetupLock.waitNotice(filename)]) }) + + test("acquireSync: a throwing notify does not abort the wait; only the busy error surfaces", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "throwing-sync.db") + const releaseHolder = holdExternally(filename) + let notified = 0 + const started = Date.now() + try { + expect(() => + SetupLock.acquireSync(filename, { + noticeAfterMs: 50, + timeoutMs: 300, + notify: () => { + notified++ + throw new Error("stderr closed") + }, + }), + ).toThrow(/locked|busy/i) + } finally { + releaseHolder() + } + expect(Date.now() - started).toBeGreaterThanOrEqual(200) + expect(notified).toBe(1) + }) + + test("hold: a throwing notify still acquires once the holder releases", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "throwing-hold.db") + const releaseHolder = holdExternally(filename) + let notified = 0 + try { + const result = await Effect.runPromise( + SetupLock.hold(filename, Effect.succeed("ran"), { + noticeAfterMs: 50, + notify: () => { + notified++ + releaseHolder() + throw new Error("stderr closed") + }, + }), + ) + expect(result).toBe("ran") + } finally { + releaseHolder() + } + expect(notified).toBe(1) + }) }) diff --git a/packages/opencode/src/altimate/telemetry/error-detail.ts b/packages/opencode/src/altimate/telemetry/error-detail.ts index dc761dd3dc..a424f6c52a 100644 --- a/packages/opencode/src/altimate/telemetry/error-detail.ts +++ b/packages/opencode/src/altimate/telemetry/error-detail.ts @@ -388,7 +388,7 @@ export function templateErrorMessage(raw: string): string { if (piece === PLACEHOLDER && out[out.length - 1] === PLACEHOLDER) continue out.push(piece) } - // non-letter characters that carried a value (digits, symbols) leave a gap that must stay visible + // non-letter characters (digits, symbols) only separate words and are dropped; unknown words they separated collapse into one placeholder return out.join(" ").slice(0, MAX_MESSAGE) } diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 87b7a6db8e..8ed5afad6f 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -64,6 +64,21 @@ export function removeFileCommand(file: string, platform: NodeJS.Platform, force if (platform === "win32") return `Remove-Item -LiteralPath '${file.replaceAll("'", "''")}' -Force` return `rm${force ? " -f" : ""} '${file.replaceAll("'", "'\\''")}'` } + +/** Heading for the manual removal commands, naming the shell they are written for. */ +export function removalHeader(platform: NodeJS.Platform): string { + return platform === "win32" ? "To finish removing the binary, run in PowerShell:" : "To finish removing the binary, run:" +} + +/** + * A command that removes `dir` only when it is empty and stays silent otherwise, quoted like + * `removeFileCommand`. PowerShell's `Remove-Item` prompts for a non-empty directory instead of + * failing, so Windows uses `Directory.Delete`, which refuses a non-empty one. + */ +export function removeEmptyDirCommand(dir: string, platform: NodeJS.Platform): string { + if (platform === "win32") return `try { [IO.Directory]::Delete('${dir.replaceAll("'", "''")}') } catch {}` + return `rmdir '${dir.replaceAll("'", "'\\''")}' 2>/dev/null` +} // altimate_change end export const UninstallCommand = { @@ -343,21 +358,17 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar if (method === "curl" && targets.binary) { UI.empty() // altimate_change start — platform-native commands (named shell on Windows), plus the installer's other files - prompts.log.message( - process.platform === "win32" - ? "To finish removing the binary, run in PowerShell:" - : "To finish removing the binary, run:", - ) + prompts.log.message(removalHeader(process.platform)) prompts.log.info(` ${removeFileCommand(targets.binary, process.platform)}`) for (const file of targets.installerFiles) { prompts.log.info(` ${removeFileCommand(file, process.platform, true)}`) } - // altimate_change end const binDir = path.dirname(targets.binary) if (binDir.includes(".opencode")) { - prompts.log.info(` rmdir "${binDir}" 2>/dev/null`) + prompts.log.info(` ${removeEmptyDirCommand(binDir, process.platform)}`) } + // altimate_change end } if (errors.length > 0) { diff --git a/packages/opencode/test/altimate/tools/schema-inspect-values.test.ts b/packages/opencode/test/altimate/tools/schema-inspect-values.test.ts index 76032cd146..e9da14f0b3 100644 --- a/packages/opencode/test/altimate/tools/schema-inspect-values.test.ts +++ b/packages/opencode/test/altimate/tools/schema-inspect-values.test.ts @@ -55,6 +55,8 @@ async function inspect(args: Record) { return tool.execute({ table: "t", ...args } as any, ctx as any) } +const ORIGINAL_DISABLE_SCHEMA_VALUES = process.env.ALTIMATE_DISABLE_SCHEMA_VALUES + beforeEach(() => { Registry.reset() Dispatcher.reset() @@ -65,6 +67,8 @@ beforeEach(() => { afterAll(() => { Registry.reset() registerAll() + if (ORIGINAL_DISABLE_SCHEMA_VALUES === undefined) delete process.env.ALTIMATE_DISABLE_SCHEMA_VALUES + else process.env.ALTIMATE_DISABLE_SCHEMA_VALUES = ORIGINAL_DISABLE_SCHEMA_VALUES }) function expectAppendix(output: string) { diff --git a/packages/opencode/test/cli/uninstall-hint.test.ts b/packages/opencode/test/cli/uninstall-hint.test.ts index 6781b44c00..24eb892cfc 100644 --- a/packages/opencode/test/cli/uninstall-hint.test.ts +++ b/packages/opencode/test/cli/uninstall-hint.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test" -import { installerSiblingFiles, removeFileCommand } from "../../src/cli/cmd/uninstall" +import { + installerSiblingFiles, + removalHeader, + removeEmptyDirCommand, + removeFileCommand, +} from "../../src/cli/cmd/uninstall" describe("uninstall — installer files beside the binary", () => { test("names the alias next to a standalone unix install", () => { @@ -77,3 +82,22 @@ describe("uninstall — removal commands", () => { ) }) }) + +describe("uninstall — removal header and empty-directory command", () => { + test("the header names PowerShell on Windows only", () => { + expect(removalHeader("win32")).toBe("To finish removing the binary, run in PowerShell:") + expect(removalHeader("linux")).toBe("To finish removing the binary, run:") + }) + + test("Windows gets a PowerShell command, never POSIX rmdir or 2>/dev/null", () => { + const cmd = removeEmptyDirCommand("C:\\Users\\o'neil\\.opencode\\bin", "win32") + expect(cmd).toBe("try { [IO.Directory]::Delete('C:\\Users\\o''neil\\.opencode\\bin') } catch {}") + expect(cmd).not.toContain("/dev/null") + }) + + test("POSIX keeps rmdir with errors silenced, single-quoted literally", () => { + expect(removeEmptyDirCommand("/home/o'neil $dev/.opencode/bin", "linux")).toBe( + "rmdir '/home/o'\\''neil $dev/.opencode/bin' 2>/dev/null", + ) + }) +}) diff --git a/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts index 9f83d99818..5479806575 100644 --- a/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts @@ -342,8 +342,9 @@ describe("v0.12.8 adversarial: templateErrorMessage", () => { expect(out).toMatch(/^[a-z<> ]*$/) }) - test("numbers and symbols leave a visible gap rather than disappearing", () => { - expect(templateErrorMessage("timeout after 30000 ms")).toBe(templateErrorMessage("timeout after 30000 ms")) + test("digits and symbols are dropped, and unknown words they separated collapse into one placeholder", () => { + // "30000" is dropped as a separator; the unknown word "ms" becomes the placeholder. + expect(templateErrorMessage("timeout after 30000 ms")).toBe("timeout after ") expect(templateErrorMessage("zzqq9876543210zzqq")).toBe("") expect(templateErrorMessage("zzqq 9876543210 zzqq")).toBe("") }) @@ -885,6 +886,12 @@ describe("v0.12.8 adversarial: schema value profile", () => { expect(out).not.toContain(long) }) + test("profileDocuments counts distinct values on the full text, not the clipped text", () => { + const prefix = "Q".repeat(500) + const out = profileDocuments([{ note: prefix + "a" }, { note: prefix + "b" }]) + expect(out).toMatch(/^note: 2 distinct\b/m) + }) + test("profileDocuments caps the number of profiled fields", () => { const doc: Record = {} for (let i = 0; i < 200; i++) doc[`field${String(i).padStart(3, "0")}`] = `v${i}` From 48163ef8ecac1a4ffef8ea05c4fdc037e5c714f7 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 23 Oct 2026 20:13:31 +0530 Subject: [PATCH 09/11] test: move the policy skip-guard case into its own describe block Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- .../test/skill/release-v0.12.8-adversarial.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts index 5479806575..dd00a80c07 100644 --- a/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.12.8-adversarial.test.ts @@ -521,6 +521,11 @@ describe("v0.12.8 adversarial: plan-skip fail-closed guards", () => { const out = applySemanticsSkipGuard({ notes: [null, 3, "ok", "plan-based analysis was skipped"] }) expect(out.skipped).toBe(true) }) + + test("a type-confused rule field that stringifies to a skip rule fails closed", () => { + const out = applyPolicySkipGuard({ allowed: true, warnings: [{ rule: ["POLICY_SKIPPED"] }] }) + expect(out.allowed).toBe(false) + }) }) describe("v0.12.8 adversarial: permission aliases only tighten", () => { @@ -552,11 +557,6 @@ describe("v0.12.8 adversarial: permission aliases only tighten", () => { expect(r.action).toBe("allow") }) - test("a type-confused rule field that stringifies to a skip rule fails closed", () => { - const out = applyPolicySkipGuard({ allowed: true, warnings: [{ rule: ["POLICY_SKIPPED"] }] }) - expect(out.allowed).toBe(false) - }) - test("an ask via alias tightens an authored allow but not an authored deny", () => { expect(evaluateRequest(req({ a: [["b"]] }), "a", [rule("allow", "a"), rule("ask", "b")]).action).toBe("ask") expect(evaluateRequest(req({ a: [["b"]] }), "a", [rule("deny", "a"), rule("ask", "b")]).action).toBe("deny") From 3a254af1af9cb560bef476b59b7f46aa194babcf Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 23 Oct 2026 20:48:06 +0530 Subject: [PATCH 10/11] fix: bot review follow-ups on the release PR - `uninstall` offers removing an empty `~/.altimate/bin`, not only a legacy `~/.opencode/bin` (`emptyBinDirToRemove`, tested) - setup lock: the `announce` comment covers what it handles (synchronous throws) - docs: `--fail-on` defaults to `none`; a semantic skip warning does not fail `--fail-on error` Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- CHANGELOG.md | 2 +- docs/docs/usage/check.md | 2 +- packages/core/src/database/setup-lock.ts | 2 +- packages/opencode/src/cli/cmd/uninstall.ts | 13 +++++++++---- packages/opencode/test/cli/uninstall-hint.test.ts | 11 +++++++++-- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dbec57f75..cd1e0f48f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`run --model` reports a failed provider list as itself**, with the HTTP status, not "Model not found". (#1472) - **A failed background file scan is handled** instead of surfacing as an unhandled rejection. (#1471) - **The read-only SQL check blocks PostgreSQL replication-slot copy and sync functions.** `pg_copy_logical_replication_slot`, `pg_copy_physical_replication_slot` and `pg_sync_replication_slots` were not on the side-effect list, so a top-level call passed as read-only; the create and drop slot functions were already blocked. Found in this release's review. -- **`uninstall` on Windows gives PowerShell commands, and says so**, including for the empty `.opencode` bin directory, which used a POSIX `rmdir … 2>/dev/null` line. Found in this release's review. +- **`uninstall` on Windows gives PowerShell commands, and says so**, and now also offers to remove the empty `~/.altimate/bin` directory (previously only a legacy `~/.opencode/bin`, with a POSIX `rmdir … 2>/dev/null` line even on Windows). Found in this release's review. ## [0.12.7] - 2026-10-13 diff --git a/docs/docs/usage/check.md b/docs/docs/usage/check.md index a07e9a446e..21494e88b7 100644 --- a/docs/docs/usage/check.md +++ b/docs/docs/usage/check.md @@ -50,7 +50,7 @@ altimate-code check --fail-on warning | `semantic` | Semantic validation (cartesian products, wrong JOINs) | Optional | No | | `grade` | SQL quality grading with recommendations | Optional | No | -`policy` and `semantic` never report a statement the engine cannot plan as clean. Such statements include table-valued functions (`TABLE(fn())`, Snowflake `LATERAL FLATTEN`, DuckDB `read_csv(...)`). `policy` fails closed: it reports a `policy_not_evaluated` error and treats the statement as blocked. `semantic` reports the skip as a finding: an error when `--schema` is given, and a warning without it, which passes the default `--fail-on error`. Earlier versions rejected these statements as invalid SQL. +`policy` and `semantic` never report a statement the engine cannot plan as clean. Such statements include table-valued functions (`TABLE(fn())`, Snowflake `LATERAL FLATTEN`, DuckDB `read_csv(...)`). `policy` fails closed: it reports a `policy_not_evaluated` error and treats the statement as blocked. `semantic` reports the skip as a finding: an error when `--schema` is given, and a warning without it, which does not fail a run with `--fail-on error`. Earlier versions rejected these statements as invalid SQL. Findings usually carry a machine-readable `rule` field (e.g. `unbalanced_quote`, `sql_injection`, `L003`) visible in `--format json` output — see the [Finding Object](#finding-object) schema below. `safety` and `policy` findings always include one; `lint` findings may omit both `rule` and `code` if the engine didn't attach them, so switch on presence rather than assuming. diff --git a/packages/core/src/database/setup-lock.ts b/packages/core/src/database/setup-lock.ts index ac289b8c15..67a08970f9 100644 --- a/packages/core/src/database/setup-lock.ts +++ b/packages/core/src/database/setup-lock.ts @@ -40,7 +40,7 @@ export function waitNotice(filename: string) { return `Waiting for another altimate-code process to finish setting up the database (lock: ${sentinel(filename)})` } -/** The notice is diagnostic only: a failing stderr write or `notify` must never abort the database open. */ +/** The notice is diagnostic only: a `notify` (or stderr write) that throws synchronously must not abort the database open. */ function announce(filename: string, options: Options) { try { ;(options.notify ?? notifyStderr)(waitNotice(filename)) diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 8ed5afad6f..828f427238 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -70,6 +70,13 @@ export function removalHeader(platform: NodeJS.Platform): string { return platform === "win32" ? "To finish removing the binary, run in PowerShell:" : "To finish removing the binary, run:" } +/** The bin directory to offer removing once it is empty: the installers' `~/.altimate/bin`, or a legacy `~/.opencode/bin`. */ +export function emptyBinDirToRemove(binary: string): string | undefined { + const binDir = path.dirname(binary) + const parent = path.basename(path.dirname(binDir)) + return path.basename(binDir) === "bin" && (parent === ".altimate" || parent === ".opencode") ? binDir : undefined +} + /** * A command that removes `dir` only when it is empty and stays silent otherwise, quoted like * `removeFileCommand`. PowerShell's `Remove-Item` prompts for a non-empty directory instead of @@ -364,10 +371,8 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar prompts.log.info(` ${removeFileCommand(file, process.platform, true)}`) } - const binDir = path.dirname(targets.binary) - if (binDir.includes(".opencode")) { - prompts.log.info(` ${removeEmptyDirCommand(binDir, process.platform)}`) - } + const binDir = emptyBinDirToRemove(targets.binary) + if (binDir) prompts.log.info(` ${removeEmptyDirCommand(binDir, process.platform)}`) // altimate_change end } diff --git a/packages/opencode/test/cli/uninstall-hint.test.ts b/packages/opencode/test/cli/uninstall-hint.test.ts index 24eb892cfc..c76377202c 100644 --- a/packages/opencode/test/cli/uninstall-hint.test.ts +++ b/packages/opencode/test/cli/uninstall-hint.test.ts @@ -3,8 +3,7 @@ import { installerSiblingFiles, removalHeader, removeEmptyDirCommand, - removeFileCommand, -} from "../../src/cli/cmd/uninstall" + removeFileCommand, emptyBinDirToRemove } from "../../src/cli/cmd/uninstall" describe("uninstall — installer files beside the binary", () => { test("names the alias next to a standalone unix install", () => { @@ -100,4 +99,12 @@ describe("uninstall — removal header and empty-directory command", () => { "rmdir '/home/o'\\''neil $dev/.opencode/bin' 2>/dev/null", ) }) + + test("the empty bin directory is offered for the installers' ~/.altimate/bin and a legacy ~/.opencode/bin only", () => { + expect(emptyBinDirToRemove("/home/dev/.altimate/bin/altimate")).toBe("/home/dev/.altimate/bin") + expect(emptyBinDirToRemove("/home/dev/.opencode/bin/opencode")).toBe("/home/dev/.opencode/bin") + expect(emptyBinDirToRemove("/usr/local/bin/altimate")).toBeUndefined() + expect(emptyBinDirToRemove("/home/dev/.altimate/other/altimate")).toBeUndefined() + expect(emptyBinDirToRemove("/home/dev/my.opencode.tools/bin/altimate")).toBeUndefined() + }) }) From f88cd2325c8f6158ac52ab299b5c1b901bf28bf8 Mon Sep 17 00:00:00 2001 From: Haider Date: Sat, 24 Oct 2026 00:42:57 +0530 Subject: [PATCH 11/11] test: build the bin-dir paths with the host path module Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011chv9Rmu7jQXokum1WNcs7 --- packages/opencode/test/cli/uninstall-hint.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/cli/uninstall-hint.test.ts b/packages/opencode/test/cli/uninstall-hint.test.ts index c76377202c..e60d5d6053 100644 --- a/packages/opencode/test/cli/uninstall-hint.test.ts +++ b/packages/opencode/test/cli/uninstall-hint.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import path from "path" import { installerSiblingFiles, removalHeader, @@ -101,10 +102,13 @@ describe("uninstall — removal header and empty-directory command", () => { }) test("the empty bin directory is offered for the installers' ~/.altimate/bin and a legacy ~/.opencode/bin only", () => { - expect(emptyBinDirToRemove("/home/dev/.altimate/bin/altimate")).toBe("/home/dev/.altimate/bin") - expect(emptyBinDirToRemove("/home/dev/.opencode/bin/opencode")).toBe("/home/dev/.opencode/bin") - expect(emptyBinDirToRemove("/usr/local/bin/altimate")).toBeUndefined() - expect(emptyBinDirToRemove("/home/dev/.altimate/other/altimate")).toBeUndefined() - expect(emptyBinDirToRemove("/home/dev/my.opencode.tools/bin/altimate")).toBeUndefined() + const home = path.join(path.sep, "home", "dev") + const altimateBin = path.join(home, ".altimate", "bin") + const opencodeBin = path.join(home, ".opencode", "bin") + expect(emptyBinDirToRemove(path.join(altimateBin, "altimate"))).toBe(altimateBin) + expect(emptyBinDirToRemove(path.join(opencodeBin, "opencode"))).toBe(opencodeBin) + expect(emptyBinDirToRemove(path.join(path.sep, "usr", "local", "bin", "altimate"))).toBeUndefined() + expect(emptyBinDirToRemove(path.join(home, ".altimate", "other", "altimate"))).toBeUndefined() + expect(emptyBinDirToRemove(path.join(home, "my.opencode.tools", "bin", "altimate"))).toBeUndefined() }) })