bopodev-api 0.1.28 → 0.1.30

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.
Files changed (46) hide show
  1. package/package.json +4 -4
  2. package/src/app.ts +17 -69
  3. package/src/lib/ceo-bootstrap-prompt.ts +1 -0
  4. package/src/lib/run-artifact-paths.ts +8 -0
  5. package/src/middleware/cors-config.ts +36 -0
  6. package/src/middleware/request-actor.ts +10 -16
  7. package/src/middleware/request-id.ts +9 -0
  8. package/src/middleware/request-logging.ts +24 -0
  9. package/src/realtime/office-space.ts +1 -0
  10. package/src/routes/agents.ts +90 -46
  11. package/src/routes/companies.ts +20 -1
  12. package/src/routes/goals.ts +7 -13
  13. package/src/routes/governance.ts +2 -5
  14. package/src/routes/heartbeats.ts +7 -25
  15. package/src/routes/issues.ts +65 -120
  16. package/src/routes/observability.ts +6 -1
  17. package/src/routes/plugins.ts +5 -17
  18. package/src/routes/projects.ts +7 -25
  19. package/src/routes/templates.ts +6 -21
  20. package/src/scripts/onboard-seed.ts +18 -8
  21. package/src/server.ts +33 -292
  22. package/src/services/company-export-service.ts +63 -0
  23. package/src/services/governance-service.ts +10 -14
  24. package/src/services/heartbeat-service/active-runs.ts +15 -0
  25. package/src/services/heartbeat-service/budget-override.ts +46 -0
  26. package/src/services/heartbeat-service/claims.ts +61 -0
  27. package/src/services/heartbeat-service/cron.ts +58 -0
  28. package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
  29. package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
  30. package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +201 -634
  31. package/src/services/heartbeat-service/index.ts +5 -0
  32. package/src/services/heartbeat-service/stop.ts +90 -0
  33. package/src/services/heartbeat-service/sweep.ts +145 -0
  34. package/src/services/heartbeat-service/types.ts +66 -0
  35. package/src/services/memory-file-service.ts +10 -2
  36. package/src/services/template-apply-service.ts +6 -0
  37. package/src/services/template-catalog.ts +37 -3
  38. package/src/shutdown/graceful-shutdown.ts +77 -0
  39. package/src/startup/database.ts +41 -0
  40. package/src/startup/deployment-validation.ts +37 -0
  41. package/src/startup/env.ts +17 -0
  42. package/src/startup/runtime-health.ts +128 -0
  43. package/src/startup/scheduler-config.ts +39 -0
  44. package/src/types/express.d.ts +13 -0
  45. package/src/types/request-actor.ts +6 -0
  46. package/src/validation/issue-routes.ts +80 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -17,9 +17,9 @@
17
17
  "nanoid": "^5.1.5",
18
18
  "ws": "^8.19.0",
19
19
  "zod": "^4.1.5",
20
- "bopodev-agent-sdk": "0.1.28",
21
- "bopodev-db": "0.1.28",
22
- "bopodev-contracts": "0.1.28"
20
+ "bopodev-contracts": "0.1.30",
21
+ "bopodev-db": "0.1.30",
22
+ "bopodev-agent-sdk": "0.1.30"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/cors": "^2.8.19",
package/src/app.ts CHANGED
@@ -1,8 +1,6 @@
1
- import cors from "cors";
2
1
  import express from "express";
3
2
  import type { NextFunction, Request, Response } from "express";
4
- import { RepositoryValidationError, sql } from "bopodev-db";
5
- import { nanoid } from "nanoid";
3
+ import { pingDatabase, RepositoryValidationError } from "bopodev-db";
6
4
  import type { AppContext } from "./context";
7
5
  import { createAgentsRouter } from "./routes/agents";
8
6
  import { createAuthRouter } from "./routes/auth";
@@ -17,6 +15,9 @@ import { createProjectsRouter } from "./routes/projects";
17
15
  import { createPluginsRouter } from "./routes/plugins";
18
16
  import { createTemplatesRouter } from "./routes/templates";
19
17
  import { sendError } from "./http";
18
+ import { createCorsMiddleware } from "./middleware/cors-config";
19
+ import { attachCrudRequestLogging } from "./middleware/request-logging";
20
+ import { attachRequestId } from "./middleware/request-id";
20
21
  import { attachRequestActor } from "./middleware/request-actor";
21
22
  import { resolveAllowedOrigins, resolveDeploymentMode } from "./security/deployment-mode";
22
23
 
@@ -24,75 +25,20 @@ export function createApp(ctx: AppContext) {
24
25
  const app = express();
25
26
  const deploymentMode = ctx.deploymentMode ?? resolveDeploymentMode();
26
27
  const allowedOrigins = ctx.allowedOrigins ?? resolveAllowedOrigins(deploymentMode);
27
- app.use(
28
- cors({
29
- origin(origin, callback) {
30
- if (!origin) {
31
- callback(null, true);
32
- return;
33
- }
34
- if (
35
- deploymentMode === "local" &&
36
- (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:"))
37
- ) {
38
- callback(null, true);
39
- return;
40
- }
41
- if (allowedOrigins.includes(origin)) {
42
- callback(null, true);
43
- return;
44
- }
45
- callback(new Error(`CORS origin denied: ${origin}`));
46
- },
47
- credentials: true,
48
- methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
49
- allowedHeaders: [
50
- "content-type",
51
- "x-company-id",
52
- "authorization",
53
- "x-client-trace-id",
54
- "x-bopo-actor-token",
55
- "x-request-id",
56
- "x-bopodev-run-id"
57
- ]
58
- })
59
- );
28
+ app.use(createCorsMiddleware(deploymentMode, allowedOrigins));
60
29
  app.use(express.json());
61
30
  app.use(attachRequestActor);
62
-
63
- app.use((req, res, next) => {
64
- const requestId = req.header("x-request-id")?.trim() || nanoid(14);
65
- req.requestId = requestId;
66
- res.setHeader("x-request-id", requestId);
67
- next();
68
- });
31
+ app.use(attachRequestId);
69
32
  const logApiRequests = process.env.BOPO_LOG_API_REQUESTS !== "0";
70
33
  if (logApiRequests) {
71
- app.use((req, res, next) => {
72
- if (req.path === "/health") {
73
- next();
74
- return;
75
- }
76
- const method = req.method.toUpperCase();
77
- if (!isCrudMethod(method)) {
78
- next();
79
- return;
80
- }
81
- const startedAt = Date.now();
82
- res.on("finish", () => {
83
- const elapsedMs = Date.now() - startedAt;
84
- const timestamp = new Date().toTimeString().slice(0, 8);
85
- process.stderr.write(`[${timestamp}] INFO: ${method} ${req.originalUrl} ${res.statusCode} ${elapsedMs}ms\n`);
86
- });
87
- next();
88
- });
34
+ app.use(attachCrudRequestLogging);
89
35
  }
90
36
 
91
37
  app.get("/health", async (_req, res) => {
92
38
  let dbReady = false;
93
39
  let dbError: string | undefined;
94
40
  try {
95
- await ctx.db.execute(sql`SELECT 1`);
41
+ await pingDatabase(ctx.db);
96
42
  dbReady = true;
97
43
  } catch (error) {
98
44
  dbError = String(error);
@@ -126,18 +72,20 @@ export function createApp(ctx: AppContext) {
126
72
  app.use("/plugins", createPluginsRouter(ctx));
127
73
  app.use("/templates", createTemplatesRouter(ctx));
128
74
 
129
- app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
75
+ app.use((error: unknown, req: Request, res: Response, _next: NextFunction) => {
130
76
  if (error instanceof RepositoryValidationError) {
131
77
  return sendError(res, error.message, 422);
132
78
  }
133
- // eslint-disable-next-line no-console
134
- console.error(error);
79
+ const requestId = req.requestId;
80
+ if (requestId) {
81
+ // eslint-disable-next-line no-console
82
+ console.error(`[request ${requestId}]`, error);
83
+ } else {
84
+ // eslint-disable-next-line no-console
85
+ console.error(error);
86
+ }
135
87
  return sendError(res, "Internal server error", 500);
136
88
  });
137
89
 
138
90
  return app;
139
91
  }
140
-
141
- function isCrudMethod(method: string) {
142
- return method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
143
- }
@@ -5,6 +5,7 @@ export function buildDefaultCeoBootstrapPrompt() {
5
5
  "- Clarify missing constraints before hiring when requirements are ambiguous.",
6
6
  "- Choose reporting lines, provider, model, and permissions that fit company goals and budget.",
7
7
  "- Use governance-safe hiring via `POST /agents` with `requestApproval: true` unless explicitly told otherwise.",
8
+ "- Always set `capabilities` on every new hire: one or two sentences describing what they do, for the org chart and team roster (delegation). If the issue metadata or body specifies requested capabilities, use or refine that text; if missing, write an appropriate line from the role and request details.",
8
9
  "- Avoid duplicate hires by checking existing agents and pending approvals first.",
9
10
  "- Use the control-plane coordination skill as the source of truth for endpoint paths, required headers, and approval workflow steps.",
10
11
  "- Record hiring rationale and key decisions in issue comments for auditability."
@@ -70,6 +70,14 @@ function normalizeWorkspaceRelativeArtifactPath(value: string) {
70
70
  }
71
71
  const workspaceScopedMatch = normalized.match(/(?:^|\/)workspace\/([^/]+)\/(.+)$/);
72
72
  if (!workspaceScopedMatch) {
73
+ const projectAgentsMatch = normalized.match(/(?:^|\/)projects\/agents\/([^/]+)\/operating\/(.+)$/);
74
+ if (projectAgentsMatch) {
75
+ const [, agentId, suffix] = projectAgentsMatch;
76
+ if (!agentId || !suffix) {
77
+ return "";
78
+ }
79
+ return `agents/${agentId}/operating/${suffix}`;
80
+ }
73
81
  return normalized;
74
82
  }
75
83
  const scopedRelativePath = workspaceScopedMatch[2];
@@ -0,0 +1,36 @@
1
+ import cors from "cors";
2
+ import type { DeploymentMode } from "../security/deployment-mode";
3
+
4
+ export function createCorsMiddleware(deploymentMode: DeploymentMode, allowedOrigins: string[]) {
5
+ return cors({
6
+ origin(origin, callback) {
7
+ if (!origin) {
8
+ callback(null, true);
9
+ return;
10
+ }
11
+ if (
12
+ deploymentMode === "local" &&
13
+ (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:"))
14
+ ) {
15
+ callback(null, true);
16
+ return;
17
+ }
18
+ if (allowedOrigins.includes(origin)) {
19
+ callback(null, true);
20
+ return;
21
+ }
22
+ callback(new Error(`CORS origin denied: ${origin}`));
23
+ },
24
+ credentials: true,
25
+ methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
26
+ allowedHeaders: [
27
+ "content-type",
28
+ "x-company-id",
29
+ "authorization",
30
+ "x-client-trace-id",
31
+ "x-bopo-actor-token",
32
+ "x-request-id",
33
+ "x-bopodev-run-id"
34
+ ]
35
+ });
36
+ }
@@ -3,23 +3,9 @@ import { RequestActorHeadersSchema } from "bopodev-contracts";
3
3
  import { sendError } from "../http";
4
4
  import { verifyActorToken } from "../security/actor-token";
5
5
  import { isAuthenticatedMode, resolveDeploymentMode } from "../security/deployment-mode";
6
+ import type { RequestActor } from "../types/request-actor";
6
7
 
7
- export type RequestActor = {
8
- type: "board" | "member" | "agent";
9
- id: string;
10
- companyIds: string[] | null;
11
- permissions: string[];
12
- };
13
-
14
- declare global {
15
- namespace Express {
16
- interface Request {
17
- actor?: RequestActor;
18
- companyId?: string;
19
- requestId?: string;
20
- }
21
- }
22
- }
8
+ export type { RequestActor };
23
9
 
24
10
  export function attachRequestActor(req: Request, res: Response, next: NextFunction) {
25
11
  const deploymentMode = resolveDeploymentMode();
@@ -136,6 +122,14 @@ export function requirePermission(permission: string) {
136
122
  };
137
123
  }
138
124
 
125
+ export function enforcePermission(req: Request, res: Response, permission: string): boolean {
126
+ let allowed = false;
127
+ requirePermission(permission)(req, res, () => {
128
+ allowed = true;
129
+ });
130
+ return allowed;
131
+ }
132
+
139
133
  export function requireBoardRole(req: Request, res: Response, next: NextFunction) {
140
134
  if (req.actor?.type !== "board") {
141
135
  return sendError(res, "Board role required.", 403);
@@ -0,0 +1,9 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+ import { nanoid } from "nanoid";
3
+
4
+ export function attachRequestId(req: Request, res: Response, next: NextFunction) {
5
+ const requestId = req.header("x-request-id")?.trim() || nanoid(14);
6
+ req.requestId = requestId;
7
+ res.setHeader("x-request-id", requestId);
8
+ next();
9
+ }
@@ -0,0 +1,24 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+
3
+ function isCrudMethod(method: string) {
4
+ return method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
5
+ }
6
+
7
+ export function attachCrudRequestLogging(req: Request, res: Response, next: NextFunction) {
8
+ if (req.path === "/health") {
9
+ next();
10
+ return;
11
+ }
12
+ const method = req.method.toUpperCase();
13
+ if (!isCrudMethod(method)) {
14
+ next();
15
+ return;
16
+ }
17
+ const startedAt = Date.now();
18
+ res.on("finish", () => {
19
+ const elapsedMs = Date.now() - startedAt;
20
+ const timestamp = new Date().toTimeString().slice(0, 8);
21
+ process.stderr.write(`[${timestamp}] INFO: ${method} ${req.originalUrl} ${res.statusCode} ${elapsedMs}ms\n`);
22
+ });
23
+ next();
24
+ }
@@ -437,6 +437,7 @@ function normalizeProviderType(value: string): OfficeOccupant["providerType"] {
437
437
  value === "gemini_cli" ||
438
438
  value === "openai_api" ||
439
439
  value === "anthropic_api" ||
440
+ value === "openclaw_gateway" ||
440
441
  value === "http" ||
441
442
  value === "shell"
442
443
  ? value
@@ -1,4 +1,4 @@
1
- import { Router } from "express";
1
+ import { Router, type Response } from "express";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { z } from "zod";
4
4
  import {
@@ -37,7 +37,7 @@ import { resolveHiringDelegate } from "../lib/hiring-delegate";
37
37
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
38
38
  import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
39
39
  import { requireCompanyScope } from "../middleware/company-scope";
40
- import { requireBoardRole, requirePermission } from "../middleware/request-actor";
40
+ import { enforcePermission, requireBoardRole, requirePermission } from "../middleware/request-actor";
41
41
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
42
42
  import { publishAttentionSnapshot } from "../realtime/attention";
43
43
  import {
@@ -82,17 +82,25 @@ const runtimePreflightSchema = z.object({
82
82
  "gemini_cli",
83
83
  "openai_api",
84
84
  "anthropic_api",
85
+ "openclaw_gateway",
85
86
  "http",
86
87
  "shell"
87
88
  ]),
88
89
  runtimeConfig: z.record(z.string(), z.unknown()).optional(),
89
90
  ...legacyRuntimeConfigSchema.shape
90
91
  });
92
+
93
+ /** Body for POST /agents/adapter-models/:providerType (runtime for CLI discovery). */
94
+ const adapterModelsBodySchema = z.object({
95
+ runtimeConfig: z.record(z.string(), z.unknown()).optional(),
96
+ ...legacyRuntimeConfigSchema.shape
97
+ });
91
98
  const UPDATE_AGENT_ALLOWED_KEYS = new Set([
92
99
  "managerAgentId",
93
100
  "role",
94
101
  "roleKey",
95
102
  "title",
103
+ "capabilities",
96
104
  "name",
97
105
  "providerType",
98
106
  "status",
@@ -135,7 +143,7 @@ function toAgentResponse(agent: Record<string, unknown>) {
135
143
  }
136
144
 
137
145
  function providerRequiresNamedModel(providerType: string) {
138
- return providerType !== "http" && providerType !== "shell";
146
+ return providerType !== "http" && providerType !== "shell" && providerType !== "openclaw_gateway";
139
147
  }
140
148
 
141
149
  const agentResponseSchema = AgentSchema.extend({
@@ -149,6 +157,66 @@ function ensureNamedRuntimeModel(providerType: string, runtimeModel: string | un
149
157
  return hasText(runtimeModel);
150
158
  }
151
159
 
160
+ type AdapterModelsProviderType = NonNullable<z.infer<typeof runtimePreflightSchema>["providerType"]>;
161
+
162
+ async function handleAdapterModelsRequest(
163
+ ctx: AppContext,
164
+ res: Response,
165
+ companyId: string,
166
+ providerType: string,
167
+ parsedBody: z.infer<typeof adapterModelsBodySchema> | null
168
+ ) {
169
+ if (!runtimePreflightSchema.shape.providerType.safeParse(providerType).success) {
170
+ return sendError(res, `Unsupported provider type: ${providerType}`, 422);
171
+ }
172
+ const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, companyId);
173
+ let runtimeConfig: ReturnType<typeof normalizeRuntimeConfig>;
174
+ try {
175
+ if (parsedBody) {
176
+ runtimeConfig = normalizeRuntimeConfig({
177
+ runtimeConfig: parsedBody.runtimeConfig,
178
+ legacy: {
179
+ runtimeCommand: parsedBody.runtimeCommand,
180
+ runtimeArgs: parsedBody.runtimeArgs,
181
+ runtimeCwd: parsedBody.runtimeCwd,
182
+ runtimeTimeoutMs: parsedBody.runtimeTimeoutMs,
183
+ runtimeModel: parsedBody.runtimeModel,
184
+ runtimeThinkingEffort: parsedBody.runtimeThinkingEffort,
185
+ bootstrapPrompt: parsedBody.bootstrapPrompt,
186
+ runtimeTimeoutSec: parsedBody.runtimeTimeoutSec,
187
+ interruptGraceSec: parsedBody.interruptGraceSec,
188
+ runtimeEnv: parsedBody.runtimeEnv,
189
+ runPolicy: parsedBody.runPolicy
190
+ },
191
+ defaultRuntimeCwd
192
+ });
193
+ } else {
194
+ runtimeConfig = normalizeRuntimeConfig({ defaultRuntimeCwd });
195
+ }
196
+ runtimeConfig = enforceRuntimeCwdPolicy(companyId, runtimeConfig);
197
+ } catch (error) {
198
+ return sendError(res, String(error), 422);
199
+ }
200
+
201
+ if (parsedBody && runtimeConfig.runtimeCwd) {
202
+ await mkdir(runtimeConfig.runtimeCwd, { recursive: true });
203
+ }
204
+
205
+ const typedProviderType = providerType as AdapterModelsProviderType;
206
+ const models = await getAdapterModels(typedProviderType, {
207
+ command: runtimeConfig.runtimeCommand,
208
+ args: runtimeConfig.runtimeArgs,
209
+ cwd: runtimeConfig.runtimeCwd,
210
+ env: runtimeConfig.runtimeEnv,
211
+ model: runtimeConfig.runtimeModel,
212
+ thinkingEffort: runtimeConfig.runtimeThinkingEffort,
213
+ timeoutMs: runtimeConfig.runtimeTimeoutSec > 0 ? runtimeConfig.runtimeTimeoutSec * 1000 : undefined,
214
+ interruptGraceSec: runtimeConfig.interruptGraceSec,
215
+ runPolicy: runtimeConfig.runPolicy
216
+ });
217
+ return sendOk(res, { providerType: typedProviderType, models });
218
+ }
219
+
152
220
  export function createAgentsRouter(ctx: AppContext) {
153
221
  const router = Router();
154
222
  router.use(requireCompanyScope);
@@ -229,42 +297,16 @@ export function createAgentsRouter(ctx: AppContext) {
229
297
 
230
298
  router.get("/adapter-models/:providerType", async (req, res) => {
231
299
  const providerType = req.params.providerType;
232
- if (!runtimePreflightSchema.shape.providerType.safeParse(providerType).success) {
233
- return sendError(res, `Unsupported provider type: ${providerType}`, 422);
234
- }
235
- const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, req.companyId!);
236
- let runtimeConfig: ReturnType<typeof normalizeRuntimeConfig>;
237
- try {
238
- runtimeConfig = normalizeRuntimeConfig({
239
- runtimeConfig: req.body?.runtimeConfig,
240
- defaultRuntimeCwd
241
- });
242
- runtimeConfig = enforceRuntimeCwdPolicy(req.companyId!, runtimeConfig);
243
- } catch (error) {
244
- return sendError(res, String(error), 422);
300
+ return handleAdapterModelsRequest(ctx, res, req.companyId!, providerType, null);
301
+ });
302
+
303
+ router.post("/adapter-models/:providerType", async (req, res) => {
304
+ const providerType = req.params.providerType;
305
+ const parsed = adapterModelsBodySchema.safeParse(req.body ?? {});
306
+ if (!parsed.success) {
307
+ return sendError(res, parsed.error.message, 422);
245
308
  }
246
- const typedProviderType = providerType as
247
- | "claude_code"
248
- | "codex"
249
- | "cursor"
250
- | "opencode"
251
- | "gemini_cli"
252
- | "openai_api"
253
- | "anthropic_api"
254
- | "http"
255
- | "shell";
256
- const models = await getAdapterModels(typedProviderType, {
257
- command: runtimeConfig.runtimeCommand,
258
- args: runtimeConfig.runtimeArgs,
259
- cwd: runtimeConfig.runtimeCwd,
260
- env: runtimeConfig.runtimeEnv,
261
- model: runtimeConfig.runtimeModel,
262
- thinkingEffort: runtimeConfig.runtimeThinkingEffort,
263
- timeoutMs: runtimeConfig.runtimeTimeoutSec > 0 ? runtimeConfig.runtimeTimeoutSec * 1000 : undefined,
264
- interruptGraceSec: runtimeConfig.interruptGraceSec,
265
- runPolicy: runtimeConfig.runPolicy
266
- });
267
- return sendOk(res, { providerType: typedProviderType, models });
309
+ return handleAdapterModelsRequest(ctx, res, req.companyId!, providerType, parsed.data);
268
310
  });
269
311
 
270
312
  router.post("/runtime-preflight", async (req, res) => {
@@ -425,6 +467,7 @@ export function createAgentsRouter(ctx: AppContext) {
425
467
  role: resolveAgentRoleText(parsed.data.role, parsed.data.roleKey, parsed.data.title),
426
468
  roleKey: normalizeRoleKey(parsed.data.roleKey),
427
469
  title: normalizeTitle(parsed.data.title),
470
+ capabilities: normalizeCapabilities(parsed.data.capabilities),
428
471
  name: parsed.data.name,
429
472
  providerType: parsed.data.providerType,
430
473
  heartbeatCron: parsed.data.heartbeatCron,
@@ -573,6 +616,8 @@ export function createAgentsRouter(ctx: AppContext) {
573
616
  : undefined,
574
617
  roleKey: parsed.data.roleKey !== undefined ? normalizeRoleKey(parsed.data.roleKey) : undefined,
575
618
  title: parsed.data.title !== undefined ? normalizeTitle(parsed.data.title) : undefined,
619
+ capabilities:
620
+ parsed.data.capabilities !== undefined ? normalizeCapabilities(parsed.data.capabilities) : undefined,
576
621
  name: parsed.data.name,
577
622
  providerType: parsed.data.providerType,
578
623
  status: parsed.data.status,
@@ -629,10 +674,7 @@ export function createAgentsRouter(ctx: AppContext) {
629
674
  });
630
675
 
631
676
  router.post("/:agentId/pause", async (req, res) => {
632
- requirePermission("agents:lifecycle")(req, res, () => {});
633
- if (res.headersSent) {
634
- return;
635
- }
677
+ if (!enforcePermission(req, res, "agents:lifecycle")) return;
636
678
  const agent = await updateAgent(ctx.db, {
637
679
  companyId: req.companyId!,
638
680
  id: req.params.agentId,
@@ -656,10 +698,7 @@ export function createAgentsRouter(ctx: AppContext) {
656
698
  });
657
699
 
658
700
  router.post("/:agentId/resume", async (req, res) => {
659
- requirePermission("agents:lifecycle")(req, res, () => {});
660
- if (res.headersSent) {
661
- return;
662
- }
701
+ if (!enforcePermission(req, res, "agents:lifecycle")) return;
663
702
  const agent = await updateAgent(ctx.db, {
664
703
  companyId: req.companyId!,
665
704
  id: req.params.agentId,
@@ -829,6 +868,11 @@ function normalizeTitle(input: string | null | undefined) {
829
868
  return normalized ? normalized : null;
830
869
  }
831
870
 
871
+ function normalizeCapabilities(input: string | null | undefined) {
872
+ const normalized = input?.trim();
873
+ return normalized ? normalized : null;
874
+ }
875
+
832
876
  function resolveAgentRoleText(
833
877
  legacyRole: string | undefined,
834
878
  roleKeyInput: string | null | undefined,
@@ -10,6 +10,7 @@ import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigTo
10
10
  import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
11
11
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
12
12
  import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
13
+ import { buildCompanyPortabilityExport } from "../services/company-export-service";
13
14
  import { canAccessCompany, requireBoardRole, requirePermission } from "../middleware/request-actor";
14
15
  import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
15
16
  import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
@@ -21,7 +22,7 @@ const createCompanySchema = z.object({
21
22
  name: z.string().min(1),
22
23
  mission: z.string().optional(),
23
24
  providerType: z
24
- .enum(["codex", "claude_code", "cursor", "gemini_cli", "opencode", "openai_api", "anthropic_api", "shell"])
25
+ .enum(["codex", "claude_code", "cursor", "gemini_cli", "opencode", "openai_api", "anthropic_api", "http", "shell"])
25
26
  .optional(),
26
27
  runtimeModel: z.string().optional()
27
28
  });
@@ -53,6 +54,21 @@ export function createCompaniesRouter(ctx: AppContext) {
53
54
  );
54
55
  });
55
56
 
57
+ router.get("/:companyId/export", async (req, res) => {
58
+ const companyId = readCompanyIdParam(req);
59
+ if (!companyId) {
60
+ return sendError(res, "Missing company id.", 422);
61
+ }
62
+ if (!canAccessCompany(req, companyId)) {
63
+ return sendError(res, "Actor does not have access to this company.", 403);
64
+ }
65
+ const payload = await buildCompanyPortabilityExport(ctx.db, companyId);
66
+ if (!payload) {
67
+ return sendError(res, "Company not found.", 404);
68
+ }
69
+ return sendOk(res, payload);
70
+ });
71
+
56
72
  router.post("/", requireBoardRole, async (req, res) => {
57
73
  const parsed = createCompanySchema.safeParse(req.body);
58
74
  if (!parsed.success) {
@@ -98,6 +114,8 @@ export function createCompaniesRouter(ctx: AppContext) {
98
114
  role: "CEO",
99
115
  roleKey: "ceo",
100
116
  title: "CEO",
117
+ capabilities:
118
+ "Company leadership: priorities, hiring, governance, and aligning agents to mission and budget.",
101
119
  name: "CEO",
102
120
  providerType,
103
121
  heartbeatCron: "*/5 * * * *",
@@ -171,6 +189,7 @@ function parseAgentProvider(value: unknown) {
171
189
  value === "opencode" ||
172
190
  value === "openai_api" ||
173
191
  value === "anthropic_api" ||
192
+ value === "http" ||
174
193
  value === "shell"
175
194
  ) {
176
195
  return value;
@@ -5,7 +5,7 @@ import { appendAuditEvent, createApprovalRequest, createGoal, deleteGoal, getApp
5
5
  import type { AppContext } from "../context";
6
6
  import { sendError, sendOk, sendOkValidated } from "../http";
7
7
  import { requireCompanyScope } from "../middleware/company-scope";
8
- import { requirePermission } from "../middleware/request-actor";
8
+ import { enforcePermission } from "../middleware/request-actor";
9
9
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
10
10
  import { publishAttentionSnapshot } from "../realtime/attention";
11
11
  import { isApprovalRequired } from "../services/governance-service";
@@ -13,6 +13,7 @@ import { isApprovalRequired } from "../services/governance-service";
13
13
  const createGoalSchema = z.object({
14
14
  projectId: z.string().optional(),
15
15
  parentGoalId: z.string().optional(),
16
+ ownerAgentId: z.string().optional(),
16
17
  level: z.enum(["company", "project", "agent"]),
17
18
  title: z.string().min(1),
18
19
  description: z.string().optional(),
@@ -23,6 +24,7 @@ const updateGoalSchema = z
23
24
  .object({
24
25
  projectId: z.string().nullable().optional(),
25
26
  parentGoalId: z.string().nullable().optional(),
27
+ ownerAgentId: z.string().nullable().optional(),
26
28
  level: z.enum(["company", "project", "agent"]).optional(),
27
29
  title: z.string().min(1).optional(),
28
30
  description: z.string().nullable().optional(),
@@ -44,10 +46,7 @@ export function createGoalsRouter(ctx: AppContext) {
44
46
  });
45
47
 
46
48
  router.post("/", async (req, res) => {
47
- requirePermission("goals:write")(req, res, () => {});
48
- if (res.headersSent) {
49
- return;
50
- }
49
+ if (!enforcePermission(req, res, "goals:write")) return;
51
50
  const parsed = createGoalSchema.safeParse(req.body);
52
51
  if (!parsed.success) {
53
52
  return sendError(res, parsed.error.message, 422);
@@ -76,6 +75,7 @@ export function createGoalsRouter(ctx: AppContext) {
76
75
  companyId: req.companyId!,
77
76
  projectId: parsed.data.projectId,
78
77
  parentGoalId: parsed.data.parentGoalId,
78
+ ownerAgentId: parsed.data.ownerAgentId,
79
79
  level: parsed.data.level,
80
80
  title: parsed.data.title,
81
81
  description: parsed.data.description
@@ -92,10 +92,7 @@ export function createGoalsRouter(ctx: AppContext) {
92
92
  });
93
93
 
94
94
  router.put("/:goalId", async (req, res) => {
95
- requirePermission("goals:write")(req, res, () => {});
96
- if (res.headersSent) {
97
- return;
98
- }
95
+ if (!enforcePermission(req, res, "goals:write")) return;
99
96
  const parsed = updateGoalSchema.safeParse(req.body);
100
97
  if (!parsed.success) {
101
98
  return sendError(res, parsed.error.message, 422);
@@ -118,10 +115,7 @@ export function createGoalsRouter(ctx: AppContext) {
118
115
  });
119
116
 
120
117
  router.delete("/:goalId", async (req, res) => {
121
- requirePermission("goals:write")(req, res, () => {});
122
- if (res.headersSent) {
123
- return;
124
- }
118
+ if (!enforcePermission(req, res, "goals:write")) return;
125
119
  const deleted = await deleteGoal(ctx.db, req.companyId!, req.params.goalId);
126
120
  if (!deleted) {
127
121
  return sendError(res, "Goal not found.", 404);
@@ -14,7 +14,7 @@ import {
14
14
  import type { AppContext } from "../context";
15
15
  import { sendError, sendOk } from "../http";
16
16
  import { requireCompanyScope } from "../middleware/company-scope";
17
- import { requirePermission } from "../middleware/request-actor";
17
+ import { enforcePermission } from "../middleware/request-actor";
18
18
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
19
19
  import { publishAttentionSnapshot } from "../realtime/attention";
20
20
  import {
@@ -146,10 +146,7 @@ export function createGovernanceRouter(ctx: AppContext) {
146
146
  });
147
147
 
148
148
  router.post("/resolve", async (req, res) => {
149
- requirePermission("governance:resolve")(req, res, () => {});
150
- if (res.headersSent) {
151
- return;
152
- }
149
+ if (!enforcePermission(req, res, "governance:resolve")) return;
153
150
  const parsed = resolveSchema.safeParse(req.body);
154
151
  if (!parsed.success) {
155
152
  return sendError(res, parsed.error.message, 422);