@opengeni/api-router 2.11.0-canary.1 → 2.11.2-canary.0

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 (38) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-EAYCECQK.js → chunk-5X3XM6CE.js} +1841 -1528
  3. package/dist/chunk-5X3XM6CE.js.map +1 -0
  4. package/dist/http/sse.d.ts +4 -3
  5. package/dist/index.js +5 -3
  6. package/dist/index.js.map +1 -1
  7. package/dist/integrations/github-skill-source.d.ts +1 -5
  8. package/dist/mcp/remember.d.ts +1 -1
  9. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  10. package/dist/mcp/server.d.ts +4 -4
  11. package/dist/routes/skill-content.d.ts +4 -0
  12. package/dist/routes/skill-install-authority.d.ts +6 -0
  13. package/dist/transcription/providers/xai-subscription.d.ts +1 -0
  14. package/dist/transcription/service.d.ts +2 -1
  15. package/package.json +19 -19
  16. package/src/app.ts +18 -0
  17. package/src/http/sse.ts +9 -25
  18. package/src/index.ts +3 -1
  19. package/src/integrations/github-skill-source.ts +2 -142
  20. package/src/mcp/company-brain-governed-writes.ts +15 -21
  21. package/src/mcp/remember.ts +14 -15
  22. package/src/mcp/server.ts +7 -4
  23. package/src/routes/automations.ts +35 -0
  24. package/src/routes/packs.ts +61 -8
  25. package/src/routes/plugins.ts +48 -5
  26. package/src/routes/preference-registry.ts +12 -93
  27. package/src/routes/sessions.ts +52 -20
  28. package/src/routes/skill-content.ts +226 -0
  29. package/src/routes/skill-install-authority.ts +64 -0
  30. package/src/routes/skills.ts +38 -3
  31. package/src/routes/transcription-recordings.ts +30 -1
  32. package/src/routes/transcriptions.ts +4 -0
  33. package/src/transcription/providers/codex-subscription.ts +2 -0
  34. package/src/transcription/providers/openai.ts +1 -0
  35. package/src/transcription/providers/xai-subscription.ts +49 -3
  36. package/src/transcription/service.ts +43 -6
  37. package/src/xai-subscription-auth.ts +9 -2
  38. package/dist/chunk-EAYCECQK.js.map +0 -1
@@ -59,7 +59,7 @@ const laneFields = {
59
59
  /**
60
60
  * Explicit user-directed remember. Content becomes exact task-note evidence,
61
61
  * promotion runs through the frozen workspace learning policy, and activation
62
- * is either automatic (Skill or instruction under Automatic mode) or requires exactly one
62
+ * is either automatic (instruction under Automatic mode) or requires exactly one
63
63
  * bound human confirmation through the built-in `request_human_input` tool.
64
64
  * Tool input cannot select scope beyond the workspace, active authority, or
65
65
  * another learning-policy source.
@@ -70,9 +70,9 @@ export function registerRememberTools(input: RegisterRememberToolsInput): void {
70
70
  "remember",
71
71
  {
72
72
  description:
73
- "Create governed durable knowledge, a Skill, or a mandatory workspace instruction. When the agent-only memory_save tool is available, use it instead for ordinary durable facts, decisions, incidents, bug fixes, and confirmed outcomes; those Memory writes are autonomous and independent of Learning mode. Use lane=knowledge only when memory_save is unavailable and the user explicitly requests reviewed workspace knowledge; lane=preference creates a Skill for reusable conditional how-to guidance; lane=instruction_policy is only for a universal always/never rule that should apply to nearly every task. " +
74
- `Write the instruction lane as the shortest complete rule, at most ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters and normally 1-3 imperative sentences, with no numbered steps, examples, rationale, or restated defaults. Keep a Skill under ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters with one clear trigger and outcome, only the necessary steps, checks, and exceptions, and a one-sentence descriptor. Do not copy one item into multiple lanes. ` +
75
- "Under Autonomous learning, an eligible Skill or workspace instruction may activate immediately through its governed lifecycle. Under Require approval, it remains inactive and the receipt returns status=confirmation_required with the exact `humanInput` payload: call `request_human_input` with it verbatim, then call `remember_confirm` with the returned requestId. Off creates no governed change. Reviewed Knowledge always needs confirmation. Do not use lane=knowledge for facts you merely inferred; use memory_save when available, otherwise knowledge_propose or task notes. Confirmed lane=knowledge content keeps its reviewed claim provenance and materializes its exact approved text into Memory for later `memory_search` retrieval.",
73
+ "Create governed durable knowledge or a mandatory workspace instruction. Skills now use skill_save; the legacy preference lane returns a redirect without writing. When memory_save is available, use it for ordinary durable facts, decisions, incidents, bug fixes, and confirmed outcomes; Memory writes are autonomous and independent of Learning mode. Use lane=knowledge only when memory_save is unavailable and the user explicitly requests reviewed workspace knowledge; lane=instruction_policy is only for a universal always/never rule that should apply to nearly every task. " +
74
+ `Write the instruction lane as the shortest complete rule, at most ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters and normally 1-3 imperative sentences, with no numbered steps, examples, rationale, or restated defaults. Do not copy one item into multiple lanes. ` +
75
+ "Under Autonomous learning, an eligible workspace instruction may activate immediately through its governed lifecycle. Under Require approval, it remains inactive and the receipt returns status=confirmation_required with the exact `humanInput` payload: call `request_human_input` with it verbatim, then call `remember_confirm` with the returned requestId. Off creates no governed change. Reviewed Knowledge always needs confirmation. Do not use lane=knowledge for facts you merely inferred; use memory_save when available, otherwise knowledge_propose or task notes. Confirmed lane=knowledge content keeps its reviewed claim provenance and materializes its exact approved text into Memory for later `memory_search` retrieval.",
76
76
  inputSchema: {
77
77
  lane: z.enum(["preference", "instruction_policy", "knowledge"]),
78
78
  ...laneFields,
@@ -97,6 +97,13 @@ export function registerRememberTools(input: RegisterRememberToolsInput): void {
97
97
  async (request) => {
98
98
  await input.authorize();
99
99
  const { lane, operationId, content, reason } = request;
100
+ if (lane === "preference")
101
+ return input.json({
102
+ status: "not_remembered",
103
+ code: "use_skill_save",
104
+ message:
105
+ "Skills use the shared file lifecycle. Read opengeni-skills with skill_read, then discover skill_save. No Knowledge evidence or confirmation question is needed for Skill edits.",
106
+ });
100
107
  if (lane !== "knowledge" && content.length > laneContentMaxChars[lane]) {
101
108
  return input.json({
102
109
  status: "not_remembered",
@@ -109,17 +116,9 @@ export function registerRememberTools(input: RegisterRememberToolsInput): void {
109
116
  }
110
117
  const base = { operationId, content, reason, scope: "workspace" as const };
111
118
  const rememberRequest =
112
- lane === "preference"
113
- ? {
114
- ...base,
115
- lane,
116
- stableKey: request.stableKey ?? `remember.${operationId.replaceAll("-", "")}`,
117
- title: request.title ?? content.slice(0, 80),
118
- description: request.description ?? content.slice(0, 200),
119
- }
120
- : lane === "instruction_policy"
121
- ? { ...base, lane, target: request.target }
122
- : { ...base, lane, subject: request.subject ?? content.slice(0, 80) };
119
+ lane === "instruction_policy"
120
+ ? { ...base, lane, target: request.target }
121
+ : { ...base, lane, subject: request.subject ?? content.slice(0, 80) };
123
122
  try {
124
123
  return input.json(
125
124
  await router.remember({ attempt: input.attempt, request: rememberRequest }),
package/src/mcp/server.ts CHANGED
@@ -2587,7 +2587,9 @@ function registerGoalTools(
2587
2587
  description:
2588
2588
  "End the current turn and wait out of turn for relevant session input. This is self-only and does not require a goal. After success, the production runtime ends the turn at the tool-batch boundary without another model step or final message. Use it for long or uncertain waits instead of sleeping or repeatedly calling session_wait/command_wait. timeoutSeconds is a relative safety-wake duration; OpenGeni persists the first absolute deadline for the turn, and repeated calls do not extend it. Timeout never cancels a background command. A human/API prompt, agent message or Steer, child terminal result, scheduled input, terminal background-command result, or the deadline wakes the session. Use goal_pause instead when the active goal itself should stop pending a human decision.",
2589
2589
  inputSchema: {
2590
- reason: inputWaitReasonSchema,
2590
+ reason: inputWaitReasonSchema.describe(
2591
+ "Shown directly to the user. Write one short, natural sentence explaining what you are waiting for, with normal spacing. Exclude internal IDs, cursors, commit hashes, paths, and continuation instructions. Example: Waiting for the build and database checks to finish.",
2592
+ ),
2591
2593
  timeoutSeconds: z4
2592
2594
  .number()
2593
2595
  .int()
@@ -4665,7 +4667,7 @@ function registerWorkspaceOrchestrationTools(
4665
4667
  "session_get",
4666
4668
  {
4667
4669
  description:
4668
- "Get an authorized session. Omit sessionId to read only the authenticated current agent session (a child reads itself, never its parent or root); sessionless/operator callers must provide an explicit ID. Both forms retain live-attempt and target authorization checks. Default detail=compact returns status, goal (including completion evidence or pause rationale), latest recorded goal progress, meaningful pause/wait state, queue counts, active turn and snapshot lastSequence. Goal completion is not a terminal child result: join with session_wait waitFor=completion from the last consumed event cursor (0 if none), not this snapshot lastSequence, which may already include an unread completion. For an already-settled child, retrieve its result-bearing completion with session_events or join from the last consumed cursor. Use detail=full for the legacy bounded configuration including resources, persisted tool refs, effectiveToolPolicy and variableSet ids (never variable values). Full mode is configuration, not a substitute for compact goal/progress facts. Self reads inspect session state, not conversation history, which is supplied directly. Text loss is explicit; REST/UI defaults are unchanged.",
4670
+ "Get an authorized session. Omit sessionId to read only the authenticated current agent session (a child reads itself, never its parent or root); sessionless/operator callers must provide an explicit ID. Both forms retain live-attempt and target authorization checks. Default detail=compact returns status, goal (including completion evidence or pause rationale), latest recorded goal progress, meaningful pause/wait state, queue counts, active turn and snapshot lastSequence. Queued status and updatedAt are not proof of execution; inspect the active turn and durable results. Goal completion is not a terminal child result: join with session_wait waitFor=completion from the last consumed event cursor (0 if none), not this snapshot lastSequence, which may already include an unread completion. For an already-settled child, retrieve its result-bearing completion with session_events or join from the last consumed cursor. Use detail=full for the legacy bounded configuration including resources, persisted tool refs, effectiveToolPolicy and variableSet ids (never variable values). Full mode is configuration, not a substitute for compact goal/progress facts. Self reads inspect session state, not conversation history, which is supplied directly. Text loss is explicit; REST/UI defaults are unchanged.",
4669
4671
  inputSchema: {
4670
4672
  sessionId: z4
4671
4673
  .string()
@@ -5241,7 +5243,7 @@ function registerWorkspaceOrchestrationTools(
5241
5243
  "session_send_message",
5242
5244
  {
5243
5245
  description:
5244
- "Send information to another session. From an OpenGeni worker this becomes a canonical coalescible machine input: it appears in the target's compact incoming queue group, is durably added to model history when claimed, and remains visible in the timeline. A sessionless operator call appends one human/API prompt.",
5246
+ "Acceptance is not execution. Keep resource.id: for agent messages, match that ID in payload.updateIds from session_events view=debug, includeTypes=[system.update.delivered], payloadMode=full; retain the event turnId and read its relevant result. An unrelated in-flight turn completing does not prove delivery. Do not resend an unconsumed message; inspect blockers. Worker messages are coalescible machine input, added to history when claimed. Sessionless operator calls append a human/API prompt and resource.id is its turn ID. Use your last consumed event sequence. Report stalled delivery if it cannot safely progress.",
5245
5247
  inputSchema: {
5246
5248
  sessionId: z4.string().uuid(),
5247
5249
  text: z4.string().min(1),
@@ -5343,7 +5345,8 @@ function registerWorkspaceOrchestrationTools(
5343
5345
  server.registerTool(
5344
5346
  "session_pause",
5345
5347
  {
5346
- description: "Pause this session. Waiting prompts stay saved and inert until Resume.",
5348
+ description:
5349
+ "Pause the selected session workstream, including descendants. From an agent, pausing an ancestor also stops this caller; it cannot then issue its own Resume. Do not use ancestor Pause merely to prevent concurrent edits. Waiting prompts stay saved and inert until Resume.",
5347
5350
  inputSchema: {
5348
5351
  sessionId: z4.string().uuid(),
5349
5352
  idempotencyKey: z4.string().uuid(),
@@ -10,6 +10,9 @@ import {
10
10
  UpdateAutomationSourceRequest,
11
11
  UpdateAutomationTriggerRequest,
12
12
  stableJson,
13
+ resolveBundledSkillSelection,
14
+ type AccessGrant,
15
+ type BundledSkillId,
13
16
  } from "@opengeni/contracts";
14
17
  import {
15
18
  AutomationDeliveryConflictError,
@@ -21,6 +24,7 @@ import {
21
24
  encryptVariableSetValue,
22
25
  getAutomationSourceSecret,
23
26
  getAutomationTriggerRevisions,
27
+ getSession,
24
28
  listActiveAutomationTriggersForSource,
25
29
  listAutomationRuns,
26
30
  listAutomationSources,
@@ -38,6 +42,7 @@ import {
38
42
  assertWorkspaceModelPolicyAllows,
39
43
  buildAutomationAcceptedExecution,
40
44
  canonicalConfiguredModel,
45
+ creationInitiatorForGrant,
41
46
  workspaceCustomModelReference,
42
47
  lockActiveCustomModelForAdmission,
43
48
  requireAccessGrant,
@@ -49,6 +54,26 @@ import {
49
54
  import type { Hono } from "hono";
50
55
  import { HTTPException } from "hono/http-exception";
51
56
 
57
+ async function callerBundleSelection(
58
+ deps: ApiRouteDeps,
59
+ grant: AccessGrant,
60
+ requested: BundledSkillId[] | undefined,
61
+ ) {
62
+ const actor = creationInitiatorForGrant(grant).actor;
63
+ const parent = actor ? await getSession(deps.db, grant.workspaceId, actor.sessionId) : null;
64
+ if (actor && (!parent || parent.accountId !== grant.accountId))
65
+ throw new HTTPException(403, {
66
+ message: "Automation Skill selection requires the creating agent's session",
67
+ });
68
+ try {
69
+ return resolveBundledSkillSelection(requested, parent?.bundledSkillIds);
70
+ } catch (error) {
71
+ throw new HTTPException(422, {
72
+ message: error instanceof Error ? error.message : "Invalid bundled Skill selection",
73
+ });
74
+ }
75
+ }
76
+
52
77
  export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
53
78
  app.get("/v1/workspaces/:workspaceId/automations/sources", async (c) => {
54
79
  const workspaceId = c.req.param("workspaceId");
@@ -146,6 +171,11 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
146
171
  const workspaceId = c.req.param("workspaceId");
147
172
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
148
173
  const request = CreateAutomationTriggerRequest.parse(await c.req.json());
174
+ request.sessionTemplate.bundledSkillIds = await callerBundleSelection(
175
+ deps,
176
+ grant,
177
+ request.sessionTemplate.bundledSkillIds,
178
+ );
149
179
  if (request.packInstallationId || request.packTemplateId) {
150
180
  throw new HTTPException(409, {
151
181
  message: "Pack-owned automation triggers must be created through their Pack setup API",
@@ -220,6 +250,11 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
220
250
  requireAutomationAdapter(existing.adapterId).validateTriggerParameters(request.parameters);
221
251
  }
222
252
  if (request.sessionTemplate) {
253
+ request.sessionTemplate.bundledSkillIds = await callerBundleSelection(
254
+ deps,
255
+ grant,
256
+ request.sessionTemplate.bundledSkillIds,
257
+ );
223
258
  for (const permission of request.sessionTemplate.firstPartyMcpPermissions) {
224
259
  requirePermission(grant, permission);
225
260
  }
@@ -29,6 +29,7 @@ import {
29
29
  getWorkspacePack,
30
30
  getSocialConnection,
31
31
  installPortableSkill,
32
+ SkillSourceRemovalAuthorityError,
32
33
  listPackInstallations,
33
34
  listSocialConnections,
34
35
  PackManifestChangedError,
@@ -50,7 +51,8 @@ import {
50
51
  import { getDocumentBase } from "@opengeni/documents";
51
52
  import type { Hono } from "hono";
52
53
  import { HTTPException } from "hono/http-exception";
53
- import { requireAccessGrant } from "@opengeni/core";
54
+ import { requireAccessGrant, requireAccessGrantAuthorization } from "@opengeni/core";
55
+ import { skillInstallerActor, skillRemovalActor } from "./skill-install-authority";
54
56
  import { requireLimit } from "@opengeni/core";
55
57
  import type { ApiRouteDeps } from "@opengeni/core";
56
58
  import { validateVariableSetAttachment } from "@opengeni/core";
@@ -172,8 +174,15 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
172
174
 
173
175
  app.post("/v1/workspaces/:workspaceId/packs/:packId/install", async (c) => {
174
176
  const workspaceId = c.req.param("workspaceId");
175
- const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
177
+ const access = await requireAccessGrantAuthorization(
178
+ c,
179
+ deps,
180
+ workspaceId,
181
+ "capabilities:manage",
182
+ );
183
+ const { grant } = access;
176
184
  const pack = await requirePack(db, workspaceId, c.req.param("packId"));
185
+ const skillActor = pack.skills.length ? skillInstallerActor(access) : undefined;
177
186
  const payload = InstallPackRequest.parse(await c.req.json());
178
187
  const preview = PackInstallationPreview.parse(
179
188
  await previewCapabilityPackInstallation(db, grant, pack, {
@@ -245,10 +254,22 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
245
254
  message: "The completed Pack operation lost its installation record",
246
255
  });
247
256
  }
248
- return c.json(PackInstallation.parse(replayed), 200);
257
+ return c.json(
258
+ PackInstallation.parse({
259
+ ...replayed,
260
+ ...(Array.isArray(prepared.replayResult.skillWrites)
261
+ ? { skillWrites: prepared.replayResult.skillWrites }
262
+ : {}),
263
+ ...(Array.isArray(prepared.replayResult.skillReleases)
264
+ ? { skillReleases: prepared.replayResult.skillReleases }
265
+ : {}),
266
+ }),
267
+ 200,
268
+ );
249
269
  }
250
270
 
251
271
  const retainedComponentKeys: string[] = [];
272
+ const skillWrites: import("@opengeni/contracts").SkillWriteReceipt[] = [];
252
273
  const retainedFacetInstallationIds: string[] = [];
253
274
  const retainedBindingIds: string[] = [];
254
275
  let activeComponentKey = "manifest";
@@ -261,10 +282,13 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
261
282
  });
262
283
  try {
263
284
  for (const skill of pack.skills) {
285
+ if (!skillActor)
286
+ throw new HTTPException(403, { message: "Skill installer authority is missing" });
264
287
  await heartbeat();
265
288
  const inline = inlinePackSkillInstall(pack, skill);
266
289
  activeComponentKey = inline.componentKey;
267
290
  const installed = await installPortableSkill(db, {
291
+ skillActor,
268
292
  accountId: grant.accountId,
269
293
  workspaceId,
270
294
  subjectId: grant.subjectId,
@@ -287,6 +311,7 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
287
311
  removable: true,
288
312
  },
289
313
  });
314
+ skillWrites.push(installed.skillReceipt);
290
315
  await heartbeat();
291
316
  await recordPackInlineSkillComponent(db, {
292
317
  accountId: grant.accountId,
@@ -316,7 +341,9 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
316
341
 
317
342
  activeComponentKey = "ownership-finalize";
318
343
  await heartbeat();
319
- await finalizePackComponentOwnership(db, {
344
+ const removalActor = skillRemovalActor(access);
345
+ const { skillReleases } = await finalizePackComponentOwnership(db, {
346
+ ...(removalActor ? { skillActor: removalActor } : {}),
320
347
  accountId: grant.accountId,
321
348
  workspaceId,
322
349
  packInstallationId: prepared.installation.id,
@@ -334,13 +361,19 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
334
361
  packId: pack.id,
335
362
  result: {
336
363
  status: "installed",
364
+ ...(skillWrites.length ? { skillWrites } : {}),
337
365
  packId: pack.id,
338
366
  manifestDigest: preview.manifestDigest,
339
367
  componentCount: retainedComponentKeys.length,
368
+ ...(skillReleases.length ? { skillReleases } : {}),
340
369
  },
341
370
  });
342
371
  return c.json(
343
- PackInstallation.parse(finalized),
372
+ PackInstallation.parse({
373
+ ...finalized,
374
+ ...(skillWrites.length ? { skillWrites } : {}),
375
+ ...(skillReleases.length ? { skillReleases } : {}),
376
+ }),
344
377
  preview.installationVersion === null ? 201 : 200,
345
378
  );
346
379
  } catch (error) {
@@ -363,7 +396,10 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
363
396
  message: `Pack component ${activeComponentKey} is pinned by another installed owner. Resolve that version conflict and retry with the same idempotency key.`,
364
397
  });
365
398
  }
366
- if (error instanceof PackOperationClaimLostError) {
399
+ if (
400
+ error instanceof PackOperationClaimLostError ||
401
+ error instanceof SkillSourceRemovalAuthorityError
402
+ ) {
367
403
  throw packMutationHttpError(error);
368
404
  }
369
405
  if (error instanceof HTTPException) throw error;
@@ -394,7 +430,14 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
394
430
 
395
431
  app.delete("/v1/workspaces/:workspaceId/packs/:packId/installation", async (c) => {
396
432
  const workspaceId = c.req.param("workspaceId");
397
- const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
433
+ const access = await requireAccessGrantAuthorization(
434
+ c,
435
+ deps,
436
+ workspaceId,
437
+ "capabilities:manage",
438
+ );
439
+ const { grant } = access;
440
+ const skillActor = skillRemovalActor(access);
398
441
  const pack = await requirePack(db, workspaceId, c.req.param("packId"));
399
442
  const payload = UninstallPackRequest.parse(await c.req.json());
400
443
  const requestDigest = sha256(
@@ -426,6 +469,9 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
426
469
  retainedComponents: Array.isArray(prepared.replayResult.retainedComponents)
427
470
  ? prepared.replayResult.retainedComponents
428
471
  : [],
472
+ ...(Array.isArray(prepared.replayResult.skillReleases)
473
+ ? { skillReleases: prepared.replayResult.skillReleases }
474
+ : {}),
429
475
  }),
430
476
  );
431
477
  }
@@ -437,6 +483,7 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
437
483
  operationVersion: prepared.operationVersion,
438
484
  });
439
485
  const released = await releasePackComponents(db, {
486
+ ...(skillActor ? { skillActor } : {}),
440
487
  accountId: grant.accountId,
441
488
  workspaceId,
442
489
  packInstallationId: prepared.installation.id,
@@ -451,6 +498,7 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
451
498
  packId: pack.id,
452
499
  status: "uninstalled",
453
500
  retainedComponents: [...new Set(released.retainedComponents)],
501
+ ...(released.skillReleases?.length ? { skillReleases: released.skillReleases } : {}),
454
502
  });
455
503
  await finalizePackUninstallOperation(db, {
456
504
  accountId: grant.accountId,
@@ -472,7 +520,10 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
472
520
  phase: "uninstall_failed",
473
521
  errorCode: packFailureCode(error),
474
522
  }).catch(() => undefined);
475
- if (error instanceof PackOperationClaimLostError) {
523
+ if (
524
+ error instanceof PackOperationClaimLostError ||
525
+ error instanceof SkillSourceRemovalAuthorityError
526
+ ) {
476
527
  throw packMutationHttpError(error);
477
528
  }
478
529
  if (error instanceof HTTPException) throw error;
@@ -628,6 +679,8 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
628
679
  }
629
680
 
630
681
  function packMutationHttpError(error: unknown): HTTPException {
682
+ if (error instanceof SkillSourceRemovalAuthorityError)
683
+ return new HTTPException(403, { message: error.message });
631
684
  if (error instanceof PackManifestChangedError) {
632
685
  return new HTTPException(409, {
633
686
  message: "The Pack manifest changed after preview. Review the updated installation plan.",
@@ -18,15 +18,18 @@ import {
18
18
  UninstallPluginResult,
19
19
  stableJson,
20
20
  type PluginComponentPreview,
21
+ type SkillActor,
21
22
  } from "@opengeni/contracts";
22
23
  import {
23
24
  portableSkillCapabilityId,
24
25
  portableSkillPluginKey,
25
26
  requireAccessGrant,
27
+ requireAccessGrantAuthorization,
26
28
  resolveSkillImport,
27
29
  type ApiRouteDeps,
28
30
  type GitHubSkillSourceClient,
29
31
  } from "@opengeni/core";
32
+ import { skillInstallerActor, skillRemovalActor } from "./skill-install-authority";
30
33
  import {
31
34
  buildConnectionTokenResolver,
32
35
  CapabilityComponentVersionConflictError,
@@ -40,6 +43,7 @@ import {
40
43
  installApiIntegration,
41
44
  installPluginMcpReference,
42
45
  installPortableSkill,
46
+ SkillSourceRemovalAuthorityError,
43
47
  listInstalledPluginPackages,
44
48
  PluginInstallationVersionConflictError,
45
49
  PluginInstallationVersionRequiredError,
@@ -71,6 +75,7 @@ type ResolvedPluginComponent = {
71
75
  install(ownerPluginInstallationId: string): Promise<{
72
76
  facetInstallationIds: string[];
73
77
  bindingIds?: string[];
78
+ skillReceipt?: import("@opengeni/contracts").SkillWriteReceipt;
74
79
  }>;
75
80
  };
76
81
 
@@ -120,9 +125,16 @@ export function registerPluginRoutes(
120
125
 
121
126
  app.post("/v1/workspaces/:workspaceId/plugins/install", async (c) => {
122
127
  const workspaceId = c.req.param("workspaceId");
123
- const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
128
+ const access = await requireAccessGrantAuthorization(
129
+ c,
130
+ deps,
131
+ workspaceId,
132
+ "capabilities:manage",
133
+ );
134
+ const { grant } = access;
124
135
  const payload = InstallPluginRequest.parse(await c.req.json());
125
136
  const resolved = await resolvePluginPackage({
137
+ skillActor: () => skillInstallerActor(access),
126
138
  deps,
127
139
  github,
128
140
  transport,
@@ -188,11 +200,13 @@ export function registerPluginRoutes(
188
200
  const retainedFacetInstallationIds: string[] = [];
189
201
  const retainedBindingIds: string[] = [];
190
202
  const completedKeys: string[] = [];
203
+ const skillWrites: import("@opengeni/contracts").SkillWriteReceipt[] = [];
191
204
  let activeComponentKey = "none";
192
205
  try {
193
206
  for (const component of resolved.components) {
194
207
  activeComponentKey = component.preview.key;
195
208
  const installed = await component.install(prepared.pluginInstallationId);
209
+ if (installed.skillReceipt) skillWrites.push(installed.skillReceipt);
196
210
  retainedFacetInstallationIds.push(...installed.facetInstallationIds);
197
211
  retainedBindingIds.push(...(installed.bindingIds ?? []));
198
212
  completedKeys.push(component.preview.key);
@@ -204,6 +218,7 @@ export function registerPluginRoutes(
204
218
  });
205
219
  }
206
220
  const result = InstalledPlugin.parse({
221
+ ...(skillWrites.length ? { skillWrites } : {}),
207
222
  pluginKey: prepared.pluginKey,
208
223
  version: prepared.version,
209
224
  pluginId: prepared.pluginId,
@@ -213,7 +228,9 @@ export function registerPluginRoutes(
213
228
  componentCount: resolved.components.length,
214
229
  status: "installed",
215
230
  });
216
- await finalizePluginPackageInstall(deps.db, {
231
+ const removalActor = skillRemovalActor(access);
232
+ const { skillReleases, skillPublications } = await finalizePluginPackageInstall(deps.db, {
233
+ ...(removalActor ? { skillActor: removalActor } : {}),
217
234
  accountId: grant.accountId,
218
235
  workspaceId,
219
236
  subjectId: grant.subjectId,
@@ -223,7 +240,14 @@ export function registerPluginRoutes(
223
240
  retainedBindingIds,
224
241
  result,
225
242
  });
226
- return c.json(result, resolved.preview.installed ? 200 : 201);
243
+ return c.json(
244
+ {
245
+ ...result,
246
+ ...(skillReleases.length ? { skillReleases } : {}),
247
+ ...(skillPublications.length ? { skillPublications } : {}),
248
+ },
249
+ resolved.preview.installed ? 200 : 201,
250
+ );
227
251
  } catch (error) {
228
252
  await deferPluginPackageOperation(deps.db, {
229
253
  workspaceId,
@@ -231,6 +255,8 @@ export function registerPluginRoutes(
231
255
  phase: `component_failed:${activeComponentKey}`,
232
256
  errorCode: pluginFailureCode(error),
233
257
  }).catch(() => undefined);
258
+ if (error instanceof SkillSourceRemovalAuthorityError)
259
+ throw new HTTPException(403, { message: error.message });
234
260
  if (error instanceof HTTPException) throw error;
235
261
  if (error instanceof CapabilityComponentVersionConflictError) {
236
262
  throw new HTTPException(409, {
@@ -253,7 +279,14 @@ export function registerPluginRoutes(
253
279
 
254
280
  app.delete("/v1/workspaces/:workspaceId/plugins/:pluginKey", async (c) => {
255
281
  const workspaceId = c.req.param("workspaceId");
256
- const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
282
+ const access = await requireAccessGrantAuthorization(
283
+ c,
284
+ deps,
285
+ workspaceId,
286
+ "capabilities:manage",
287
+ );
288
+ const { grant } = access;
289
+ const skillActor = skillRemovalActor(access);
257
290
  const pluginKey = decodeURIComponent(c.req.param("pluginKey"));
258
291
  const payload = UninstallPluginRequest.parse(await c.req.json());
259
292
  try {
@@ -261,6 +294,7 @@ export function registerPluginRoutes(
261
294
  UninstallPluginResult.parse({
262
295
  pluginKey,
263
296
  ...(await uninstallPluginPackage(deps.db, {
297
+ ...(skillActor ? { skillActor } : {}),
264
298
  accountId: grant.accountId,
265
299
  workspaceId,
266
300
  subjectId: grant.subjectId,
@@ -283,6 +317,7 @@ async function resolvePluginPackage(input: {
283
317
  accountId: string;
284
318
  workspaceId: string;
285
319
  subjectId: string;
320
+ skillActor?: () => SkillActor;
286
321
  url: string;
287
322
  bindings: Record<
288
323
  string,
@@ -342,10 +377,13 @@ async function resolvePluginPackage(input: {
342
377
  },
343
378
  },
344
379
  install: async (ownerPluginInstallationId) => {
380
+ if (!input.skillActor)
381
+ throw new HTTPException(403, { message: "Skill installer authority is missing" });
345
382
  const summaries = new Map(
346
383
  resolved.preview.files.map((file) => [file.path, file] as const),
347
384
  );
348
385
  const installed = await installPortableSkill(input.deps.db, {
386
+ skillActor: input.skillActor(),
349
387
  accountId: input.accountId,
350
388
  workspaceId: input.workspaceId,
351
389
  subjectId: input.subjectId,
@@ -366,7 +404,10 @@ async function resolvePluginPackage(input: {
366
404
  }),
367
405
  owner: { kind: "plugin", id: ownerPluginInstallationId, removable: true },
368
406
  });
369
- return { facetInstallationIds: [installed.facetInstallationId] };
407
+ return {
408
+ facetInstallationIds: [installed.facetInstallationId],
409
+ skillReceipt: installed.skillReceipt,
410
+ };
370
411
  },
371
412
  });
372
413
  continue;
@@ -725,6 +766,8 @@ function pluginDiff(
725
766
  }
726
767
 
727
768
  function pluginMutationHttpError(error: unknown): HTTPException {
769
+ if (error instanceof SkillSourceRemovalAuthorityError)
770
+ return new HTTPException(403, { message: error.message });
728
771
  if (error instanceof PluginOperationIdempotencyError) {
729
772
  return new HTTPException(409, { message: "Plugin idempotency key was already used" });
730
773
  }