diff --git a/src/main/database.ts b/src/main/database.ts index 4ffa948b..12f1400b 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -98,6 +98,7 @@ export class AppDatabase extends AppDatabaseBase { updateWorkProduct(id: string, companyId: string, patch: Parameters["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); } diff --git a/src/main/database/documents.ts b/src/main/database/documents.ts index b0e92091..87560e79 100644 --- a/src/main/database/documents.ts +++ b/src/main/database/documents.ts @@ -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 = ` 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; diff --git a/src/main/ipc-core-read-handlers.ts b/src/main/ipc-core-read-handlers.ts index 95119f9c..a036c92c 100644 --- a/src/main/ipc-core-read-handlers.ts +++ b/src/main/ipc-core-read-handlers.ts @@ -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"], diff --git a/src/preload/index.ts b/src/preload/index.ts index 757d95f5..10722b67 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/components/company-operations/DocumentCenter.tsx b/src/renderer/components/company-operations/DocumentCenter.tsx index ba74f8aa..d1a8a8af 100644 --- a/src/renderer/components/company-operations/DocumentCenter.tsx +++ b/src/renderer/components/company-operations/DocumentCenter.tsx @@ -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 = (
@@ -155,7 +158,7 @@ export function DocumentCenter({ companyId, documents, workProducts, agents, pro
{workProducts.map((product) => ( - + ))}
diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 5c6b4563..633fc993 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -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", diff --git a/src/shared/contracts/desktop-api/companies.ts b/src/shared/contracts/desktop-api/companies.ts index 0b520aa1..d9dd65ae 100644 --- a/src/shared/contracts/desktop-api/companies.ts +++ b/src/shared/contracts/desktop-api/companies.ts @@ -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>; pickDirectory: () => Promise>; openPath: (targetPath: string) => Promise>; + openWorkProduct: (input: z.infer) => Promise>; saveCompany: (input: z.infer) => Promise>; setCurrentCompany: (companyId: string) => Promise>; saveConnector: (input: z.infer) => Promise>;