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:
1 parent
1a0fdc6f31
commit
ff9a52324e
7 files changed
+113
-8
No files matched your search
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
Reference in new issue
Block a user