Skip to content

fix(cli): warn at boot when an app's branding names a runtime asset the server will not serve - #22089

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22071-runtime-assets-boot-warning
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22071-runtime-assets-boot-warning

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22071
Clause-②: no

What changed

os serve resolves the runtime assets directory as OS_RUNTIME_ASSETS_DIR, else the assets/ directory under its working directory. createRuntimeAssetsPlugin mounted GET /runtime/assets/:filename only when that directory existed, and said nothing when it did not. An artifact booted from any other directory therefore served a 404 for an app's branding.logo / branding.favicon and printed nothing about it.

Now, once the boot has settled (kernel:bootstrapped), the plugin reads the served app list. It prints ONE warning per branding URL under /runtime/assets/ that this boot will not serve. The line names:

  • every app and key that uses the file;
  • the file;
  • the directory searched;
  • OS_RUNTIME_ASSETS_DIR, and whether the directory came from it or from the working-directory default;
  • when the directory does not exist, that fact, and that nothing under /runtime/assets/ is mounted for this run.

What the route serves does not change. No route is added or removed (triage ruling 6038494234; carrying the files in the artifact stays out of scope).

Files: packages/cli/src/utils/console.ts (the plugin), packages/cli/src/commands/serve.ts (the runtime assets block now passes which source the directory came from), the two test files below, and one @objectstack/cli patch changeset.

The PM's readings (dispatch H1 to H5), measured

H1, reproduce first. Held. Measured on origin/main 3d918850 with the CLI run from source. The artifact had one app with branding: { logo: '/runtime/assets/icon.svg', favicon: '/runtime/assets/icon.svg' }. Each boot ran from a fresh directory holding only that artifact (OS_ARTIFACT_PATH, the same serve that start --artifact spawns in its own working directory).

boot GET /runtime/assets/icon.svg boot lines mentioning assets, branding or icon.svg
before, no assets/ 404 0
before, assets/icon.svg beside the artifact 200 image/svg+xml 0
before, OS_RUNTIME_ASSETS_DIR naming a directory with icon.svg 200 image/svg+xml 0
after, no assets/ 404 1, in Boot diagnostics
after, assets/ control 200 image/svg+xml 0
after, OS_RUNTIME_ASSETS_DIR control 200 image/svg+xml 0

The line printed on the first after-boot (temp path shortened to DEPLOY):

WARN Branding asset not served: app 'brand_app' (branding.logo, branding.favicon) → /runtime/assets/icon.svg, but the directory searched, DEPLOY/assets (the CWD/assets default, since OS_RUNTIME_ASSETS_DIR is unset), does not exist, so /runtime/assets/ is not mounted this run; the console will draw a broken image. To fix, put icon.svg in that directory and restart, or set OS_RUNTIME_ASSETS_DIR to the directory that holds it.

The real line spells CWD as cwd inside angle brackets. It is written CWD here because GitHub strips angle-bracket fragments from bodies.

H2, where the loaded apps' branding is read. kernel.getService('protocol').getMetaItems({ type: 'app' }) (packages/metadata-protocol/src/protocol.ts, getMetaItems, the served audience). GET /api/v1/meta/app answers from the same call (packages/rest/src/rest-server.ts, the GET /meta/:type list door, p.getMetaItems(listRequest)). The console's app list and chrome are drawn from that route: objectui MetadataProvider calls client.meta.getItems('app'), read at objectui 9dfaca654, not at the .objectui-sha pin. Config boots and artifact boots both register their apps with that protocol. The artifact boot above is the measurement: the warning names brand_app, which only that read could have supplied. Both { type, items } and a bare array are accepted, as the REST door's own comment says getMetaItems can answer either.

H3, one resolution. Took the PM's lean. A private helper in utils/console.ts, resolveRuntimeAssetPath(assetsDir, filename), holds the route's own steps: strip separators, path.join, then the traversal guard (null maps to 403). The route now serves through it and the check judges through it. The route's behaviour is byte-for-byte what it was. "Servable" adds only what the route's readFileSync needs: a readable regular file. A directory and a missing file both answer no, exactly as the route 404s them. The helper is not exported: this module's export set is pinned by test/published-subpath-console.pin.test.ts, and a 14th export would need that pin edited.

H4, what the matcher accepts. A branding.logo / branding.favicon string that:

  • after trimming, is a root path (one leading /);
  • resolved the way a browser resolves an img src on this origin, stays on this origin and has a pathname that starts with /runtime/assets/ and names something after it.

Query, fragment and dot segments are removed by that resolution. The segment is percent-decoded the way the route's :filename parameter is. A path below a subdirectory is reported as a subdirectory, because the route's single segment never matches it.

These print nothing: absolute URLs, protocol-relative URLs (including the backslash spelling a browser reads the same way), data URIs, relative paths, and any other root path (/assets/…, /runtime/assetsx/…). The unit test pins every one of them, with a positive control in the same test case.

H5, the channel. One, named. The line goes out through the plugin's own ctx.logger.warn, at kernel:bootstrapped, inside runtime.start():

  • under serve's boot-quiet window, BootLogCapture captures it and Boot diagnostics (printBootDiagnostics) replays it once;
  • at --log-level debug or info it streams live;
  • at error or silent it is hidden, like every boot warning.

It is never printed from the banner list. The sibling #22073 de-duplicates the banner list against Boot diagnostics, and that work sees this line in exactly one of the two.

Failure handling: the hook must never fail the boot it reports on. A missing protocol, or a read that throws, is logged at debug and the hook returns.

Tests

Final head is c397a0f3. Every run below is from that head; origin/main had not moved from 3d918850.

  • packages/cli/test/runtime-assets.test.ts, unit tier, per PR. The existing three cases were kept and their two-argument call updated. The old "silently skips" case now asserts that no route is mounted AND the check is registered. New cases:
    • the absent-directory warning appears ONCE for a file both keys name;
    • the file-present control: a 200 from the route and no line, with a positive control that the app list WAS read;
    • a present directory with the file missing: no claim that the directory is absent;
    • one line per file across apps;
    • the H4 matcher table;
    • a URL by URL check that the warning and the captured route handler agree (query, fragment, dot segment, whitespace, percent-encoding, a directory, a subdirectory, a missing file);
    • the subdirectory reason;
    • a protocol that is absent or throws never fails the boot.
    • Result: Tests 12 passed (12).
  • packages/cli/test/serve-runtime-assets-branding-warning.e2e.test.ts, integration project, e2e tier. The triage pins as three real boots of one artifact from a fresh directory: no assets/; the OS_RUNTIME_ASSETS_DIR control; the assets/ control. It asserts exactly one boot line naming the URL (with the app, both keys, the directory, OS_RUNTIME_ASSETS_DIR and the absent directory), and a 404 that does not change. In both controls, no line and a 200 with the exact bytes. By its name it runs on the nightly tier (scripts/nightly-tiers.mjs); the per-PR half is the unit file. Local run with OS_TEST_TIERS=nightly: Tests 3 passed (3).
  • Ablations, committed first, mutated and restored by scripts/ablation-replace.mjs (anchor hit 1 to 0, blob changed, restored blob equal to HEAD, git diff HEAD empty). Both subjects are imported from src: the unit test imports ../src/utils/console.js and the e2e child runs bin/run-dev.js from source through tsx. No dist/ is in the path, so no rebuild leg applies.
    • kernel:bootstrapped renamed so the check never fires: unit 9 failed | 3 passed. The e2e no-assets/ boot went red; both controls stayed green.
    • The parameter decode dropped, so the matcher diverges from the route: unit 1 failed | 11 passed, the route-agreement case.
    • The first unit leg of the first ablation was run under OS_TEST_TIERS=nightly and selected nothing ("FILTER SELECTED NOTHING"). It was a no-op and is not counted; it was re-run without the switch.
  • pnpm --filter @objectstack/cli test (unit and integration projects, e2e tier excluded by default): Test Files 352 passed (352), Tests 4692 passed | 2 skipped (4694), exit 0. pnpm --filter @objectstack/cli typecheck: exit 0. It is tsc --noEmit plus check:test-typecheck: OK, with 3 files, 28 errors and 6 pinned signatures held in test-typecheck-debt.json, unchanged.

Gates

All of these ran at c397a0f3, and each exit code was captured before any pipe.

  • The 67 families node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives over this diff all exited 0. That list is identical to the dispatch's.
    • check:dual-build-cjs-loads and check:i18n-coverage first answered exit 3, PREREQUISITE NOT MET: some packages had no dist/, so nothing was measured.
    • Both were re-run after turbo run build --filter='!@objectstack/docs' (72 tasks, 71 cached) and both exited 0: 106 published require entry point(s) across 66 package(s) load and OK (13 config(s), 621 baselined untranslated string(s), none new).
    • --ran reconcile: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.
  • pnpm lint, the full eslint . --no-inline-config the dispatch adds: exit 0, no findings.
  • pnpm check:startup-registry-verdict exited 0, with 43 startup/open-registry seam(s) … none recording a verdict the boot can contradict. It is not derived for this diff; it was run because the change adds a boot-time registry read.

Acceptance notes

  • examples/app-todo/src/apps/todo.app.ts sets branding.logo: '/assets/todo-logo.png' and favicon: '/assets/todo-favicon.ico'. Neither file exists in the example, and nothing serves /assets/ at the root. This is a read-only inference: the example was not booted or measured. By H4 it is outside this check, which reads only /runtime/assets/. No carrier.
  • The boot check reads the environment-wide app list (no organization), the same list an unscoped GET /api/v1/meta/app answers. An app that exists only as one organization's overlay row is not checked at boot.
  • When the directory is absent, the route stays unmounted for the life of the process; the line says "restart". Mounting it lazily would change what /runtime/assets/* serves, which this card rules out.
  • Every boot measured here printed WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id …} with a full knex stack in Boot diagnostics. That is 6 of 6 artifact boots on a fresh :memory: database, 3 of them on origin/main before this change. It is unrelated to this card and is reported to the seat in the dev report.

Generated by Claude Code

claude added 3 commits October 7, 2026 13:49
…he server will not serve

createRuntimeAssetsPlugin skipped its /runtime/assets/:filename mount without a
word when the assets directory was absent, so an artifact booted outside its
project directory drew a broken logo and favicon with nothing said. Once the
boot settles, the plugin now reads the served app list and warns once per
branding logo / favicon URL under /runtime/assets/ that the route will not
serve, naming the apps, the file, the directory searched, OS_RUNTIME_ASSETS_DIR
and whether that directory exists. The route and the check share one
resolution of the filename; what the route serves is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 12 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ui/apps.mdx (via /api/v1/meta/app (route, a path literal in a comment on a changed line))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-7.mdx (via /api/v1/meta/app (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 31 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 28 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 3d9188502e1b07ae70df8b0b5e733fce44a74cfa → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fb8e309f2f02892f83b9d582eaa2cf2eac86401a — the merge of head c397a0f3c6c7f45e2bd74b1fce270b911fc09cba into base 3d9188502e1b07ae70df8b0b5e733fce44a74cfa, 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 fb8e309f2f02892f83b9d582eaa2cf2eac86401a && git checkout fb8e309f2f02892f83b9d582eaa2cf2eac86401a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3d9188502e1b07ae70df8b0b5e733fce44a74cfa c397a0f3c6c7f45e2bd74b1fce270b911fc09cba && git checkout -B drift-repro 3d9188502e1b07ae70df8b0b5e733fce44a74cfa && git merge --no-ff c397a0f3c6c7f45e2bd74b1fce270b911fc09cba

node scripts/docs-audit/affected-docs.mjs --json 3d9188502e1b07ae70df8b0b5e733fce44a74cfa

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3d9188502e1b07ae70df8b0b5e733fce44a74cfa → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 15:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 15:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit bafb58b Oct 7, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22071-runtime-assets-boot-warning branch October 7, 2026 15:52
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/l tests tooling

Projects

None yet

2 participants