Skip to content

fix(runtime): standalone database-URL refusals name the scheme, never the URL - #23791

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23764-unsupported-scheme-no-url
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23764-unsupported-scheme-no-url

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23764
Clause-②: no

What changed

The standalone host (packages/runtime/src/standalone-stack.ts) now names only the URL's scheme in its database-URL refusals and never prints the URL. A password, a username or a query-string token in OS_DATABASE_URL no longer reaches the console, the boot log or the --json error field.

Not changed:

  • which URLs resolve, and to which driver;
  • the URL handed to the driver, which keeps its credentials;
  • where each refusal is raised, and its error class (plain Error).

Not touched: packages/cli, createStandaloneStack's mkdirSync (#23737 remains its own card), and every driver package.

Why the scheme alone, not a redacted URL. packages/runtime already depends on @objectstack/spec, which exports redactUrlCredentials. That helper keeps the username, and it strips only the query keys on its credential list. The pin requires that neither the username nor an unlisted token appear. The scheme cannot carry either one, and it is what the operator needs in order to fix the URL.

Before and after, measured at the public doors

Built CLI and runtime at c61b1366e1, with a plain defineStack config and OS_DATABASE_URL=redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN. "Before" is the same build with standalone-stack.ts reset to the base ec44cf60bf and the runtime rebuilt; it was restored afterwards (blob equal to HEAD, git diff HEAD empty) and the runtime rebuilt again. The counts are occurrences in the door's whole stdout and stderr.

door exit before: USER / SECRET / TOKEN after: USER / SECRET / TOKEN
os dev --no-watch 1 1 / 1 / 1 0 / 0 / 0
os start 1 1 / 1 / 1 0 / 0 / 0
os serve 1 1 / 1 / 1 0 / 0 / 0
os migrate plan 1 1 / 1 / 1 0 / 0 / 0
os migrate recorded-by --json (stdout error field) 1 1 / 1 / 1 0 / 0 / 0
  • Before: ✗ [StandaloneStack] Unsupported database URL scheme: redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN. Supported schemes: …
  • After: ✗ [StandaloneStack] Unsupported database URL scheme "redis://". Supported schemes: …

URL to message, per refusal (unit reading at the base, then at HEAD):

URL (placeholder credentials) before after
redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@…?authToken=PLACEHOLDER-TOKEN Unsupported database URL scheme: redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@… Unsupported database URL scheme "redis://"
https://PLACEHOLDER-USER:PLACEHOLDER-SECRET@… (not a Turso host) the whole URL Unsupported database URL scheme "https://"
memory://PLACEHOLDER-USER:… as databaseUrl The database URL "memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@…" A database URL with the "memory://" scheme
memory://PLACEHOLDER-USER:… as OS_DATABASE_URL OS_DATABASE_URL "memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@…" An OS_DATABASE_URL with the "memory://" scheme
a declared default datasource whose url is memory://… (driver: memory, URL "memory://…") (driver: memory, URL scheme "memory://")
postgres://PLACEHOLDER-USER:… with OS_DATABASE_DRIVER=sqlite …does not look like a file path: "postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@…" …does not look like a file path (its scheme is "postgres://")
file: …does not look like a file path: "file:" …does not look like a file path (it names no file after "file:")

Sibling messages in standalone-stack.ts

site can it carry a credential? (measured) disposition
detectDriverFromUrl unsupported-scheme refusal yes, any scheme://user:password@…?token=… fixed (the card)
refuseRetiredMemoryEngine: explicit, env and declared-datasource phrases yes: memory:// and mingo:// accept anything after //; all three phrases echoed it fixed
sqliteFilenameFromUrl non-path refusal yes: OS_DATABASE_DRIVER=sqlite beside a credentialed postgres:// URL echoed it fixed
unsupportedDriverMessage (the OS_DATABASE_DRIVER value) not a URL by contract; it carries one only if an operator writes a URL into the driver variable listed, not changed
assertUrlNamedForRemoteDriver names the driver kind only no
the legacy-file notice (console.warn) a state-directory file path, never a URL no
the unreachable-kind throw the kind only no

Mechanism readings

  • (a) Is there a second copy of the URL? No. Before the change, each door printed each placeholder exactly once: the refusal line. The CLI's Database: row already redacts (redis://127.0.0.1:6399/0). The error has no cause, no door printed a stack, and no file written under OS_HOME or the project carried a placeholder. After the change, every door counts 0. The pins also serialise every own property of the thrown error (message and stack) and find no placeholder.
  • (b) Which doors reach it? All five measured: os dev, os start, os serve, os migrate plan and os migrate recorded-by --json. A door-level pin would live in packages/cli. packages/cli/test/migrate-exit-code.e2e.test.ts already drives os migrate recorded-by --json over wat://nope and reads Unsupported database URL scheme from the payload, and that text is kept. This PR does not edit packages/cli, by the dispatch's ruling, so the door rows above are a one-time measurement, not a pin.

Pins

  • packages/runtime/src/standalone-stack-url-refusal-credentials.test.ts (new, 30 cases):
    • each refusal, through each door it has (the databaseUrl option, OS_DATABASE_URL, a declared default datasource, databaseDriver / OS_DATABASE_DRIVER), driven with PLACEHOLDER-USER:PLACEHOLDER-SECRET@…?authToken=PLACEHOLDER-TOKEN;
    • each asserts that the refusal still happens as a plain Error with no cause, names the scheme and the remedy, and that no own property of the error carries any placeholder;
    • CONTROL: 16 rows keep every detection arm (postgres://, postgresql://, pg://, mysql://, mysql2://, mongodb://, mongodb+srv://, libsql://, https://*.turso.*, wasm-sqlite://, *.wasm.db, file:, sqlite://, a bare path, a scheme-less relative path, :memory:) on the same driver kind, and 3 rows show that a credentialed postgres:// / mysql:// / mongodb:// URL still resolves with url byte-identical.
  • packages/runtime/src/standalone-stack.test.ts:
    • the memory:// boot pin now expects "memory://" scheme and no URL tail (it used to pin the whole URL);
    • one new boot row: createStandaloneStack over a credentialed redis:// URL rejects, naming the scheme, with no placeholder anywhere on the error.

Ablation

Run through scripts/ablation-replace.mjs (wrap mode), with a shell trap that restores from HEAD and checks the blob hash. The suites import ./standalone-stack.js from src, so no dist/ leg applies.

mutation red green
the raw URL put back into the unsupported-scheme refusal, scheme wording kept 5: the 4 unsupported-scheme rows and the boot row, all on the placeholder assertion 116, including every memory, sqlite and CONTROL row and the libsql and mysql suites
the raw URL put back into the env memory phrase, wording kept 1: that row, on the placeholder assertion 63
the raw URL put back into the sqlite non-path refusal 2: both sqlite rows, on the placeholder assertion 62
  • Every leg reported the anchor going from 1 to 0, a changed blob, and a restore with the blob equal to HEAD and git diff HEAD empty.
  • The first memory attempt inserted the URL between scheme and selects. That went red on the wording assertion, not the credential one, so it proved nothing. It was redone with the wording kept (the row above).

Verification at c61b1366e1

  • pnpm --filter @objectstack/runtime test (project local): 399 files, 5979 passed, 19 skipped, exit 0.
  • pnpm --filter @objectstack/runtime typecheck (build program plus test layer): exit 0. The test-layer ledger holds at 27 files / 190 errors. tsc --listFiles -p tsconfig.test.json includes both changed test files.
  • Changed and neighbouring suites by path: standalone-stack-url-refusal-credentials, standalone-stack, standalone-stack.libsql, standalone-stack.mysql and resolve-project-database: 155 passed, exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 62 commands, all run, 62 exit 0.
    • check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, packages without dist/). It was re-run after those were built, and exited 0.
    • --ran reconciliation: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN, all with recorded exit codes.
  • Lint, narrowed to the three changed .ts files with eslint --no-inline-config: exit 0. Three pieces of evidence that the narrowing is sound:
    1. These are the derived change set's lintable files.
    2. --print-config shows only per-file syntactic rules and no typed-lint parserOptions.project, so no other file can change these files' verdict.
    3. Positive control: a max-len of 40 injected on the command line fires on all three files, so none is ignored.
    • The full pnpm lint runs in CI's Lint & Repo Gates.
  • Not run locally, and left to CI: test:repo (no repo-project test is changed), the packages/cli suites (not touched), and the full lint farm.

Acceptance notes

  • The CLI host's own memory:// refusal echoes the whole URL. It is in packages/cli/src/utils/storage-driver.ts, the inferDriverTypeFromUrl memory arm, which builds OS_DATABASE_URL "…" from the raw value. Measured: OS_MODE=off OS_DATABASE_URL=memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN os serve exits 1 and prints each placeholder once. This is outside this card's file surface and is reported to the seat to file.
  • The sqlite file path strips file: case-sensitively, but detection is case-insensitive. Measured: OS_DATABASE_URL=FILE:./urd.db os serve created a directory literally named FILE:. in the working directory, with a SQLite file inside it. This is a different defect class from this card's, so it is not changed here and is reported to the seat to file.
  • The resolve-project-database.ts docstring for retiredMemoryEngineMessage still gives The database URL "memory://" as an example selection phrase. It is a comment, outside this card's file surface, and left as it is.

Generated by Claude Code

The standalone host's unsupported-scheme refusal interpolated the whole
database URL, so a password or token in OS_DATABASE_URL reached the
console and the boot log. The retired in-memory engine refusal and the
sqlite non-path refusal did the same. All three now name only the URL's
scheme, which cannot hold a userinfo, host, path or query.

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8684079f2a4953b124a25cbb5542e83cf6c9dda4 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e8903aca348649aef25804a990c7248956e66a5 — the merge of head c61b1366e1a36689f3400fc5762aa2ed7f56cae9 into base 8684079f2a4953b124a25cbb5542e83cf6c9dda4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e8903aca348649aef25804a990c7248956e66a5 && git checkout 1e8903aca348649aef25804a990c7248956e66a5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8684079f2a4953b124a25cbb5542e83cf6c9dda4 c61b1366e1a36689f3400fc5762aa2ed7f56cae9 && git checkout -B drift-repro 8684079f2a4953b124a25cbb5542e83cf6c9dda4 && git merge --no-ff c61b1366e1a36689f3400fc5762aa2ed7f56cae9

node scripts/docs-audit/affected-docs.mjs --json 8684079f2a4953b124a25cbb5542e83cf6c9dda4

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

✅ ACCEPT: PR #23791 at c61b1366e1 (#23764, p1 security), round 0. Every check on this head is green or a roster skip. No contract review is owed · 2026-10-16T03:04Z

domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6206304815. Report: the os-dev-report on #23764.

Checklist, read against GitHub and origin/main, not against the report:

  • PR shape: draft, base main, title fix(runtime):, assignee sunhaolin. Line 1 is Fixes #23764, the body's only closing keyword. Line 2 is Clause-②: no.
  • Scope: 4 files, +285 / −8: standalone-stack.ts, one new pin file, one updated pin file, one changeset. No packages/cli, no createStandaloneStack mkdirSync (runtime/cli: os start on an OS_DATABASE_URL sqlite file whose directory cannot be made hangs with no timeout: createStandaloneStack's synchronous recursive mkdirSync loops on ENOENT under an existing parent (the #23696 class, a second producer) #23737), no driver package.
    • check-governed-merges --pr 23791: NOT governed. git merge-tree is clean against origin/main 46ca793edb.
  • The fix, read in the diff:
    • A new urlSchemeOf returns the <scheme>:// prefix by the regex the classifier already used. A scheme is letters, digits and + - . up to ://, so it cannot hold a userinfo, a host, a path or a query.
    • detectDriverFromUrl's unsupported-scheme refusal now reads Unsupported database URL scheme "redis://", with the supported list unchanged.
    • Both siblings were measured to carry a credential and are changed the same way: the retired in-memory engine's three phrases (explicit, env, declared datasource), and the sqlite non-path refusal under an explicit sqlite driver beside a credentialed server URL.
    • Every arm returns the same driver kind as before, and the URL handed to the driver keeps its credentials. Each message keeps its identifying phrase, so the CLI e2e's toContain('Unsupported database URL scheme') and resolve-project-database.test.ts' regex still read it.
  • Pins and reverse verification:
    • 30 cases in the new file: each refusal through each door the host reads (databaseUrl, OS_DATABASE_URL, a declared default datasource, databaseDriver, OS_DATABASE_DRIVER). Each is a plain Error with no cause, names the scheme and the remedy, and no own property of the error (message, stack) carries PLACEHOLDER-USER, PLACEHOLDER-SECRET or PLACEHOLDER-TOKEN. CONTROL: 16 detection-arm rows on the same kind, and 3 rows where a credentialed postgres / mysql / mongodb URL resolves with url byte-identical.
    • Three ablations, each restored and proven against HEAD: the raw URL back in the unsupported-scheme refusal turned exactly its 5 rows red; the env memory phrase, 1; the sqlite refusal, 2. Every control stayed green.
    • Door readings with the built CLI, before and after: os dev --no-watch, os start, os serve, os migrate plan and os migrate recorded-by --json printed each placeholder once before, and 0 / 0 / 0 after. No second copy of the URL (no cause, no stack, no file).
  • Placeholders only: the PR, the pins and the changeset use PLACEHOLDER-* values, as triage's grade asks.
  • Changeset prose: @objectstack/runtime patch, fix(runtime):, Clause-②: no. The three FROM / TO pairs match the pins and the diff, and "what does not change" matches the CONTROL rows.
  • Gates:
    • Local, at c61b1366e1: 62 derived, 62 run, 0 unmeasured, all exit 0 (check:dual-build-cjs-loads first exited 3 for missing dist, and 0 after the build). The runtime suite: 399 files, 5979 passed. The typecheck exits 0.
    • Gate evidence: the check-runs pinned to c61b1366e1, read at this stamp. 40 distinct checks: 34 success, including Lint · ESLint, the Type Check family, all six Test Core shards, Build Core, Check Changeset and Governed Surface Queue Guard, and 6 skipped. All 6 skips are roster names: Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix), Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance. Nothing is running and nothing failed.
  • Contract review: none owed. record-recognisers.mjs --template --paths over the three code paths derives no schema, rules or narrowing face, and the changeset's Clause-②: no is not a narrowing.

The dev's open question (a door-level row in packages/cli?): no, option A. All five doors reach the one throw these pins hold, and the door readings show no second copy of the URL, so a CLI e2e row would re-assert the unit pin through a slower path in a lane this card held off.

Out of scope, filed from the report:

Accepted deviations:

  • the username placeholder PLACEHOLDER-USER, so its absence cannot collide with an ordinary word;
  • the reworded memory and sqlite phrases, so they do not claim the URL was exactly the scheme;
  • lint narrowed, with its three pieces of evidence;
  • one void ablation attempt, redone.

Landing: pr_ready + automerge_enable through the relay, in this act. If #23685's driver-memory timeout ejects it, it is not re-queued blind.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants