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.
- package/package.json +4 -4
- package/src/app.ts +57 -1
- package/src/context.ts +3 -0
- package/src/lib/agent-config.ts +10 -1
- package/src/lib/git-runtime.ts +447 -0
- package/src/lib/instance-paths.ts +75 -10
- package/src/lib/workspace-policy.ts +153 -10
- package/src/middleware/request-actor.ts +67 -2
- package/src/realtime/hub.ts +31 -2
- package/src/routes/agents.ts +146 -107
- package/src/routes/auth.ts +54 -0
- package/src/routes/companies.ts +2 -0
- package/src/routes/governance.ts +8 -0
- package/src/routes/issues.ts +23 -10
- package/src/routes/projects.ts +361 -63
- package/src/routes/templates.ts +439 -0
- package/src/scripts/backfill-project-workspaces.ts +61 -24
- package/src/scripts/db-init.ts +7 -1
- package/src/scripts/onboard-seed.ts +140 -12
- package/src/security/actor-token.ts +133 -0
- package/src/security/deployment-mode.ts +73 -0
- package/src/server.ts +72 -4
- package/src/services/governance-service.ts +122 -15
- package/src/services/heartbeat-service.ts +136 -36
- package/src/services/plugin-runtime.ts +2 -2
- package/src/services/template-apply-service.ts +138 -0
- package/src/services/template-catalog.ts +325 -0
- package/src/services/template-preview-service.ts +78 -0
package/src/routes/agents.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
+
}
|
package/src/routes/companies.ts
CHANGED
|
@@ -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
|
});
|
package/src/routes/governance.ts
CHANGED
|
@@ -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"
|
package/src/routes/issues.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
721
|
-
if (
|
|
722
|
-
return
|
|
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
|
-
|
|
768
|
+
workspaceCwd: workspace?.cwd ?? null
|
|
756
769
|
};
|
|
757
770
|
}
|
|
758
771
|
|