`od media wait`/`od media generate` write their terminal JSON line and then
call process.exit() in the same synchronous block. process.exit() terminates
the process without waiting for pending async writes, so when stdout is a pipe
(Windows named pipes, or output larger than the pipe buffer on any platform)
the final line is dropped: the task succeeds and the daemon records a valid
file, but the caller sees exit 0 with empty stdout and reports a failure -
often re-submitting a duplicate generation job.
Drain both streams before exiting so the terminal JSON always reaches the
caller. The exit-code contract (0 done, 2 still running, 5 failed) is
unchanged.
Regression spec spawns the real CLI against a fake daemon with a
multi-megabyte terminal snapshot to force the write/exit race, and asserts
exit code plus complete JSON/stderr delivery. Red on main, green here.
artifact_count deliberately counts only renderable outputs (HTML +
image/video/audio), so a run whose deliverable was PROMPTS.md or
report.docx reported artifact_count: 0 and was indistinguishable from a
pure chat turn in the artifact funnel. A 2026-08 sample of
"artifact_count = 0" successful runs on 0.18–0.19 found ~1/3 had
actually written such non-renderable files.
files_written_count counts distinct files of ANY type the run created
or modified. Primary source is the filesystem snapshot diff (the
snapshot walk now stamps non-tracked files with a size+mtime-only
fingerprint under a separate 5000-file cap, so code-heavy imported
repos cannot starve artifact fingerprinting and no extra content
hashing happens at run finish). When no baseline exists the tool-stream
fallback (countWrittenFiles) and the truncation-proof side-effect
ledger (writtenFilePaths) report it with the usual per-agent caveats.
artifact_count semantics are unchanged.
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
* feat(cli): od lint — CLI half of the live artifact linter
POST /api/artifacts/lint has been reachable by the web layer since the
linter landed, but had no SUBCOMMAND_MAP entry, leaving it invisible to
external agents in violation of the dual-track rule. Adds the shared DTO
in contracts and a runLint mirroring runExport's flag shape (stdin via
'-', --json envelope, --fail-on threshold, exit 1 on findings at/above).
* address review: contracts as producer authority, cli-lint entrypoint spec, root-help discoverability
- lint-artifact.ts now imports ArtifactLintFinding/ArtifactLintSeverity
from @open-design/contracts and aliases the legacy exported names to
them, so lintArtifact() is typed by the shared contract and a producer
change that breaks the wire shape fails compilation instead of
drifting behind the CLI's consumer-side assertion. The lint route
types its request/response against LintArtifactRequest/Response.
- New tests/cli-lint.test.ts on the cli-templates/cli-deploy stub-server
harness: file + stdin input, all four --fail-on thresholds, clean and
finding responses, human/--json/--agent-message output, usage errors
(unknown flag, bad threshold, missing/empty file), HTTP failure
(structured exit 64), unreachable daemon (exit 3), and root-help
presence. 12 specs.
- printRootHelp() lists od lint beside od export with its headless/
threshold purpose.
---------
Co-authored-by: Cruz Morales <273886418+DojoGenesis@users.noreply.github.com>
String.replace expands $$, $&, $` and $' in string replacements, corrupting
user-derived artifact titles in PDF/image export and skill-derived titles in
example assembly. Switch the affected interpolations to function replacements
and add regression tests for both exporters and assembleExample.
* fix(security): reject loopback for BYOK asset download URLs (#5478)
The assertExternalAssetUrl guard previously routed through
validateBaseUrlResolved without forbidLoopback, so asset URLs pointing
at 127.0.0.1, localhost, or ::1 passed validation and were fetched by
the daemon. This allowed a compromised or misconfigured upstream gateway
to exfiltrate data from internal services via SSRF.
Changes:
- Add forbidLoopback option to ValidateBaseUrlOptions (packages/contracts)
- validateBaseUrl rejects loopback hosts when forbidLoopback is set
- validateBaseUrlResolved skips the loopback early-return when forbidLoopback
- validateBaseUrlResolved: when forbidLoopback is true, DNS-resolved
loopback addresses are also rejected (not just literal loopback hosts)
- assertExternalAssetUrl now passes forbidLoopback: true
- User-configured provider endpoints (validateUserProviderBaseUrl) are
unaffected — they still allow loopback for local gateways
- Adds regression tests covering literal loopback rejection, DNS-resolved
loopback rejection (IPv4, IPv6, mixed results), and provider endpoint
exemption
Note: PR #5529 fixed the library-ingest SSRF path. This PR closes the
separate BYOK asset-download path (assertExternalAssetUrl) that retains
the loopback carve-out.
Closes#5478
* fix(security): pin DNS resolution to prevent rebinding in asset fetch (#5478)
The validation step (validateBaseUrlResolved) and the fetch step
(assertAndFetchExternalAsset) previously performed independent DNS
lookups. A DNS-rebinding domain could return a public IP for validation
and then 127.0.0.1 / ::1 at fetch time, bypassing the loopback guard.
Fix:
- validateBaseUrlResolved now attaches the DNS-resolved addresses that
passed validation to its return value (BaseUrlValidationResult.resolvedAddresses)
- assertExternalAssetUrl passes these addresses through on the ok branch
- assertAndFetchExternalAsset creates an Undici Agent with a custom
connect.lookup that returns only the validated addresses, pinning the
TCP connection regardless of any DNS rebind between validation and fetch
- 3 call sites in byok-tools.ts that used validate-then-fetch separately
now route through assertAndFetchExternalAsset for a single unified path
- 4 regression tests covering: single validated address, DNS-rebind mock
(public→loopback), round-robin DNS, and IP-literal short-circuit
* fix(security): fail-closed on DNS error + connection-time validating lookup (#5478)
Addresses mrcfps third review: the fallback to unpinned fetch when
validation did not attach resolved addresses left a DNS-rebinding hole
where an attacker could make the validation lookup throw (ENOTFOUND /
SERVFAIL) and then answer loopback for the fetch lookup.
Changes:
1. validateBaseUrlResolved: when forbidLoopback is true, DNS lookup
failures now return a forbidden result instead of the sync success.
This prevents the fail-then-rebind vector.
2. assertAndFetchExternalAsset: removed the unpinned-fetch fallback.
Non-IP-literal hostnames without resolvedAddresses now throw — the
fetch never happens. IP literals are safe because they were validated
synchronously and have no hostname to rebind.
3. Replaced the per-request pinned Agent with a long-lived Undici Agent
(assetDispatcher) whose connection-time lookup (createAssetValidatingLookup)
rejects any non-public address. This is defense-in-depth on top of the
pre-validation, following the same pattern as brands/safe-fetch.ts and
plugins/plugin-asset-cache.ts. A shared dispatcher also avoids the
keep-alive socket leak of per-request Agents.
4. createAssetValidatingLookup is exported for unit testing.
5. Expanded test coverage:
- DNS failure → fail-closed (forbidLoopback true and false)
- createAssetValidatingLookup: rejects loopback, RFC1918, metadata IP;
allows public
- assertAndFetchExternalAsset: throws on blocked URLs and internal IPs
- Updated existing public-CDN tests to use IP literals (fail-closed
behavior now correctly rejects hostnames when DNS is unavailable)
* fix(security): injectable lookup + testable fetch for asset SSRF guard (#5478)
Addresses mrcfps 4th review: daemon CI was red because fail-closed
behavior caused hostname-based test fixtures to NXDOMAIN, and
undiciFetch bypassed vi.stubGlobal('fetch') stubs.
Changes:
1. assertExternalAssetUrl and assertAndFetchExternalAsset now accept
an optional injectable `lookup` (DnsLookupFn) and `fetchImpl`
parameter. Production callers pass neither, so default DNS and
globalThis.fetch are used. Tests can inject mock lookups/fetch.
2. assertAndFetchExternalAsset uses globalThis.fetch (not undiciFetch)
so vi.stubGlobal('fetch', ...) stubs in existing test suites
(byok-tools, aihubmix, proxy-routes, senseaudio) still intercept.
3. Existing test fixtures: replaced cdn.example.test with 93.184.216.34
(public IP literal) in 6 test files (41 substitutions) so IP
literals skip DNS resolution and don't trigger fail-closed on
NXDOMAIN .test hostnames.
4. Added 3 TOCTOU regression tests driving assertAndFetchExternalAsset
with injected lookup + fetch:
- Validation lookup public → fetch called with redirect:error
- Validation lookup throws → assertAndFetchExternalAsset rejects
without invoking fetch (fail-then-rebind vector closed)
- IP literal → skips DNS, calls fetch directly
5. assetDispatcher + createAssetValidatingLookup kept for production
connection-time defense-in-depth (exported, available for future
wiring to a production fetch dispatcher).
Total: 31 tests in asset-ssrf-loopback.test.ts, all passing.
* fix(security): attach validating dispatcher to asset fetch (#5478)
mrcfps 5th review: assetDispatcher was built but never attached to the
fetch call. The validating lookup only runs at connect time through the
dispatcher, so without it the fetch re-resolves the attacker-controlled
hostname and a public-then-loopback rebind still reaches the daemon.
Fix: attach assetDispatcher to the RequestInit via the dispatcher key
(same pattern as plugins/plugin-asset-cache.ts safeExternalFetch).
Production globalThis.fetch (Node/undici) uses it to refuse connect-time
non-public addresses. Test stubs still see redirect:'error' and can
ignore dispatcher.
Also fixed the misnamed TOCTOU test to assert dispatcher is present on
the captured RequestInit, and removed unused undiciFetch import.
* fix(tests): update dispatcher assertions for assertAndFetchExternalAsset
assertAndFetchExternalAsset now attaches a validating assetDispatcher to
the fetch RequestInit (issue #5478). Tests that blanket-checked
init?.dispatcher === testMock fail because asset-download calls correctly
carry the validating dispatcher instead.
Fix: conditional assertion — when init.redirect === 'error' (asset download
path), check dispatcher exists rather than matching the test mock. API
calls still verify the caller's dispatcher is forwarded unchanged.
* fix(tests): URL-keyed dispatcher assertions + document asset dispatcher override
Address mrcfps's non-blocking review items:
1. Replace the redirect==='error' heuristic with URL-keyed assertions.
The heuristic silently dropped the submit dispatcher identity check
on the AIHubMix video path (its submit hop also sets redirect:'error').
Submit/poll hops now assert toBe(callerDispatcher); the asset hop
asserts toBe(getAssetValidatingDispatcher()) — exported from
connectionTest.ts so the exact validating instance is compared.
2. Document the intentional dispatcher override on the
BYOKToolContext.requestInit JSDoc: asset downloads through
assertAndFetchExternalAsset intentionally never ride the turn proxy
dispatcher; submit/poll hops keep it.
---------
Co-authored-by: CVE-Hunter-Leo <cve-hunter-leo@users.noreply.github.com>
The tools-dev daemon runs through tsx, which loads pptxgenjs's CJS
build and double-wraps its exports: the namespace's `.default` is
`{ default: PptxGenJS }`, not the class. `new PptxGenJSModule.default()`
then constructs a plain Object, and every screenshot PPTX export fails
with "PptxGenJS is not a constructor" (HTTP 400) once the desktop
renderer is available.
Resolve the constructor once at module load across all three known
shapes: bare CJS namespace (the namespace itself is the class), pure
ESM (`default` is the class), and tsx interop (`default.default` is
the class). Throw a descriptive error when no constructor is reachable
instead of failing later at construct time.
* feat(export): add standalone HTML export
Bundle project-local HTML, CSS, JavaScript, images, fonts, workers,
and iframe dependencies into one offline-safe file.
Expose the same contract through the web download flow and od CLI,
with structured failures and bounded output assembly.
* fix(daemon): honor standalone source URL semantics
Resolve document assets against local base href values, reject browser-invalid TypeScript and JSX sources with a structured 422, and count unquoted first-level resources before graph traversal.
Validated with daemon tests, workspace typecheck, and guard.
* fix(daemon): handle standalone data scripts and CSS URL tokens
Preserve non-JavaScript script payloads as data blocks and scan CSS values without treating strings or comments as project dependencies.
Validated with focused and full daemon tests, workspace typecheck, and guard.
* fix(daemon): classify standalone export resources correctly
Preserve non-HTML iframe MIME types, inline only resource-bearing link relations, and propagate unexpected Vite dist read failures instead of silently falling back.
Validated with focused export tests, the full daemon suite, guard, and workspace typecheck.
* fix(export): preserve version and stylesheet semantics
Hide standalone HTML export for historical versions until dependency snapshots are available, avoiding an action that always fails.
Keep disabled and alternate stylesheets as embedded link elements so standalone export preserves activation semantics. Validated with focused red/green coverage, the full web suite, pnpm guard, and pnpm typecheck.
---------
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
* feat(campaign): extend the DeepSeek V4 campaign to Pro + Flash
The V4 Flash free week ends 8/13 20:00; a two-week window offering BOTH
V4 Pro and V4 Flash opens at that same instant and runs to 8/27 20:00.
This grows the campaign already in production rather than landing a
second stack beside it. That implementation ran the free week for a
week: its audience resolution (wallet balance is not a subscription),
i18n wiring, localized countdown, frequency control and attribution are
all already exercised, and it carries a product decision this change
must not undo -- the URL review backdoors (campaign / audience / usage
overrides) were deliberately removed, and reviewing the campaign is done
by overriding `window.startAt`, never through query parameters.
Almost all of it is the campaign module growing a second model:
`isDeepSeekV4FlashCampaignModel` now matches either id, which is why the
model switcher badges both without its rendering changing at all, and
the modal's benefit line is an i18n key, so naming both models was copy.
`「立即使用」` now switches a paid user to Pro, the headline benefit.
Two things needed real edits. Model-benefit impressions fired once with
a hardcoded Flash id; product compares the two models' reach separately,
so they now fire once per campaign model actually on screen, each with
its own `model_id`. And `campaign_id` / `element` move to the spec's
`deepseek_v4_pro` naming, with `deepseek_v4_flash` kept declared so the
finished campaign's warehouse rows stay valid.
Copy is rewritten across all 19 locales -- dates, the two-week framing,
both model names, and the peak-hours queue caveat product added. Dates
follow each locale's existing format rather than one imposed format, so
only the day numbers move.
Frequency control is keyed on the campaign id, and the id is new, so a
user who dismissed the free week is still owed this campaign's single
showing. That was an invisible dependency on remembering to change the
id; it now has a test.
Module and i18n key names keep their `…V4Flash` spelling: renaming 28
keys across 19 locales mid-campaign is risk without user-visible
benefit, and belongs in a cleanup pass once the campaign is off.
* fix(web): read a rolling model window as a wait, not a failure
vela's hosted gateway rejects a request that exhausted its rolling
per-model window with `model_limit_exceeded`, phrased for API callers as
"You have reached the 5-hour usage limit for X. Try again after <instant>.
This request was not charged to Wallet Credits."
Two things went wrong with that sentence on the way to a user.
The daemon's `isHardQuotaText` matches its "usage limit" wording, so a
window that resets on its own was classified as `hard_quota` — an
exhausted quota, marked non-retryable. Nothing was charged and nothing
needs topping up, so that both misnames the cause and counts a self-
healing wait against reliability as a real failure.
The web runtime then had nowhere to put it. The AMR branch of
`resolveRunFailureUi` ends in a catch-all returning the generic "task
failed" title with a null message key, which renders the raw upstream
string — so a localized chat showed an English sentence under a wrong
heading. Home is worse: its send path fails before a run exists, never
reaches that mapping at all, and prints `err.message` verbatim.
Classify the window separately, ahead of the hard-quota reading, and give
both surfaces the same localized copy: high demand, when it reopens, and
the reassurance that nothing was charged. The instant is rendered in the
reader's locale — the gateway reports UTC. Both surfaces read the signal
through one shared contracts module so they cannot drift, and degrade to
copy that does not promise a time when no instant is readable.
Other rate limits are untouched: a genuine session-limit exhaustion keeps
its hard_quota reading, and every other business failure on Home still
prints verbatim.
* fix(analytics): keep a campaign entry for the whole campaign
The attribution record expires after seven days, but this campaign runs
fourteen. A visitor who clicks the banner on day 1 and subscribes on day
11 arrives at checkout with no entry left to attribute the payment to,
so the campaign under-reports against the metric it is judged on --
活动归因付费人数 / 金额, both defined as payments carrying its
`campaign_id`.
Extended only for entries that carry a `campaignId`. Raising the window
globally would move the attribution口径 for every other entry point and
the dashboards already built on it; the campaign is the thing that needs
a longer memory, so it is the thing that gets one. Past fourteen days a
campaign entry expires like any other -- the longer window is scoped to
the campaign, not an open-ended exemption.
* fix(campaign): carry the new campaign id through ingest and e2e fixtures
Two places still recognised only the finished free week's id, both found
in review.
The daemon's AMR entry ingest allowlist is fail-closed: an unrecognised
`campaignId` voids the WHOLE entry rather than just its campaign field.
With only `deepseek_v4_flash` listed, every entry this campaign produces
would have been dropped at ingest -- and its success metrics are defined
as payments carrying its `campaign_id`, so it would have reported zero
conversions while converting normally. Both ids are listed now, since
entries minted during the free week can still arrive inside their
attribution window.
The Playwright suite pre-seeds a dismissal so the campaign modal never
opens over a functional spec. That seeding was keyed on the old campaign
id, so the modal would have covered Home in every UI spec. `home-hero-
rail` also clears storage and re-seeded its own hardcoded copy of the
key; it now reapplies the shared map instead, so a future campaign
cannot be dismissed in one place and left interrupting specs in the
other.
* fix(campaign): land the workbench badge on the console, like the modal
The badge opened a hardcoded `https://open-design.ai/zh/pricing/` while
the modal's CTA opened the console's plan surface scoped to the caller's
workspace. Both are in-product entry points for a signed-in user, so one
funnel was being split across two destinations -- and only one of them
lets the user actually subscribe.
The hardcoded link also pinned `/zh/`, so every non-Chinese user landed
on a Chinese page from a badge that renders in all 19 locales.
Now shares the modal's destination, including the workspace scope, so
the console can resolve the right upgrade surface for that workspace.
Verified against a real browser: the badge opens
`/amr/dashboard?workspaceId=…&billing=plan` carrying entry id, source,
campaign id, conversion source and device id.
* fix(daemon): point users at the Settings section that actually exists
When image generation failed for a missing credential, the reply told
the user to configure it in "Settings → Media". No such nav item exists —
the section is "Media providers" ("媒体生成提供商" in zh-CN) — so users
following the instruction found nothing.
The cause is that Settings destinations were spelled independently
wherever a producer happened to need one: 29 provider errors in
apps/daemon/src/media/index.ts said a bare unnamed "Settings" (one said
"Settings -> Media Providers"), and the od-media-generation skill said
"Settings → Media". None of them were tied to the Settings dialog, so a
rename could not reach them.
packages/contracts/src/settings-nav.ts is now the single place a Settings
destination is spelled, and every producer reads it: the media provider
errors, the ElevenLabs voice-options prompt (daemon + contracts), and the
external-MCP auth guidance. e2e/tests/settings-nav-copy.test.ts holds the
constants against the labels apps/web actually renders and fails the
build if a producer re-inlines its own guess.
Swept the same drift class while here: both hatch-pet SKILL.md copies
pointed at "Settings → Pets", but #5517 folded the pet picker into
General, so the real path is "Settings → General → Pets".
Verified the gate red on the pre-fix tree (3 failing assertions) and
green after. Adjacent, not fixed: these strings are English and the
agent translates them ad hoc, so a localized client can still render a
section name that does not match its own UI — see the report.
Record: recvre8FrTE2Oa (V0.19.1 acceptance)
* fix(contracts): gate destinations on rendered nav, not the i18n dictionary
Review on #6831 caught that SETTINGS_EXTERNAL_MCP named a place users
cannot navigate to. `mcpClient` kept its Settings render block but lost
its sidebar nav item (SettingsDialog.tsx says so in its own comment), and
reconnecting an external MCP server now lives in the top-level
Integrations view. So the destination is not a Settings section at all.
The stale string predates this branch — it was hardcoded in
prompts/system.ts on main. But promoting it into a named constant made it
worse twice over: the docblock asserted "The Settings nav item where
external MCP servers are added/reconnected" in a canonical location, and
the new gate whitelisted it by construction, so the check built to catch
dead paths became the thing defending one.
The deeper defect was the gate's invariant. Comparing a constant to its
i18n key's value proves only that some dictionary entry matches — not
that anything renders it. `settings.externalMcpTitle` still resolves to
"External MCP" with no nav item behind it, which is exactly how the dead
path passed. The same hole covered SETTINGS_MEDIA_PROVIDERS.
Now the gate parses what the UI actually renders — `settings-nav-item`
buttons and INTEGRATION_TABS — and resolves those keys through the
dictionary, using it as a lookup rather than as the source of truth.
Verified by reintroducing "Settings → External MCP": the old gate passed
it, the new one fails with the rendered destinations listed.
Replaces SETTINGS_EXTERNAL_MCP with INTEGRATIONS_MCP_PATH
(`Integrations → MCP`) and adds a regression test pinning that a
dictionary-only label is rejected.
The od:url-selection-bridge-ready latch now requires the message to carry the document href and match the current iframe src, so a stale ready from a previously loaded document can no longer keep Comment on a bridge-less URL-load preview. The daemon bridge posts href in both the probe reply and the initial ready. Falls back to srcDoc comments when no matching ready arrives.
Fixes#6702
* Fix workspace-scoped MCP resources/read and resources/list (#6770)
The MCP `resources/list` and `resources/read` handlers were calling
`/api/skills` and `/api/design-systems` without workspace headers, while
the project/run tools (after #6569) all resolve the signed-in workspace
and forward `x-od-workspace-id` / `x-od-workspace-member-id`.
This meant an MCP client listing resources only saw the NO-SCOPE catalog:
any Personal design system that the workspace actually owned was filtered
out, and reading `od://design-systems/<personal-id>/DESIGN.md` returned
`404 design system not found` from the daemon even though the binding
exists. The same blind spot affected `/api/skills` listing.
Fix: route both handlers through the same `resolveMcpWorkspaceContext`
that the project/run tools use, and forward the headers on the listing
and read calls. The two handlers are extracted to exported
`_listMcpResources` / `_readMcpResource` helpers so the logic is
reachable from a vitest harness without spinning up a real stdio MCP
server; the original `server.setRequestHandler` bodies now delegate to
those helpers (no behavioral change to the stdio path).
Tests: `apps/daemon/tests/mcp-resources-workspace-scope.test.ts` covers
the header forwarding on both `resources/list` and `resources/read`, the
NO-SCOPE fallback when no workspace resolves, the brief-app resource
fast path that bypasses the daemon, and the unsupported-URI rejection.
* Export OPEN_DESIGN_BRIEF_APP_RESOURCE for tests
The new mcp-resources-workspace-scope test imports this constant to
assert the brief-app resource fast path bypasses the daemon. It is
referenced inside mcp.ts but never exported — typecheck broke with
TS2459.
* fix(cli): make od project list honor the signed-in workspace (#6679)
od project list always called GET /api/projects (the NO-SCOPE catalog),
which after 0.18.0's workspace isolation returns only projects never
adopted into a workspace. immediately
workspace-adopts its result, so reported 0 projects
while the UI listed them (#6679).
Mirror #6595's MCP fix: when no explicit --workspace/--workspace-member
pair is supplied, resolve the signed-in workspace once via
GET /api/workspace/directory (caching the result for 15s to avoid
repeated directory hits during interactive use), then route to
GET /api/workspaces/:id/projects with the workspace headers. When the
directory is unavailable (signed-out, non-vela, or transient outage),
fall back to the original headerless catalog so unbound projects still
appear. An explicit --workspace pair wins and short-circuits the
auto-resolution, preserving the existing behavior.
Refs: #6569, #6595 (MCP bridge fix, cli.ts line 6589 in 0.16.2)
Refs: #6679 (CLI/UI project list inconsistency)
Signed-off-by: xxiaoxiong <2482929840@qq.com>
* fix(cli): route explicit --workspace to the scoped catalog too (#6679)
PerishCode review caught that the implicit signed-in path now uses the
scoped catalog, but an explicit --workspace/--workspace-member pair still
fell back to /api/projects, which is the unbound catalog — passing
--workspace to /api/projects does NOT scope it (#6679 repro). Route both
the implicit resolved identity and the explicit flags through
GET /api/workspaces/:id/projects so the explicit path fixes the same
#6679 behavior the implicit path fixed.
Updates the explicit test case to assert the workspace-scoped URL, and
adds a /api/workspaces/ws-1/projects branch to the test stub server.
Signed-off-by: xxiaoxiong <2482929840@qq.com>
---------
Signed-off-by: xxiaoxiong <2482929840@qq.com>
A brand re-finalize drops the workspaceId claim from a personal design
system's metadata.json while the workspace_resources envelope keeps the
binding correct. The read side trusted the metadata claim alone, so a
correctly bound system vanished from the scoped catalog and every by-id
read (catalog, detail, preview, showcase, static).
List the user directory unscoped and make the exact envelope binding
(workspaceId + personal + active + createdByWorkspaceMemberId) the
visibility authority for scoped requests; the signed-out lane now hides
both envelope-bound and metadata-claimed systems. By-id reads follow the
same authority, and the detail/preview/showcase/static routes forward the
verified workspaceMemberId they already resolve for the catalog.
Fixes#6763
* feat(mcp): add claude-desktop to MCP install targets
Add 'claude-desktop' agent slug with JSON merge strategy.
Writes to the platform-appropriate config path:
- macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
- Windows: %APPDATA%/Claude/claude_desktop_config.json
- Linux: ~/.config/Claude/claude_desktop_config.json
Claude Desktop has native MCP support and reads from
claude_desktop_config.json using the standard {mcpServers: {...}} schema.
Also updates README table and file header comment.
* fix(mcp): refuse claude-desktop on unsupported platforms
Claude Desktop is only available on macOS and Windows.
The planAgentInstall case now returns a ManualInstallPlan with
a clear reason on other platforms, instead of silently writing
to an invented ~/.config/Claude/ path.
Changes:
- planAgentInstall: branch on platform, return manual plan for
non-darwin/non-win32
- claudeDesktopConfigPath: remove Linux fallback path
- Tests: flip Linux test from json-write expectation to manual-plan
with unsupported-platform assertion
* docs: clarify Claude Desktop MCP config is macOS/Windows only
Update the unsupported-platform reason, test, and README footnote to say
automatic MCP configuration (not Claude Desktop itself) is currently
supported only on macOS and Windows. Claude Desktop is now available on
Linux (beta), but the authoritative local-MCP config path is not yet
documented for that platform.
Ref: https://support.claude.com/en/articles/10065433-install-claude-desktop
* fix(daemon): keep daemon as sole writer of run events on stale message PUT
The web client saves whole-message snapshots through PUT /messages/:mid.
A stale snapshot — captured in memory before a reconnect or project
switch, then PUT after the daemon appended more events — overwrote
`events_json` and wiped early run events, including the `status:model`
event the conversation page renders its Model module from (#6396).
Make the daemon the single writer of the daemon-owned fields for
daemon-backed assistant messages (stored role=assistant with a run_id):
the PUT route now keeps the stored events / content / last-run-event id /
run status instead of the client snapshot's values. Client-owned metadata
(feedback, comment attachments, telemetry) still flows through the same
PUT.
Adds getMessage (single-message getter) and a concurrency regression
test: a real run persists its early status event, then a stale snapshot
PUT must not wipe it — red before the fix, green after.
* fix(daemon): scope stale-snapshot guard to event-list regression
The #6396 guard previously preserved stored events/content/run-status for
every daemon-backed assistant message, which broke mock-agent flows (UI P0
app-restoration): e2e suites mock the run SSE end-to-end, so the daemon
never persists events and the web client is the legitimate writer of the
assistant message's content/run-status.
Run events are append-only, so a stale snapshot can only shrink the stored
list. Narrow the guard to a no-regression rule: preserve the stored
events/content/last-run-event-id/run-status only when the incoming
snapshot would drop already-persisted events. A web write that carries at
least as many events still flows through, keeping mock-agent flows and
client metadata writes working.
Adds a regression test for the mock-agent path (web writes events and a
terminal runStatus when the daemon never persisted any).
* fix(daemon): preserve daemon ownership marker across repeated stale PUTs
The stale-snapshot guard preserved events/content/last-run-event-id/run
status but spread the incoming snapshot's runId/role unchanged. A snapshot
captured before `/api/runs` assigned a run id omits `runId`, so
upsertMessage wrote `run_id = NULL` — dropping the message back out of the
daemon-backed path on the NEXT stale PUT, which could then wipe the
protected events/content again (looper review on #6418).
Preserve the stored role + runId alongside the other daemon-owned fields
when the guard fires, and extend the regression test to replay two stale
PUTs that both omit runId, asserting the run id, events, content, and
terminal status survive both.
* fix(daemon): latch terminal run status against equal-length stale snapshots
The length-based freshness check only caught snapshots that SHRANK the
stored event list. But the daemon writes the terminal run_status in a
separate UPDATE (no event appended), so a web snapshot captured after the
final event but before that write has the SAME event count while still
carrying a non-terminal status — it previously flowed through and regressed
the stored status back to 'running', sticking conversations in a
nonterminal state (looper review on #6418).
Make terminal run status a daemon-owned latch: once the stored status is
terminal (succeeded/failed/canceled), a client PUT that carries any other
status is rejected for the daemon-owned fields. Combined with the existing
event-shrink rule, and preserving role/runId on both paths.
Adds a regression test: run completes (terminal status + events), then an
equal-length snapshot with 'running' is replayed and must not regress the
terminal status or ownership fields.
* fix(daemon): preserve run lifecycle timestamps across stale message PUTs
The stale-snapshot guard protected events/content/run-status/ownership but
a snapshot that omits startedAt/endedAt still nulled the daemon-written
run timestamps via upsertMessage — and saved.endedAt feeds persisted-run
telemetry, so a terminal message lost its timing data (looper review on
#6418).
Preserve stored startedAt/endedAt alongside the other daemon-owned fields
in the stale-write branch, and assert in the regression test that the
timestamps survive repeated stale PUTs.
* fix(daemon): let metadata updates write fresh timestamps on stale-snapshot path
Preserving startedAt/endedAt unconditionally in the stale-snapshot branch
broke the retry flow: a metadata update that carries a NEW endedAt (empty
events, matching terminal status) was treated as a stale full snapshot, so
the web's fresh completion timestamp was discarded and the daemon's was
kept (e2e retry-after-stop).
Preserve the lifecycle timestamps only when the snapshot OMITS them (the
#6396 stale case, where upsertMessage would null started_at/ended_at) and
let a snapshot that genuinely provides them write through. Events, content,
terminal status, and ownership fields keep the no-regression protection.
Adds a regression test for the metadata-update path: a fresh endedAt lands
while daemon-owned events and terminal status survive.
* fix(daemon): scope stale-snapshot lookup to the authorized conversation
Two data-integrity gaps from the #6418 guard (looper review):
1. getMessage selected globally by id, so a message id from another
conversation could be read (data disclosure in the response) and
rewritten through the wrong project/conversation endpoint. Scope the
lookup by conversation_id and reject a PUT whose message id belongs to
a different conversation.
2. The timestamp exception let a stale snapshot carrying an older endedAt
overwrite the daemon's newer value. Make lifecycle timestamps monotonic
watermarks: startedAt keeps the earliest (the daemon's first start),
endedAt only advances — while a metadata update that genuinely advances
endedAt (retry flow) still lands.
Adds regression tests: a cross-conversation message id is rejected and the
other conversation's row is untouched; monotonic timestamps preserved.
* fix(daemon): never regress daemon-written startedAt on stale message PUTs
The stale-write branch accepted an earlier startedAt from the incoming
snapshot ("earliest wins"), so a captured snapshot carrying an older start
time inflated persisted duration/telemetry even though events/content/
status were protected (looper review on #6418).
startedAt is the daemon's first start (COALESCE keeps it), so keep the
stored value unconditionally in the stale branch; endedAt stays an
advance-only watermark so the retry flow's fresh completion timestamp still
lands. Extend the stale-snapshot regression with an explicitly older
startedAt that must not replace the stored value.
* fix(daemon): keep pinned runId on stale pre-run message PUTs
A daemon-backed row pinned by /api/runs (runId set, runStatus queued, no
events yet) followed by a stale web snapshot that omits runId used to have
its run_id nulled: neither shrinksEvents nor regressesTerminalStatus fires
on an empty event list with a non-terminal status, so the early return
spread the runId-less snapshot unchanged. The row then dropped out of the
protected path and the next stale PUT could wipe events once they landed.
The no-regression early return now preserves the daemon-ownership markers
(role + runId) while still letting the client's non-terminal status and
metadata flow. Regression test drives the real PUT route (#6418 review).
* fix(daemon): let same-message retries with a new runId replace the failed attempt
The stale-write guard added for #6418 correctly preserved role+runId on the
no-regression early return, but it also caught legitimate same-message
retries: side-chat retries reuse the failed assistant's id, and
pinAssistantMessageOnRunCreate assigns a NEW run id while keeping the old
terminal status/events/content until the retry's final PUT. The guard then
routed that snapshot into the stale branch, keeping the retry stuck on the
previous attempt's failure.
A snapshot carrying a different runId is the retry (not a stale copy of the
old run), so it now flows through untouched. Regression test drives the real
PUT route (nettee review on #6418).
* fix(daemon): reset run generation at pin time so retries replace the failed attempt
The same-message retry path stays stuck on the previous failure: after
pinAssistantMessageOnRunCreate rebinds a terminal assistant row to a new run
id, the retry's final PUT shares that runId, so the no-regression guard
classifies it as a stale shrink/status regression and restores the old
failure. A runId carve-out in the guard cannot fix this (the ids already
match post-pin) and would open a stale-write hole for old-run snapshots.
Make pin the generation boundary instead: when a run creation rebinds an
existing assistant message, reset the run-owned fields to this run's start —
run_status to the (non-terminal) run status, events/content cleared, ended_at
nulled, started_at set to the run's createdAt. The retry's final PUT then
flows through the guard normally. Also preserve pin-written startedAt on the
eventless early-return path.
Tests model the post-pin shape (same runId, fewer events, fresh terminal
status) and assert the pin-written start time survives a stale pre-run
snapshot (mrcfps review on #6418).
* fix(daemon): scope run pin to the conversation and clear last_run_event_id on rebind
Addresses the four findings on #6418 (mrcfps + nettee):
- pinAssistantMessageOnRunCreate now scopes both the SELECT and the UPDATE to
the run's own conversation (and skips a no-row pin whose message id belongs
to another conversation), so a mis-scoped run can no longer wipe another
conversation's assistant message (nettee P1).
- A rebind to a NEW run id now also clears last_run_event_id, so a reattach
never resumes from a superseded run's event cursor (mrcfps P1).
- A same-run re-pin (AMR recharge-resume) only refreshes status/context and
keeps the persisted transcript (mrcfps P2).
- mergeMessageWriteForDaemonBacked discards a delayed PUT from a superseded
run generation (incoming.runId differs from stored.runId), keeping the
current generation's run fields while letting metadata land (nettee P2).
Tests: new pin unit suite (rebind reset / resume keep / cross-conversation
skip) and a stale-message e2e case for the old-generation discard.
* fix(daemon): enforce assistant-message ownership and resume lifecycle timestamps
Addresses the three data-integrity blockers on #6418 (nettee 8/8):
- Ownership end-to-end: POST /api/runs and /api/chat now reject an
assistantMessageId that references a user message (409
INVALID_ASSISTANT_MESSAGE) or an assistant message in another conversation
(409 IDEMPOTENCY_CONFLICT), and pinAssistantMessageOnRunCreate scopes its
lookup by role too - a mis-scoped run can no longer pin/append/finalize a
row it does not own.
- Recharge resume: the same-run pin branch clears ended_at so the resumed
completion records a fresh terminal timestamp instead of the prior
failure's end.
- Stale terminal snapshot: the PUT guard discards a terminal failed snapshot
when the stored row is non-terminal (i.e. after a same-run resume), so the
resumed run cannot relatch the old failure; the current run's failure is
finalized by the daemon, never by a web PUT.
Tests: route ownership rejections (foreign conversation + user row), pin
unit ended_at clear, and a stale-message e2e case for the resume relatch.
* fix(daemon): reject unverifiable assistantMessageId and active-run rebinds
Addresses the two additional ownership/lifecycle hazards on #6418 (nettee 8/8):
- POST /api/runs and /api/chat now reject a supplied assistantMessageId when
no conversation can be resolved (400) — without a conversation there is
nothing to validate ownership against, and the run would mutate a foreign
row via the id-only writers.
- Both routes also reject an assistantMessageId whose existing row is owned
by a still-active (queued/running) run (409 RUN_IN_PROGRESS), so two
concurrent runs sharing the id cannot reset each other's in-flight
generation. pinAssistantMessageOnRunCreate adds the same skip as defense in
depth.
Tests: route regressions for no-conversation (400) and active-run rebind
(409), plus a pin unit case asserting an active row's transcript is never
generation-reset.
* fix(daemon): allow runId-less web placeholders through the active-run guard
The active-run rebind guard broke the normal pre-run flow: the web persists an
assistant placeholder with runStatus set but no run bound yet, then submits
the run with that assistantMessageId. The guard read runStatus only and
rejected it as RUN_IN_PROGRESS, failing the e2e dialog suites.
Scope the guard to rows bound to a REAL run (run_id non-empty): only those can
be owned by a still-active concurrent run. A runId-less placeholder stays
rebindable. Same condition applied to the pin-side defense-in-depth skip.
Tests: pin unit case for a runId-less placeholder binding to the new run;
existing active-run rebind rejection (with a real runId) unchanged.
* fix(daemon): keep web-persisted placeholder startedAt on first pin
The generation-reset rebind overwrote started_at with the run's createdAt for
a runId-less web placeholder, clobbering the turn's own start time the UI had
already persisted (retry-after-stop e2e asserted the stopped attempt keeps its
startedAt).
Only a retry that rebinds an OLD-run row resets started_at to the new run's
createdAt; a runId-less placeholder keeps its web-persisted start via
COALESCE. Pin unit test updated to assert the placeholder start time survives.
* fix(daemon): make rebind concurrency guard daemon-aware; drop staleResumeFailure
The route-level active-run guard was rejecting the normal UI flow: the web
persists a runId-less assistant placeholder, submits the run, and after a
failure clicks retry — the guard read the message row's own runStatus and
rejected both as RUN_IN_PROGRESS, failing the dialog/workspace-restoration
e2e suites. The message row's runStatus is not authoritative for concurrency.
- The active-run reject now consults design.runs.get(runId): only when the
daemon STILL has that run active is the rebind rejected (409
RUN_IN_PROGRESS). A runId-less placeholder, or a run the daemon has already
finished (normal retry), stays rebindable.
- Drop the staleResumeFailure heuristic: it treated every non-terminal stored
row + incoming failed as a stale post-resume snapshot, which also swallowed
a genuine run failure (stored queued -> incoming failed) and left the
message stuck non-terminal, breaking the retry chain. Genuine failures now
persist again; post-resume stale-snapshot relatch is left to a future
daemon-side generation boundary rather than an unreliable heuristic.
Tests: retry/placeholder rebind now asserts the daemon-no-longer-owns case
succeeds (no RUN_IN_PROGRESS); the staleResumeFailure-only test is removed.
* fix(daemon): exempt idempotent retries and confirm failures via daemon state
Addresses the nettee re-review on b4c33bf:
- The active-run rebind guard now exempts a same-clientRequestId idempotent
retry: when the daemon run's clientRequestId matches the request, the daemon
reuses the original run and must not be rejected as RUN_IN_PROGRESS
(run-request-idempotency suite).
- The post-resume stale-snapshot guard is now daemon-aware: a terminal failed
snapshot against a non-terminal stored row is discarded only when the daemon
does NOT report the run as failed (stale pre-resume copy). A failure the
daemon confirms (written via reconcileAssistantMessageOnRunEnd) still lands,
so genuine run failures persist and the retry chain works.
run-request-idempotency (6) + stale-message + pin + cross-project (20) pass;
daemon typecheck clean.
* fix(daemon): make assistant-message ownership atomic at run creation
The core concurrency hole (PerishCode): two runs sharing an assistantMessageId
could both create and write through the same message row, because the pin was
a void best-effort — on detecting an active owner it skipped, and the run
started anyway. Every route-level heuristic to close it mis-fired on the
normal UI flow (placeholder, retry, idempotent retry).
Make ownership atomic: pinAssistantMessageOnRunCreate is now an atomic claim
inside db.transaction().immediate(), executing a single conditional UPDATE
(WHERE run_id IS NULL OR run_id = <this run> OR run_status IN
(succeeded,failed,canceled)) that returns changes>0. A claim failure drops
the just-created run (design.runs.drop) and rejects with 409 RUN_IN_PROGRESS,
so no run ever starts without owning its assistant message. The racy
design.runs.get pre-checks are removed; placeholder/retry/idempotent semantics
fall out of the claim's WHERE conditions (same-run refresh, terminal rebind
generation reset, placeholder startedAt preservation).
Tests: run-atomic-ownership.test.ts — two concurrent POSTs with the same
assistantMessageId resolve to exactly [202, 409], only the winner spawns a
child, and a web-persisted placeholder startedAt survives the first claim.
Existing pin/stale-message/cross-project/idempotency suites (69 total) pass.
* fix(daemon): arbitrate terminal writes, claim before seed, never drop resumed runs
Addresses nettee's 8/10 review on the atomic-ownership head:
- Terminal-write arbitration (conversations.ts): the guard now keys on ALL
client terminal statuses against what the daemon positively knows. A daemon
it does not know (mock/client-owned row) accepts the client terminal write;
a daemon-owned run still active preserves every daemon field (no premature
succeeded/failed/canceled latch, no reopen for a competing claim); a daemon
terminal that disagrees is authoritative. This stops both discarded
mock-path failures and premature-terminal rebinds.
- Claim before seed (runs.ts POST /api/runs): the atomic ownership claim runs
BEFORE user-message seeding, so a rejected concurrent run never leaves an
orphan user turn.
- Resume safety (runs.ts + chat-run-messages.ts): a resumed run's claim runs
before prepareRestart and only freshly-created losers are dropped — a
resumable run that loses a claim stays terminal + resumable instead of being
deleted. pinAssistantMessageOnRunCreate gains a status override so a resume
claim writes the post-restart queued intent.
Tests: status-override pin unit; concurrent orphan-user-turn route case.
71 tests + typecheck pass.
* fix(daemon): key terminal arbitration on daemon state, not stored status presence
nettee 8/10 blocking: a stale whole-message PUT that omits runStatus used to
null the daemon-owned status column, which reopened the terminal gate — a
later premature terminal PUT could then latch succeeded/failed onto an active
daemon run and make the row re-claimable.
- Terminal arbitration now keys on the daemon lookup for ANY non-terminal (or
status-less) stored row, not only when stored.runStatus is defined.
- The accepted (no-regression) merge preserves the stored run status when the
snapshot omits it, and applies the same monotonic endedAt watermark.
- New integration regression: status-less PUT -> premature terminal PUT against
an active run -> competing claim is still rejected with 409.
72 tests + typecheck pass.
* fix(daemon): return ok from the workspace-gate claim stub
The atomic claim contract changed from void to { ok, reason }; the
run-create-workspace-gate suite's injected stub still returned void, so every
created run was treated as an unclaimed loser (31 failures). Return { ok: true }
from the stub — the suite is back to its 3 pre-existing BYOK failures.
* fix(daemon): preserve role on the terminal-arbitration preserve path
The arbitration preserve return was missing role: stored.role (sibling preserve
paths already restore it), which could let a stale snapshot carry a different
role. Restore it for consistency with the daemon-ownership marker.
* fix(daemon): preserve run seed message order
* fix(daemon): reject aliased run message ids
* fix(daemon): preserve stale write event cursor
* fix(daemon): preserve placeholder content on run claim
* fix(daemon): validate chat assistant conversation claims
* fix(daemon): harden run message ownership edge cases
* fix(daemon): preserve content on equal-length stale snapshots
* fix(daemon): protect partial equal-length content snapshots
* fix(daemon): preserve daemon content on event growth
* fix(daemon): atomically seed claimed run user turn
The system-prompt composer rendered each pipeline stage's atoms
independently, so an atom declared by multiple stages (od-default
declares discovery-question-form in both task-type and discovery) had
its full SKILL.md body inlined once per stage — duplicating ~70 lines
in every default free-form Home run's system prompt.
Add renderActiveStageBlocks to @open-design/contracts: it renders all
stage blocks while inlining each atom body exactly once; a later stage
that re-declares an atom keeps its subsection header but carries a
one-line back-reference to the stage that inlined the body. Stage
headers stay per spec §23.4. The server's activeStageBlocks build now
goes through this builder, so any plugin sharing atoms across stages is
handled at composition time.
Fixes#6238
Co-authored-by: John Doe <johndoe@example.com>
* test(e2e): harden coverage and runtime isolation
* test(e2e): gate prerelease on full functional suite
* test(daemon): enforce prerelease coverage
* test(daemon): stabilize full-suite fixtures
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* test(e2e): preserve interaction regression coverage
Keep deck navigation subject to Playwright actionability checks and restore the current rail updater/composer stacking witness as an explicit expected failure.
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* fix(ci): close conformance validation gaps
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* test(e2e): restore MCP snippet style guard
Restore the browser-computed style witness that was over-deleted when the OAuth flow moved to Integrations, and navigate through the current Settings MCP server entry.\n\nGenerated-By: looper 0.11.8 (runner=fixer, agent=codex)
* fix(ci): close directive and job-boundary gaps
Compare normalized MUST_FIX directives as sets so identical multi-item rounds remain weak debates, and bound workflow topology assertions to their immediate sibling jobs.\n\nGenerated-By: looper 0.11.8 (runner=fixer, agent=codex)
The form-answer transition block already embeds the trimmed currentPrompt,
so appending body = currentPrompt after it on the resume path shipped the
submitted answers twice in the same # User request. Return the transition
alone on that branch; the non-resume path is unchanged.
Fixes#6239
Co-authored-by: John Doe <johndoe@example.com>
* refactor(collab): extract the team resource listing cache out of server.ts
Pure move, no behavior change: `cachedTeamResourceList` and its scope key
leave the 11k-line server.ts closure for the module that already owns the
listing caches. Nothing about the SWR window, the sync fan-out, or the
invalidation path changes here — the move is what makes the fan-out
testable in the next commit.
* test(collab): pin the vela fan-out concurrency cap (red)
Both collab fan-outs are unbounded today: the peak equals the data size,
not any daemon-owned limit. On this commit the two cap specs fail with
peak=60 (one per shared resource) and peak=40 (one per dirty project).
The three sibling specs — every resource materialized exactly once, a
failing pull still surfacing, every dirty project eventually published —
pass here and must keep passing once the cap lands.
* fix(collab): bound the two vela fan-outs to a daemon-owned cap
Both collab fan-outs were sized by user data rather than by the machine.
Materializing a workspace's shared resources ran one `vela resource pull`
per shared design system / plugin / skill, all at once, on a single
listing read. Publishing ran one `vela resource push` per project whose
debounce expired in the same tick — per-project re-entry was guarded,
across projects nothing was. A workspace that shares 200 resources
therefore decided how many concurrent transfers this daemon opened.
Route both through one `ConcurrencyGate`. Every operation still runs,
exactly once, and still propagates its own failure to its own caller;
only the peak changes. The uncontended path stays synchronous so callers
that observe "the adapter was invoked by the time this returned" — the
run-boundary flush — behave as before.
The scheduler distinguishes queued from reading: a publish waiting for a
slot has not looked at the project yet, so a change arriving in that
window updates the reason it will publish under instead of stacking a
second publish behind it. Once the adapter is reading, the pre-existing
dirty/re-publish path is unchanged.
Cap specs went red at peak=60 and peak=40 on the parent commit.
* fix(collab): size the fan-out cap at 8, matching the hub's own bound
Measured against real child processes rather than promises: a 40-resource
fan-out peaked at 40 concurrent processes before the cap, 4 at cap=4, and
8 at cap=8. The latency cost is real and worth stating — 457ms unbounded,
4235ms at 4, 2152ms at 8 — so pick the bound the hub already puts on its
own per-request fan-out (MAX_BLOB_INSPECTION_CONCURRENCY) instead of a
tighter number neither side would recognize.
* fix(collab): give every resource kind one shared materialization budget
The gate was built inside createTeamResourceListCache, and server.ts
builds three of those caches — design systems, plugins, skills. One
client poll refreshes all three, so each acquired its own eight slots and
the daemon's real peak was cap x 3. The PR claimed a peak of eight; it
was twenty-four.
Move the gate to the composition root and inject it, so the bound is a
property of the daemon rather than of each listing surface. Measured with
real child processes across the three caches: 24 before, 8 after, with
all 60 resources still materialized.
The publish scheduler keeps its own gate: pulls and pushes are opposite
directions with different latency sensitivity, and coupling them would
let a large listing refresh delay a teammate's publish. The daemon-wide
worst case from these two sites is therefore 8 + 8, which is what the
docs now say instead of an unqualified 8.
Reported by PerishCode on #6711.
* docs(collab): state the real daemon-wide peak, not a per-site number
* feat(media): route Open Design Cloud generation through Vela
Add Vela image and video adapters behind od media generate with trusted workspace attribution, multi-image support, video polling, and fail-closed behavior.
Expose AMR-specific media defaults and pin the packaged Vela CLI used by the integration.
* fix(nix): refresh pnpm dependency hashes
Refresh the daemon and web fixed-output hashes after the bundled Vela CLI lockfile update. The values come from the failed Nix CI derivations.
* chore(pack): bundle Vela CLI 0.0.31-test.0
0.0.29-test.0 is the build QA hit the 413 with: an agent that reads a
generated image turns it into base64 for the next model request, and a
1.7 MB PNG became 2.13 MiB of attachment that the ingress in front of Link
rejected. 0.0.31-test.0 configures OpenCode to downscale read images to
512px and cap them at 1 MiB of base64.
Verified against this build: the same 1,678,735-byte PNG now reaches the
model as a 512x288 attachment, 326,762 bytes of base64 -- 6.85x smaller --
and the model still describes the image correctly from it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDg4zffeaNboRdhAh17evB
* chore(pack): bundle Vela CLI 0.0.31-test.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDg4zffeaNboRdhAh17evB
* feat(media): request published aspect ratios from Vela
The Vela image adapter refused every aspect but 1:1 and then sent no size at
all, so the one shape it did accept was still whatever the provider defaulted
to -- the guard proved nothing about the output. Vela now publishes
(aspect_ratio, resolution) profiles per model and per request kind, and all
five image models carry 1:1, 16:9, 9:16, 4:3, and 3:4.
Resolve the pair from `vela media models --json` and pass --aspect-ratio and
--resolution, which the CLI requires together. When one aspect ratio publishes
several resolutions, prefer the model's default tier: asking for a shape should
not quietly change quality or price. When the requested ratio is not published,
fail with the ratios that are, before spawning a render.
The lookup runs per request rather than from a cache. Vela owns which shapes a
model can deliver, so a catalogue that gains or loses one has to take effect
immediately, and one CLI call is nothing beside the generation behind it.
The prompt contract already advertised these five ratios, so this closes the
gap where the prompt promised what the adapter rejected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDg4zffeaNboRdhAh17evB
* fix(media): harden multimodal generation and delivery (#6607)
* fix(media): harden multimodal generation and delivery
Use daemon-authored artifact paths for created and modified outputs.
Preserve media references through CLI staging and make Cloud model routing
deterministic. Recover one transient post-tool continuation, surface useful
generation progress, and pin the verified Vela CLI package.
* fix(web): make produced rows keyboard accessible
Expose the row-level open action to keyboard and assistive technology users
while preserving the nested Open and Download controls. Cover Enter and
Space activation in the component regression test.
* build(nix): refresh pnpm dependency hashes
Update the daemon and web fixed-output hashes after the bundled Vela CLI lockfile change.
---------
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
* fix(media): keep image errors user-friendly and diagnosable
* feat(media): forward quality and resolution to Vela images
`od media generate` had no way to name an image quality tier or output
resolution, so neither reached the Vela CLI. A user asking for "high
quality, 1K" got whatever the model's default profile produced, with the
request surviving only as prose inside the prompt.
Thread both through the existing layers -- CLI flag whitelist, the
generate route, generateMedia, MediaContext -- and append them to the
`vela image gen|edit` argv.
Unspecified stays unspecified. Tiers are priced more than an order of
magnitude apart, so substituting a tier for a caller who named none would
silently re-price every unqualified request; omitting `--quality` is what
leaves the model's own default in charge. Resolution keeps its existing
default-profile selection, because Vela's CLI refuses a resolution
without an aspect ratio and an aspect ratio is always sent.
Both values are validated against `vela media models` before spawning:
quality is published per model (only gpt-image-2 is tiered today) and
resolution must be published for the requested aspect. Failing here costs
nothing, versus a round trip that ends in a provider rejection.
Bump the bundled Vela CLI to 0.0.32, the first release whose `image
gen`/`image edit` publish `--quality`.
* feat(media): teach the agent to map a named size or tier onto the flags
The forwarding change added `--quality` / `--resolution` and told the agent
when NOT to send them, but never said what counts as the user asking. A
request for "2k" came back as `16:9 2K · model default quality`: the size
survived only as prose inside the prompt, where the provider never sees it.
Name the mapping in both prompt copies, in one sentence each. The golden
system-prompt matrix tracks total prompt length, so its two character
counts move with the added text.
* fix(media): match published Vela resolutions and tiers case-insensitively
A real run asked for `--resolution 2k`. Vela publishes `2K`, the lookup
compared the two exactly, and the request failed before reaching the
provider; the agent read the supported value out of the error and retried
ten seconds later with the shifted letter.
Fold case on both sides when matching, and put the catalogue's own spelling
on the wire rather than the caller's. No published value differs from
another by case alone, so nothing becomes ambiguous, and a request that
names a shape or tier the model genuinely lacks still fails the same way.
---------
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: a1chzt <chizblank@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>