Route work-product clicks through a resolver IPC

Filesystem work products store `external_id` as a workspace-relative
path so artifacts survive a workspace-root move, but the renderer was
handing that relative fragment straight to `shell.openPath`, which
needs an absolute managed path. Clicks flickered into a failed open.

Add a dedicated `openWorkProduct({ companyId, id })` IPC backed by
`resolveWorkProductTarget` on the DB layer. The resolver joins the
work-product row back to its originating task → workspace (or run →
workspace for taskless runs), composes an absolute path, and rejects
any relative fragment that would escape the workspace root. The
renderer now passes the work product's id — path resolution stays on
the main process where the managed-path policy already lives.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 15:05:21 -04:00
1 parent 1a0fdc6f31
commit ff9a52324e
7 files changed
+113 -8

No files matched your search

+1
View File
@@ -98,6 +98,7 @@ export class AppDatabase extends AppDatabaseBase {
updateWorkProduct(id: string, companyId: string, patch: Parameters<ReturnType<typeof import("./database/documents").buildDocumentMethods>["updateWorkProduct"]>[2]) { return this.documentsMod.updateWorkProduct(id, companyId, patch); }
listWorkProductsForTask(taskId: string, companyId: string) { return this.documentsMod.listWorkProductsForTask(taskId, companyId); }
findWorkProductByExternalId(companyId: string, provider: string, externalId: string) { return this.documentsMod.findWorkProductByExternalId(companyId, provider, externalId); }
resolveWorkProductTarget(workProductId: string, companyId: string) { return this.documentsMod.resolveWorkProductTarget(workProductId, companyId); }
deleteWorkProduct(id: string, companyId: string) { return this.documentsMod.deleteWorkProduct(id, companyId); }
linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string) { return this.documentsMod.linkDocumentToTask(taskId, documentId, companyId, key); }
unlinkDocumentFromTask(taskId: string, key: string, companyId: string) { this.documentsMod.unlinkDocumentFromTask(taskId, key, companyId); }
+69
View File
@@ -1,9 +1,45 @@
import { randomUUID } from "node:crypto";
import { isAbsolute, resolve as resolvePath, relative as relativePath } from "node:path";
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import type { ReadAll, Transaction } from "./helpers";
import { ensureId, nowIso } from "./helpers";
import { buildConfigRevisionMethods } from "./config-revisions";
function resolveWorkspaceRoot(
getDb: () => DatabaseSync,
companyId: string,
taskId: string | null,
createdByRunId: string | null,
): string | null {
if (taskId) {
const row = getDb().prepare(
"SELECT w.local_path AS localPath FROM tasks t JOIN workspaces w ON w.id = t.workspace_id WHERE t.id = ? AND t.company_id = ?",
).get(taskId, companyId) as { localPath: string } | undefined;
if (row?.localPath) return row.localPath;
}
if (createdByRunId) {
const row = getDb().prepare(
"SELECT w.local_path AS localPath FROM runs r JOIN workspaces w ON w.id = r.workspace_id WHERE r.id = ? AND r.company_id = ?",
).get(createdByRunId, companyId) as { localPath: string } | undefined;
if (row?.localPath) return row.localPath;
}
return null;
}
/**
* Joins a workspace root with a relative path, refusing paths that escape
* the workspace (parent-dir traversal or absolute substitution). Returns
* null when the relative fragment would land outside `root`; the caller
* treats that as "cannot resolve" and does not open anything.
*/
function joinWorkspaceRelative(root: string, rel: string): string | null {
if (isAbsolute(rel)) return null;
const joined = resolvePath(root, rel);
const within = relativePath(root, joined);
if (within.startsWith("..") || isAbsolute(within)) return null;
return joined;
}
/**
* Document row shape returned by listDocuments — used by
* GET /api/companies/:companyId/documents. Mirrors the historical inline
@@ -279,6 +315,39 @@ export function buildDocumentMethods(
return Number(result.changes ?? 0);
},
/**
* Resolves the absolute filesystem (or URL) target for a work product.
*
* Filesystem artifacts are stored as `external_id = <workspace-relative
* path>` to stay portable across workspace-root moves; `shell.openPath`
* needs an absolute managed path, so the renderer can't just forward
* the external id. This method joins the work-product row back to its
* originating task → workspace, or — for taskless runs — through the
* creating run, so the caller gets either a URL (non-filesystem
* artifact) or a concrete absolute path that downstream
* `canOpenPath` validates.
*/
resolveWorkProductTarget(workProductId: string, companyId: string): { kind: "url" | "path"; value: string } | null {
const row = getDb().prepare(
"SELECT provider, external_id, url, task_id, created_by_run_id FROM work_products WHERE id = ? AND company_id = ?",
).get(workProductId, companyId) as
| { provider: string; external_id: string | null; url: string | null; task_id: string | null; created_by_run_id: string | null }
| undefined;
if (!row) return null;
if (row.url && row.url.length > 0) {
return { kind: "url", value: row.url };
}
if (row.provider !== "filesystem" || !row.external_id) return null;
const workspaceRoot = resolveWorkspaceRoot(getDb, companyId, row.task_id, row.created_by_run_id);
if (!workspaceRoot) return null;
const absolute = joinWorkspaceRelative(workspaceRoot, row.external_id);
if (!absolute) return null;
return { kind: "path", value: absolute };
},
listWorkProductsForTask(taskId: string, companyId: string) {
return readAll<{
id: string; company_id: string; project_id: string | null; task_id: string;
+29
View File
@@ -51,6 +51,35 @@ export function registerCoreReadHandlers({
return error ? fail("OPEN_PATH_FAILED", error) : ok(true);
});
/**
* openWorkProduct resolves a work_product row to a concrete absolute
* path (or URL) before handing off to the OS shell. The DocumentCenter
* can no longer forward `externalId` straight into `openPath`: that
* column stores a workspace-relative path so artifacts survive a
* workspace-root move, but `shell.openPath` wants an absolute managed
* path. The DB resolver joins the work_product back to its originating
* task → workspace (or run → workspace for taskless runs) so the path
* we hand to the shell is always inside the currently-mounted managed
* tree and passes `canOpenPath` validation.
*/
registerHandle(IPC_CHANNELS.openWorkProduct, async (_event, payload) => {
const parsed = entityGetterSchema.safeParse(payload);
if (!parsed.success) return fail("INVALID_PAYLOAD", "openWorkProduct requires { companyId, id }.");
const companyError = ensureCompanyExists(parsed.data.companyId);
if (companyError) return companyError;
const target = db.resolveWorkProductTarget(parsed.data.id, parsed.data.companyId);
if (!target) return fail("WORK_PRODUCT_NOT_FOUND", "Work product not found or its workspace is unresolved.");
if (target.kind === "url") {
const error = await shell.openExternal(target.value).then(() => "").catch((err: unknown) => err instanceof Error ? err.message : String(err));
return error ? fail("OPEN_PATH_FAILED", error) : ok(true);
}
if (!db.canOpenPath(target.value)) {
return fail("OPEN_PATH_FORBIDDEN", "The requested path is outside the managed desktop workspace.");
}
const error = await shell.openPath(target.value);
return error ? fail("OPEN_PATH_FAILED", error) : ok(true);
});
registerHandle(IPC_CHANNELS.backupProfile, async () => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", "createDirectory"],