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"],
+1
View File
@@ -9,6 +9,7 @@ const api: DesktopApi = {
loadSnapshot: () => ipcRenderer.invoke(IPC_CHANNELS.loadSnapshot),
pickDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.pickDirectory),
openPath: (targetPath) => ipcRenderer.invoke(IPC_CHANNELS.openPath, targetPath),
openWorkProduct: (input) => ipcRenderer.invoke(IPC_CHANNELS.openWorkProduct, input),
saveCompany: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveCompany, input),
setCurrentCompany: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.setCurrentCompany, companyId),
saveConnector: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveConnector, input),
@@ -65,18 +65,21 @@ function DocumentEditor({ companyId, agents, projects, goals, document, onSaved
);
}
function WorkProductRow({ product }: { product: WorkProductRecord }) {
function WorkProductRow({ product, companyId }: { product: WorkProductRecord; companyId: string }) {
const subtitle = [
product.type.replaceAll("_", " "),
product.provider,
product.externalId ?? product.url ?? null,
product.updatedAt.slice(0, 10),
].filter((v): v is string => Boolean(v)).join(" · ");
const primaryAction = product.url
? () => { void window.agentCompany.openPath(product.url!); }
: product.provider === "filesystem" && product.externalId
? () => { void window.agentCompany.openPath(product.externalId!); }
: null;
// externalId is workspace-relative; route through openWorkProduct so
// the main process can join it against the work product's workspace
// root before handing off to the OS shell. Direct `openPath(externalId)`
// used to flash a failure when the renderer tried to treat the relative
// path as absolute.
const primaryAction = product.url || (product.provider === "filesystem" && product.externalId)
? () => { void window.agentCompany.openWorkProduct({ companyId, id: product.id }); }
: null;
const rowBody = (
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
@@ -155,7 +158,7 @@ export function DocumentCenter({ companyId, documents, workProducts, agents, pro
</div>
<ListFrame>
{workProducts.map((product) => (
<WorkProductRow key={product.id} product={product} />
<WorkProductRow key={product.id} product={product} companyId={companyId} />
))}
</ListFrame>
</div>
+1
View File
@@ -2,6 +2,7 @@ export const IPC_CHANNELS = {
loadSnapshot: "agent-company:load-snapshot",
pickDirectory: "agent-company:pick-directory",
openPath: "agent-company:open-path",
openWorkProduct: "agent-company:open-work-product",
saveCompany: "agent-company:save-company",
setCurrentCompany: "agent-company:set-current-company",
saveConnector: "agent-company:save-connector",
@@ -1,13 +1,14 @@
import type { z } from "zod";
import type { CompanyMetrics, OnboardingBootstrapResult, ProfileSnapshot, StandupReport } from "../../types";
import type { bootstrapOnboardingSchema, companyInputSchema, connectorDisabledModelsSchema, connectorInputSchema, settingsSchema } from "../schemas-company";
import type { metricsQuerySchema } from "../schemas-badges";
import type { entityGetterSchema, metricsQuerySchema } from "../schemas-badges";
import type { DesktopResult } from "./common";
export type CompaniesApi = {
loadSnapshot: () => Promise<DesktopResult<ProfileSnapshot>>;
pickDirectory: () => Promise<DesktopResult<string | null>>;
openPath: (targetPath: string) => Promise<DesktopResult<boolean>>;
openWorkProduct: (input: z.infer<typeof entityGetterSchema>) => Promise<DesktopResult<boolean>>;
saveCompany: (input: z.infer<typeof companyInputSchema>) => Promise<DesktopResult<string>>;
setCurrentCompany: (companyId: string) => Promise<DesktopResult<boolean>>;
saveConnector: (input: z.infer<typeof connectorInputSchema>) => Promise<DesktopResult<boolean>>;