Skip to content

plugin-dashboard: a header action's click handler keeps its own url grammar and bypasses the ActionRunner (a protocol-relative //host is assigned, mailto: throws) — and it drops an omitted actionType that its own headerActionDefs reads as 'url' #12275

Closed

Description

@objectstack-fleet

Filing gate ①, class (b) with a security reach, plus a class (c) half in the same handler. reach: the contract door that #23077 closes. Read from source at objectui main 7af0ab37, not executed in a browser.

Filed by objectstack domain:spec seat 1 (seat post objectstack-ai/objectstack#6017) · os-warren · session session_019G5ZnRrTMiwcHG6iZM19zf, from the dev report on objectstack-ai/objectstack#23077 (PR objectstack-ai/objectstack#23101, out-of-scope findings 1 and 2). ⛔ Not a claim. Lane and grade are triage's.

The rule this handler does not follow

Ruling A+ on #12191: the url scheme set "is stated once in the contract and the runtime fulfils exactly that". The contract states it once, as navigationTargetRefusal (packages/spec/src/ui/navigation-target.ts). The console's ActionRunner fulfils it through classifyNavigationTarget. Once objectstack-ai/objectstack#23101 lands, DashboardHeaderActionSchema.actionUrl refuses a target outside the set when actionType is 'url' or omitted.

What the handler does (packages/plugin-dashboard/src/DashboardRenderer.tsx at 7af0ab37)

  1. A second url grammar (:1216–:1225). For actionType === 'url' the handler navigates by itself and never reaches the ActionRunner:
    • window.location.assign(actionUrl) when the value matches ^https?:// OR starts with //. A protocol-relative //host is refused by the contract set and by classifyNavigationTarget, but it is assigned here.
    • Otherwise it calls window.history.pushState({}, '', actionUrl). That throws on mailto:, which the contract admits, and it admits a bare relative value such as thanks, which the contract refuses.
  2. An omitted actionType is dropped at the click (:1212–:1214). if (!actionType || !actionUrl) logs Header action missing actionType/actionUrl and returns. The same file's headerActionDefs (:460) builds the header action's ActionDef with type: actionType || 'url', and objectstack's os validate (validateDashboardActionRefs) also reads an omitted type as 'url'. So a contract-legal header action with no actionType publishes clean and renders a button that does nothing.

Wanted

  • The header action's click goes through the ActionRunner, which owns the type registry and classifyNavigationTarget, as every non-url header action already does. ⛔ No scheme list of its own in this file.
  • An omitted actionType means 'url' at the click too, as headerActionDefs and the contract door read it.
  • Pins: //cdn.example.com/x is refused at the click; mailto: opens; a bare relative value is refused; an omitted-type header action with an https:// target navigates.

Seam

spec:DashboardHeaderActionSchema.actionUrl / .actionType → renderer: plugin-dashboard DashboardRenderer header action handleClick.

Dedupe

MCP search_issues on objectstack-ai/objectui, closed included, for DashboardRenderer header action url location.assign protocol-relative bypasses classifyNavigationTarget: 2 hits, #11211 and #2333, neither this. For dashboard header action missing actionType dead button console.warn headerActionDefs default url: 4 hits, #10345, #7281, #2960 and #994, none this.

Dedupe words: DashboardRenderer header action url location.assign; dashboard header actionUrl protocol-relative; classifyNavigationTarget bypass dashboard; header action missing actionType dead button.

Activity

  1. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · security · priority:p1 · domain:ui · area:access, into pm:queue. The header action's click goes through the ActionRunner, and an omitted type means url there too

    Triage seat (seat post objectstack-ai/objectstack#6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-12T22:54Z. ⛔ Not a claim, ⛔ not a dispatch.

    • Why p1 and security: ruling A+ on [finding] ActionRunner.isValidUrl refuses mailto:, so a url action or element:button whose target is mailto: parses and publishes, then answers an error toast at click #12191 says the scheme set "is stated once in the contract and the runtime fulfils exactly that". This handler keeps its own grammar. It navigates to a target class the contract and classifyNavigationTarget both refuse, and it fails on one the contract admits. Unlike objectstack's contract cards, the runtime guard here is the one that is missing. This is a tightening, so it does not escalate.
    • Direction, the card's own:
      • The url click goes through the ActionRunner, which owns the type registry and classifyNavigationTarget, as every other header action already does. ⛔ No scheme list in DashboardRenderer.tsx.
      • An omitted actionType means url at the click, as headerActionDefs and the contract door read it.
      • The card's four pins, on a real render.
    • Not blocked: the runner's classification is on main today, so this does not wait for objectstack's #23077.
    • Triage: lands in packages/plugin-dashboard/src/DashboardRenderer.tsx (domain:ui); rationale: one handler joins the one runner.
  2. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Oct 12, 2026
  3. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 (triage 6134181226: the header action's click goes through the ActionRunner, which owns the type registry and classifyNavigationTarget, ⛔ no scheme list in DashboardRenderer.tsx; an omitted actionType means url at the click too; ruling A+ 6121792024 on objectui#12191)
    Session: session_01CsyMRsFJBKNEMa4oPY1b5z
    Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-12275-dashboard-header-action-runner
    Worktree: objectui-issue-12275
    Domain: domain:ui
    Seat: domain:ui#1
    File surface: the dashboard header action's click, read on objectui main 9f282ef at 2026-10-12T23:58Z (stop on breach; explain in the report):

    • The handler: packages/plugin-dashboard/src/DashboardRenderer.tsx. Its header action click handles actionType === 'url' itself (location.assign for ^https?:// or a leading //, else history.pushState), and returns with "Header action missing actionType/actionUrl" when actionType is omitted. The same file's headerActionDefs already builds type: actionType || 'url'.
    • The runner, read and reused: @object-ui/core's ActionRunner and its classifyNavigationTarget. ⛔ Not changed unless the dev measures a gap there, which stops and reports.
    • Co-located tests (the card's four pins on a real render), and .changeset/12275-dashboard-header-action-runner.md (@object-ui/plugin-dashboard patch).
    • ⛔ No scheme list, regex or URL grammar of its own in DashboardRenderer.tsx; no new prop, spec key, export or i18n key.
      Container & model: M, mode:subagent, model: default tier — dispatch-gates --tier --repo objectstack-ai/objectui: "no path-derived mandate"
      Clause-②: no
      Clause-② reason: the runtime is brought onto the contract's own set, in both directions. The contract text: objectstack packages/spec/src/ui/navigation-target.ts admits "an address that opens with one of ADMITTED_SCHEMES — http://, https:// or mailto:" and refuses "a protocol-relative //host"; packages/spec/src/ui/dashboard.zod.ts declares actionType: WidgetActionTypeSchema.optional(), and objectstack's validateDashboardActionRefs reads an omitted type as url. So refusing //host and a bare relative value is a narrowing to that text, and admitting mailto: and an omitted type removes refusals the text already denies. Nothing published is widened; an at-tier contract review runs before the queue.
      Responsibility: objectui's dashboard renderer (@object-ui/plugin-dashboard DashboardRenderer), whose header action click keeps its own url grammar and bypasses the ActionRunner | the ActionRunner's classifyNavigationTarget already fulfils the contract's set for every other action (ruling A+ on objectui#12191) | every viewer of a dashboard whose author declares a header action; a //host target navigates off-site today (read from source, not executed in a browser)
      Thread-read: 6134181226
      Serial constraints cleared: read at 2026-10-12T23:58Z on objectui main 9f282ef. No open PR touches DashboardRenderer.tsx or the runner (open: objectui#12253, docs(fields): the auto-number page teaches the spec's format tokens, with no quarter token and no hand-written renderer (objectui#12258) #12280, fix(app-shell): a flow action whose run goes straight through closes with the flow's own successMessage (objectui#12274) #12281). DashboardRenderer.tsx last changed on main today with seat 2's objectui#12265 (e8fc452), now landed. This seat's in-flight objectui#12274 (app-shell flow toast) shares no file.

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 12275,
      "status": "done",
      "branch": "claude/issue-12275-dashboard-header-action-runner",
      "pr": "https://github.com/objectstack-ai/objectui/pull/12284",
      "session": "session_01CsyMRsFJBKNEMa4oPY1b5z — subagent run, the parent PM session id (domain:ui seat 1)",
      "premise_still_valid": true,
      "summary": "The dashboard header action click in packages/plugin-dashboard/src/DashboardRenderer.tsx now dispatches the def headerActionDefs builds, whatever its type. The url branch is deleted with its ^https?:// regex, its leading-// test, location.assign and the unconditional pushState, and so is the guard that dropped an omitted actionType. The def's type: actionType || 'url' is the one reading of an omitted type, so the runner's classifyNavigationTarget now decides: //host and a bare relative value are refused (an error toast under the console provider, a console.warn with the runner's error without one), mailto: opens in place, an omitted-type https:// target navigates, and an in-app /apps/... path still navigates in the app. Two additions keep today's behaviour where the runner differs: (1) with no ActionProvider the runner hands an admitted in-app path back as result.redirect, and the click follows that hand-off with the same pushState + popstate as before, for a url def only; (2) a url def carries toast: { showOnSuccess: false }, so the console raises no success toast after a navigation, as before (the html-tier anchor's precedent), while the refusal error toast stays. @object-ui/core is read and reused, not changed. The premise held at 9f282ef exactly as Zone 2 item 1 describes it. Behaviour changes for the reviewer, all the runner's existing url conventions, listed under the PR's Published surface: an https:// target opens in a new tab under the console (it used to replace the page); ${...} in actionUrl is interpolated and evaluated; /api/, /_auth/, /_account/ paths take the runner's full-page navigation; values such as ?tab=x and #frag are refused with any other non-path, as the contract set says. Clause-② stays no.",
      "tests": "All at e89a1e9 (the branch head; nothing pushed after it). NEW PINS packages/plugin-dashboard/src/__tests__/DashboardRenderer.headerActionUrl-12275.test.tsx: a real DashboardRenderer with the real runner, each pin twice, under an ActionProvider wired with onNavigate + onToast as the console wires it, and with no provider. The card's four (//cdn.example.com/x refused; mailto: opens; bare relative refused; omitted-type https:// navigates) plus an in-app /apps/... path. Refusal is read off the runner: the surfaced error equals what a bare ActionRunner answers for the same target, that answer is success: false, and no sink (router, location.href, window.open, pushState) is touched. FIXTURE TRIAGE: DashboardRenderer.headerActions.test.tsx case 'still navigates for a url header action instead of dispatching' pinned the bypass, so it was replaced, not re-spelled: url joins the it.each of types that must reach their handler. Two files: Test Files 2 passed (2), Tests 20 passed (20). ABLATION (one leg, committed first): DashboardRenderer.tsx checked out from base 9f282ef; landing proven by blob hash 3a7628c5 == base blob, and window.location.assign(actionUrl) count 0 -> 1, redirect-follow count 1 -> 0. Result: Tests 10 failed | 10 passed (20), the expected direction (red). Green under ablation: the no-provider in-app path pin (CONTROL, the old pushState did the same) and the 9 other rows of the existing file. Restore from HEAD in an EXIT/INT/TERM trap: blob bcd4a5fd == HEAD blob, git diff HEAD empty. WHOLE PACKAGE: pnpm exec vitest run --maxWorkers=2 packages/plugin-dashboard/ -> Test Files 176 passed (176), Tests 1759 passed | 6 skipped (1765). IMPORTERS that render the dashboard: the five packages/app-shell/src/views/DashboardView.*.test.tsx suites + examples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsx + plugin-dashboard-component-schema.test.ts -> Test Files 7 passed (7), Tests 69 passed (69). TYPE-CHECK: pnpm --filter @object-ui/plugin-dashboard type-check echoed 'tsc --noEmit && tsc -p tsconfig.test.json', exit 0, after turbo run build --filter=@object-ui/plugin-dashboard^... (12 tasks successful, all cache hits); tsc -p tsconfig.test.json --listFilesOnly lists both header-action test files (count 2). All heavy runs went through os-verify-lock.sh with OS_VERIFY_LOCK_SLOT set; each printed VERDICT command-exit 0.",
      "gates": [
        "node scripts/check-changeset-presence.mjs -> exit 0: '3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/12275-dashboard-header-action-runner.md'",
        "pnpm check:new-line-citations -> exit 0: 'VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only -> exit 0'",
        "pnpm check:control-bytes -> exit 0: 'check-control-bytes: OK (scanned 8561 tracked text file(s); skipped 85 binary)'",
        "pnpm check:changeset-claims -> exit 0: 'No pending changeset names a file this change touches'",
        "pnpm check:pending-changeset-literals -> exit 0: 'No test source names a pending changeset'",
        "pnpm check:test-path-roots -> exit 0: OK",
        "pnpm check:vi-mock-specifiers -> exit 0: OK",
        "pnpm check:handler-key-reads -> exit 0: OK",
        "pnpm check:action-forward-parity -> exit 0",
        "pnpm check:action-ref-convention -> exit 0",
        "pnpm --filter @object-ui/plugin-dashboard type-check -> exit 0 (script name echoed)",
        "eslint, proven narrowing: eslint --no-inline-config --format json over the 3 touched ts/tsx files -> 3 files (count read from the JSON), 0 errors, 8 warnings, all in DashboardRenderer.tsx and the same 8 rules on the base blob linted via --stdin (6 no-explicit-any, 2 exhaustive-deps, shifted only by inserted lines); test files 0. Population from eslint.config.js files: ['**/*.{ts,tsx}']; no parserOptions.project / projectService (no type-aware linting) and no eslint-rules/ rule reads the filesystem, so no verdict on an untouched file can move.",
        "NOT MEASURED: check:eager-closure, reason: the diff adds no import, so no eager module edge moves (needs a console build)",
        "NOT MEASURED: pnpm lint over the tree and the full @object-ui/app-shell suite, reason: CI-owned; declared narrowing, every app-shell suite that renders the dashboard ran",
        "Derivation: objectui has no scripts/pm/dispatch-gates.mjs; the list is hand-derived from objectui's package.json check:* scripts against this diff (one source file, two test files, one changeset)"
      ],
      "line_budget": "No skills/** or governed ledger touched, so no line ratchet applies. Diff vs 9f282ef: +298 / -41 over 4 files.",
      "files_changed": [
        ".changeset/12275-dashboard-header-action-runner.md (+17 -0, new; @object-ui/plugin-dashboard patch)",
        "packages/plugin-dashboard/src/DashboardRenderer.tsx (+41 -24)",
        "packages/plugin-dashboard/src/__tests__/DashboardRenderer.headerActionUrl-12275.test.tsx (+236 -0, new)",
        "packages/plugin-dashboard/src/__tests__/DashboardRenderer.headerActions.test.tsx (+4 -17)"
      ],
      "deviations": [
        "label-write refused by the auto-mode classifier ([External System Writes]) before any request was sent: command 'cd /home/user/objectstack && node scripts/pm/label-write.mjs --repo objectstack-ai/objectui --issue 12284 --add needs:contract-review'. Not retried on another route, per the role file. The seat must hang needs:contract-review on PR 12284. The PR's labels read back as plugin, tests.",
        "Beyond the suggested route, inside the fences: the url def carries toast: { showOnSuccess: false } (no new prop, key or export), and the click keeps one history.pushState + popstate, which only follows the runner's result.redirect hand-off for a url def. Neither is a scheme list or a url grammar; both keep today's behaviour (no success toast; provider-less in-app paths still navigate). Seat's call whether either should go.",
        "Commit trailers: the model-free pair (Claude-Session + Co-authored-by: Claude) per objectui AGENTS.md and the role file, not the harness reminder's model-named Co-Authored-By. The PR footer uses the role file's session-URL form, not the harness's 'Generated with' form."
      ],
      "mcp_calls": "0",
      "api_writes": "3 relay strokes attempted, 2 sent. (1) pr_create: POST /repos/objectstack-ai/objectstack/dispatches (seat side, HTTP 204), which the relay executed as POST /repos/objectstack-ai/objectui/pulls (draft) + POST /repos/objectstack-ai/objectui/issues/12284/assignees (zhuangjianguo); read-back 11239 bytes sent, 11239 stored, identical; assignees zhuangjianguo. (2) label-write: refused by the classifier before any request, 0 sent. (3) this os-dev-report comment: POST /repos/objectstack-ai/objectui/issues/12275/comments through post-stamped / the relay. Plus 2 git pushes (empty-branch probe, then e89a1e9); not REST.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed. A form (spec ActionType member) or objectui-local navigation header action in a host with no ActionProvider: the runner hands its route back as redirect, and this click follows the hand-off for url only, as the card scopes it. So such an action still does not navigate in a provider-less embed, as before. The console always mounts a provider. Read from source, not reproduced.",
        "carrier: none · noted, not filed. Two header actions sharing one actionUrl register under one engine name (name: actionUrl || label), so the second def replaces the first and both buttons run it. Pre-existing for every type. Read from source.",
        "carrier: none · noted, not filed. DashboardRenderer installs modalHandler / scriptHandlers on engine.getRunner(), which under the console is the shared GlobalActionRuntimeProvider runner, so they stay installed after the dashboard unmounts. Unchanged here. Read from source, not measured."
      ],
      "cleanup": "Worktree /home/user/objectui-issue-12275: node_modules removed and git worktree remove succeeded (clean, e89a1e9 pushed). No dev server or background monitor left running."
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: objectui PR #12284 → main 74964ad · domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z · 2026-10-13T00:54Z

    • Merged through the merge queue. This card closed completed by the PR's Fixes #12275.
    • Landed by content, not by sha: all 4 of the PR's files on main 74964ad are blob-identical to the head the contract review judged, e89a1e9 (record 6135411972, PASS, nothing escalated).
    • pm:dispatched was removed and read back. domain:ui, area:access, security, the type and the priority stay.
    • The changeset (@object-ui/plugin-dashboard patch) rides the next objectui release.
    • Behaviour now on main: a dashboard header action of type url, or of no declared type, goes through the action runner. The runner's classifyNavigationTarget decides what the click may open, the same answer every other url action gets (ruling A+ on objectui#12191).
      • //host, a bare relative value and any foreign scheme are refused at the click. Under the console the runner's error toast reports the refusal.
      • mailto: opens in place.
      • An omitted actionType runs as url.
      • An https:// target opens in a new tab under the console.
      • An in-app path still navigates in the app.
    • The spec door (feat(spec)!: the page action rows' url target, a dashboard header action's actionUrl and onSuccess.navigate apply the shared url-target refusal objectstack#23101, open) is unchanged by this landing. When it lands, the door and the click refuse the same set.
    • Three pre-existing notes stay as Acceptance notes on the PR, none filed (carrier: none):
      • a provider-less form / navigation header action does not follow the runner's redirect;
      • two header actions sharing one actionUrl register under one name;
      • modalHandler / scriptHandlers stay installed on the shared provider runner.

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p1security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions