bopodev-api 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,7 @@ import {
28
28
  runtimeConfigToStateBlobPatch
29
29
  } from "../lib/agent-config";
30
30
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
31
- import { hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
31
+ import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
32
32
  import { requireCompanyScope } from "../middleware/company-scope";
33
33
  import { requireBoardRole, requirePermission } from "../middleware/request-actor";
34
34
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
@@ -148,7 +148,13 @@ export function createAgentsRouter(ctx: AppContext) {
148
148
  });
149
149
 
150
150
  router.get("/runtime-default-cwd", async (req, res) => {
151
- const runtimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, req.companyId!);
151
+ let runtimeCwd: string;
152
+ try {
153
+ runtimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, req.companyId!);
154
+ runtimeCwd = assertRuntimeCwdForCompany(req.companyId!, runtimeCwd, "runtimeCwd");
155
+ } catch (error) {
156
+ return sendError(res, String(error), 422);
157
+ }
152
158
  await mkdir(runtimeCwd, { recursive: true });
153
159
  return sendOk(res, { runtimeCwd });
154
160
  });
@@ -163,10 +169,16 @@ export function createAgentsRouter(ctx: AppContext) {
163
169
  return sendError(res, `Unsupported provider type: ${providerType}`, 422);
164
170
  }
165
171
  const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, req.companyId!);
166
- const runtimeConfig = normalizeRuntimeConfig({
167
- runtimeConfig: req.body?.runtimeConfig,
168
- defaultRuntimeCwd
169
- });
172
+ let runtimeConfig: ReturnType<typeof normalizeRuntimeConfig>;
173
+ try {
174
+ runtimeConfig = normalizeRuntimeConfig({
175
+ runtimeConfig: req.body?.runtimeConfig,
176
+ defaultRuntimeCwd
177
+ });
178
+ runtimeConfig = enforceRuntimeCwdPolicy(req.companyId!, runtimeConfig);
179
+ } catch (error) {
180
+ return sendError(res, String(error), 422);
181
+ }
170
182
  const typedProviderType = providerType as
171
183
  | "claude_code"
172
184
  | "codex"
@@ -197,23 +209,29 @@ export function createAgentsRouter(ctx: AppContext) {
197
209
  return sendError(res, parsed.error.message, 422);
198
210
  }
199
211
  const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, req.companyId!);
200
- const runtimeConfig = normalizeRuntimeConfig({
201
- runtimeConfig: parsed.data.runtimeConfig,
202
- legacy: {
203
- runtimeCommand: parsed.data.runtimeCommand,
204
- runtimeArgs: parsed.data.runtimeArgs,
205
- runtimeCwd: parsed.data.runtimeCwd,
206
- runtimeTimeoutMs: parsed.data.runtimeTimeoutMs,
207
- runtimeModel: parsed.data.runtimeModel,
208
- runtimeThinkingEffort: parsed.data.runtimeThinkingEffort,
209
- bootstrapPrompt: parsed.data.bootstrapPrompt,
210
- runtimeTimeoutSec: parsed.data.runtimeTimeoutSec,
211
- interruptGraceSec: parsed.data.interruptGraceSec,
212
- runtimeEnv: parsed.data.runtimeEnv,
213
- runPolicy: parsed.data.runPolicy
214
- },
215
- defaultRuntimeCwd
216
- });
212
+ let runtimeConfig: ReturnType<typeof normalizeRuntimeConfig>;
213
+ try {
214
+ runtimeConfig = normalizeRuntimeConfig({
215
+ runtimeConfig: parsed.data.runtimeConfig,
216
+ legacy: {
217
+ runtimeCommand: parsed.data.runtimeCommand,
218
+ runtimeArgs: parsed.data.runtimeArgs,
219
+ runtimeCwd: parsed.data.runtimeCwd,
220
+ runtimeTimeoutMs: parsed.data.runtimeTimeoutMs,
221
+ runtimeModel: parsed.data.runtimeModel,
222
+ runtimeThinkingEffort: parsed.data.runtimeThinkingEffort,
223
+ bootstrapPrompt: parsed.data.bootstrapPrompt,
224
+ runtimeTimeoutSec: parsed.data.runtimeTimeoutSec,
225
+ interruptGraceSec: parsed.data.interruptGraceSec,
226
+ runtimeEnv: parsed.data.runtimeEnv,
227
+ runPolicy: parsed.data.runPolicy
228
+ },
229
+ defaultRuntimeCwd
230
+ });
231
+ runtimeConfig = enforceRuntimeCwdPolicy(req.companyId!, runtimeConfig);
232
+ } catch (error) {
233
+ return sendError(res, String(error), 422);
234
+ }
217
235
 
218
236
  if (runtimeConfig.runtimeCwd) {
219
237
  await mkdir(runtimeConfig.runtimeCwd, { recursive: true });
@@ -261,23 +279,29 @@ export function createAgentsRouter(ctx: AppContext) {
261
279
  return sendError(res, parsed.error.message, 422);
262
280
  }
263
281
  const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, req.companyId!);
264
- const runtimeConfig = normalizeRuntimeConfig({
265
- runtimeConfig: parsed.data.runtimeConfig,
266
- legacy: {
267
- runtimeCommand: parsed.data.runtimeCommand,
268
- runtimeArgs: parsed.data.runtimeArgs,
269
- runtimeCwd: parsed.data.runtimeCwd,
270
- runtimeTimeoutMs: parsed.data.runtimeTimeoutMs,
271
- runtimeModel: parsed.data.runtimeModel,
272
- runtimeThinkingEffort: parsed.data.runtimeThinkingEffort,
273
- bootstrapPrompt: parsed.data.bootstrapPrompt,
274
- runtimeTimeoutSec: parsed.data.runtimeTimeoutSec,
275
- interruptGraceSec: parsed.data.interruptGraceSec,
276
- runtimeEnv: parsed.data.runtimeEnv,
277
- runPolicy: parsed.data.runPolicy
278
- },
279
- defaultRuntimeCwd
280
- });
282
+ let runtimeConfig: ReturnType<typeof normalizeRuntimeConfig>;
283
+ try {
284
+ runtimeConfig = normalizeRuntimeConfig({
285
+ runtimeConfig: parsed.data.runtimeConfig,
286
+ legacy: {
287
+ runtimeCommand: parsed.data.runtimeCommand,
288
+ runtimeArgs: parsed.data.runtimeArgs,
289
+ runtimeCwd: parsed.data.runtimeCwd,
290
+ runtimeTimeoutMs: parsed.data.runtimeTimeoutMs,
291
+ runtimeModel: parsed.data.runtimeModel,
292
+ runtimeThinkingEffort: parsed.data.runtimeThinkingEffort,
293
+ bootstrapPrompt: parsed.data.bootstrapPrompt,
294
+ runtimeTimeoutSec: parsed.data.runtimeTimeoutSec,
295
+ interruptGraceSec: parsed.data.interruptGraceSec,
296
+ runtimeEnv: parsed.data.runtimeEnv,
297
+ runPolicy: parsed.data.runPolicy
298
+ },
299
+ defaultRuntimeCwd
300
+ });
301
+ runtimeConfig = enforceRuntimeCwdPolicy(req.companyId!, runtimeConfig);
302
+ } catch (error) {
303
+ return sendError(res, String(error), 422);
304
+ }
281
305
  runtimeConfig.runtimeModel = await resolveOpencodeRuntimeModel(parsed.data.providerType, runtimeConfig);
282
306
  runtimeConfig.runtimeModel = resolveRuntimeModelForProvider(parsed.data.providerType, runtimeConfig.runtimeModel);
283
307
  if (!ensureNamedRuntimeModel(parsed.data.providerType, runtimeConfig.runtimeModel)) {
@@ -390,74 +414,79 @@ export function createAgentsRouter(ctx: AppContext) {
390
414
  parsed.data.interruptGraceSec !== undefined ||
391
415
  parsed.data.runtimeEnv !== undefined ||
392
416
  parsed.data.runPolicy !== undefined;
393
- const nextRuntime = {
394
- ...existingRuntime,
395
- ...(hasRuntimeInput
396
- ? normalizeRuntimeConfig({
397
- runtimeConfig: {
398
- ...existingRuntime,
399
- ...(parsed.data.runtimeConfig ?? {})
400
- },
401
- legacy: {
402
- runtimeCommand: parsed.data.runtimeCommand,
403
- runtimeArgs: parsed.data.runtimeArgs,
404
- runtimeCwd: parsed.data.runtimeCwd,
405
- runtimeTimeoutMs: parsed.data.runtimeTimeoutMs,
406
- runtimeModel: parsed.data.runtimeModel,
407
- runtimeThinkingEffort: parsed.data.runtimeThinkingEffort,
408
- bootstrapPrompt: parsed.data.bootstrapPrompt,
409
- runtimeTimeoutSec: parsed.data.runtimeTimeoutSec ?? existingRuntime.runtimeTimeoutSec,
410
- interruptGraceSec: parsed.data.interruptGraceSec,
411
- runtimeEnv: parsed.data.runtimeEnv,
412
- runPolicy: parsed.data.runPolicy
413
- }
414
- })
415
- : {})
416
- };
417
- nextRuntime.runtimeModel = await resolveOpencodeRuntimeModel(effectiveProviderType, nextRuntime);
418
- nextRuntime.runtimeModel = resolveRuntimeModelForProvider(effectiveProviderType, nextRuntime.runtimeModel);
419
- if (!ensureNamedRuntimeModel(effectiveProviderType, nextRuntime.runtimeModel)) {
420
- return sendError(res, "A named runtime model is required for this provider.", 422);
421
- }
422
- if (!nextRuntime.runtimeCwd && defaultRuntimeCwd) {
423
- nextRuntime.runtimeCwd = defaultRuntimeCwd;
424
- }
425
- const effectiveRuntimeCwd = nextRuntime.runtimeCwd ?? "";
426
- if (requiresRuntimeCwd(effectiveProviderType) && !hasText(effectiveRuntimeCwd)) {
427
- return sendError(res, "Runtime working directory is required for this runtime provider.", 422);
428
- }
429
- if (requiresRuntimeCwd(effectiveProviderType) && hasText(effectiveRuntimeCwd)) {
430
- await mkdir(effectiveRuntimeCwd, { recursive: true });
431
- }
432
- const agent = await updateAgent(ctx.db, {
433
- companyId: req.companyId!,
434
- id: req.params.agentId,
435
- managerAgentId: parsed.data.managerAgentId,
436
- role: parsed.data.role,
437
- name: parsed.data.name,
438
- providerType: parsed.data.providerType,
439
- status: parsed.data.status,
440
- heartbeatCron: parsed.data.heartbeatCron,
441
- monthlyBudgetUsd:
442
- typeof parsed.data.monthlyBudgetUsd === "number" ? parsed.data.monthlyBudgetUsd.toFixed(4) : undefined,
443
- canHireAgents: parsed.data.canHireAgents,
444
- ...runtimeConfigToDb(nextRuntime),
445
- stateBlob: runtimeConfigToStateBlobPatch(nextRuntime)
446
- });
447
- if (!agent) {
448
- return sendError(res, "Agent not found.", 404);
449
- }
417
+ try {
418
+ let nextRuntime = {
419
+ ...existingRuntime,
420
+ ...(hasRuntimeInput
421
+ ? normalizeRuntimeConfig({
422
+ runtimeConfig: {
423
+ ...existingRuntime,
424
+ ...(parsed.data.runtimeConfig ?? {})
425
+ },
426
+ legacy: {
427
+ runtimeCommand: parsed.data.runtimeCommand,
428
+ runtimeArgs: parsed.data.runtimeArgs,
429
+ runtimeCwd: parsed.data.runtimeCwd,
430
+ runtimeTimeoutMs: parsed.data.runtimeTimeoutMs,
431
+ runtimeModel: parsed.data.runtimeModel,
432
+ runtimeThinkingEffort: parsed.data.runtimeThinkingEffort,
433
+ bootstrapPrompt: parsed.data.bootstrapPrompt,
434
+ runtimeTimeoutSec: parsed.data.runtimeTimeoutSec ?? existingRuntime.runtimeTimeoutSec,
435
+ interruptGraceSec: parsed.data.interruptGraceSec,
436
+ runtimeEnv: parsed.data.runtimeEnv,
437
+ runPolicy: parsed.data.runPolicy
438
+ }
439
+ })
440
+ : {})
441
+ };
442
+ nextRuntime = enforceRuntimeCwdPolicy(req.companyId!, nextRuntime);
443
+ nextRuntime.runtimeModel = await resolveOpencodeRuntimeModel(effectiveProviderType, nextRuntime);
444
+ nextRuntime.runtimeModel = resolveRuntimeModelForProvider(effectiveProviderType, nextRuntime.runtimeModel);
445
+ if (!ensureNamedRuntimeModel(effectiveProviderType, nextRuntime.runtimeModel)) {
446
+ return sendError(res, "A named runtime model is required for this provider.", 422);
447
+ }
448
+ if (!nextRuntime.runtimeCwd && defaultRuntimeCwd) {
449
+ nextRuntime.runtimeCwd = assertRuntimeCwdForCompany(req.companyId!, defaultRuntimeCwd, "runtimeCwd");
450
+ }
451
+ const effectiveRuntimeCwd = nextRuntime.runtimeCwd ?? "";
452
+ if (requiresRuntimeCwd(effectiveProviderType) && !hasText(effectiveRuntimeCwd)) {
453
+ return sendError(res, "Runtime working directory is required for this runtime provider.", 422);
454
+ }
455
+ if (requiresRuntimeCwd(effectiveProviderType) && hasText(effectiveRuntimeCwd)) {
456
+ await mkdir(effectiveRuntimeCwd, { recursive: true });
457
+ }
458
+ const agent = await updateAgent(ctx.db, {
459
+ companyId: req.companyId!,
460
+ id: req.params.agentId,
461
+ managerAgentId: parsed.data.managerAgentId,
462
+ role: parsed.data.role,
463
+ name: parsed.data.name,
464
+ providerType: parsed.data.providerType,
465
+ status: parsed.data.status,
466
+ heartbeatCron: parsed.data.heartbeatCron,
467
+ monthlyBudgetUsd:
468
+ typeof parsed.data.monthlyBudgetUsd === "number" ? parsed.data.monthlyBudgetUsd.toFixed(4) : undefined,
469
+ canHireAgents: parsed.data.canHireAgents,
470
+ ...runtimeConfigToDb(nextRuntime),
471
+ stateBlob: runtimeConfigToStateBlobPatch(nextRuntime)
472
+ });
473
+ if (!agent) {
474
+ return sendError(res, "Agent not found.", 404);
475
+ }
450
476
 
451
- await appendAuditEvent(ctx.db, {
452
- companyId: req.companyId!,
453
- actorType: "human",
454
- eventType: "agent.updated",
455
- entityType: "agent",
456
- entityId: agent.id,
457
- payload: agent
458
- });
459
- await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, agent.id);
460
- return sendOk(res, toAgentResponse(agent as unknown as Record<string, unknown>));
477
+ await appendAuditEvent(ctx.db, {
478
+ companyId: req.companyId!,
479
+ actorType: "human",
480
+ eventType: "agent.updated",
481
+ entityType: "agent",
482
+ entityId: agent.id,
483
+ payload: agent
484
+ });
485
+ await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, agent.id);
486
+ return sendOk(res, toAgentResponse(agent as unknown as Record<string, unknown>));
487
+ } catch (error) {
488
+ return sendError(res, String(error), 422);
489
+ }
461
490
  });
462
491
 
463
492
  router.delete("/:agentId", async (req, res) => {
@@ -579,6 +608,16 @@ function listUnsupportedAgentUpdateKeys(payload: unknown) {
579
608
  return unsupported;
580
609
  }
581
610
 
611
+ function enforceRuntimeCwdPolicy(companyId: string, runtime: ReturnType<typeof normalizeRuntimeConfig>) {
612
+ if (!runtime.runtimeCwd) {
613
+ return runtime;
614
+ }
615
+ return {
616
+ ...runtime,
617
+ runtimeCwd: assertRuntimeCwdForCompany(companyId, runtime.runtimeCwd, "runtimeCwd")
618
+ };
619
+ }
620
+
582
621
  async function findDuplicateHireRequest(
583
622
  db: AppContext["db"],
584
623
  companyId: string,
@@ -0,0 +1,54 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import type { AppContext } from "../context";
4
+ import { sendError, sendOk } from "../http";
5
+ import { issueActorToken } from "../security/actor-token";
6
+ import { isAuthenticatedMode, resolveDeploymentMode } from "../security/deployment-mode";
7
+
8
+ const createActorTokenSchema = z.object({
9
+ actorType: z.enum(["board", "member", "agent"]),
10
+ actorId: z.string().trim().min(1),
11
+ actorCompanies: z.array(z.string().trim().min(1)).default([]),
12
+ actorPermissions: z.array(z.string().trim().min(1)).default([]),
13
+ ttlSec: z.number().int().positive().max(86_400).optional()
14
+ });
15
+
16
+ export function createAuthRouter(ctx: AppContext) {
17
+ const router = Router();
18
+
19
+ router.post("/actor-token", (req, res) => {
20
+ const parsed = createActorTokenSchema.safeParse(req.body);
21
+ if (!parsed.success) {
22
+ return sendError(res, parsed.error.message, 422);
23
+ }
24
+ const secret = process.env.BOPO_AUTH_TOKEN_SECRET?.trim();
25
+ if (!secret) {
26
+ return sendError(
27
+ res,
28
+ "Actor token support is not configured. Set BOPO_AUTH_TOKEN_SECRET in the API environment.",
29
+ 503
30
+ );
31
+ }
32
+ const bootstrapSecret = process.env.BOPO_AUTH_BOOTSTRAP_SECRET?.trim();
33
+ const headerSecret = req.header("x-bopo-bootstrap-secret")?.trim();
34
+ const localMode = !isAuthenticatedMode(ctx.deploymentMode ?? resolveDeploymentMode());
35
+ const bootstrapAllowed = localMode || (bootstrapSecret && headerSecret && bootstrapSecret === headerSecret);
36
+ if (!bootstrapAllowed) {
37
+ return sendError(res, "Actor token issuance requires bootstrap authorization.", 403);
38
+ }
39
+
40
+ const token = issueActorToken(
41
+ {
42
+ actorType: parsed.data.actorType,
43
+ actorId: parsed.data.actorId,
44
+ actorCompanies: parsed.data.actorType === "board" ? null : parsed.data.actorCompanies,
45
+ actorPermissions: parsed.data.actorPermissions,
46
+ ttlSec: parsed.data.ttlSec
47
+ },
48
+ secret
49
+ );
50
+ return sendOk(res, { token, expiresInSec: parsed.data.ttlSec ?? 3_600 });
51
+ });
52
+
53
+ return router;
54
+ }
@@ -5,6 +5,7 @@ import type { AppContext } from "../context";
5
5
  import { sendError, sendOk } from "../http";
6
6
  import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
7
7
  import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
8
+ import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
8
9
 
9
10
  const createCompanySchema = z.object({
10
11
  name: z.string().min(1),
@@ -33,6 +34,7 @@ export function createCompaniesRouter(ctx: AppContext) {
33
34
  }
34
35
  const company = await createCompany(ctx.db, parsed.data);
35
36
  await ensureCompanyBuiltinPluginDefaults(ctx.db, company.id);
37
+ await ensureCompanyBuiltinTemplateDefaults(ctx.db, company.id);
36
38
  await ensureCompanyModelPricingDefaults(ctx.db, company.id);
37
39
  return sendOk(res, company);
38
40
  });
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import {
4
4
  appendAuditEvent,
5
5
  clearApprovalInboxDismissed,
6
+ countPendingApprovalRequests,
6
7
  getApprovalRequest,
7
8
  listApprovalInboxStates,
8
9
  listApprovalRequests,
@@ -44,6 +45,11 @@ export function createGovernanceRouter(ctx: AppContext) {
44
45
  );
45
46
  });
46
47
 
48
+ router.get("/approvals/pending-count", async (req, res) => {
49
+ const count = await countPendingApprovalRequests(ctx.db, req.companyId!);
50
+ return sendOk(res, { count });
51
+ });
52
+
47
53
  router.get("/inbox", async (req, res) => {
48
54
  const actorId = req.actor?.id ?? "local-board";
49
55
  const [approvals, inboxStates] = await Promise.all([
@@ -166,6 +172,8 @@ export function createGovernanceRouter(ctx: AppContext) {
166
172
  const eventType =
167
173
  resolution.action === "grant_plugin_capabilities"
168
174
  ? "plugin.capabilities_granted_from_approval"
175
+ : resolution.action === "apply_template"
176
+ ? "template.applied_from_approval"
169
177
  : resolution.execution.entityType === "agent"
170
178
  ? "agent.hired_from_approval"
171
179
  : resolution.execution.entityType === "goal"
@@ -21,13 +21,14 @@ import {
21
21
  listIssueComments,
22
22
  listIssues,
23
23
  projects,
24
+ projectWorkspaces,
24
25
  updateIssueComment,
25
26
  updateIssue
26
27
  } from "bopodev-db";
27
28
  import { nanoid } from "nanoid";
28
29
  import type { AppContext } from "../context";
29
30
  import { sendError, sendOk } from "../http";
30
- import { isInsidePath, normalizeAbsolutePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
31
+ import { isInsidePath, normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
31
32
  import { requireCompanyScope } from "../middleware/company-scope";
32
33
  import { requirePermission } from "../middleware/request-actor";
33
34
 
@@ -220,7 +221,7 @@ export function createIssuesRouter(ctx: AppContext) {
220
221
  if (!issueContext) {
221
222
  return sendError(res, "Issue not found.", 404);
222
223
  }
223
- const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceLocalPath);
224
+ const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceCwd);
224
225
  const attachmentDir = join(workspacePath, ".bopo", "issues", issueContext.issueId, "attachments");
225
226
  await mkdir(attachmentDir, { recursive: true });
226
227
 
@@ -313,7 +314,7 @@ export function createIssuesRouter(ctx: AppContext) {
313
314
  if (!attachment) {
314
315
  return sendError(res, "Attachment not found.", 404);
315
316
  }
316
- const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceLocalPath);
317
+ const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceCwd);
317
318
  const absolutePath = resolve(workspacePath, attachment.relativePath);
318
319
  if (!isInsidePath(workspacePath, absolutePath)) {
319
320
  return sendError(res, "Invalid attachment path.", 422);
@@ -346,7 +347,7 @@ export function createIssuesRouter(ctx: AppContext) {
346
347
  if (!attachment) {
347
348
  return sendError(res, "Attachment not found.", 404);
348
349
  }
349
- const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceLocalPath);
350
+ const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceCwd);
350
351
  const absolutePath = resolve(workspacePath, attachment.relativePath);
351
352
  if (!isInsidePath(workspacePath, absolutePath)) {
352
353
  return sendError(res, "Invalid attachment path.", 422);
@@ -717,9 +718,9 @@ function toIssueAttachmentResponse(attachment: Record<string, unknown>, issueId:
717
718
  };
718
719
  }
719
720
 
720
- function resolveWorkspacePath(companyId: string, projectId: string, workspaceLocalPath: string | null) {
721
- if (workspaceLocalPath && workspaceLocalPath.trim().length > 0) {
722
- return normalizeAbsolutePath(workspaceLocalPath);
721
+ function resolveWorkspacePath(companyId: string, projectId: string, workspaceCwd: string | null) {
722
+ if (workspaceCwd && workspaceCwd.trim().length > 0) {
723
+ return normalizeCompanyWorkspacePath(companyId, workspaceCwd);
723
724
  }
724
725
  return resolveProjectWorkspacePath(companyId, projectId);
725
726
  }
@@ -739,8 +740,7 @@ async function getIssueContextForAttachment(ctx: AppContext, companyId: string,
739
740
  }
740
741
  const [project] = await ctx.db
741
742
  .select({
742
- id: projects.id,
743
- workspaceLocalPath: projects.workspaceLocalPath
743
+ id: projects.id
744
744
  })
745
745
  .from(projects)
746
746
  .where(and(eq(projects.companyId, companyId), eq(projects.id, issue.projectId)))
@@ -748,11 +748,24 @@ async function getIssueContextForAttachment(ctx: AppContext, companyId: string,
748
748
  if (!project) {
749
749
  return null;
750
750
  }
751
+ const [workspace] = await ctx.db
752
+ .select({
753
+ cwd: projectWorkspaces.cwd
754
+ })
755
+ .from(projectWorkspaces)
756
+ .where(
757
+ and(
758
+ eq(projectWorkspaces.companyId, companyId),
759
+ eq(projectWorkspaces.projectId, issue.projectId),
760
+ eq(projectWorkspaces.isPrimary, true)
761
+ )
762
+ )
763
+ .limit(1);
751
764
  return {
752
765
  issueId: issue.issueId,
753
766
  companyId: issue.companyId,
754
767
  projectId: issue.projectId,
755
- workspaceLocalPath: project.workspaceLocalPath
768
+ workspaceCwd: workspace?.cwd ?? null
756
769
  };
757
770
  }
758
771