Commit graph

22 commits

Author SHA1 Message Date
Denis Redozubov
f4c5d22f22
fix(daemon): confine sandbox project roots and host discovery (#3243)
* fix(daemon): confine sandbox project and host discovery

* fix(daemon): resolve sandbox data dir for toolchain discovery

* fix(daemon): resolve sandbox data dir for agent env

* fix(daemon): fail fast for sandbox imported folders

* test(daemon): assert sandbox imported folder rejection

* fix(daemon): keep sandbox import guard at run start

* fix(daemon): reject sandbox imported project file roots

* fix(daemon): preserve imported project detail roots

* test(daemon): expect sandbox profiles to stay scoped

* fix(daemon): bypass proxies for agent tool callbacks

* test(daemon): isolate media policy route memory extraction

* fix(daemon): keep loopback no-proxy scoped to sandbox
2026-05-30 16:57:04 +00:00
Denis Redozubov
c7f55388a9
fix: ignore generated project dirs before watching (#2838) 2026-05-25 06:32:45 +00:00
lefarcen
c80acfefeb
fix(daemon,web): block pitch-deck placeholder publishes and unbreak framework decks (#2384)
Two preview-time bugs surfaced ahead of 0.8.0:

1. Pitch-deck example (#2215): the official html-ppt-pitch-deck prompt asked
   the agent to confirm three facts first, but the manifest had no
   structured `od.inputs`, so the platform's required-input gate had no
   fields to enforce and the run could publish HTML that still contained
   unresolved fundraising placeholders (`Name to confirm`, `$X.XM`,
   `Replace this panel with`, ...). Add structured required inputs to the
   manifest and a daemon-side publication guard that rejects HTML/deck
   artifact writes whose body still contains those placeholders. Scope is
   the file-write boundary only (no assistant-text scanning), so the
   guard cannot trip on the agent's chat prose mid-clarification.

2. Framework deck preview off-screen: `injectDeckBridge` injected
   `place-content: center !important` on `.deck-shell` for every deck-mode
   srcdoc, which forced the framework's `display: grid` shell to re-center
   its implicit track. The framework's `fit()` already centers a
   `transform-origin: top left` stage with an explicit `translate(tx, ty)`
   that assumes the stage's natural layout position is (0, 0); the two
   centerings stacked and the scaled stage landed ~1000px off-screen, so
   the preview showed a sliver of slide content in the top-left with the
   rest black. Skip the override when the framework's `id="deck-stage"`
   marker is in the doc, and drop the dead `display: grid; place-items:
   center` from the deck framework template so future drift can't
   re-introduce the same stack.
2026-05-20 16:20:34 +08:00
kami
dc4d8cdddb
fix(daemon): reconcile missing artifact manifests (#2068)
* fix(daemon): reconcile missing artifact manifests

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): preserve reconciled artifact mtimes

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 10:41:08 +08:00
Caprika
7975514b3d
Add normal artifact creation via MCP and CLI (#2057) 2026-05-18 17:50:38 +08:00
huyhoangnhh98
140a4e1ff6
Improve responsive preview and design handoff outputs (#1224)
* feat: improve responsive design handoff

* feat: refine cross-platform design outputs

Changelog:\n- Add auto-fit responsive preview behavior for tablet/mobile frames.\n- Add landing page and OS widgets metadata options with project header chips.\n- Strengthen prompt contracts for modern breakpoints, app-specific modules, CJX-ready UX, and final product surfaces.\n- Require cross-platform outputs to use separate platform files instead of tabbed demo selectors.\n- Add DESIGN-MANIFEST.json plus richer handoff guidance to daemon/client exports.\n- Update archive/export tests for manifest and responsive viewport matrix.

* feat: enforce screen-file design outputs

Changelog:\n- Enforce screen-file-first generation for landing pages, app screens, platform surfaces, and OS widgets.\n- Update design handoff and manifest exports so coding tools map each screen file to separate routes/surfaces.\n- Strengthen minimal-brief visual guidance to avoid monochrome or unstyled design outputs.

* fix: address responsive handoff review feedback

* fix: address handoff review blockers

* fix: preserve proxy auth and normalized export entry

* fix: narrow frame wrapper filter to directory paths only

* fix: make artifact save failure banner generic

---------

Co-authored-by: Huy Hoàng <macos@MacBook-Pro-Hoang.local>
2026-05-12 14:18:33 +08:00
Sebastian Westberg
8962088c75
feat(daemon): guard against agent-emitted stub artifact regressions (#1171)
* feat(daemon): guard against agent-emitted stub artifact regressions

When an agent emits an <artifact> block whose body is a placeholder
("see other-file.html in this project", a bare filename string, a tiny
fallback page) instead of the full document, the daemon writes the
placeholder to disk verbatim. Users see a 25-500 byte HTML file where
their previous version had tens of kilobytes of real markup.

Add a structural regression guard in writeProjectFile: before writing
an html/deck artifact whose manifest carries metadata.identifier, scan
the project dir for prior siblings matching <identifier>(-\d+)?\.html?
and compare sizes. If the new body is below minRetainedRatio (default
0.2) of the largest prior sibling >= minPriorBytes (default 4096),
flag a regression. Three modes via env:

- OD_ARTIFACT_STUB_GUARD=warn (default) writes the file and attaches
  stubGuardWarning to the response so the frontend can surface it.
- OD_ARTIFACT_STUB_GUARD=reject throws ArtifactRegressionError before
  fs.writeFile; the route returns 422 ARTIFACT_REGRESSION with the
  prior sibling's name and size in error.details.
- OD_ARTIFACT_STUB_GUARD=off skips the guard entirely.

Cross-agent by design: anchored on size delta + identifier match,
no agent-specific stub-phrase regex, so works for any agent backend
behind the agent-adapter abstraction.

The body-then-manifest write order pre-dates this change; the reject
path throws before fs.writeFile so rejections never leave a partial
state behind.

24 unit + 8 HTTP tests cover happy paths, all three modes, deck kind,
.htm extension sibling detection, ratio=1 edge case, and verify
rejected writes leave neither the html nor its manifest sidecar on
disk.

* fix(stub-guard): close same-name, nested-dir, and non-slug bypasses

Code review on PR #1171 (lefarcen, Codex, mrcfps) found three holes
where the stub guard could be silently bypassed. All three are now
closed with HTTP test coverage.

Same-name overwrite (lefarcen P1): the writer's prior-sibling scan
deliberately skipped the file at safeName, but for an in-session
overwrite (persistArtifact reuses the same fileName when
savedArtifactRef.current matches) that file is the prior content,
not the new entry. Drop the exclude-by-name filter; the current
on-disk size at scan time is always the prior because the overwrite
happens after this check.

Subdirectory scoping (Codex/mrcfps P2): writeProjectFile creates
parent directories for nested paths like reports/overview.html, but
the guard only scanned the project root. Pass path.dirname(target)
as scanDir so nested artifacts are evaluated against their real
sibling set.

Non-slug identifier (Codex/lefarcen/mrcfps P2): the web's
persistArtifact slugifies the filename basename but stores the raw
identifier in the manifest, so an identifier like "Landing Page"
yields filename landing-page.html with metadata.identifier="Landing
Page". Build the sibling regex from both the raw identifier and a
slugified variant (mirroring the frontend's slugifier) so either
form matches the same priors.

Also surface warn-mode warnings in the web UI: ProjectView now
checks file.stubGuardWarning after writeProjectTextFile and renders
the warning via setError. Reject-mode 422 surfacing requires
restructuring writeProjectTextFile's return contract and is
deferred.

API change inside the daemon: evaluateArtifactStubGuard /
findPriorArtifactSiblings drop excludeSafeName and rename projectDir
to scanDir. Tests updated.

Tests: 4 new HTTP cases (same-name overwrite preserves prior body,
nested subdir rejects, slug-form match rejects, plus the existing
warn/off/deck/.htm cases) and 1 new unit case (slug-form sibling
match). 44 tests pass.

* fix(stub-guard): empty-slug fallback + reject-mode UI surface

Round 3 review on PR #1171 (lefarcen, mrcfps) found two remaining
holes after 9cc82430 closed the same-name / subdir / non-slug bypasses.

Empty-slug fallback bypass (lefarcen P2): an identifier like "测试"
(all-non-ASCII) strips to empty through the web slugifier, and
persistArtifact's `slice(0,60) || 'artifact'` falls back to the
literal "artifact" basename. The guard searched for raw identifier +
slug only, so a later artifact-2.html stub bypassed the prior. Add
EMPTY_SLUG_FALLBACK_NAME = 'artifact' as a sibling-name candidate
when the slug is empty, mirroring the frontend fallback exactly.

Reject-mode UI silence (mrcfps P2 + lefarcen P2): writeProjectTextFile
collapses any non-OK response (including 422 ARTIFACT_REGRESSION) to
null, and persistArtifact previously had no else branch. Users in
reject mode saw the daemon log fire but the UI was silent. Add an
else branch that surfaces a generic banner pointing at the most
likely cause and mentions checking the daemon logs for structured
details. Also clear savedArtifactRef.current on failure so retries
re-enter the persistence path.

Plumbing the structured 422 details through writeProjectTextFile
itself remains out of scope (cross-cutting client contract change
affecting 5+ call sites). The generic banner is the "at minimum"
path mrcfps suggested.

Tests: 1 new unit case (artifact.html sibling discovery for non-ASCII
identifier) + 1 new HTTP case (empty-slug stub regression rejected
end-to-end). 46 tests pass across stub-guard suites (was 44).

* fix(stub-guard): verify sidecar identity to avoid cross-identifier false positives

Round 4 review on PR #1171 (mrcfps inline + lefarcen review) caught
a false-positive introduced by the round-3 empty-slug fallback. Two
distinct identifiers that both slugify to empty (e.g. "测试" and
"首页") share the artifact*.html basename, so a brand-new save under
the second identifier was being compared against — and falsely
rejected because of — the unrelated first.

The same shape exists symmetrically: a non-empty-slug identifier
literally named "artifact" would falsely match empty-slug fallback
files written under any other identifier.

Fix: filename pattern matching is now a candidate generator, not
the source of truth. For every candidate sibling, read its
.artifact.json sidecar and verify metadata.identifier matches the
input via artifactIdentifiersMatch (raw equality OR shared non-empty
slug). Files without a sidecar are skipped — they weren't written
through the artifact-tag path this guard targets, and treating them
as priors was always a stretch.

Empty-slug equivalence is intentionally NOT honored: 测试 != 首页
even though both slugify to empty. The whole bug was conflating
distinct identifiers via the fallback name; slug-equivalence kicks
in only for non-empty slugs (Landing Page <-> landing-page).

Tests: unit fixtures now write file+sidecar pairs (mirrors prod);
new artifactIdentifiersMatch suite covers the 5 equivalence cases;
new HTTP test does NOT cross-reject distinct empty-slug identifiers
asserts the second save returns 200 instead of 422; new unit test
skips files without a sidecar.

42 tests pass across stub-guard suites.

* fix(stub-guard): require canonical-form anchor in identifier match to avoid 60-char truncation collisions

Round 5 review on PR #1171 (mrcfps) caught another false-positive in
artifactIdentifiersMatch: slugifyArtifactIdentifier truncates at 60
chars, so two distinct >60-char identifiers that share their first
60 chars (e.g. "A...A1" and "A...A2", 70 chars each) slugify to the
same string and would falsely bridge. Same shape as the empty-slug
fallback bug from round 4, just at the other end of the input range.

Tighten the rule: slug-equivalence requires at least one input to BE
its own canonical slug form. That keeps the legitimate bridge
("Landing Page" <-> "landing-page" — second input IS the slug) but
rejects truncation collisions ("A...A1" <-> "A...A2" — neither is in
canonical form).

Side effect: two non-canonical forms that slugify to the same value
no longer bridge (e.g. "Landing Page" vs "LANDING-PAGE"). This is
correct: without one canonical anchor we can't safely call them the
same lineage. Updated the slug-equivalence test to assert the new
semantics explicitly with both directions and a negative case.

Tests: 2 new cases (no bridge for >60-char truncation collision; raw
70-char to its 60-char truncated slug still bridges) + 1 negative
test for the non-canonical-pair case. 45 tests pass.

* fix(stub-guard): cover legacy sidecar-less HTML priors

Round 6 review on PR #1171 (mrcfps, non-blocking) caught a real
legacy bypass: round 4's sidecar-required policy skipped any HTML
file without an .artifact.json companion, but readManifestForPath
(projects.ts) treats those same files as legitimate artifacts via
inferLegacyManifest. So a project with an older sidecar-less
dashboard.html (pre-sidecar era, Write-tool-emitted, paste-text,
manual import, etc.) let its first stub rewrite through as a
supposed "first emission".

Fix: when the sidecar is missing, derive a synthetic identifier
from the filename (strip the (-N)?\.html? suffix) and run it
through the same artifactIdentifiersMatch rules. Synthetic
identifiers come from already-slugified filenames, so they bridge
raw inputs only via the canonical-form rule established in round
5 — no truncation collisions, no empty-slug conflation, no
unrelated cross-identifier matches.

Tests: 3 new unit cases (legacy fallback finds the prior; bridges
raw->slug under the same rules; does NOT bridge unrelated slug
forms via inference) + 1 new HTTP test that seeds a sidecar-less
prior via the artifact-manifest-less write path and asserts the
stub rewrite is rejected with 422 ARTIFACT_REGRESSION.

48 tests pass across stub-guard suites (was 45).

* fix(stub-guard): try both interpretations for legacy filename inference

Round 7 review on PR #1171 (mrcfps, non-blocking) caught a real
ambiguity in the round-6 legacy fallback: a filename like
`phase-2.html` is genuinely ambiguous without a sidecar. It could
be the identifier "phase" with a -2 collision suffix, OR the
standalone identifier "phase-2". The round-6 helper only stripped
the suffix, so a sidecar-less `phase-2.html` followed by a stub
emission with metadata.identifier="phase-2" bypassed the guard
("phase-2" doesn't match the inferred "phase").

Fix: when the sidecar is missing, generate both candidate
identifiers (full basename and suffix-stripped basename) and
accept the file as a prior if either matches. Visible false
positives are preferable to silent false negatives — and the
canonical-form anchor in artifactIdentifiersMatch still rules out
truncation collisions and empty-slug conflations regardless of
which candidate matched.

Tests: 2 new unit cases (full-basename interpretation finds
"phase-2"; suffix-stripped interpretation also finds "phase") and
1 new HTTP test that seeds a sidecar-less `phase-2.html` and
asserts the stub rewrite is rejected with 422 ARTIFACT_REGRESSION.

51 tests pass across stub-guard suites (was 48).

---------

Co-authored-by: Sebastian Westberg <sebastianwestberg@users.noreply.github.com>
2026-05-11 19:59:37 +08:00
nettee
b1d440d2bd
refactor(daemon): split route registration (#1043)
* spec

* refactor(daemon): split server route registrars

* refactor(daemon): group route registrar dependencies

* refactor(daemon): move remaining domain routes out of server

* update doc

* revert spec

* fix daemon route context contract

Generated-By: looper 0.5.6 (runner=fixer, agent=opencode)

* fix media task persistence

Generated-By: looper 0.5.6 (runner=fixer, agent=opencode)

* fix: restore daemon route registrations

* fix: restore static resource mutation origin checks
2026-05-11 15:00:23 +08:00
Arya Kaushal
9079c51ba3
feat(daemon): HTTP 206 range request support for video/audio files Fixes #784 (#1105)
* feat(daemon): HTTP 206 range request support for video/audio files (#784)

Stream video and audio via fs.createReadStream with Accept-Ranges: bytes
and 206 Partial Content responses so browsers can play and seek media
inline. Non-media files keep the existing buffer path unchanged.

Add parseByteRange (RFC 7233-compliant) and resolveProjectFilePath to
projects.ts, and 23 unit tests covering all range edge cases.

* fix(daemon): move range streaming to /raw/* route used by media viewers

The inline VideoViewer and AudioViewer components fetch
/api/projects/:id/raw/* (via projectRawUrl), not /files/*.
Apply the HTTP 206 / Accept-Ranges streaming path to the raw route
while preserving its Origin: null CORS behaviour for sandboxed iframes.

Add 7 route-level HTTP tests against a real startServer() instance
covering 200 full, 206 partial, suffix range, open-ended range, 416
unsatisfiable, non-media passthrough, and 404 cases.

---------

Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-10 12:16:52 +08:00
CIoudherd
724d071c01
feat: add design file rename support (#894)
* feat(contracts): add project file rename contract

* feat(daemon): add safe project file rename API

* feat(web): support renaming design files

* fix(daemon): handle case-only file renames

* fix(web): prevent rename collisions with pending sketches

* fix(daemon): preserve source names during rename

* test(daemon): cover rename symlink escapes

* fix(daemon): avoid clobbering rename targets

* test(web): align rename tests after rebase

* test(web): align rename tests with latest main
2026-05-09 21:24:36 +08:00
leprincep35700
c2facb0e02
fix: serve python files as text (#947)
Co-authored-by: leprincep35700 <leprincep35700@users.noreply.github.com>
2026-05-08 21:10:22 +08:00
Bryan A
e13adf2e63
feat(daemon): finalize design package endpoint (closes #450) (#832)
* feat(daemon): scaffold /api/projects/:id/finalize/anthropic (refs #450)

Phase C of the PR 2 plan for issue #450: scaffold the route + module
shape so subsequent phases (D-I) land function bodies and tests against
a stable surface that already passes typecheck.

What lands here:
- apps/daemon/src/finalize-design.ts: module-level constants
  (DEFAULT_BASE_URL, DEFAULT_MAX_TOKENS=16000, INPUT_BODY_CAP_BYTES=384KiB,
  LOCK_FILENAME=.finalize.lock, OUTPUT_FILENAME=DESIGN.md,
  DEFAULT_TIMEOUT_MS=120s); inline interfaces for the request/response
  shape (kept out of packages/contracts per scope rules); two error
  classes - FinalizePackageLockedError (mirrors PR #493's
  TranscriptExportLockedError) and FinalizeUpstreamError (carries upstream
  HTTP status for the route's error mapping); function stub that throws
  "not yet implemented".
- apps/daemon/tests/finalize-design.test.ts: vitest harness with
  describe.skip placeholder so the file imports cleanly. Real cases land
  in phases D-I. Default-import of node:fs (per memory: vi.spyOn cannot
  redefine on the frozen ESM Module Namespace; CJS exports object is
  mutable).
- apps/daemon/src/server.ts: route handler at
  POST /api/projects/:id/finalize/anthropic, slotted next to the
  existing :id/deploy* family. Validates apiKey/model non-empty, optional
  baseUrl via the existing validateExternalApiBaseUrl closure (forbidden
  -> 403, invalid -> 400), optional maxTokens positive number; calls
  getProject (404 on miss); calls finalizeDesignPackage (which throws,
  caught and mapped to 500 for now); maps known error classes
  (FinalizePackageLockedError -> 409, FinalizeUpstreamError -> 502)
  pre-emptively.

Path shape rationale (Bryan-confirmed): project-scoped path matches every
sibling /api/projects/:id/* route in server.ts (deploy, deployments,
deploy/preflight); provider-namespaced segment leaves a clean expansion
line for /api/projects/:id/finalize/openai etc. as follow-ups.

Field-name rationale: apiKey, baseUrl, model, maxTokens match
ProxyStreamRequest verbatim (packages/contracts/src/api/proxy.ts:8-19)
so a future caller can reuse the same body shape. baseUrl is optional
here (intentional divergence from the proxy at server.ts which requires
it) so standard Anthropic users do not need to set it; Bedrock /
self-hosted-proxy users still can.

Verification: pnpm --filter @open-design/daemon typecheck exits 0;
finalize-design.test.ts loads cleanly with 1 skipped placeholder; no
other tests touched.

Refs nexu-io/open-design#450 (PR 2 scaffold; pipeline body in subsequent
commits)

* feat(daemon): transcript truncation helper for /finalize prompt

Phase D of the PR 2 plan for issue #450: lands the helper that bounds
the transcript section of the synthesis prompt.

Why this exists: real-world signal at authoring time was a local project
transcript already at 3.95 MB. Anthropic's claude-opus-4-7 context cap
is roughly 200K tokens (~700 KB at typical density). Inserting an
unbounded transcript would 4xx upstream on the first real call. This
helper keeps the on-disk .transcript.jsonl lossless (PR #493's contract)
while making the prompt-inclusion bounded.

Strategy:
- Cap output at INPUT_BODY_CAP_BYTES (384 KiB) so the prompt has room
  for the system prompt + design system body + current artifact + room
  for the synthesis output.
- Always preserve the header line - it carries projectId, schemaVersion,
  conversation/message counts, attachment counts; synthesis quality
  depends on knowing the original sizes.
- Split equal byte budgets between head and tail so both project genesis
  and most-recent intent survive. Two thinking segments separated only
  by mid-session truncation lose the same kind of boundary that PR #493
  preserves between thinking blocks - that's accepted; smarter
  semantic chunking is a follow-up.
- Insert a single `{"kind":"truncated","reason":"size","omittedBytes":N}`
  sentinel JSON line between the head and tail so a synthesis consumer
  can detect the gap. omittedBytes is the difference between the
  original UTF-8 byte length and the output's UTF-8 byte length.
- If the head + tail budgets together cover the whole body (e.g. all
  message lines are tiny), no marker is emitted - the output is the
  input verbatim.

Tests:
- "returns the input verbatim when the JSONL fits under the 384 KiB cap"
  pins that small transcripts pass through unchanged with no marker.
- "head+tail truncates with a single marker line when the JSONL exceeds
  the 384 KiB cap" pins that output is bounded, header survives, exactly
  one marker emitted with non-zero omittedBytes, both ends of the body
  preserved, and at least one middle message omitted.

Suite delta: +2 tests in finalize-design.test.ts.

Refs nexu-io/open-design#450

* fix(daemon): resolve noUncheckedIndexedAccess in truncateTranscriptForPrompt

D1 (0eaa123) shipped with `body[headIndex]` and `body[i]` typed as
`string | undefined` under TypeScript's `noUncheckedIndexedAccess`
strict mode. Local typecheck would have caught it but the prior
verification piped through `tail` which masked the non-zero exit code
of `tsc`. Coalesce each access via `?? ''` (the array is from
`String.split('\n')` so undefined elements are not actually reachable;
the coalesce is a type-narrowing convenience, not a behavior change).

Verification: `pnpm --filter @open-design/daemon typecheck` exits 0;
`pnpm --filter @open-design/daemon test finalize-design` shows 2/2 +
1 skipped, identical to the pre-fix run.

Refs nexu-io/open-design#450

* feat(daemon): current-artifact resolver for /finalize

Phase E of the PR 2 plan for issue #450: resolves which artifact (if
any) accompanies the transcript + design system in the synthesis prompt.

Priority order (Bryan-locked in plan §6):
  1. The file referenced by tabs.is_active = 1 IF an
     <name>.artifact.json sidecar exists on disk. Sidecar presence is
     the discriminator: an inferred manifest from
     `inferLegacyManifest` (e.g. for a bare .html with no sidecar)
     does NOT count, and an active tab pointing at a non-artifact file
     (.md, .txt) falls through.
  2. Newest project file with a real .artifact.json sidecar, sorted by
     manifest.updatedAt descending. Files without an updatedAt sort
     last so legacy pre-streaming manifests do not get accidentally
     promoted.
  3. Returns null - "no artifact in scope". The Phase H caller will
     emit `artifact: null` in the response and the prompt's "Current
     artifact" section will read "none".

Sidecar presence is checked via `existsSync` on the on-disk path, NOT
via the `artifactManifest` field returned by readProjectFile/listFiles
(those run inferLegacyManifest as a fallback for known kinds, which
would otherwise cause a bare .html with no sidecar to look like an
artifact).

Tests:
- "returns the active-tab artifact when its sidecar is present, even
  if a newer artifact exists elsewhere": pinned.html (older
  updatedAt) is in the active tab; newer.html (newer updatedAt) is
  not. Resolver returns pinned.html - intent (active tab) beats
  recency.
- "falls through to newest .artifact.json when active tab points at a
  non-artifact file": README.md is the active tab (no sidecar);
  design.html has a real sidecar. Resolver falls through and returns
  design.html.
- "returns null when no active tab and no .artifact.json sidecars
  exist": only a README.md is in the project; no tabs row. Resolver
  returns null.

Suite delta: +3 tests in finalize-design.test.ts (5 active total).

Refs nexu-io/open-design#450

* feat(daemon): synthesis prompt construction for /finalize

Phase F of the PR 2 plan for issue #450: builds the system + user
prompts that get sent to Anthropic's Messages API in the synthesis
call. Pure function; no IO, no side effects.

System prompt (literal, stored as a module-level constant): instructs
Claude to emit a DESIGN.md document with a fixed 7-heading structure
(# DESIGN.md / ## Summary / ## Brand & Voice / ## Information
Architecture / ## Components & Patterns / ## Visual System / ## Open
Questions / ## Provenance). The Provenance section is required to list
project ID, design system, current artifact, transcript message count,
and the UTC generation timestamp.

User prompt (built at runtime): structured payload with the truncated
transcript JSONL, the design system body, and the current artifact
body, each under a ## heading. Missing inputs (no design system
selected, no artifact in scope) produce explicit "none" headings +
parenthetical placeholder body so Claude does not hallucinate content
for absent sections.

Truncation is the caller's concern - this function does not
re-truncate. The caller (Phase H pipeline) feeds in a JSONL that has
already been bounded by truncateTranscriptForPrompt.

Tests:
- "includes the transcript JSONL verbatim and the generation context":
  pins all section headings, the transcript body verbatim, the design
  system body verbatim, the artifact body verbatim, and every
  generation-context line.
- "falls back to \"none\" + parenthetical when no design system is
  selected": designSystemId=null and designSystemBody=null -> heading
  reads "## Active design system: none" with the parenthetical body.
- "falls back to \"none\" + parenthetical when no artifact is in
  scope": artifact=null -> heading reads "## Current artifact: none"
  with the parenthetical body.

Suite delta: +3 tests in finalize-design.test.ts (8 active total).

Refs nexu-io/open-design#450

* feat(daemon): Anthropic call + retry strategy for /finalize

Phase G of the PR 2 plan for issue #450: lands the upstream Claude
Messages API call with a single transient-error retry, plus the
response extractor that turns Anthropic's content array into the
DESIGN.md body.

What lands here:
- appendVersionedApiPath: inlined from the connectionTest helper at
  apps/daemon/src/connectionTest.ts:188-195 (it is not exported there).
  Appends /v1/messages when the base URL has no /vN segment, otherwise
  appends /messages directly. Same semantics; ~5 lines.
- callAnthropicWithRetry: POSTs to <base>/v1/messages with the canonical
  Anthropic headers (content-type, x-api-key, anthropic-version:
  2023-06-01) and body shape ({ model, max_tokens, system, messages,
  stream:false }). One retry on transient (HTTP 429 or 5xx); on terminal
  failure throws FinalizeUpstreamError carrying the upstream HTTP
  status and raw body text. The route handler in Phase I maps status
  to AUTH_FAILED / RATE_LIMITED / UPSTREAM_FAILED and runs the body
  through redactSecrets before exposing it as `details`.
- extractDesignMd: concatenates content[].text for every block where
  type === 'text', preserving order. Throws FinalizeUpstreamError(502)
  on three malformed-response shapes: non-object payload, missing
  content array, zero text blocks. The route handler maps the throw
  to 502 UPSTREAM_FAILED so synthesis cannot land a half-empty
  DESIGN.md on disk.
- Test-only `_sleepMs` injection on the call params so the retry-delay
  sleep is instant under vitest. Default sleep uses setTimeout.

Retry posture (1 retry on transient) is opinionated; the maintainer's
"standard exponential backoff" answer was directional and a single
retry matches the existing daemon's posture (transcript export and
connectionTest do zero retries) while staying inside the daemon's
blocking-fast posture for /finalize.

Tests:
- callAnthropicWithRetry: throws on 401 with no retry; retries once
  on 429 and resolves on second 200; throws after both 5xx attempts;
  propagates AbortError when signal is pre-aborted.
- extractDesignMd: concatenates ordered text blocks; throws on
  missing content array; throws on content with zero text blocks.

A spurious typecheck error from `exactOptionalPropertyTypes` (signal
typed as AbortSignal | undefined where RequestInit expects
AbortSignal | null) was resolved by conditionally spreading signal
into the RequestInit literal.

Suite delta: +7 tests in finalize-design.test.ts (15 active total).

Refs nexu-io/open-design#450

* feat(daemon): wire /finalize pipeline end-to-end

Phase H of the PR 2 plan for issue #450: stitches together every
phase D-G primitive into the full finalizeDesignPackage pipeline that
the route handler in Phase I will expose over HTTP.

Pipeline (in execution order, all inside a try/finally that always
releases the lockfile):
1. getProject(db, projectId): defensive 404 (the route validates first;
   this throw catches direct CLI/script callers).
2. mkdirSync(<projectDir>, { recursive: true }): some projects have DB
   rows but no on-disk dir yet (PR #493's same fix).
3. fs.openSync(.finalize.lock, 'wx'): EEXIST -> FinalizePackageLockedError
   (mirror PR #493's TranscriptExportLockedError).
4. exportProjectTranscript(db, projectsRoot, projectId, { now }): produces
   .transcript.jsonl on disk; we read the body and run it through
   truncateTranscriptForPrompt to bound the prompt-inclusion size.
5. readDesignSystem(designSystemsRoot, designSystemId): returns null when
   the project has no design_system_id selected, when the design system
   directory does not exist, or when the DESIGN.md file is missing.
6. resolveCurrentArtifact(db, projectsRoot, projectId): active tab ->
   newest .artifact.json by manifest.updatedAt -> null.
7. buildSynthesisPrompt({...}): system + user prompt (per Phase F).
8. callAnthropicWithRetry({...}): one retry on 429/5xx; throws
   FinalizeUpstreamError on terminal failure.
9. extractDesignMd(payload): concatenates content[].text blocks; throws
   FinalizeUpstreamError(502) on malformed shape.
10. Atomic write: writeFileSync({flag:'wx'}) -> reopen for fsync ->
    rename. Errors unlink tmp before rethrowing.
11. Lock release in finally (always closeSync + unlinkSync).

Bounded blocking: the function uses its own AbortController + 120s
timeout when the caller does not supply a signal. Caller-supplied
signal takes precedence.

Type tightening: switched the local Db interface to
`type Db = Database.Database` (better-sqlite3) so the function signature
is compatible with `exportProjectTranscript`'s typed parameter. Source
file already had a `better-sqlite3` import in claude-design-import area
of the daemon, so no new dependency.

Tests:
- "writes DESIGN.md atomically on the happy path": end-to-end with
  seeded project + conversation + 2 messages + design system on disk;
  asserts file at exact path + body bytes match the fetch mock.
- "response carries every documented field with correct types":
  designMdPath/bytesWritten/model/inputTokens/outputTokens/artifact/
  transcriptMessageCount/designSystemId all present and typed.
- "emits design system 'none' in the prompt when no design_system_id is
  set": fetch mock asserts on the body it receives.
- "throws FinalizePackageLockedError when .finalize.lock is already
  held": pre-create lockfile; assert throw + DESIGN.md not written +
  pre-existing lock NOT unlinked (we did not own it).
- "replaces an existing DESIGN.md atomically on a second finalize":
  inject a sentinel between two finalize calls; assert sentinel is
  gone after second run.
- "cleans up tmp file AND lock file on every error path": mock
  fs.writeFileSync to throw on the tmp path; assert no DESIGN.md.tmp.*
  remain, no DESIGN.md, no .finalize.lock.
- "uses the default https://api.anthropic.com baseUrl when baseUrl is
  omitted": fetch URL begins with the default; baseUrl=undefined path.

vi.restoreAllMocks() now runs in afterEach so the writeFileSync spy
from the cleanup test does not leak into subsequent tests.

Suite delta: +7 tests in finalize-design.test.ts (22 active total).

Refs nexu-io/open-design#450

* feat(daemon): /finalize HTTP route handler + error mapping

Phase I of the PR 2 plan for issue #450: replaces the Phase C stub's
catch-all 500 with status-aware error mapping that surfaces the right
HTTP status + error code for each documented failure mode, and adds
HTTP-layer tests that boot startServer to exercise the route's
validation branches.

Route handler changes:
- :id format guard: an inline regex matching isSafeId at
  apps/daemon/src/projects.ts:556-558 rejects unsafe ids with 400
  BAD_REQUEST before any DB or filesystem work. Without this, an id
  like 'bad!id' would either fail getProject as 404 (wrong code) or
  reach the function and throw 'invalid project id' (mapped to 500).
- FinalizeUpstreamError mapping is now status-aware:
  - upstream 401 -> 401 AUTH_FAILED
  - upstream 429 -> 429 RATE_LIMITED
  - upstream 5xx (or our own 502 sentinel for malformed responses)
    -> 502 UPSTREAM_FAILED
  In all cases the upstream raw text is run through redactSecrets so
  the apiKey cannot leak through `details` even if the upstream
  echoes the inbound headers.
- AbortError mapping: when the 120s AbortController fires (or the
  caller pre-aborted the signal), surface as 503 TIMEOUT.
- Default case: console.error the error per daemon convention; client
  sees 500 INTERNAL with the message routed through redactSecrets.
- Imported redactSecrets alongside the existing connectionTest
  imports (apps/daemon/src/server.ts:51).

HTTP-layer tests (boot startServer({port:0,returnServer:true}) once
in beforeAll, mirror the proxy-routes.test.ts pattern):
- "400 BAD_REQUEST when baseUrl is not a valid URL (test #13)":
  baseUrl='not-a-url'.
- "403 FORBIDDEN when baseUrl points at a private internal IP (test
  #14)": baseUrl='http://10.0.0.1'. Note: validateBaseUrl explicitly
  allows loopback (for local OpenAI-compatible servers) and only
  blocks non-loopback private IPs (10/8, 172.16/12, 192.168/16,
  fc00::/7, fe80::/10).
- "400 BAD_REQUEST when apiKey is missing (test #15)": apiKey omitted.
- "400 BAD_REQUEST when :id contains characters outside the safe-id
  regex (test #16)": id='bad!id' contains '!' which is not in
  [A-Za-z0-9._-].

Suite delta: +4 tests (26 active in finalize-design.test.ts).
Full daemon suite: 1078/1078 pass; baseline+26 (the +5 above plan
target reflects retry+extract split into more granular unit tests
than originally enumerated; all real, none skipped).

Refs nexu-io/open-design#450

* fix(daemon): tighten isSafeId to reject pure-dot project ids

Addresses the P1 path-traversal finding from @lefarcen on PR #832
(https://github.com/nexu-io/open-design/pull/832#discussion_r3202512644).

The pre-fix `isSafeId` at apps/daemon/src/projects.ts:556-558 used
regex `/^[A-Za-z0-9._-]{1,128}$/` which permitted pure-dot ids
(`.`, `..`, `...`) because `.` is in the character class. `projectDir`
and `resolveProjectDir` both delegated to `isSafeId`, so an id of
`..` would resolve to the PARENT of `.od/projects/` via `path.join`.

Threat model (per @lefarcen):
- An attacker creates a project row whose stored id is `..` (or
  another pure-dot variant) — for instance via a workflow that
  writes the row directly without going through the API. Subsequent
  finalize/write ops keyed by that id then escape the project tree.
- A direct CLI / scripted caller passing `..` as the project id
  reaches the function without HTTP normalization saving us. (Express
  normalizes %2e%2e to .. and collapses path segments, which yields
  404 for the URL `/api/projects/%2e%2e/...` in practice — but that's
  Express's protection, not ours.)

Fix:
- isSafeId now explicitly rejects pure-dot ids (`/^\.+$/.test(id)`)
  before the char-class regex check. Empty string and inputs longer
  than 128 chars are also rejected explicitly so the function fails
  closed on edge cases.
- isSafeId is now exported from apps/daemon/src/projects.ts so the
  /finalize route handler in apps/daemon/src/server.ts can use the
  same validator instead of re-implementing the regex inline. This
  prevents drift between the route guard and the projectDir guard,
  which was how this hole originally appeared.

Tests (in finalize-design.test.ts because that's where the threat was
flagged; isSafeId is daemon-wide so a dedicated test file would also
work):
- isSafeId rejects `.`, `..`, `...`, `....`
- isSafeId rejects ids with `/`, `\`, `!`, leading whitespace
- isSafeId rejects empty string and >128 chars
- isSafeId rejects non-string inputs (null/undefined/number)
- isSafeId accepts plain ids, ids with mid-string dots, UUIDs, single chars

Suite delta: +7 tests (33 active in finalize-design.test.ts).
Full daemon suite: 1085/1085.

Refs nexu-io/open-design#832

* fix(daemon): address PR #832 P1 findings — imported folders + network 502

Addresses two of the three P1 findings from @lefarcen on PR #832:

1. Imported-folder projects route DESIGN.md to metadata.baseDir
   (https://github.com/nexu-io/open-design/pull/832#discussion_r3202512656,
   also flagged independently by @chatgpt-codex-connector at #discussion_r3202430470)

   The pipeline previously called `projectDir(projectsRoot, projectId)`
   unconditionally, which resolves to `.od/projects/<id>`. For projects
   created via /api/import/folder the project row's `metadata.baseDir`
   carries the user's actual folder; without threading metadata through,
   finalize would silently land DESIGN.md in the hidden daemon data dir
   and the current-artifact resolver would miss the user's real files.

   Fix: switch from `projectDir` to `resolveProjectDir(projectsRoot,
   projectId, metadata)` in both `finalizeDesignPackage` and
   `resolveCurrentArtifact`. Thread `project.metadata` (from
   `getProject`'s normalized row) through both call paths. The resolver
   gets a new optional `metadata` parameter; native projects pass null
   and get identical behavior.

2. Network failures and JSON parse errors now map to 502 UPSTREAM_FAILED
   (https://github.com/nexu-io/open-design/pull/832#discussion_r3202512661)

   Pre-fix, only HTTP-non-OK responses were wrapped as
   FinalizeUpstreamError. DNS failures (ECONNREFUSED, ENOTFOUND), fetch
   TypeErrors, and `response.json()` SyntaxErrors fell through to the
   route's catch-all and surfaced as 500 INTERNAL — incorrect: those are
   upstream-level failures, not daemon bugs.

   Fix:
   - Wrap callAnthropicWithRetry in a try/catch that passes
     FinalizeUpstreamError and AbortError through verbatim, but rewraps
     any other thrown error as FinalizeUpstreamError(502, '', message).
   - Wrap response.json() in a try/catch that rewraps SyntaxError as
     FinalizeUpstreamError(502, '', "upstream Anthropic returned non-JSON
     body: ...").
   - The route handler's existing FinalizeUpstreamError mapping then
     correctly maps these to 502 with the message in `details` (run
     through redactSecrets first).

Tests:
- "writes DESIGN.md under metadata.baseDir for imported-folder projects":
  inserts a project row with metadata.baseDir pointing at a
  user-folder temp dir; asserts result.designMdPath lands there AND
  the hidden .od/projects/<id> dir does NOT contain a DESIGN.md.
- "rewraps fetch network rejection as FinalizeUpstreamError(502)":
  fetchImpl throws TypeError with cause.code='ENOTFOUND'; assert thrown
  error has name=FinalizeUpstreamError and status=502.
- "rewraps 200 with non-JSON body as FinalizeUpstreamError(502)":
  fetchImpl returns 200 with text/html body; response.json() throws
  SyntaxError internally; assert FinalizeUpstreamError(502).

Suite delta: +3 tests (36 active in finalize-design.test.ts).
Full daemon suite: green at last check; will re-verify before push.

Refs nexu-io/open-design#832

* refactor(daemon): move /finalize DTOs to contracts + map error codes + validate active-tab

Addresses the P2 and P3 findings from @lefarcen on PR #832:

P2 — Error codes + DTOs not in packages/contracts
  https://github.com/nexu-io/open-design/pull/832#discussion_r3202512673

  Reverses my plan's locked decision #10 ("no contracts changes in this
  PR; inline the request/response types"). That rule came from the
  predecessor PROMPT brief's anti-pattern table; @lefarcen's review is
  fresher signal and supersedes it. Drift risk between the daemon's
  inline types and any future PR 3 web client is real.

  - New contracts module: packages/contracts/src/api/finalize.ts with
    FinalizeAnthropicRequest / FinalizeArtifactRef /
    FinalizeAnthropicResponse. Re-exported from the package root and
    made addressable via `@open-design/contracts/api/finalize` subpath.
  - Daemon source imports the canonical types from contracts and
    re-exports the public type names so internal references keep
    working without touching every call site.
  - Daemon-local error codes remapped to existing ApiErrorCode union
    members (apps/daemon/src/server.ts), per @lefarcen's suggested
    mapping:
      FINALIZE_IN_PROGRESS -> CONFLICT
      AUTH_FAILED          -> UNAUTHORIZED
      UPSTREAM_FAILED      -> UPSTREAM_UNAVAILABLE
      TIMEOUT              -> UPSTREAM_UNAVAILABLE (status 503)
      INTERNAL             -> INTERNAL_ERROR
    HTTP status codes are unchanged; only the `code` field in the
    error JSON body changed.

P3 — Active-tab name not validated before sidecar probe
  https://github.com/nexu-io/open-design/pull/832#discussion_r3202512684

  resolveCurrentArtifact now runs the active tab's name through
  validateProjectPath BEFORE composing it into a path.join expression.
  An invalid tab (traversal segments, absolute path, null byte,
  reserved segment) causes resolveCurrentArtifact to fall through to
  the newest-artifact branch rather than abort or probe outside the
  project directory.

Tests:
- "falls through (does not throw) when active tab name contains
  traversal segments": injects a malformed `tabs.name =
  '../../../etc/passwd'` row directly via SQL (bypassing production
  tab-creation validation), seeds a real artifact, asserts the
  resolver returns the real artifact rather than the malformed name.

Suite delta: +1 test (37 active in finalize-design.test.ts).
Full daemon suite: 1089/1089 green.

Refs nexu-io/open-design#832

* fix(contracts): publish /api/finalize as standalone runtime entrypoint

Addresses @mrcfps's CI-red review on PR #832
(https://github.com/nexu-io/open-design/pull/832, inline comment on
packages/contracts/package.json).

The previous J3 commit added `./api/finalize` as a type-only subpath:
the entry had only a `types` field, no `default`. That broke the
contracts package-runtime gate (packages/contracts/tests/package-
runtime.test.ts:38-47) which asserts every exports entry exposes both
a `.mjs` runtime and a `.d.ts` types target. mrcfps proposed two fixes;
this commit takes path B — make finalize a first-class published
module rather than a type-only re-export from the package root.

Path B vs path A (a peer-AI second opinion via /collaborate confirmed):
under NodeNext + ESM with exports-map semantics, TypeScript validates
re-exported symbols against the published module-identity surface.
Because the previous J3 had `./api/finalize` neither declared as an
exports-map entry nor materialized as a standalone .mjs, TS omitted
the re-exported names during package boundary analysis. Even at
runtime `import('@open-design/contracts').FINALIZE_SCHEMA_VERSION`
worked from the bundled index.mjs but the type-checker rejected it.
Path B aligns the runtime and declaration surfaces.

Changes:
- packages/contracts/esbuild.config.mjs: add `./src/api/finalize.ts`
  to entryPoints so dist/api/finalize.mjs is generated as a standalone
  module rather than only inlined into the bundled root.
- packages/contracts/package.json: re-add `./api/finalize` to the
  exports map with both `default: ./dist/api/finalize.mjs` AND
  `types: ./dist/api/finalize.d.ts`. Mirrors `./api/connectionTest`'s
  shape (the canonical pattern for first-class submodule entries).
- packages/contracts/src/api/finalize.ts: keep the runtime export
  `FINALIZE_SCHEMA_VERSION = 1` (giving the standalone module a real
  value to emit beyond the type-only interfaces) and update the
  doc-comment now that the standalone .mjs is wired.
- apps/daemon/src/finalize-design.ts: switch the type import from
  the inline declarations introduced in the prior J3 fallback to
  `import type { ... } from '@open-design/contracts/api/finalize'`.
  Re-export the names so internal references inside finalize-design.ts
  keep working without touching every call site.

Verified:
- node --input-type=module -e "import('@open-design/contracts/api/finalize').then(m=>console.log(JSON.stringify(Object.keys(m))))"
  prints ["FINALIZE_SCHEMA_VERSION"] — runtime resolution clean.
- pnpm --filter @open-design/contracts test: 6/6 (including both
  package-runtime.test.ts cases on the rebuilt exports map).
- pnpm --filter @open-design/daemon typecheck: exits 0.
- pnpm --filter @open-design/daemon test: 1089/1089 (no regression vs
  the prior J3 number).

Refs nexu-io/open-design#832

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
2026-05-08 19:52:11 +08:00
INFINITY
988fd6db5e
feat: import existing local folder as project (#597) (#624)
* feat(contracts): types for folder-import endpoint

Add ImportFolderRequest, ImportFolderResponse to the public contract
surface. Extend ProjectMetadata with a baseDir field — when set, the
project's files live at this absolute path instead of .od/projects/<id>/.
Stored as the realpath() result so symlinks cannot redirect later writes.

Refs nexu-io/open-design#597

* feat(daemon): support metadata.baseDir for folder-rooted projects

Add resolveProjectDir() and metadata-aware variants of listFiles,
readProjectFile, writeProjectFile, ensureProject so a project's files
can live under metadata.baseDir (the user's chosen folder) instead of
.od/projects/<id>/. metadata.baseDir is opt-in — projects without it
keep the existing .od/projects/<id>/ behavior unchanged.

When listFiles walks a baseDir-rooted project, it skips conventional
build / install dirs (node_modules, .git, dist, build, .next, .nuxt,
.turbo, .cache, .output, out, coverage, __pycache__, .venv, vendor,
target, .od, .tmp) so the file panel stays focused on design content
instead of being dominated by lockfiles and node_modules.

Add detectEntryFile() — best-effort lookup for index.html or any
.html at the folder root, used by the import endpoint to seed the
initial active tab.

Refs nexu-io/open-design#597

* feat(daemon): add POST /api/import/folder endpoint

Creates a project rooted at the submitted local folder. metadata.baseDir
points at that folder and OD reads / writes there directly — no copy,
no shadow tree, mirroring how Cursor / Claude Code / Aider behave. The
user owns the workspace and is responsible for their own version
control.

Safety:
- baseDir is canonicalized via fs.promises.realpath() at import time so
  user-controlled symlinks can't redirect later writes. resolveSafe
  enforces the bounds check against the literal stored path; without
  realpath, a symlink (e.g. ~/sneaky → /etc) would let writeProjectFile
  escape the project tree at every later call because the OS follows
  the symlink at open() time.
- Post-realpath lstat ensures the canonical target is itself a real
  directory (defense-in-depth).
- The data directory (RUNTIME_DATA_DIR) and its descendants are
  refused after symlink resolution so a redirect into the daemon's
  own state can't masquerade as a project import.

The web client wires this through state/projects.ts → App.tsx,
landing the user on the auto-detected entry file when present.

Refs nexu-io/open-design#597

* feat(desktop): expose native folder picker to renderer

Adds an Electron preload script that exposes window.electronAPI.pickFolder
via contextBridge. Wires dialog.showOpenDialog through ipcMain so the
web UI can open a native folder selector for project import. Browser-only
users fall back to a text input for the absolute path (handled in the
web layer); the picker stays an optional convenience on the desktop
binary.

ipcMain.handle() registers handlers in an internal map that is not
exposed via eventNames(), so the natural-looking guard
  if (!ipcMain.eventNames().includes('dialog:pick-folder')) ipcMain.handle(...)
is always true. On a second createDesktopRuntime() call (dev hot-reload,
packaged-vs-electron mode swap) the body re-runs and ipcMain.handle()
throws 'Attempted to register a second handler'. Use removeHandler()
+ handle() unconditionally — removeHandler() is a documented no-op
when nothing is registered, making the pair idempotent.

Includes *.cts in the apps/desktop tsconfig so the preload script is
typechecked.

Refs nexu-io/open-design#597

* feat(web): add 'From existing folder' option to New Project

UI surface for the import flow:
- A new 'Open folder' affordance in NewProjectPanel that uses the
  native picker on Electron (window.electronAPI.pickFolder) and falls
  back to an absolute-path text input in the browser.
- importFolderProject() in state/projects.ts: typed wrapper around
  POST /api/import/folder using @open-design/contracts types.
- App.tsx wires the response: prepend the new project to the list,
  navigate to it, and select the auto-detected entry file as the
  active tab.

Skill / design-system pickers from the existing prototype tab are
reused — folder import is a project-creation flow, not a separate
project type.

Refs nexu-io/open-design#597

* docs(architecture): document folder-import endpoint

Adds POST /api/import/folder to the daemon API table and a 'Folder
import' section explaining the single-mode design (direct read/write
in metadata.baseDir, mirroring Cursor / Claude Code / Aider), the
realpath() canonicalization, the RUNTIME_DATA_DIR refusal, and the
SKIP_DIRS list applied to listFiles for baseDir-rooted projects.

Refs nexu-io/open-design#597

* test(daemon): unit + integration tests for folder import

Two new files:

apps/daemon/tests/folder-import-projects.test.ts (13 unit tests):
- resolveProjectDir behavior under all metadata combinations,
  including the fallback when baseDir is relative and the
  isSafeId-bypass when baseDir is set
- detectEntryFile: index.html priority, .html fallback, null when
  no html, no descent into subdirs
- listFiles with metadata.baseDir: walk, SKIP_DIRS hides node_modules
  / .git / dist, back-compat for projects without baseDir

apps/daemon/tests/folder-import-route.test.ts (10 integration tests):
- Happy path: baseDir stored in metadata, importedFrom='folder',
  conversation created, entry file detected
- Error paths: missing baseDir, empty, relative, non-existent,
  pointing at a file
- Security: realpath canonicalization (the symlink test was the one
  that surfaced the original /var vs /private/var mismatch in
  RUNTIME_DATA_DIR comparison on macOS)
- Security: a symlink that resolves into RUNTIME_DATA_DIR is rejected
  after realpath, not before

Refs nexu-io/open-design#597

* fix(daemon): wire baseDir metadata into chat + deploy reads

Two bugs caught in Codex automated review of #624:

1. chat-route was passing the metadata object directly as the listFiles
   opts argument: `listFiles(PROJECTS_DIR, projectId, chatMeta)`. The
   listFiles contract reads opts.metadata, not opts itself, so this
   silently fell back to .od/projects/<id>/ instead of the imported
   folder. existingProjectFiles was empty for baseDir-rooted projects.
   Wrap as `{ metadata: chatMeta }`.

2. deploy.ts read project files via readProjectFile without the
   metadata third argument, so for baseDir-rooted projects the deploy
   and preflight endpoints would look in .od/projects/<id>/ and fail
   with file-not-found instead of reading the imported folder. Thread
   options.metadata through buildDeployFilePlan → readProjectFile and
   pass project?.metadata at the two server.ts callsites
   (`POST /api/projects/:id/deploy` and the preflight endpoint).

Add a regression test that locks the listFiles contract: passing a
bare metadata object as opts must NOT scan baseDir — it must fall back
to the standard project dir, otherwise callers can leak the wrong
folder by mistake.

Refs nexu-io/open-design#597, #624 (Codex review)

* fix(daemon): ensure correct metadata handling in folder import

Addressed issues with metadata handling in folder import functionality. Updated the listFiles and readProjectFile methods to correctly utilize the metadata.baseDir, ensuring that project files are read from the intended directory. Added regression tests to verify that passing a bare metadata object does not inadvertently scan the baseDir, maintaining the integrity of project file access.

Refs nexu-io/open-design#597

* fix(daemon): security hardening from Codex review of #624

P1 findings from automated review:

1. POST /api/projects + PATCH /api/projects/:id rejected
   client-supplied metadata.baseDir. baseDir is privileged: it lets a
   project root inside the user's filesystem, and the realpath() +
   RUNTIME_DATA_DIR reentry checks live only on /api/import/folder.
   Allowing it on the generic create/patch path lets an attacker
   smuggle e.g. /etc through and bypass every import-time guard.
   Both endpoints now refuse a baseDir field with 400.

2. resolveSafeReal() helper: realpath()s each candidate path (or its
   longest existing prefix for write paths) and re-validates against
   realpath(projectRoot). The original resolveSafe() only did a
   string-prefix check, which was fooled by symlinks *inside* a
   baseDir-rooted project. A repo containing 'assets -> /Users/me/.ssh'
   passed the literal prefix check but readFile() followed the link
   at open() time. resolveSafeReal() is now used by readProjectFile,
   writeProjectFile, and deleteProjectFile.

3. Multer chat-upload destination now resolves to metadata.baseDir for
   imported folder projects via a module-level lookup wired to db at
   startServer() boot. Previously attachments landed in
   .od/projects/<id>/ even for baseDir projects, so the agent (which
   runs with cwd=baseDir) couldn't open them.

P2 findings:

4. searchProjectFiles threads metadata through listFiles +
   resolveProjectDir so /api/projects/:id/search hits the right tree.
5. buildProjectArchive + buildBatchArchive now accept metadata so
   'Download .zip' works for imported folder projects.
6. Watcher subscribe() resolves to baseDir for imported projects so
   live-reload SSE actually fires when the user edits files in their
   own folder. Registry stays keyed by the canonical directory.
7. Template snapshotting reads source-project files with metadata
   so a template can be saved from a baseDir-rooted source.

Tests:

- Regression: POST /api/projects with metadata.baseDir → 400.
- Regression: descendant symlink (assets/leak.txt -> /etc/hosts) is
  refused on the raw read endpoint.

Refs nexu-io/open-design#597, #624 (Codex P1+P2 review)

* fix(daemon): close two regressions found in #624 review round 2

@mrcfps caught two more correctness gaps:

1. Archive root symlink escape — buildProjectArchive accepts an optional
   ?root=<subdir> param to scope the zip to a subdirectory. The path was
   resolved with the string-only resolveSafe(), so a directory symlink
   inside an imported folder (docs -> /Users/me/.ssh) passed the prefix
   check and collectArchiveEntries() then walked outside the project
   tree. Switch to the symlink-aware resolveSafeReal() — the same one
   that already protects raw read/write/delete paths. The walker itself
   already skips dirent symlinks via !isDirectory && !isFile, so
   canonicalizing the root is the only missing piece.

2. PATCH metadata wiped baseDir — updateProject() replaces metadata
   wholesale. The previous guard only blocked an explicit baseDir
   change, but a normal patch that *omits* baseDir (a UI editing
   linkedDirs only sends { metadata: { kind, linkedDirs } }) silently
   detached imported projects from their folder root. Subsequent
   reads/writes/watch/deploy fell back to .od/projects/<id>.

   Re-stamp the immutable folder-import fields (baseDir, importedFrom='folder')
   from the existing project record onto the incoming patch when the
   project is imported. A patch that supplies a *different* baseDir
   still gets rejected as before; a patch that supplies the *same*
   baseDir is accepted as a no-op. A patch on a non-imported project
   that tries to set baseDir is also still rejected (preserves the
   POST /api/projects guard from the previous round).

Tests:

- archive endpoint: ?root=<symlink-to-/etc> → 400.
- patch endpoint: PATCH that omits baseDir on an imported project keeps
  baseDir intact (project still resolves to the user's folder after).

Refs nexu-io/open-design#597, #624 (Codex P1 round 2)

* fix(web): add Indonesian deploy provider copy

---------

Co-authored-by: INFINITY <valentyn.sotov@trendarena.app>
Co-authored-by: Siri-Ray <2667192167@qq.com>
2026-05-07 20:43:31 +08:00
Marc Chan
c3d9136a0c
Add live artifacts and Composio connector catalog (#381)
* docs: add live artifacts implementation spec

* docs: align live artifacts implementation plan

* Ralph iteration 1: work in progress

* Ralph iteration 2: work in progress

* Ralph iteration 3: work in progress

* Ralph iteration 4: work in progress

* Ralph iteration 5: work in progress

* Ralph iteration 6: work in progress

* Ralph iteration 7: work in progress

* Ralph iteration 8: work in progress

* Ralph iteration 9: work in progress

* Ralph iteration 10: work in progress

* Ralph iteration 11: work in progress

* Ralph iteration 12: work in progress

* Ralph iteration 13: work in progress

* Ralph iteration 14: work in progress

* Ralph iteration 15: work in progress

* Ralph iteration 16: work in progress

* Ralph iteration 17: work in progress

* Ralph iteration 18: work in progress

* Ralph iteration 19: work in progress

* Ralph iteration 20: work in progress

* Ralph iteration 21: work in progress

* Ralph iteration 22: work in progress

* Ralph iteration 23: work in progress

* Ralph iteration 24: work in progress

* Ralph iteration 25: work in progress

* Ralph iteration 26: work in progress

* Ralph iteration 27: work in progress

* Ralph iteration 28: work in progress

* Ralph iteration 29: work in progress

* Ralph iteration 30: work in progress

* Ralph iteration 31: work in progress

* Ralph iteration 32: work in progress

* Ralph iteration 33: work in progress

* Ralph iteration 34: work in progress

* Ralph iteration 35: work in progress

* Ralph iteration 36: work in progress

* Ralph iteration 37: work in progress

* Ralph iteration 38: work in progress

* Ralph iteration 39: work in progress

* Ralph iteration 40: work in progress

* Ralph iteration 41: work in progress

* Ralph iteration 42: work in progress

* Ralph iteration 43: work in progress

* Ralph iteration 44: work in progress

* Ralph iteration 45: work in progress

* Ralph iteration 46: work in progress

* Ralph iteration 47: work in progress

* Ralph iteration 48: work in progress

* Ralph iteration 49: work in progress

* Ralph iteration 50: work in progress

* Ralph iteration 51: work in progress

* Ralph iteration 52: work in progress

* Ralph iteration 53: work in progress

* Ralph iteration 54: work in progress

* Ralph iteration 55: work in progress

* Ralph iteration 56: work in progress

* Ralph iteration 57: work in progress

* Ralph iteration 58: work in progress

* Ralph iteration 59: work in progress

* Ralph iteration 60: work in progress

* Ralph iteration 61: work in progress

* Ralph iteration 62: work in progress

* Ralph iteration 63: work in progress

* Ralph iteration 64: work in progress

* Ralph iteration 65: work in progress

* Ralph iteration 1: work in progress

* Ralph iteration 2: work in progress

* Ralph iteration 3: work in progress

* Ralph iteration 4: work in progress

* Ralph iteration 5: work in progress

* Ralph iteration 6: work in progress

* Ralph iteration 8: work in progress

* Ralph iteration 9: work in progress

* Ralph iteration 17: work in progress

* Add Composio-backed connectors

* Add Composio-backed connector catalog

* Fix connector callback flow

* Update live artifact connector refresh

* Fix live artifact refresh updates

* Improve live artifact viewer toolbar

* Refine live artifact source tabs

* Expand Composio connector catalog

* Improve Composio connector browsing

* Fix artifact refresh source safety checks

Generated-By: looper 0.4.1 (runner=fixer, agent=opencode)

* Fix live artifacts PR feedback

Generated-By: looper 0.5.0 (runner=fixer, agent=opencode)

* Fix live artifact preview CORS validation

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix connector OAuth IPv6 loopback hosts

Allow bracketed IPv6 loopback Host headers when deriving connector OAuth callback URLs so IPv6-bound daemons can complete connection flow.

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Preserve live artifact refresh permissions

Respect explicit refresh permission choices during live artifact create and update flows so revoked connector sources remain gated.

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix live artifact preview cache freshness

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix live artifact refresh validation

Guard manual refreshes with local daemon checks and reject daemon_tool sources without a toolName before refresh execution.

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix Composio credential invalidation

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix live artifact CORS methods

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix workspace validation

Restore media config test isolation under Vitest setup data-dir overrides and add the missing French live artifact display copy so the workspace test suite stays aligned.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix connector safety filtering

Keep agent-preview connector listings aligned with execution safety policy and prune stale Composio OAuth state records before they accumulate.

Generated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix agent runtime cleanup

Generated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix live artifact daemon access

Validate local-only live artifact routes against the peer socket address and pass daemon-resolved CLI paths to ACP MCP descriptors.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix connector run limit pruning

Evict stale connector rate-limit buckets so long-lived daemon processes do not retain per-run entries indefinitely.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix connector compact schemas

Generated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Improve connector connection feedback

* Adjust connector gate positioning

* Fix live artifact refresh commits

Avoid marking refresh candidates failed after snapshot or state persistence errors by deferring live artifact mutations until the durable refresh metadata is written. Also align connector OAuth callback host validation with daemon loopback handling.\n\nGenerated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* Improve connector search relevance

* fix(daemon): harden connector connection state

Require loopback daemon validation before connector connect side effects and only clear provider-owned connector statuses during credential reset.

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix(daemon): guard connector disconnect route

Require local daemon request validation before connector disconnect side effects.

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix(daemon): guard composio config updates

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix(daemon): dispatch live artifacts mcp first

Route the live-artifacts MCP server before the generic MCP CLI so od mcp live-artifacts starts the dedicated server instead of failing generic argument parsing.\n\nGenerated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix(daemon): handle integer connector schemas

Allow JSON Schema integer connector inputs while preserving fractional-value validation so generated connector tool schemas accept valid page sizes and limits.

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix: align live artifact refresh error codes

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* Fix live artifact connector refresh flow

* Update live artifact design cards

* Add beta badge to live artifact form

* Remove live artifact tile model

* Fix live artifact refresh sync

* Fix live artifact MCP refresh durability

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* Fix live artifact refresh safety

Enforce persisted refresh opt-out and connector auto-read gating before refresh sources execute.

Generated-By: looper 0.5.5 (runner=fixer, agent=opencode)
2026-05-05 16:42:11 +08:00
ChildhoodAndy
bc04d61903
feat(design-files): add batch ZIP download with multi-select (#405)
* feat(design-files): add batch ZIP download with multi-select

Add checkbox-based multi-select to the Design Files panel and a new
POST /api/projects/:id/archive/batch endpoint that zips selected files
using the project name as the archive filename.

* fix(i18n): add missing batch-download keys to uk locale

The upstream main branch has a uk.ts locale that didn't exist in the
fork. Without these keys the web typecheck fails against the full
locale set.

* fix(design-files): harden batch archive and prune stale selections

- Use lstat to reject symlinks, dotfiles, and .artifact.json sidecars
  in buildBatchArchive (mirror listFiles/collectFiles allowlist)
- Reject invalid names explicitly instead of silently skipping
- Prune stale filenames from selected set on files/projectId change

* fix(daemon): tighten batch archive allowlist with segment-level checks

- Check every path segment for hidden directories, not just basename
- Walk intermediate directories with lstat to reject symlinks at any level
- Fail-fast on any ineligible file instead of silently skipping
2026-05-05 00:10:26 +08:00
emilneander
33c3b94b42
feat(daemon): add od mcp - expose Open Design as an MCP server (#399)
* feat(daemon): add `od mcp` subcommand for stdio MCP server

Lets a coding agent in a different repo (Claude Code, Cursor, Zed)
pull files from a locally-running OD project over the Model Context
Protocol — no export/import zip dance.

The MCP server is a thin stdio process that proxies read-only tool
calls to the daemon's existing HTTP API; no daemon-side changes
required. Exposes 8 tools:

  list_projects, get_project,
  list_files, get_file,
  list_skills, get_skill,
  list_design_systems, get_design_system

Wired exactly like `od media`: a hoisted flag set, a SUBCOMMAND_MAP
entry, a thin handler that resolves OD_DAEMON_URL and hands off to
src/mcp.ts. Tool dispatch is a switch over the tool name; each branch
fetches the matching daemon route and surfaces the response as MCP
text content. Binary mimes return a clear error pending phase-2
support.

Lifecycle gotcha worth flagging: Server.connect(transport) only
*starts* the stdio reader; the promise resolves immediately. Without
holding the function awaiting until transport/stdin close, cli.ts's
top-level process.exit(0) kills the server before the first request
arrives. The fix in src/mcp.ts holds until onclose / stdin EOF.

Wire-up example for a consuming repo:

    {
      "mcpServers": {
        "open-design": {
          "command": "od",
          "args": ["mcp"],
          "env": { "OD_DAEMON_URL": "http://127.0.0.1:7456" }
        }
      }
    }

New dep: @modelcontextprotocol/sdk (MIT, official Anthropic SDK).

* feat(daemon): add MCP server instructions for zero-shot LLM context

Hand the consuming LLM a system-prompt-style overview of the OD
workflow so it picks the right tool without prompt-engineering on
the user's side. Mentions get_artifact and project-name resolution
ahead of their actual implementation; both ship in the same batch.

* feat(daemon): resolve MCP project args by UUID, name, or substring

Lets a consuming agent say `project: "recaptr"` instead of pasting a
UUID. Match order: exact id → exact name (case-insensitive) →
slug-normalized name (strips trailing " (N)", normalizes whitespace) →
substring (errors if multiple). UUID inputs short-circuit and never
hit the daemon.

* feat(daemon): surface entryFile and kind on MCP get_project response

Promote metadata.entryFile and metadata.kind to top-level fields so
consumers (including get_artifact in this branch) can find the entry
without digging through nested metadata blobs.

* feat(daemon): add MCP get_artifact tool for bundle retrieval

A design rarely lives in a single file. get_artifact pulls the entry
HTML/JSX plus every sibling it references (tokens CSS, JSX modules,
imported components) in one call, so a consuming agent doesn't need
to parse HTML and round-trip per file.

Three modes:
  auto (default): BFS over relative <script src>, <link href>,
    <img src>, <source/video src>, JSX import/from, CSS url(), with
    depth cap 3 and a visited set. CDN, data:, mailto:, anchors, and
    paths containing .. are skipped.
  all:    every textual file in the project (mirror of /archive
          minus binaries).
  shallow: just the entry file (same as get_file).

Output is a structured JSON blob with name/mime/size/content per
file and the project's manifest metadata at the top.

* feat(daemon): add /api/projects/:id/search route + MCP search_files

Server-side substring search across textual project files. Returns
file, 1-indexed line, and snippet, capped at 1000 matches. Exposed
through the MCP layer as search_files(project, query, pattern?, max?).

Treats the query as a literal substring (regex chars escaped) to
avoid catastrophic-backtracking attacks from LLM-supplied input.
Honors the project dir's existing path-safety guards via listFiles.

* feat(daemon): add since= filter to /files route + MCP list_files arg

Lets a consumer poll for "what's changed since I last looked" without
re-walking every file. Daemon-side: parse since= as ms, filter
listFiles output by mtime. MCP-side: forward as URL query.

* feat(daemon): expose skills and design systems as MCP resources

Catalog reads are stable reference material — they fit MCP's
resources surface (LLM-passive) better than tools (LLM-active).
Skills and design systems each become resources at
od://skills/<id>/SKILL.md and od://design-systems/<id>/DESIGN.md;
existing list_skills / get_skill / list_design_systems /
get_design_system tools remain as fallbacks for clients that don't
handle resources cleanly.

* fix(daemon): tighten MCP correctness in get_artifact and resources

Several silent-failure paths and minor footguns the first pass missed:

  - get_artifact auto: the entry's own fetch now raises a clear
    error instead of returning files: []. Previously a typo in
    `entry:` looked like an empty project.
  - get_artifact: invalid `include` value returns a clear error
    listing the valid modes instead of silently behaving as auto.
  - get_artifact all: includes binary files as metadata stubs to
    match auto's behavior. Both modes are now strict supersets of
    shallow.
  - extractRelativeRefs: gate JS-only patterns (import/from/require/
    dynamic-import) by file mime/extension so prose in markdown or
    HTML doesn't generate spurious 404 round-trips on words like
    "imported from 'X'".
  - extractRelativeRefs: cover <iframe>, <audio>, srcset, and
    CSS @import — common in real OD output.
  - resources/list descriptions are collapsed to a single line
    (newlines + repeated whitespace -> one space) so MCP UIs that
    don't normalize whitespace render cleanly.
  - fetchProjectFile: 0-byte binary files no longer report size: null
    due to falsy short-circuit on Number(content-length).

* perf(daemon): cache MCP project list for 5s in resolveProjectId

A typical agent session calls list_files/get_file/get_artifact several
times in a row, each with a project name argument. Each previously
re-fetched /api/projects. Cache the list in module scope with a 5s
TTL so back-to-back lookups are local; renames in the OD UI still
propagate within a few seconds.

* feat(daemon): MCP UX polish — tool order, annotations, get_artifact maxBytes

Three changes well-behaved MCP clients pick up automatically:

  - Tool ordering. list_projects + get_artifact are now first; LLMs
    that weight earlier entries surface the bundle path before
    per-file fetching. Catalog tools (list_skills, get_skill,
    list_design_systems, get_design_system) sit at the bottom; they
    are also exposed as MCP resources.
  - readOnlyHint / idempotentHint / openWorldHint annotations on
    every tool so clients can skip confirmation prompts on safe
    tools and let the LLM know re-running is fine. Per-tool `title`
    annotations give clients a friendlier display name than the
    snake_case tool id.
  - get_artifact gains a `maxBytes` arg (default 1.5MB). Once the
    accumulated textual content crosses the cap, remaining files
    are dropped and `truncated: true` is set on the bundle so the
    consumer knows to use list_files / get_file for the rest.

* feat(daemon): expose user's active OD project/file via MCP

The "what file are you on?" round-trip the agent had to do every
session is now answered automatically. Three pieces:

  - Daemon: in-memory active-context slot with 5-minute TTL.
    POST /api/active sets {projectId, fileName}; GET /api/active
    returns the current value enriched with projectName, or
    {active:false} when the slot is empty/stale. Cleared on
    daemon restart.
  - Web: a small useEffect in App.tsx posts the active project +
    file to the daemon on every route change. Best-effort fire-
    and-forget; a missing daemon doesn't surface an error.
  - MCP: get_active_context tool (no args) and a matching MCP
    resource at od://focus/active. The tool is listed second,
    right after list_projects, so an LLM picks it up before
    asking for ids. Server instructions tell the model to call
    it FIRST when the user says "this file" / "the design I have
    open" / "what I'm looking at."

End to end: user opens a project in OD, agent in another repo
calls get_active_context() → gets {projectName: "recaptr",
fileName: "recaptr-onboarding-4.html"}, then immediately calls
get_artifact(project: "recaptr") with no further user input.

* feat(daemon): make MCP project arg optional, fall back to active OD context

get_artifact, get_project, get_file, search_files, and list_files now
accept project as optional. When omitted, the MCP resolves project
from /api/active so an agent in another repo can call

  search_files({ query: "Polaroid" })

without first asking the user "which project?". get_file and
get_artifact also default their path/entry to the active file, so
get_file({}) returns whatever the user is currently looking at.

The implicit path stamps `usedActiveContext` on JSON responses (or a
separate `[od:active-context …]` content block on get_file) so the
agent can see exactly which project/file got chosen. Explicit
project args pass through with zero added overhead.

Cuts the common case from two MCP round trips
(get_active_context → search_files) to one. Server instructions and
get_active_context's own description are updated to point at the
new default.

* fix(daemon): require same-origin for /api/active POST and GET

The active-context endpoint was added without isLocalSameOrigin
guard. Since the daemon binds 0.0.0.0 by default, a LAN peer could
GET it to learn what file the user has open, or POST it to redirect
the MCP fallback to a project of their choice. Same-origin only is
the right scope: the web app proxies its requests through Next.js
on the daemon port, and the MCP runs over loopback in-process, so
both legitimate callers pass.

Pattern matches the existing /api/app-config etc. guards.

* feat(daemon): add /api/mcp/install-info for cross-platform install snippets

The Settings -> MCP server panel needs absolute paths to node and
the daemon's built cli.js so it can render snippets that work on a
fresh source clone (where `od` is not on PATH) and dodge the
/usr/bin/od octal-dump tool that ships on macOS/Linux and would
otherwise shadow ours.

Endpoint returns:
  - command: process.execPath (the node binary running the daemon)
  - args: [<absolute path to dist/cli.js>, "mcp"]
  - daemonUrl: http://127.0.0.1:<port>
  - platform: process.platform (so the panel can localize ~/.cursor
    vs %USERPROFILE%\.cursor and Cmd vs Ctrl shortcuts)
  - cliExists / nodeExists: existsSync checks on both binaries
  - buildHint: human-readable build/reinstall instructions when
    either path is missing

isLocalSameOrigin guard same as /api/active. Cached for 5s because
the panel may re-fetch on every open and the paths cannot change
without a daemon restart.

Test file covers the happy path, cross-origin rejection, two
allowed-Origin variants, and the cache by counting fresh resolves
across rapid calls. 5/5 pass.

* refactor(daemon): tighten MCP surface, trim descriptions, polish copy

Three intertwined cleanups that all live in mcp.ts + cli.ts:

1. Drop catalog tools from MCP. list_skills / get_skill /
   list_design_systems / get_design_system are removed. The audience
   is a coding agent in a separate repo consuming Open Design's
   output; it cannot run skills (those are recipes Open Design uses
   to generate) and design-system DESIGN.md is reference material
   that already ships as an MCP resource. Keeping the catalog as
   tools cost ~350 token-overhead per turn for capabilities the
   agent could not act on. Tool count: 11 -> 7.

2. Trim tool descriptions. The active-context fallback explanation
   was repeated in 5 separate tool descriptions; hoisted into
   PROJECT_ARG and explained once in the server `instructions`
   block instead. Saves ~150-200 tokens per tools/list response.

3. User-facing branding pass. Tool titles, tool descriptions,
   resource names, error messages, comments, and `od mcp --help`
   now consistently use "Open Design" rather than "OD". Internal
   abbreviation `OD` is retained only inside the server
   instructions block where it is introduced inline as "Open Design
   (OD)" for compactness across multi-paragraph guidance.

Em dashes replaced with hyphens throughout, per project style.

* feat(web): add MCP server install panel in Settings

New "MCP server" section in the Settings dialog, surfacing
copy-paste install snippets for the major MCP-compatible coding
agents (Claude Code, Cursor, VS Code, Antigravity, Zed, Windsurf).

Highlights:
  - In-brand custom dropdown (reuses the existing .ds-picker
    pattern from the design-system / prompt-template pickers, click
    outside / Escape to close, chevron animates) instead of a
    native <select>.
  - Per-client snippet that uses absolute paths to node + cli.js
    fetched from /api/mcp/install-info on mount, so it works even
    when `od` is not on PATH.
  - Cursor gets a one-click "Install in Cursor" deeplink
    (cursor://anysphere.cursor-deeplink/mcp/install) that pops an
    approval dialog and writes the config for the user. UTF-8-safe
    base64 so paths with accented characters do not throw.
  - Per-OS path hints (~/.cursor on POSIX, %USERPROFILE%\.cursor
    on Windows) and keyboard shortcuts (Cmd vs Ctrl).
  - Build-required warning card when cli.js or the node binary
    does not exist on disk; deeplink button disables in that state.
  - Prominent "restart your client to pick up the new server"
    callout below the snippet, with per-client guidance.
  - Capability list ("what your agent can do") instead of a tool-
    name dump, so non-developer designers can also tell what is
    possible without reading MCP docs.

README adds a short "Use Open Design from your coding agent"
section that points at the panel and summarizes the per-client
flow (one-click for Cursor, JSON merge elsewhere). Read-only by
design; the daemon must be running locally.

* docs(readme): align MCP server section with the Settings panel

The "Use Open Design from your coding agent" section had drifted
from what the panel actually emits and lists.

- Add Antigravity to the supported-client list (previously missing).
- Drop the "(GitHub Copilot)" parenthetical from VS Code so the
  label matches the panel.
- Fix the Claude Code line: we no longer emit a single
  `claude mcp add ...` shell command. The snippet is JSON; the
  panel additionally suggests `claude mcp add-json` as the safer
  way to apply it instead of hand-editing ~/.claude.json.
- Swap the "find the Polaroid section" example for two more
  universal phrases ("build this in my app", "match these
  styles") that match what the panel surfaces.
- Add a one-line "restart or reload your client after install"
  note - this was prominent in the panel and absent from the
  README.
- Trim the /usr/bin/od octal-dump aside; it was technical detail
  that did not earn its space at the README intro level.

* feat(web): add Codex CLI to the MCP server install panel

Codex is a first-class supported coding agent (listed alongside
Claude Code, Cursor, etc. in the README's PATH-detected agent
table) but the install panel was missing it.

Codex stores MCP server config at ~/.codex/config.toml (TOML, not
JSON) under an `[mcp_servers.<name>]` table, and the same file is
shared between the Codex CLI and the Codex IDE extension - so one
install covers both. Added a 7th client entry that emits the right
TOML snippet, expanded the snippet-lang union to include 'toml'
(behaves like 'json' for whitespace handling, just a different
syntax-highlight hint).

For our minimal payload (just command + args), JSON.stringify
happens to produce valid TOML literal values since TOML basic
strings use the same double-quote escape rules as JSON, and TOML
inline arrays match JSON array syntax. No new TOML serializer
needed.

README updated to list Codex among the supported clients.

Schema verified against https://developers.openai.com/codex/mcp.

* fix(daemon): accept any loopback origin in same-origin guard

The previous port-pinned check required the request's Origin to match
either the daemon's own port or OD_WEB_PORT. tools-dev does not pass
OD_WEB_PORT to the daemon process, so any browser POST to /api/active
proxied through the dev web (port 17573 etc.) was rejected with 403,
and get_active_context always returned {active: false}.

Relax to a loopback-prefix match: any http://127.0.0.1:*,
http://localhost:*, or http://[::1]:* origin passes regardless of
port. Cross-origin (https://evil.com) is still rejected. The
trade-off is that another local web app on a different loopback port
could now CSRF the daemon; same-origin checks are inherently a CSRF
defense, not a network ACL.

* fix(web): make Claude Code MCP snippet a real copyable one-liner

claude mcp add-json open-design '<json>' takes only the inner
server-config object, not the full {"mcpServers": ...} wrapper, and
rejected the wrapped shape with "Invalid configuration: : Invalid
input". Pass only the inner config, and inline the JSON into the
command itself so the snippet is a real one-liner the user can copy
and paste, no template substitution.

* test(daemon): drop loopback-prefix assertions superseded by upstream origin policy

The two proxy-flow allow tests were added in ae13094 to cover our
relaxed isLocalSameOrigin. Main's port-pinned implementation (from
#365) now handles the dev-flow via the web sidecar proxy origin
rewrite (#a719f02), making the relaxation -- and these tests --
unnecessary.

Also replace the inline LOOPBACK_*_RE / isLocalSameOrigin replica in
mcp-install-info.test.ts with a direct import from server.ts so both
test files stay in sync with the production guard automatically.

* fix(daemon): bake daemon URL into MCP install-info args

The install panel snippet previously emitted `od mcp` with no daemon
URL, so the MCP server always fell back to the hardcoded default port
7456. When tools-dev starts the daemon on a non-default port the
snippet silently targets the wrong daemon.

Fix: include --daemon-url http://127.0.0.1:<port> as the third arg so
the generated snippet is always tied to the running daemon's actual
port. Update the matching mini-app and assertion in the install-info
test.

* fix(daemon): address MCP reviewer feedback

- extractRelativeRefs: replace blanket `includes('..')` rejection with
  proper POSIX-style path normalization. `../tokens.css` in a nested
  project layout now resolves to `tokens.css` instead of being
  silently dropped.

- getArtifact: add MAX_FILES=200 cap to BFS auto and include=all modes.
  Pass `remainingBytes` to fetchProjectFile so it can bail early when
  the server-advertised content-length would already exceed the budget.

- resolveProjectId: return {id, name, source} instead of a bare id.
  Callers echo `resolvedProject` in the response when the match was by
  slug or substring, letting the agent confirm which project was
  chosen without an extra round-trip.

- getFile: thread `resolved` through so substring matches surface
  the same `[od:resolved-project ...]` annotation.

- @ts-nocheck: add a comment explaining the Zod-vs-JSON-Schema SDK
  mismatch so future contributors don't remove it accidentally.

- get_active_context description: note the ~5-minute cache TTL.

* test(daemon): restore @ts-nocheck on mcp-install-info test

Dropped accidentally when replacing the import header. The directive
suppresses expected test-file noise (baseUrl pre-assignment and
res.json() unknown return type); keeping it avoids littering the test
body with `as any` casts for zero real safety benefit.

* docs(readme): expand MCP section with why-MCP, security model, and recovery note

- Soften "No zip export, no copy-paste" to "Replaces the
  export-then-attach loop" per reviewer feedback.
- Add "Why MCP?" paragraph explaining the structured-API benefit over
  zip exports.
- Add daemon-not-running recovery note (clear error, not a crash;
  start with pnpm tools-dev and retry).
- Add security model callout: read-only, loopback-only, Host/Origin
  guard rejects non-loopback requests.

* docs: complete security model and daemon recovery notes for MCP section

8.3: Expand README security model to include stdio child process context,
trust framing (treat like a VS Code extension), and OD_BIND_HOST opt-in
for LAN exposure.

8.4: Replace terse "daemon not running" note in README with a full
recovery sentence covering the start-agent-before-Open-Design case.
Add the same recovery note as a footer paragraph in IntegrationsSection
so users see it in the Settings panel without needing to read the README.

* fix(daemon): pass resolved through get_artifact so substring matches echo resolvedProject

* feat(daemon): add MCP unit tests and fill description/instructions gaps

- Export extractRelativeRefs, resolveProjectId, resolveProjectArg,
  withActiveEcho, fetchProjectFile, getArtifact for testing
- mcp-extract-refs.test.ts: 10 cases covering flat, nested, deep,
  escape attempts, external/data/anchor/mailto URLs, srcset
- mcp-get-artifact.test.ts: MAX_FILES=200 cap, maxBytes cap,
  per-file content-length pre-check via fetchProjectFile
- mcp-resolve-project.test.ts: uuid/exact/slug/substring source
  values, ambiguity error, withActiveEcho resolvedProject stamping
- get_artifact maxBytes description now mentions the 200-file cap
- Instructions block now mentions resolvedProject field and when it
  appears (slug or substring match)

* docs(daemon): document MCP active-context TTL and surface wake-up hint

Address PR #399 review item P2.5 (active-context TTL undocumented) plus
the related UX gap where the agent had no way to tell the user that
clicking around in Open Design refreshes the cache.

- PROJECT_ARG, get_artifact entry, get_file path: append TTL note to
  argument descriptions so agents see the ~5-minute fallback window.
- get_active_context: when /api/active reports active:false, return
  an explicit hint string explaining the recovery action ("ask the
  user to click into a project") instead of a bare {active:false}
  the agent can't act on.
- get_active_context tool description: mention the new hint payload.
- resolveProjectArg error: extend the missing-active-context message
  with the same TTL + recovery wording for tool calls that omit
  project= and have no fallback.

* feat(daemon): add offset/limit pagination to MCP get_file

Real-world MCP usage hit a wall on large files: get_file returned the
full body, the agent decided the result was too large for its context
budget, and recovered by spawning a sub-agent that ran Python with
manual brace-matching for several minutes. That defeats the value
proposition of skipping zip-export.

Mirror Claude Code's Read tool semantics: get_file now accepts
optional offset (0-indexed line) and limit (default 2000) args, slices
the file in mcp.ts after fetching from the daemon, and stamps an
[od:file-window offset=.. returnedLines=.. totalLines=..] marker on
sliced or truncated responses so the agent can page by re-calling
with the next offset.

- Tool definition: add offset/limit args, expand description.
- getFile helper: line-split, slice, marker, range clamp at EOF.
- Instructions block: mention pagination in the get_file bullet.
- Binary rejection unchanged.
- New tests in mcp-get-file.test.ts cover default behavior, limit
  truncation, mid-file offset, offset past EOF, and binary rejection.

* fix(daemon): set truncated: true when per-file content-length pre-check fires

When fetchProjectFile throws because a file's advertised content-length
exceeds the remaining byte budget, both the include=all loop and the auto
BFS loop silently skipped the file without setting truncated: true. The
bundle could then report truncated: false even though files were dropped.

Introduce BudgetExceededError as a sentinel so callers can distinguish a
budget rejection (truncated: true) from a genuine fetch failure (404,
network) that should just be skipped. Both getArtifact call sites now
check instanceof BudgetExceededError and set truncated accordingly.

Adds a regression test: 5 files of 250 bytes with explicit content-length,
maxBytes=400. Only file 0 fits; files 1-4 each exceed the remaining 150
bytes. totalTextBytes never reaches maxBytes, so only the new path sets
truncated=true. Previously the bundle reported truncated: false.
2026-05-04 22:34:17 +08:00
Sid
0851df379e
fix(daemon): serve .jsx / .tsx with JS-family MIME so browser loaders accept them (#340)
Multi-file React prototypes loaded via Babel-standalone
(`<script type="text/babel" src="…">`) fetch each `.jsx` / `.tsx` source
over HTTP. The daemon's `EXT_MIME` map in `apps/daemon/src/projects.ts`
served `.jsx` as `application/octet-stream` (no entry → fallback) and
`.tsx` as `text/typescript`, neither of which is a recognized JavaScript
MIME. Strict CSPs and browser module loaders reject both, which is one
of the failure modes flagged in #336 for the Open-button render path.

Map both to `text/javascript; charset=utf-8`, matching the existing
treatment of `.js` / `.mjs` / `.cjs`. `.ts` is intentionally left as
`text/typescript` — it has no browser-execution path here; tooling that
reads `.ts` as TypeScript source is the load-bearing consumer.

Tests in `apps/daemon/tests/project-classifiers.test.ts` were updated
to pin the new mappings and document the rationale.

Refs #336.
2026-05-03 10:37:14 +08:00
monshunter
30f8036c9a
fix(web): make share-menu "Download as .zip" return the actual project tree (#341)
* fix(web): download project tree as zip from share menu

The "Download as .zip" share action previously produced a single-file
ZIP of the rendered HTML srcdoc. Add a daemon archive endpoint that
bundles the on-disk project tree (scoped to the active top-level
directory when applicable) and have FileViewer call it, falling back to
the in-memory single-file ZIP on failure. UTF-8 filenames are preserved
via RFC 5987 Content-Disposition.

* fix(daemon,web): address PR #341 review — diagnostics, comments, more tests

- Stat the archive root up-front so a missing/non-directory target
  surfaces a clear ENOENT/ENOTDIR ("does not exist" vs "is empty"),
  instead of being swallowed by the recursive walk and reported as
  empty. Distinguishes a deleted project from one with no archivable
  files for on-call diagnostics.
- Document the DEFLATE level-6 choice in the archive builder so future
  maintainers don't have to guess at the speed/ratio trade-off.
- Add a daemon test that the baseName preserves a multi-byte UTF-8
  directory name (café-design), covering the path that feeds RFC 5987
  filename* on the server.
- Add a daemon test that a missing archive root surfaces ENOENT with a
  "does not exist" message, distinct from the empty-directory case.
- Export archiveRootFromFilePath and archiveFilenameFrom for testing,
  and add web unit tests covering the Content-Disposition fallback
  chain (UTF-8 filename* → legacy quoted filename= → root slug → title
  slug, plus malformed-encoding fall-through).
- Replace the CJK example string in code comments and tests with a
  Latin-extended example (café-design) so the codebase stays English
  while still exercising multi-byte UTF-8 handling.
2026-05-03 10:34:33 +08:00
Sid
b9d8bd36b9
fix(daemon): treat .py files as previewable code in Design Files (#261)
Python files in Design Files cannot be previewed inline today: the side
panel falls back to BinaryViewer and users can only download or open
externally, even though `.py` is plain text and reasonable to inspect
in-app.

Root cause: `kindFor()` in `apps/daemon/src/projects.ts` (line 278)
classifies files into coarse buckets the frontend uses to pick a viewer.
The `'code'` branch lists `.js / .mjs / .cjs / .ts / .tsx / .json / .css`
but not `.py`, so Python files fall through to `'binary'`. The frontend
already renders the `'code'` bucket via `TextViewer` in
`apps/web/src/components/FileViewer.tsx`, so once a file lands in the
`'code'` bucket it gets the existing read-only code preview for free.

Fix: add `'.py'` to the `'code'` extension list. No frontend change
needed — `TextViewer` handles it on receipt.

Scope: this PR keeps the change minimal to match the issue
(`.py` only). Other commonly-requested text-like extensions
(`.yaml / .yml / .toml / .sh`, plus other code languages) can be added
in a follow-up if maintainers want that surface area.

Closes #61
2026-05-02 15:06:24 +08:00
Tom Huang
3f266103b0
feat(media): port generation workflow onto main (#12)
Co-authored-by: Elian <elian@EliandeMacBook-Pro.local>
2026-04-30 22:44:00 +08:00
Xinmin Zeng
132adac3bb
fix(daemon): preserve non-ASCII filenames on multipart upload (#166)
* fix(daemon): preserve non-ASCII filenames on multipart upload

multer@1 hands callers latin1-decoded multipart filenames, and
sanitizeName() then collapses every non-ASCII character to '_'. A
Chinese DOCX uploaded as 测试文档.docx therefore landed on disk as
____.docx, while the response shipped a latin1-mangled originalName
back to the client. The chat composer compared that to the UTF-8
File.name from the picker, missed the match, and reported "some
files could not be stored" even though the bytes were already on
disk.

Add decodeMultipartFilename() that round-trip-checks latin1->utf8
so genuine latin1 names aren't corrupted, and switch sanitizeName()
to keep \p{L}\p{N} (Unicode letters/digits) while still stripping
path separators, control characters, and Windows-reserved
punctuation. Apply the decoder to all three multer storage filename
callbacks so /api/upload, /api/import/claude-design, and
/api/projects/:id/upload all return UTF-8 originalNames.

Closes #144

* fix(daemon): address review feedback on filename decoder

Three changes from review:

1. decodeMultipartFilename now bails out early when any code point in
   the input exceeds 0xFF. multer can hand us an already-decoded UTF-8
   string when the client uses the RFC 5987 `filename*` parameter, and
   the previous round-trip-only check would corrupt those names — for
   example, a properly decoded `测试文档.docx` could be re-mangled into
   `KՇc.docx` because the latin1-encoded bytes happened to form a
   valid UTF-8 sequence. Code points above 0xFF can never appear in a
   genuine latin1-decoding-of-utf8 input, so they're an unambiguous
   signal that no further decoding should run.

2. decodeMultipartFilename now handles null/undefined defensively (it
   was relying on multer always populating originalname; this just
   makes the helper safer to reuse from other call sites).

3. The inline sanitiser regex in the `upload` and `importUpload`
   multer instances is replaced with a direct `sanitizeName()` call,
   matching what `projectUpload` already did. This keeps a single
   source of truth for the allowed character set so future tweaks
   only have to land in one place.

Adds two more tests to the existing sanitize-name suite covering the
above-0xFF early return and the null/undefined inputs (13/13 in that
file, 58/58 across the daemon).
2026-04-30 19:49:43 +08:00
PerishFire
c6d11018a0
Refresh desktop integration control plane (#123)
* feat(dev): add desktop tools-dev control plane

* refactor(sidecar): split Open Design contracts

Move Open Design-specific sidecar protocol definitions into @open-design/contracts so sidecar and platform can remain descriptor-driven primitives.

* refactor(daemon): organize package sources

Keep daemon app code, tests, and sidecar entrypoints in separate package directories so each layer can be built and verified independently.

* chore(repo): streamline maintenance entrypoints

Centralize agent guidance by directory and reduce root command chains while preserving the existing build scope.

* docs: translate agent guidance to English

* fix(sidecar): tolerate stale IPC sockets

Remove stale Unix socket files only after confirming no listener is active, so tools-dev can restart after unclean shutdowns.
2026-04-30 14:23:53 +08:00
Renamed from apps/daemon/projects.ts (Browse further)