open-design/apps/daemon/src/import-export-routes.ts
Eli 18b947c25f
[codex] Land design system GitHub intake handoff (#2187)
* Add Claude-style design system workflow

* Merge design system workflow into main

* Restore design system workflow UI styles

* Fix design system setup scrolling

* Fix design system setup connector button

* Preserve connector auth link after popup block

* Simplify connected GitHub setup state

* Open generated design system workspace project

* Summarize design system auto prompt in chat

* Add bounded GitHub connector design intake

* Prefer path-scoped GitHub intake tools

* Restore branch GitHub design context intake

* Restore design system review workspace

* Restore design system manager tab

* Let design system workflow routes own details

* Open editable design systems as projects

* Restore design system workspace coverage

* Fix bounded GitHub connector intake

* Hide design system review while generating

* Suppress design system generation questions

* Constrain GitHub design intake to bounded command

* Tolerate oversized GitHub metadata during intake

* Rebuild daemon CLI when sources change

* Fallback when GitHub connector snapshots are rate limited

* Allow GitHub intake without Composio

* Use native GitHub auth for design intake

* Remove design system review group heading

* Improve design system extraction evidence

* Align design system scaffold with Claude output

* Add evidence inventory for design system intake

* Add local design system evidence intake

* Add design system package audit gate

* Allow auditing Claude Design reference packages

* Audit design system package content quality

* Migrate legacy design system artifacts

* Clean migrated design system artifacts

* Require modular design system UI kits

* Reject thin design system UI kits

* Prioritize core design evidence intake

* Require role-based design system UI kits

* Clean stale design system manifest references

* Require representative preserved design assets

* Warn on generic design system visuals

* Enforce design system quality warnings

* Audit connected design system UI kits

* Require mounted design system UI kits

* Require composed design system app shells

* Require runnable JSX design system kits

* Require browser globals for design system components

* Infer design system names from source URLs

* Require source examples in design system packages

* Bind preserved fonts in design system tokens

* Require skill frontmatter in design system packages

* Preserve build icons in design system packages

* Require real assets in brand previews

* Require substantive source examples

* Require product overview in design system README

* Require reusable UI kit README

* Require reusable design system skill docs

* Seed Claude-style UI kit entry contract

* Preserve runtime build assets in design packages

* Audit design system packages after generation

* Audit design system first-run output

* Audit source-backed preview cards

* Align design system UI kit scaffolds

* Materialize design evidence package artifacts

* Show project chat during design system setup

* Hand off design system setup to project chat

* Auto-repair design system audit failures

* Harden design system evidence preservation

* Tighten design system package guidance

* Add targeted design system repair guidance

* Bound design system audit auto repair

* Use connector statuses in design system setup

* Audit design system preview manifests

* Require README preview manifests for design systems

* Fix design system GitHub intake handoff

* Fix daemon prompt CI assertions
2026-05-19 14:30:17 +08:00

692 lines
26 KiB
TypeScript

import type { Express } from 'express';
import type { RouteDeps } from './server-context.js';
import {
InlineAssetsLimitError,
MAX_INLINE_OWNER_BYTES,
inlineRelativeAssets,
type InlineAssetReader,
} from './inline-assets.js';
export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {}
export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps) {
const { db } = ctx;
const { sendApiError } = ctx.http;
const { importUpload } = ctx.uploads;
const { fs, path } = ctx.node;
const { randomId } = ctx.ids;
const { PROJECTS_DIR, RUNTIME_DATA_DIR_CANONICAL } = ctx.paths;
const { importClaudeDesignZip, projectDir, detectEntryFile } = ctx.imports;
const {
consumedImportNonces,
desktopAuthSecret,
isDesktopAuthGateActive,
pruneExpiredImportNonces,
verifyDesktopImportToken,
} = ctx.auth;
const { insertProject } = ctx.projectStore;
const { insertConversation } = ctx.conversations;
const { setTabs } = ctx.projectFiles;
const { validateProjectDesignSystemId } = ctx.validation;
app.post(
'/api/import/claude-design',
importUpload.single('file'),
async (req, res) => {
try {
if (!req.file)
return res.status(400).json({ error: 'zip file required' });
const originalName =
req.file.originalname || 'Claude Design export.zip';
if (!/\.zip$/i.test(originalName)) {
fs.promises.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'expected a .zip file' });
}
const id = randomId();
const now = Date.now();
const baseName =
originalName.replace(/\.zip$/i, '').trim() || 'Claude Design import';
const imported = await importClaudeDesignZip(
req.file.path,
projectDir(PROJECTS_DIR, id),
);
fs.promises.unlink(req.file.path).catch(() => {});
const project = insertProject(db, {
id,
name: baseName,
skillId: null,
designSystemId: null,
pendingPrompt: `Imported from Claude Design ZIP: ${originalName}. Continue editing ${imported.entryFile}.`,
metadata: {
kind: 'prototype',
importedFrom: 'claude-design',
entryFile: imported.entryFile,
sourceFileName: originalName,
},
createdAt: now,
updatedAt: now,
});
const cid = randomId();
insertConversation(db, {
id: cid,
projectId: id,
title: 'Imported Claude Design project',
createdAt: now,
updatedAt: now,
});
setTabs(db, id, [imported.entryFile], imported.entryFile);
res.json({
project,
conversationId: cid,
entryFile: imported.entryFile,
files: imported.files,
});
} catch (err: any) {
if (req.file?.path) fs.promises.unlink(req.file.path).catch(() => {});
res.status(400).json({ error: String(err) });
}
},
);
// Import an existing local folder as a project. The user picks a folder
// and OD works inside it directly: every write goes to metadata.baseDir.
// No copy, no shadow tree — the user owns the workspace and is
// responsible for their own version control (git, time machine, etc.),
// mirroring how Cursor / Claude Code / Aider behave.
app.post('/api/import/folder', async (req, res) => {
try {
const { baseDir, name, skillId, designSystemId } = req.body || {};
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
}
let trustedPickerImport = false;
if (isDesktopAuthGateActive()) {
const secret = desktopAuthSecret();
if (secret == null) {
return sendApiError(
res,
503,
'DESKTOP_AUTH_PENDING',
'desktop auth required but secret not yet registered',
{
details: { hint: 'restart desktop or wait for sidecar registration' },
retryable: true,
},
);
}
const headerValue = req.get('x-od-desktop-import-token');
const token = typeof headerValue === 'string' ? headerValue : '';
const now = Date.now();
pruneExpiredImportNonces(now);
const verification = verifyDesktopImportToken(
secret,
baseDir,
token,
now,
consumedImportNonces,
);
if (!verification.ok) {
return sendApiError(
res,
403,
'FORBIDDEN',
'desktop import token rejected',
{ details: { reason: verification.reason } },
);
}
consumedImportNonces.set(verification.nonce, verification.exp);
trustedPickerImport = true;
}
const trimmedInput = baseDir.trim();
if (!path.isAbsolute(path.normalize(trimmedInput))) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir must be absolute');
}
// Resolve symlinks once at import and persist the canonical path.
// Without this, a user-controlled symlink (e.g. ~/sneaky → /etc) at
// baseDir would let writeProjectFile escape the project sandbox at
// every later call: resolveSafe checks the *literal* baseDir, but
// the OS follows the symlink at write time. realpath() collapses
// the chain so the stored baseDir == what the kernel will write to.
let normalizedPath: string;
try {
normalizedPath = await fs.promises.realpath(trimmedInput);
} catch {
return sendApiError(res, 400, 'BAD_REQUEST', 'folder not found');
}
// realpath resolved → lstat the canonical path to ensure it's a
// real directory, not another symlink (defense-in-depth).
let dirStat;
try {
dirStat = await fs.promises.lstat(normalizedPath);
} catch {
return sendApiError(res, 400, 'BAD_REQUEST', 'folder not found');
}
if (!dirStat.isDirectory()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'path must be a directory');
}
if (path.parse(normalizedPath).root === normalizedPath) {
return sendApiError(res, 400, 'BAD_REQUEST', 'cannot import the filesystem root');
}
// Prevent importing the data directory into itself (post-realpath so
// a symlink pointing into RUNTIME_DATA_DIR is also caught). Compare
// against the canonical alias because `normalizedPath` is the import
// folder's realpath; on macOS the data dir at /var/... resolves to
// /private/var/... and would never start-with the user-shaped path.
if (
normalizedPath === RUNTIME_DATA_DIR_CANONICAL ||
normalizedPath.startsWith(RUNTIME_DATA_DIR_CANONICAL + path.sep)
) {
return sendApiError(res, 400, 'BAD_REQUEST', 'cannot import the data directory');
}
const id = randomId();
const now = Date.now();
const projectName =
typeof name === 'string' && name.trim()
? name.trim()
: path.basename(normalizedPath);
const entryFile = await detectEntryFile(normalizedPath);
const designSystemValidation = await validateProjectDesignSystemId(designSystemId);
if (!designSystemValidation.ok) {
return sendApiError(
res,
400,
designSystemValidation.code,
designSystemValidation.message,
);
}
const project = insertProject(db, {
id,
name: projectName,
skillId: skillId ?? null,
designSystemId: designSystemValidation.id,
pendingPrompt: null,
metadata: {
kind: 'prototype',
baseDir: normalizedPath,
importedFrom: 'folder',
entryFile,
...(trustedPickerImport ? { fromTrustedPicker: true as const } : {}),
},
createdAt: now,
updatedAt: now,
});
const cid = randomId();
insertConversation(db, {
id: cid,
projectId: id,
title: `Imported from ${projectName}`,
createdAt: now,
updatedAt: now,
});
if (entryFile) setTabs(db, id, [entryFile], entryFile);
/** @type {import('@open-design/contracts').ImportFolderResponse} */
const body = { project, conversationId: cid, entryFile };
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
}
export interface RegisterProjectExportRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'exports' | 'projectFiles' | 'validation'> {}
export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectExportRoutesDeps) {
const { db } = ctx;
const { sendApiError } = ctx.http;
const { PROJECTS_DIR } = ctx.paths;
const { getProject } = ctx.projectStore;
const { readProjectFile, resolveProjectFilePath } = ctx.projectFiles;
const { isSafeId } = ctx.validation;
const {
buildProjectArchive,
buildBatchArchive,
buildDesktopPdfExportInput,
desktopPdfExporter,
daemonUrlRef,
sanitizeArchiveFilename,
} = ctx.exports;
// Streams a ZIP of the project's on-disk tree so the "Download as .zip"
// share menu can hand the user the actual files they uploaded — e.g. the
// imported `ui-design/` folder — instead of a one-file snapshot of the
// rendered HTML. `root` scopes the archive to a subdirectory; without
// it, the whole project is packed.
app.get('/api/projects/:id/archive', async (req, res) => {
try {
const root = typeof req.query?.root === 'string' ? req.query.root : '';
const project = getProject(db, req.params.id);
const { buffer, baseName } = await buildProjectArchive(
PROJECTS_DIR,
req.params.id,
root,
project?.metadata,
);
const fallbackName = project?.name || req.params.id;
const fileSlug = sanitizeArchiveFilename(baseName || fallbackName) || 'project';
const filename = `${fileSlug}.zip`;
// RFC 5987 dance: legacy `filename=` carries an ASCII fallback, while
// `filename*=UTF-8''…` lets modern browsers pick up project names
// with non-ASCII characters (accents, CJK, etc.) without mojibake.
const asciiFallback =
filename.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '_') || 'project.zip';
res.setHeader('Content-Type', 'application/zip');
res.setHeader(
'Content-Disposition',
`attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
);
res.send(buffer);
} catch (err: any) {
const code = err && err.code;
const status = code === 'ENOENT' || code === 'ENOTDIR' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err?.message || err),
);
}
});
// Batch archive: accepts a list of file names and returns a ZIP of just
// those files. Used by the Design Files panel multi-select download.
app.post('/api/projects/:id/archive/batch', async (req, res) => {
try {
const { files } = req.body || {};
if (!Array.isArray(files) || files.length === 0) {
sendApiError(res, 400, 'BAD_REQUEST', 'files must be a non-empty array');
return;
}
const project = getProject(db, req.params.id);
const { buffer } = await buildBatchArchive(
PROJECTS_DIR,
req.params.id,
files,
project?.metadata,
);
const fileSlug = sanitizeArchiveFilename(project?.name || req.params.id) || 'project';
const filename = `${fileSlug}.zip`;
const asciiFallback =
filename.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '_') || 'project.zip';
res.setHeader('Content-Type', 'application/zip');
res.setHeader(
'Content-Disposition',
`attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
);
res.send(buffer);
} catch (err: any) {
const code = err && err.code;
const status = code === 'ENOENT' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err?.message || err),
);
}
});
app.post('/api/projects/:id/export/pdf', async (req, res) => {
if (typeof desktopPdfExporter !== 'function') {
return sendApiError(
res,
501,
'UPSTREAM_UNAVAILABLE',
'desktop PDF export is only available in the desktop runtime',
);
}
try {
const { fileName, title, deck } = req.body || {};
if (typeof fileName !== 'string' || fileName.length === 0) {
return sendApiError(res, 400, 'BAD_REQUEST', 'fileName required');
}
const input = await buildDesktopPdfExportInput({
daemonUrl: daemonUrlRef.current,
deck: deck === true,
fileName,
projectId: req.params.id,
projectsRoot: PROJECTS_DIR,
title: typeof title === 'string' ? title : undefined,
});
const result = await desktopPdfExporter(input);
res.json(result);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err?.message || err),
);
}
});
// Export endpoint: serves an HTML body with every same-project
// top-level `<link rel=stylesheet>` / `<script src>` inlined.
// Counterpart to GET /api/projects/:id/raw/* — that route stays
// URL-load (one request per asset; FileViewer's default since
// PR #384). This route exists for explicit "Inline top-level
// CSS/JS" exports + the screenshot path where the headless browser
// fetches the response and renders it.
//
// Scope is intentionally narrow: only `<link rel=stylesheet>` and
// `<script src>` are rewritten. `<img src>`, CSS `url(...)` refs,
// `@import`, ES module imports, font sources, and similar remain
// external in the response — see the docstring on
// `apps/daemon/src/inline-assets.ts` for the full not-rewritten list
// and rationale. A fully offline "self-contained" export with image
// and font bundling would be a follow-up issue.
//
// Null-origin (sandboxed iframe srcdoc) callers are intentionally
// NOT supported — the only consumers are the daemon UI (same-origin)
// and server-side screenshot tooling (no Origin header). The
// response also carries `Content-Security-Policy: sandbox
// allow-scripts` so top-level browser navigation (no Origin header,
// would otherwise pass the daemon middleware) cannot escalate to
// daemon-origin privileges through script execution.
//
// See nexu-io/open-design#368 and the architecture lock at
// https://github.com/nexu-io/open-design/issues/368#issuecomment-4366243218.
app.get('/api/projects/:id/export/*', async (req, res) => {
try {
if (!isSafeId(req.params.id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
const inlineRaw =
typeof req.query.inline === 'string' ? req.query.inline.trim().toLowerCase() : '';
if (!['1', 'true', 'yes', 'on'].includes(inlineRaw)) {
return sendApiError(
res,
400,
'BAD_REQUEST',
"query parameter 'inline=1' is required",
);
}
const project = getProject(db, req.params.id);
const relPath = (req.params as any)[0];
// PR #1312 round-5 (lefarcen P2): stat the owner file BEFORE
// readProjectFile so a 100 MiB owner HTML is rejected after a
// cheap stat() call, not after a 100 MiB readFile() into memory.
// The size check + mime check both run pre-buffer here, mirroring
// the sibling-asset stat-then-read contract round 4 already
// applied via AssetHandle. Size fires before mime so an oversize
// non-HTML file returns 413 (not 415) — that ordering is the
// observable Red→Green for this round.
//
// The helper's ownerBytes check (inline-assets.ts:127-133) stays
// as defense-in-depth: it still catches direct in-process callers
// that skip the route and any future drift in the size reported
// by stat vs the bytes actually returned by readFile.
let ownerMeta;
try {
ownerMeta = await resolveProjectFilePath(
PROJECTS_DIR,
req.params.id,
relPath,
project?.metadata,
);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
return sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err),
);
}
if (ownerMeta.size > MAX_INLINE_OWNER_BYTES) {
return sendApiError(
res,
413,
'PAYLOAD_TOO_LARGE',
`owner html ${ownerMeta.size} bytes exceeds MAX_INLINE_OWNER_BYTES ${MAX_INLINE_OWNER_BYTES}`,
);
}
if (!ownerMeta.mime.startsWith('text/html')) {
return sendApiError(
res,
415,
'UNSUPPORTED_MEDIA_TYPE',
'export endpoint only supports HTML files',
);
}
let file;
try {
file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath, project?.metadata);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
return sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err),
);
}
// PR #1312 round-4 (lefarcen P2): stat first, then read. This
// lets the helper short-circuit on maxAssetBytes / maxTotalBytes
// BEFORE the buffer is materialized into memory. A 100 MiB
// sibling file is rejected after the cheap stat call, not after
// a 100 MiB readFile.
const fileReader: InlineAssetReader = async (sibling) => {
let meta;
try {
meta = await resolveProjectFilePath(
PROJECTS_DIR,
req.params.id,
sibling,
project?.metadata,
);
} catch {
return null;
}
return {
size: meta.size,
read: async () => {
try {
const siblingFile = await readProjectFile(
PROJECTS_DIR,
req.params.id,
sibling,
project?.metadata,
);
return siblingFile.buffer.toString('utf8');
} catch {
return null;
}
},
};
};
const rendered = await inlineRelativeAssets(
file.buffer.toString('utf8'),
relPath,
fileReader,
);
// PR #1312 round-2 (lefarcen P2): top-level browser navigation to
// this URL sends no Origin header, so the /api middleware lets it
// through. Without a CSP, any JS in the exported document would
// run at daemon origin with access to /api/, cookies, localStorage,
// etc. `sandbox allow-scripts` treats the response like a sandboxed
// iframe with an opaque origin — scripts execute (that's the point
// of inlining JS for screenshot tooling), but cannot read cookies,
// hit /api/, or escalate to daemon-origin privileges.
res.setHeader('Content-Security-Policy', 'sandbox allow-scripts');
res.type('text/html').send(rendered);
} catch (err: any) {
// PR #1312 round-3 (lefarcen P2): the inliner's cap-enforcement
// throws InlineAssetsLimitError when the owner HTML, candidate
// count, or assembled output exceeds the module-level limits.
// Map every such throw to a 413 PAYLOAD_TOO_LARGE envelope so
// callers see a structured error rather than a generic 400.
if (err instanceof InlineAssetsLimitError || err?.name === 'InlineAssetsLimitError') {
return sendApiError(res, 413, 'PAYLOAD_TOO_LARGE', String(err));
}
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
}
export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {}
export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutesDeps) {
const { db } = ctx;
const { sendApiError } = ctx.http;
const { PROJECTS_DIR, DESIGN_SYSTEMS_DIR } = ctx.paths;
const { getProject } = ctx.projectStore;
const { isSafeId, validateExternalApiBaseUrl } = ctx.validation;
const {
defaultBaseUrlForFinalizeProtocol,
finalizeDesignPackage,
FinalizePackageLockedError,
FinalizeUpstreamError,
isFinalizeProviderProtocol,
redactSecrets,
} = ctx.finalize;
app.post('/api/projects/:id/finalize/:provider', async (req, res) => {
const { apiKey, baseUrl, model, maxTokens, apiVersion, protocol: bodyProtocol } = req.body || {};
try {
// Centralized path-traversal guard. `isSafeId` (apps/daemon/src/projects.ts)
// rejects pure-dot ids (`.`, `..`, etc.) which would otherwise pass
// the char-class regex and resolve to the parent directory under
// path.join. Express decodes percent-encoded `%2e%2e` to `..` before
// we see it, so this check covers both URL-supplied and stored-row
// attack vectors.
if (!isSafeId(req.params.id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
const protocol = req.params.provider;
if (!isFinalizeProviderProtocol(protocol)) {
return sendApiError(
res,
400,
'BAD_REQUEST',
'provider must be one of anthropic|openai|azure|google|ollama',
);
}
if (bodyProtocol !== undefined && bodyProtocol !== protocol) {
return sendApiError(res, 400, 'BAD_REQUEST', 'body protocol must match route provider');
}
if (typeof apiKey !== 'string' || !apiKey.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'apiKey is required');
}
if (typeof model !== 'string' || !model.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'model is required');
}
let effectiveBaseUrl = defaultBaseUrlForFinalizeProtocol(protocol);
if (baseUrl !== undefined) {
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseUrl must be a non-empty string when provided');
}
effectiveBaseUrl = baseUrl.trim();
}
if (!effectiveBaseUrl) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseUrl is required for this provider');
}
const validated = await validateExternalApiBaseUrl(effectiveBaseUrl);
if (validated.error) {
return sendApiError(
res,
validated.forbidden ? 403 : 400,
validated.forbidden ? 'FORBIDDEN' : 'BAD_REQUEST',
validated.error,
);
}
if (maxTokens !== undefined && (typeof maxTokens !== 'number' || maxTokens <= 0)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'maxTokens must be a positive number when provided');
}
if (apiVersion !== undefined && typeof apiVersion !== 'string') {
return sendApiError(res, 400, 'BAD_REQUEST', 'apiVersion must be a string when provided');
}
const project = getProject(db, req.params.id);
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const finalizeAbort = new AbortController();
const abortFromRequest = (): void => {
if (!finalizeAbort.signal.aborted) finalizeAbort.abort();
};
res.on('close', abortFromRequest);
let result;
try {
result = await finalizeDesignPackage(
db,
PROJECTS_DIR,
DESIGN_SYSTEMS_DIR,
req.params.id,
{
protocol,
apiKey,
baseUrl: effectiveBaseUrl,
model,
maxTokens,
...(typeof apiVersion === 'string' && apiVersion.trim()
? { apiVersion: apiVersion.trim() }
: {}),
signal: finalizeAbort.signal,
},
);
} finally {
res.off('close', abortFromRequest);
}
res.json(result);
} catch (err: any) {
// Concurrent finalize - the lockfile was already held by another
// call. Caller can retry after a short wait; not a client error.
// Maps to the shared CONFLICT code per @lefarcen P2 on PR #832.
if (err instanceof FinalizePackageLockedError) {
return sendApiError(res, 409, 'CONFLICT', err.message);
}
// Upstream provider error - status-aware mapping using shared
// ApiErrorCode values. Run the raw upstream body through
// redactSecrets so the API key cannot leak even if the provider
// echoes the inbound headers. Codes per @lefarcen P2 on PR #832:
// 401 -> UNAUTHORIZED, 429 -> RATE_LIMITED, others -> UPSTREAM_UNAVAILABLE.
if (err instanceof FinalizeUpstreamError) {
const safeDetails = redactSecrets(err.rawText || '', [apiKey]);
const init = safeDetails ? { details: safeDetails } : {};
if (err.status === 401) {
return sendApiError(res, 401, 'UNAUTHORIZED', err.message, init);
}
if (err.status === 429) {
return sendApiError(res, 429, 'RATE_LIMITED', err.message, init);
}
return sendApiError(res, 502, 'UPSTREAM_UNAVAILABLE', err.message, init);
}
// The blocking call hit our 120s AbortController timeout - or the
// caller passed an already-aborted signal. Either way, surface as
// 503 with the shared UPSTREAM_UNAVAILABLE code (no dedicated
// TIMEOUT code in the contracts ApiErrorCode union).
const errName =
err && typeof err === 'object' && 'name' in err ? (err as { name?: unknown }).name : '';
if (errName === 'AbortError') {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'finalize timed out');
}
// Unexpected runtime failure (file IO, db access, prompt build).
// Log via console.error per the daemon convention; client sees a
// generic 500 with the shared INTERNAL_ERROR code. Run the message
// through redactSecrets defensively.
console.error('[finalize]', err);
const safeMsg = redactSecrets(String(err?.message || err), [apiKey]);
return sendApiError(res, 500, 'INTERNAL_ERROR', safeMsg);
}
});
}