@xemahq/agent-session-runtime 0.11.1 → 0.11.2

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.
@@ -1,1315 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
-
3
- import {
4
- LogicalWorkspaceSlot,
5
- LogicalMountPlanEntrySchema,
6
- WorkspaceMountMode,
7
- type LogicalMountPlanEntry,
8
- type WorkspaceMountPlan,
9
- } from '@xemahq/kernel-contracts/agent-workspace';
10
- import {
11
- RuntimePlacementAssessment,
12
- UnsupportedRuntimePlacementError,
13
- WorkerRuntimeKind,
14
- type RuntimeLayoutAdapter,
15
- type UnsupportedPlacement,
16
- } from '@xemahq/kernel-contracts/worker-runtime';
17
- import {
18
- BriefcaseReferenceKind,
19
- type AgentRunRole,
20
- type Briefcase,
21
- type BriefcaseReference,
22
- type BriefcaseUpload,
23
- type CompiledWorkingFile,
24
- type MountSource,
25
- type RenderedAgentRunContextParams,
26
- } from '@xemahq/kernel-contracts/workflow';
27
-
28
- import { UnknownSlotError } from './errors';
29
- import type {
30
- ComposeWorkspaceImageRequest,
31
- WorkspaceImageComposer,
32
- } from './types';
33
-
34
- // ═══════════════════════════════════════════════════════════════════════════
35
- // ── WorkspaceImageComposer ──
36
- //
37
- // Turns a (manifest, bindInputs, agentContext) tuple into a typed
38
- // WorkspaceMountPlan. The plan is what the workspace-proxy
39
- // `/workspace/mounts/apply` handler resolves into bytes-on-disk.
40
- //
41
- // The composer auto-emits five platform-rendered slots from the agent
42
- // block (agents-md, context-json, agent-bundles per delegated subagent,
43
- // skill-bundles per skill, instructions). User-data slots are emitted
44
- // from the manifest's mounts block + agentContext refs.
45
- //
46
- // Resolution of WHICH agents/skills/instructions to mount happens
47
- // upstream in llm-registry-api (via the resolvers calling
48
- // `agent-run-context/render` and the per-resource bundle endpoints).
49
- // The composer only enumerates which `MountSource` discriminators to
50
- // emit; the resolver fetches the bytes at apply time.
51
- // ═══════════════════════════════════════════════════════════════════════════
52
-
53
- /**
54
- * Resolved agent metadata the caller passed in via `agentContext`.
55
- * Keys are arbitrary; the composer recognizes a closed set of common
56
- * keys to drive the platform-rendered slot emission and falls back to
57
- * the manifest's declared agent block for everything else.
58
- */
59
- interface AgentContextShape {
60
- readonly delegatedSubAgents?: readonly string[];
61
- readonly skills?: readonly string[];
62
- readonly instructions?: readonly string[];
63
- readonly runtimeConfig?: Readonly<Record<string, unknown>>;
64
- readonly mcpConfig?: Readonly<Record<string, unknown>>;
65
- readonly inputsJsonBase64?: string;
66
- readonly kbSpaceIds?: readonly string[];
67
- /**
68
- * Per-space page selections — emitted as `kb-pages` MountSource
69
- * entries under `references/kb/<spaceId>/<pageSlug>.md`. Used by the
70
- * runtime KB-mount feature on Interactive Sessions / Design System Builder
71
- * (the user picks individual pages from a space to attach to a live
72
- * session). Empty `pageSlugs` selections are dropped by the composer
73
- * — fail-fast on an empty selection happens in the resolver.
74
- */
75
- readonly kbPageMounts?: ReadonlyArray<{
76
- readonly spaceId: string;
77
- readonly pageSlugs: readonly string[];
78
- }>;
79
- readonly externalProjectIds?: readonly string[];
80
- readonly repoRef?: string;
81
- readonly repoRevision?: string;
82
- readonly deliverableSpecRef?: string;
83
- readonly deliverablesRef?: string | readonly string[];
84
- readonly sessionId?: string;
85
- /**
86
- * Per-job hand-off: artifact-store versions emitted by upstream jobs
87
- * (typically `xema/http@v1` with `outputMode: artifact` or any
88
- * `xema/emit-artifact@v1` step) that this agent should see as
89
- * concrete files inside `/workspace/<slot>/<fileName>`. The agent
90
- * reads them via its tool surface — they do NOT travel through
91
- * `agent-run-context.workflowInputs.*` or the system prompt.
92
- *
93
- * Each entry specifies which workspace slot to mount into; the
94
- * manifest MUST enable that slot or the composer fails fast at
95
- * `extractInputArtifactSources`.
96
- */
97
- readonly inputArtifacts?: ReadonlyArray<InputArtifactRef>;
98
- }
99
-
100
- /**
101
- * Wire shape for `agentContext.inputArtifacts[]`. Workflow authors
102
- * populate these by referencing upstream outputs:
103
- *
104
- * ```yaml
105
- * inputArtifacts:
106
- * - artifactId: ${{ needs.fetch.outputs.artifactId }}
107
- * version: ${{ needs.fetch.outputs.version }}
108
- * fileName: page.html
109
- * payloadField: body
110
- * ```
111
- *
112
- * `slot` defaults to `inputs` — the canonical workspace slot for "data
113
- * the workflow is feeding into this agent."
114
- */
115
- export interface InputArtifactRef {
116
- readonly artifactId: string;
117
- readonly version: number;
118
- readonly fileName: string;
119
- readonly slot?: string;
120
- readonly payloadField?: string;
121
- readonly contentType?: string;
122
- }
123
-
124
- export interface ExplicitlyUnsupportedOptionalEntry {
125
- readonly mountKey: string;
126
- readonly slot: LogicalWorkspaceSlot;
127
- readonly placement: UnsupportedPlacement;
128
- }
129
-
130
- export interface RuntimeAssessedWorkspaceMountPlan extends WorkspaceMountPlan {
131
- readonly runtimeKind: WorkerRuntimeKind;
132
- readonly runtimeLayoutRevision: string;
133
- readonly runtimeLayoutProfileRef: string;
134
- readonly staticDependencyClosureHash: string;
135
- readonly unsupportedOptionalEntries: readonly ExplicitlyUnsupportedOptionalEntry[];
136
- }
137
-
138
- export class DefaultWorkspaceImageComposer implements WorkspaceImageComposer {
139
- public constructor(
140
- private readonly runtimeLayout: RuntimeLayoutAdapter,
141
- private readonly optionalMountKeys: ReadonlySet<string> = new Set(),
142
- ) {}
143
-
144
- async compose(
145
- req: ComposeWorkspaceImageRequest,
146
- ): Promise<RuntimeAssessedWorkspaceMountPlan> {
147
- // Exactly-one-of invariant: the doc on `ComposeWorkspaceImageRequest`
148
- // promises `workflowRun` xor `interactive`. Enforce it here so
149
- // `deriveApplyId` can never collapse two unrelated requests onto a
150
- // shared `applyId` (which would let workspace-proxy dedup
151
- // unrelated mounts). No silent fallback per the engineering
152
- // constitution.
153
- const hasWorkflow = req.workflowRun !== undefined;
154
- const hasInteractive = req.interactive !== undefined;
155
- if (hasWorkflow === hasInteractive) {
156
- throw new Error(
157
- `ComposeWorkspaceImageRequest must declare exactly one of \`workflowRun\` or \`interactive\` (got workflowRun=${hasWorkflow}, interactive=${hasInteractive}).`,
158
- );
159
- }
160
- const ctx = req.agentContext as AgentContextShape;
161
- // Surface discriminator. The xor-checked `workflowRun` / `interactive`
162
- // fields above ARE the kernel surface signal; we project them onto a
163
- // boolean here for the gates further down. Workflow content
164
- // (deliverable specs, input artifacts, deliverables) must never leak
165
- // into a session surface — fail-fast per CLAUDE.md.
166
- const isWorkflowSurface = hasWorkflow;
167
- if (!isWorkflowSurface) {
168
- // Hard-assert: a session must not be built with any workflow-only
169
- // payload. This catches the "wrong agentContext fed into the session
170
- // bootstrap" class of misuse before it materialises on disk.
171
- if (ctx.inputArtifacts !== undefined && ctx.inputArtifacts.length > 0) {
172
- throw new Error(
173
- 'WorkspaceImageComposer: agent-session surface received non-empty `agentContext.inputArtifacts` — input artifacts are a workflow-only concept. Drop the field or switch to a workflow surface.',
174
- );
175
- }
176
- if (
177
- ctx.deliverableSpecRef !== undefined ||
178
- ctx.deliverablesRef !== undefined ||
179
- req.agentOverrides?.deliverableSpecRef !== undefined ||
180
- req.manifest.agent.deliverableSpecRef !== undefined
181
- ) {
182
- throw new Error(
183
- 'WorkspaceImageComposer: agent-session surface received a deliverable-spec / deliverables reference — deliverables are a workflow-only concept. Drop `deliverableSpecRef` / `deliverablesRef` for session calls, or switch to a workflow surface.',
184
- );
185
- }
186
- }
187
- // Role is caller-supplied. The silent `?? manifest.agent.role`
188
- // fallback was removed as part of the unified-role refactor — every
189
- // caller (worker activity, agent-session bootstrap, future
190
- // entry points) MUST set `agentOverrides.role` explicitly. This
191
- // prevents the class of bugs where a manifest declared the wrong
192
- // role and the runtime silently propagated it to the wrong template.
193
- if (req.agentOverrides?.role === undefined) {
194
- throw new Error(
195
- 'ComposeWorkspaceImageRequest.agentOverrides.role is required — pass `manifest.agent.role` explicitly so the renderer can validate role-vs-surface up front.',
196
- );
197
- }
198
- const role: AgentRunRole = req.agentOverrides.role;
199
- const deliverableSpecRef =
200
- req.agentOverrides?.deliverableSpecRef ??
201
- req.manifest.agent.deliverableSpecRef ??
202
- ctx.deliverableSpecRef;
203
-
204
- assertNoSecretOrBindingMaterial(req);
205
- const entries: LogicalMountPlanEntry[] = [];
206
-
207
- // ── Platform-rendered single-file slots ───────────────────────────
208
- // The three rendered-* sources (agents-md, context-json, system-overlay)
209
- // all dispatch to llm-registry's `agent-run-context/render`, which
210
- // returns `{agentsMd, contextJson, systemOverlay}` in a single
211
- // response. Building them from one shared param object guarantees
212
- // they hash to the same workspace-proxy cache key — so one
213
- // mount-apply triggers exactly one upstream render call (instead of
214
- // three) and the three slots are guaranteed to come from the same
215
- // render snapshot. Diverging the params is a silent perf+consistency
216
- // bug; do not do it.
217
- // Workflow-only: `context.json.workflowInputs.*` is the workflow
218
- // engine's `with:` block projection. Sessions have no such concept;
219
- // surfacing it would be a lie. The hard-assert above ensures no
220
- // workflow-only fields slip through; we still drop the projection
221
- // here as a defence-in-depth.
222
- const workflowInputs = isWorkflowSurface
223
- ? pickWorkflowInputs(req.agentContext)
224
- : null;
225
- const renderedMounts = buildRenderedMounts(
226
- req.manifest.mounts,
227
- req.manifest.slug,
228
- req.manifest.version,
229
- );
230
- const renderedInputArtifacts = buildRenderedInputArtifacts(
231
- ctx.inputArtifacts,
232
- );
233
- const renderedWorkingFiles = buildRenderedWorkingFiles(
234
- req.manifest.workingFiles,
235
- );
236
- const renderedSourceParams: RenderedAgentRunContextParams = {
237
- orgId: req.orgId,
238
- projectId: req.projectId,
239
- agentSlug: req.manifest.agent.slug,
240
- stageKey: req.manifest.agent.stageKey,
241
- role,
242
- ...(deliverableSpecRef === undefined ? {} : { deliverableSpecRef }),
243
- ...(req.workflowRun?.runId === undefined
244
- ? {}
245
- : { runId: req.workflowRun.runId }),
246
- ...(req.workflowRun?.jobRunId === undefined
247
- ? {}
248
- : { jobRunId: req.workflowRun.jobRunId }),
249
- ...(req.interactive?.sessionId === undefined
250
- ? {}
251
- : { sessionId: req.interactive.sessionId }),
252
- ...(workflowInputs === null ? {} : { workflowInputs }),
253
- ...(renderedMounts.length === 0 ? {} : { mounts: renderedMounts }),
254
- ...(renderedInputArtifacts.length === 0
255
- ? {}
256
- : { inputArtifacts: renderedInputArtifacts }),
257
- ...(renderedWorkingFiles.length === 0
258
- ? {}
259
- : { workingFiles: renderedWorkingFiles }),
260
- };
261
-
262
- entries.push(
263
- buildEntry({
264
- slot: LogicalWorkspaceSlot.AgentContext,
265
- relPath: '',
266
- mode: WorkspaceMountMode.ReadOnly,
267
- mountKey: 'agents-md',
268
- source: { kind: 'rendered-agents-md', ...renderedSourceParams },
269
- }),
270
- );
271
-
272
- if (ctx.runtimeConfig !== undefined) {
273
- assertRuntimeConfigExcludesImageOwnedContent(ctx.runtimeConfig);
274
- entries.push(
275
- buildCanonicalConfigEntry(
276
- LogicalWorkspaceSlot.RuntimeConfig,
277
- 'runtime-config',
278
- 'runtime-config.json',
279
- ctx.runtimeConfig,
280
- ),
281
- );
282
- }
283
- if (ctx.mcpConfig !== undefined) {
284
- entries.push(
285
- buildCanonicalConfigEntry(
286
- LogicalWorkspaceSlot.McpConfig,
287
- 'mcp-config',
288
- 'mcp-config.json',
289
- ctx.mcpConfig,
290
- ),
291
- );
292
- }
293
-
294
- entries.push(
295
- buildEntry({
296
- slot: LogicalWorkspaceSlot.AgentContext,
297
- relPath: '',
298
- mode: WorkspaceMountMode.ReadOnly,
299
- mountKey: 'context-json',
300
- source: { kind: 'rendered-context-json', ...renderedSourceParams },
301
- }),
302
- );
303
-
304
- entries.push(
305
- buildEntry({
306
- slot: LogicalWorkspaceSlot.SystemOverlay,
307
- relPath: '',
308
- mode: WorkspaceMountMode.ReadOnly,
309
- mountKey: 'system-overlay',
310
- source: { kind: 'rendered-system-overlay', ...renderedSourceParams },
311
- }),
312
- );
313
-
314
- // ── /workspace/tmp (scratch space) ──
315
- // Always-on, read-write, tarball-persisted. Empty seed so the
316
- // directory exists from session start; agents drop scratch files in
317
- // and snapshots carry them across retries automatically.
318
- entries.push(
319
- buildEntry({
320
- slot: LogicalWorkspaceSlot.Temporary,
321
- relPath: '.keep',
322
- mode: WorkspaceMountMode.ReadWrite,
323
- mountKey: 'tmp:seed',
324
- source: {
325
- kind: 'static-literal',
326
- pathWithinWorkspace: '.keep',
327
- bytes: '',
328
- },
329
- }),
330
- );
331
-
332
- // ── Agent definitions (primary + delegated subagents) ─────────────
333
- // The logical plan is the sole static closure authority on both
334
- // workflow and interactive surfaces. Runtime-specific writers may render
335
- // these differently, but no second control bundle may reconstruct them.
336
- entries.push(
337
- buildEntry({
338
- slot: LogicalWorkspaceSlot.AgentDefinitions,
339
- relPath: `${req.manifest.agent.slug}.md`,
340
- mode: WorkspaceMountMode.ReadOnly,
341
- mountKey: `agent-bundle:${req.manifest.agent.slug}`,
342
- source: {
343
- kind: 'agent-kernel',
344
- orgId: req.orgId,
345
- agentSlug: req.manifest.agent.slug,
346
- stageKey: req.manifest.agent.stageKey,
347
- agentMode: 'primary',
348
- },
349
- }),
350
- );
351
- for (const sub of dedupe(ctx.delegatedSubAgents ?? [])) {
352
- if (sub === req.manifest.agent.slug) {
353
- continue;
354
- }
355
- entries.push(
356
- buildEntry({
357
- slot: LogicalWorkspaceSlot.AgentDefinitions,
358
- relPath: `${sub}.md`,
359
- mode: WorkspaceMountMode.ReadOnly,
360
- mountKey: `agent-bundle:${sub}`,
361
- source: {
362
- kind: 'agent-kernel',
363
- orgId: req.orgId,
364
- agentSlug: sub,
365
- stageKey: req.manifest.agent.stageKey,
366
- agentMode: 'subagent',
367
- },
368
- }),
369
- );
370
- }
371
-
372
- // ── Skill bundles + /skill launch commands ────────────────────────
373
- // Each skill is a multi-file BUNDLE (SKILL.md + bundled resources).
374
- // The entry's relPath is the skill DIRECTORY; the `skill-bundle`
375
- // resolver yields one file per bundle member, so the writer composes
376
- // `<skill-bundles slot>/<skillKey>/<resolver relPath>`.
377
- //
378
- // Alongside each bundle we emit one logical invocation intent, rendered
379
- // as a command file. A runtime whose adapter consumed the intent NATIVELY
380
- // (no command file) was the reason this used to branch on runtimeKind; no
381
- // such runtime ships, and the kernel placement kind that expressed it is
382
- // gone, so every skill gets a rendered command.
383
- //
384
- // Kernel-shipped System skills are PREPENDED to whatever skills the
385
- // caller already named in `agentContext.skills` (per the skill
386
- // composition rules — `SkillSpace.System`
387
- // skills are auto-injected into every agent's mounted bundle).
388
- // `dedupe` collapses any overlap so a caller that re-mentions a
389
- // System skill in `agentContext.skills` does not double-mount it.
390
- for (const skillKey of dedupe([
391
- ...(req.systemSkillSlugs ?? []),
392
- ...(ctx.skills ?? []),
393
- ])) {
394
- entries.push(
395
- buildEntry({
396
- slot: LogicalWorkspaceSlot.SkillBundles,
397
- relPath: skillKey,
398
- mode: WorkspaceMountMode.ReadOnly,
399
- mountKey: `skill-bundle:${skillKey}`,
400
- source: {
401
- kind: 'skill-bundle',
402
- skillKey,
403
- },
404
- }),
405
- );
406
- const commandRelPath = `${skillKey}.md`;
407
- entries.push(
408
- buildEntry({
409
- slot: LogicalWorkspaceSlot.SkillCommands,
410
- relPath: commandRelPath,
411
- mode: WorkspaceMountMode.ReadOnly,
412
- mountKey: `skill-command:${skillKey}`,
413
- source: {
414
- kind: 'static-literal',
415
- pathWithinWorkspace: commandRelPath,
416
- bytes: Buffer.from(renderSkillCommand(skillKey), 'utf8').toString(
417
- 'base64',
418
- ),
419
- },
420
- }),
421
- );
422
- }
423
-
424
- // ── Instruction sections ──────────────────────────────────────────
425
- for (const sectionKey of dedupe(ctx.instructions ?? [])) {
426
- entries.push(
427
- buildEntry({
428
- slot: LogicalWorkspaceSlot.Instructions,
429
- relPath: `${sectionKey}.md`,
430
- mode: WorkspaceMountMode.ReadOnly,
431
- mountKey: `instruction:${sectionKey}`,
432
- source: {
433
- kind: 'instruction-section',
434
- orgId: req.orgId,
435
- sectionKey,
436
- agentSlug: req.manifest.agent.slug,
437
- stageKey: req.manifest.agent.stageKey,
438
- },
439
- }),
440
- );
441
- }
442
-
443
- // ── Input artifacts (workflow-step output → file in workspace) ──
444
- // Walks `agentContext.inputArtifacts[]` and lands each artifact
445
- // version as a single file under its target slot. Slot defaults to
446
- // `inputs`. The slot MUST be enabled in the manifest — otherwise
447
- // the workspace-proxy authority guard would reject any tool read on
448
- // the resulting path, and the workflow author wants a hard signal
449
- // not a silent skip.
450
- //
451
- // Workflow-surface only: the hard-assert at the top of compose()
452
- // guarantees `ctx.inputArtifacts` is empty on session surfaces, so
453
- // this loop is effectively a no-op there.
454
- const enabledSlots = new Set(
455
- Object.entries(req.manifest.mounts)
456
- .filter(([, decl]) => decl.enabled)
457
- .map(([slotKey]) => slotKey),
458
- );
459
- for (const ia of ctx.inputArtifacts ?? []) {
460
- const slotKey = ia.slot ?? 'inputs';
461
- if (!enabledSlots.has(slotKey)) {
462
- throw new Error(
463
- `WorkspaceImageComposer: agentContext.inputArtifacts targets slot '${slotKey}' but the manifest '${req.manifest.slug}@${req.manifest.version}' does not enable it. ` +
464
- `Enable the slot in the agent manifest or change the inputArtifact's 'slot' to one of: [${[...enabledSlots].join(', ') || '(none)'}].`,
465
- );
466
- }
467
- const slotDef = requireLogicalSlot(
468
- slotKey,
469
- 'inputArtifacts',
470
- req.manifest.slug,
471
- req.manifest.version,
472
- );
473
- const fileName = ia.fileName.trim();
474
- if (
475
- fileName.length === 0 ||
476
- fileName.includes('..') ||
477
- fileName.startsWith('/')
478
- ) {
479
- throw new Error(
480
- `WorkspaceImageComposer: inputArtifact fileName '${ia.fileName}' is invalid — must be a non-empty relative path without '..' segments.`,
481
- );
482
- }
483
- entries.push(
484
- buildEntry({
485
- slot: slotDef.slot,
486
- relPath: fileName,
487
- mode: WorkspaceMountMode.ReadOnly,
488
- mountKey: `input-artifact:${ia.artifactId}:v${ia.version}:${fileName}`,
489
- source: {
490
- kind: 'artifact-version',
491
- artifactId: ia.artifactId,
492
- version: ia.version,
493
- fileName,
494
- ...(ia.payloadField !== undefined && {
495
- payloadField: ia.payloadField,
496
- }),
497
- ...(ia.contentType !== undefined && {
498
- contentType: ia.contentType,
499
- }),
500
- },
501
- }),
502
- );
503
- }
504
-
505
- // ── User-data mounts (manifest-declared) ──────────────────────────
506
- for (const [slotKey, decl] of Object.entries(req.manifest.mounts)) {
507
- if (!decl.enabled) continue;
508
- const slotDef = requireLogicalSlot(
509
- slotKey,
510
- 'manifest.mounts',
511
- req.manifest.slug,
512
- req.manifest.version,
513
- );
514
- const mode = decl.mode ?? slotDef.defaultMode;
515
- const mountMode =
516
- mode === 'read-write'
517
- ? WorkspaceMountMode.ReadWrite
518
- : WorkspaceMountMode.ReadOnly;
519
- const sources = extractMountSourcesForSlot(
520
- slotKey,
521
- decl,
522
- ctx,
523
- deliverableSpecRef,
524
- req.briefcase,
525
- isWorkflowSurface,
526
- );
527
- for (const { source, relPath, mountKey } of sources) {
528
- entries.push(
529
- buildEntry({
530
- slot: slotDef.slot,
531
- relPath,
532
- mode: mountMode,
533
- mountKey,
534
- source,
535
- }),
536
- );
537
- }
538
- }
539
-
540
- // ── Manifest seed files (inline literal or resolved template) ─────
541
- if (
542
- req.manifest.requiredTemplateNames.length > 0 &&
543
- req.templateResolver === undefined
544
- ) {
545
- throw new Error(
546
- `WorkspaceImageComposer: manifest '${req.manifest.slug}@${req.manifest.version}' references templates ` +
547
- `[${req.manifest.requiredTemplateNames.join(', ')}] but no templateResolver was supplied.`,
548
- );
549
- }
550
- for (const sf of req.manifest.seedFiles) {
551
- const slotDef = requireLogicalSlot(
552
- sf.slot,
553
- 'manifest.seedFiles',
554
- req.manifest.slug,
555
- req.manifest.version,
556
- );
557
- let bytesBase64: string;
558
- if (sf.source.kind === 'inline') {
559
- bytesBase64 = sf.source.bytesBase64;
560
- } else {
561
- const rendered = await req.templateResolver!.resolve(
562
- sf.source.name,
563
- sf.source.vars,
564
- );
565
- bytesBase64 = Buffer.from(rendered, 'utf8').toString('base64');
566
- }
567
- // `entry.relPath` is the file's location within the slot — used by
568
- // the plan validator to dedup target paths AND by the writer as the
569
- // file's final position. `source.pathWithinWorkspace` is identity
570
- // metadata only (audit/telemetry), redundantly carrying the same
571
- // value. The static-literal resolver yields `sub.relPath: ''` so
572
- // the writer composes `<slot>/<entry.relPath>/<''>` → `<slot>/<entry.relPath>`,
573
- // which is exactly what we want.
574
- entries.push(
575
- buildEntry({
576
- slot: slotDef.slot,
577
- relPath: sf.relPath,
578
- mode: WorkspaceMountMode.ReadOnly,
579
- mountKey: `seed:${sf.slot}:${sf.relPath}`,
580
- source: {
581
- kind: 'static-literal',
582
- pathWithinWorkspace: sf.relPath,
583
- bytes: bytesBase64,
584
- },
585
- }),
586
- );
587
- }
588
-
589
- return finalizeLogicalPlan(
590
- req,
591
- entries,
592
- this.runtimeLayout,
593
- this.optionalMountKeys,
594
- );
595
- }
596
- }
597
-
598
- /**
599
- * Stable apply-id — feeds the workspace-proxy lease key.
600
- *
601
- * Includes manifest slug + version + a content hash of the manifest's
602
- * mount-affecting blocks (`agent`, `mounts`, `seedFiles`,
603
- * `requiredTemplateNames`, `extends`). The content hash defends against
604
- * the failure mode where a manifest is mutated in place without bumping
605
- * `version` — without it, workspace-proxy would serve stale mounts from
606
- * a cached lease keyed only on the unchanged version. The bind inputs
607
- * hash + scopeId ensure cross-run / cross-input isolation.
608
- *
609
- * Hash is base16; collision probability is negligible at the per-lease
610
- * scale we operate at.
611
- */
612
- function deriveApplyId(req: ComposeWorkspaceImageRequest): string {
613
- // Exactly one of the two is set — enforced at the top of compose().
614
- const scopeId = req.workflowRun
615
- ? `wf:${req.workflowRun.runId}:${req.workflowRun.jobRunId}`
616
- : `is:${req.interactive!.sessionId}:${req.interactive!.turnGen ?? 0}`;
617
- const inputsHash = createHash('sha256')
618
- .update(JSON.stringify(stableSort(req.agentContext)))
619
- .digest('hex')
620
- .slice(0, 16);
621
- const manifestContentHash = deriveManifestContentHash(req.manifest);
622
- const components = [
623
- req.orgId,
624
- req.projectId,
625
- req.manifest.slug,
626
- req.manifest.version,
627
- manifestContentHash,
628
- req.manifest.agent.slug,
629
- req.agentOverrides?.role ?? req.manifest.agent.role,
630
- inputsHash,
631
- scopeId,
632
- ];
633
- return createHash('sha256').update(components.join(':')).digest('hex');
634
- }
635
-
636
- function finalizeLogicalPlan(
637
- req: ComposeWorkspaceImageRequest,
638
- entries: readonly LogicalMountPlanEntry[],
639
- runtimeLayout: RuntimeLayoutAdapter,
640
- optionalMountKeys: ReadonlySet<string>,
641
- ): RuntimeAssessedWorkspaceMountPlan {
642
- return compileLogicalWorkspacePlan({
643
- applySeed: deriveApplyId(req),
644
- entries,
645
- runtimeLayout,
646
- optionalMountKeys,
647
- });
648
- }
649
-
650
- export interface CompileLogicalWorkspacePlanInput {
651
- readonly applySeed: string;
652
- readonly entries: readonly LogicalMountPlanEntry[];
653
- readonly runtimeLayout: RuntimeLayoutAdapter;
654
- readonly optionalMountKeys?: ReadonlySet<string>;
655
- }
656
-
657
- export function compileLogicalWorkspacePlan(
658
- input: CompileLogicalWorkspacePlanInput,
659
- ): RuntimeAssessedWorkspaceMountPlan {
660
- const { applySeed, entries, runtimeLayout } = input;
661
- const optionalMountKeys = input.optionalMountKeys ?? new Set<string>();
662
- const ordered = [...entries].sort(compareLogicalEntries);
663
- const seenMountKeys = new Set<string>();
664
- const supported: LogicalMountPlanEntry[] = [];
665
- const unsupportedOptionalEntries: ExplicitlyUnsupportedOptionalEntry[] = [];
666
-
667
- for (const entry of ordered) {
668
- assertNoSecretOrBindingMaterial(entry.source);
669
- LogicalMountPlanEntrySchema.parse(entry);
670
- if (seenMountKeys.has(entry.mountKey)) {
671
- throw new Error(`DUPLICATE_LOGICAL_MOUNT_KEY:${entry.mountKey}`);
672
- }
673
- seenMountKeys.add(entry.mountKey);
674
- const placement = runtimeLayout.assess(entry);
675
- if (placement.assessment === RuntimePlacementAssessment.Unsupported) {
676
- if (!optionalMountKeys.has(entry.mountKey)) {
677
- throw new UnsupportedRuntimePlacementError(placement);
678
- }
679
- unsupportedOptionalEntries.push({
680
- mountKey: entry.mountKey,
681
- slot: entry.slot,
682
- placement,
683
- });
684
- continue;
685
- }
686
- supported.push(entry);
687
- }
688
-
689
- const closure = {
690
- schemaVersion: 1,
691
- runtimeKind: runtimeLayout.runtimeKind,
692
- runtimeLayoutRevision: runtimeLayout.revision,
693
- runtimeLayoutProfileRef: runtimeLayout.profileRef,
694
- entries: supported,
695
- unsupportedOptionalEntries,
696
- } as const;
697
- const staticDependencyClosureHash = createHash('sha256')
698
- .update(JSON.stringify(stableSort(closure)))
699
- .digest('hex');
700
- const applyId = createHash('sha256')
701
- .update(`${applySeed}:${staticDependencyClosureHash}`)
702
- .digest('hex');
703
- return {
704
- applyId,
705
- entries: supported,
706
- runtimeKind: runtimeLayout.runtimeKind,
707
- runtimeLayoutRevision: runtimeLayout.revision,
708
- runtimeLayoutProfileRef: runtimeLayout.profileRef,
709
- staticDependencyClosureHash,
710
- unsupportedOptionalEntries,
711
- };
712
- }
713
-
714
- function compareLogicalEntries(
715
- left: LogicalMountPlanEntry,
716
- right: LogicalMountPlanEntry,
717
- ): number {
718
- const leftKey = `${left.slot}\u0000${left.relPath}\u0000${left.mountKey}`;
719
- const rightKey = `${right.slot}\u0000${right.relPath}\u0000${right.mountKey}`;
720
- return leftKey === rightKey ? 0 : leftKey < rightKey ? -1 : 1;
721
- }
722
-
723
- const FORBIDDEN_MATERIAL_KEY =
724
- /(?:secret|password|accessToken|refreshToken|credentialLease|runtimeBinding|sessionBinding)/i;
725
-
726
- function assertNoSecretOrBindingMaterial(value: unknown, path = 'input'): void {
727
- if (value === null || typeof value !== 'object') {
728
- return;
729
- }
730
- if (Array.isArray(value)) {
731
- value.forEach((member, index) =>
732
- assertNoSecretOrBindingMaterial(member, `${path}[${index}]`),
733
- );
734
- return;
735
- }
736
- for (const [key, member] of Object.entries(
737
- value as Readonly<Record<string, unknown>>,
738
- )) {
739
- if (FORBIDDEN_MATERIAL_KEY.test(key) && member !== undefined) {
740
- throw new Error(`FORBIDDEN_RUNTIME_MATERIAL:${path}.${key}`);
741
- }
742
- assertNoSecretOrBindingMaterial(member, `${path}.${key}`);
743
- }
744
- }
745
-
746
- /**
747
- * Hash the manifest fields that drive mount composition. A change to
748
- * any of these fields produces a different `applyId`, forcing
749
- * workspace-proxy to allocate a fresh lease and re-resolve mounts. Any
750
- * non-mount field (`description`, `metadata.author`, …) is excluded so
751
- * cosmetic edits don't churn the lease.
752
- */
753
- function deriveManifestContentHash(
754
- manifest: ComposeWorkspaceImageRequest['manifest'],
755
- ): string {
756
- // `extends` is already flattened into `agent`/`mounts`/`seedFiles`/`env`
757
- // by the DSL compiler, so hashing those four fields captures the
758
- // effective workspace shape after inheritance.
759
- const projection = {
760
- agent: manifest.agent,
761
- mounts: manifest.mounts,
762
- seedFiles: manifest.seedFiles,
763
- env: manifest.env,
764
- requiredTemplateNames: manifest.requiredTemplateNames,
765
- };
766
- return createHash('sha256')
767
- .update(JSON.stringify(stableSort(projection)))
768
- .digest('hex')
769
- .slice(0, 16);
770
- }
771
-
772
- function stableSort(value: unknown): unknown {
773
- if (value === null || typeof value !== 'object') return value;
774
- if (Array.isArray(value)) return value.map(stableSort);
775
- const obj = value as Record<string, unknown>;
776
- const sorted: Record<string, unknown> = {};
777
- for (const key of Object.keys(obj).sort()) {
778
- sorted[key] = stableSort(obj[key]);
779
- }
780
- return sorted;
781
- }
782
-
783
- function dedupe<T>(items: readonly T[]): readonly T[] {
784
- return Array.from(new Set(items));
785
- }
786
-
787
- /**
788
- * The OpenCode command-file body for a `/skill` launch command. Typing
789
- * `/<skillKey>` in a session expands to this prompt — load the skill,
790
- * then act on whatever the user passed as arguments.
791
- */
792
- function renderSkillCommand(skillKey: string): string {
793
- return [
794
- '---',
795
- `description: Load and apply the ${skillKey} skill`,
796
- '---',
797
- `Load and apply the \`${skillKey}\` skill, then act on the request: $ARGUMENTS`,
798
- '',
799
- ].join('\n');
800
- }
801
-
802
- function buildEntry(args: {
803
- slot: LogicalWorkspaceSlot;
804
- relPath: string;
805
- mode: WorkspaceMountMode;
806
- mountKey: string;
807
- source: MountSource;
808
- }): LogicalMountPlanEntry {
809
- return {
810
- mountKey: args.mountKey,
811
- slot: args.slot,
812
- relPath: args.relPath,
813
- mode: args.mode,
814
- source: args.source,
815
- };
816
- }
817
-
818
- function buildCanonicalConfigEntry(
819
- slot: LogicalWorkspaceSlot.RuntimeConfig | LogicalWorkspaceSlot.McpConfig,
820
- mountKey: string,
821
- sourceName: string,
822
- value: Readonly<Record<string, unknown>>,
823
- ): LogicalMountPlanEntry {
824
- assertNoSecretOrBindingMaterial(value, mountKey);
825
- return buildEntry({
826
- slot,
827
- relPath: '',
828
- mode: WorkspaceMountMode.ReadOnly,
829
- mountKey,
830
- source: {
831
- kind: 'static-literal',
832
- pathWithinWorkspace: sourceName,
833
- bytes: Buffer.from(JSON.stringify(stableSort(value)), 'utf8').toString(
834
- 'base64',
835
- ),
836
- },
837
- });
838
- }
839
-
840
- function assertRuntimeConfigExcludesImageOwnedContent(
841
- config: Readonly<Record<string, unknown>>,
842
- ): void {
843
- for (const key of ['plugins', 'tools']) {
844
- if (config[key] !== undefined) {
845
- throw new Error(`RUNTIME_IMAGE_OWNED_CONTENT:${key}`);
846
- }
847
- }
848
- }
849
-
850
- interface ExtractedMountSource {
851
- readonly source: MountSource;
852
- readonly relPath: string;
853
- readonly mountKey: string;
854
- }
855
-
856
- /**
857
- * Translate a manifest mount declaration + agentContext into one or
858
- * more concrete MountSources. Closed switch on slot key — adding a
859
- * new user-data slot requires extending here.
860
- */
861
- function extractMountSourcesForSlot(
862
- slotKey: string,
863
- _decl: { readonly config: Readonly<Record<string, unknown>> },
864
- ctx: AgentContextShape,
865
- deliverableSpecRef: string | undefined,
866
- briefcase: Briefcase | undefined,
867
- isWorkflowSurface: boolean,
868
- ): readonly ExtractedMountSource[] {
869
- switch (slotKey) {
870
- case 'inputs':
871
- // Workflow-only slot. The inputs slot carries the rendered
872
- // inputs.json blob serialized from workflow `with:` inputs;
873
- // sessions have no such concept — the hard-assert at the top of
874
- // compose() ensures workflow-only payloads never reach a session,
875
- // and this guard makes the rule explicit at the source-extractor
876
- // boundary too.
877
- if (!isWorkflowSurface) return [];
878
- if (!ctx.inputsJsonBase64) return [];
879
- return [
880
- {
881
- source: {
882
- kind: 'static-literal',
883
- pathWithinWorkspace: 'inputs.json',
884
- bytes: ctx.inputsJsonBase64,
885
- },
886
- relPath: 'inputs.json',
887
- mountKey: 'inputs:json',
888
- },
889
- ];
890
- case 'uploads':
891
- return extractBriefcaseUploadMounts(briefcase?.uploads);
892
- case 'references': {
893
- const out: ExtractedMountSource[] = [];
894
- for (const spaceId of dedupe(ctx.kbSpaceIds ?? [])) {
895
- out.push({
896
- source: { kind: 'kb-space', spaceId },
897
- relPath: `kb/${spaceId}`,
898
- mountKey: `references:kb:${spaceId}`,
899
- });
900
- }
901
- for (const mount of ctx.kbPageMounts ?? []) {
902
- const pageSlugs = dedupe(mount.pageSlugs);
903
- if (pageSlugs.length === 0) continue;
904
- out.push({
905
- source: { kind: 'kb-pages', spaceId: mount.spaceId, pageSlugs },
906
- relPath: `kb/${mount.spaceId}`,
907
- // Mount-key disambiguates from the kb-space entry on the same
908
- // space when both coexist — e.g. user mounted the whole space
909
- // and later pinned additional specific pages. Slot promote
910
- // does an atomic `rm -rf` + rename so the two entries' files
911
- // coexist under `references/kb/<spaceId>/` after apply.
912
- mountKey: `references:kb-pages:${mount.spaceId}`,
913
- });
914
- }
915
- for (const projectId of dedupe(ctx.externalProjectIds ?? [])) {
916
- out.push({
917
- source: { kind: 'scm-repo', repoRef: projectId, ref: 'HEAD' },
918
- relPath: `external-projects/${projectId}`,
919
- mountKey: `references:project:${projectId}`,
920
- });
921
- }
922
- for (const entry of extractBriefcaseReferenceMounts(
923
- briefcase?.references,
924
- )) {
925
- out.push(entry);
926
- }
927
- return out;
928
- }
929
- case 'repos':
930
- if (!ctx.repoRef) return [];
931
- return [
932
- {
933
- source: {
934
- kind: 'scm-repo',
935
- repoRef: ctx.repoRef,
936
- ref: ctx.repoRevision ?? 'HEAD',
937
- },
938
- relPath: ctx.repoRef,
939
- mountKey: `repo:${ctx.repoRef}`,
940
- },
941
- ];
942
- case 'deliverable-specs':
943
- // Workflow-only slot. Sessions never harvest deliverables, so a
944
- // spec mount would be dead weight at best, misleading at worst.
945
- // The compose()-level hard-assert ensures `deliverableSpecRef` is
946
- // undefined on session surfaces, but we mirror the gate here so
947
- // the slot-extractor switch is the single readable place that
948
- // documents which slots are workflow-only.
949
- if (!isWorkflowSurface) return [];
950
- if (!deliverableSpecRef) return [];
951
- return [
952
- {
953
- source: {
954
- kind: 'deliverable-specs',
955
- contractKey: deliverableSpecRef,
956
- },
957
- relPath: deliverableSpecRef,
958
- mountKey: `deliverable-specs:${deliverableSpecRef}`,
959
- },
960
- ];
961
- case 'deliverables': {
962
- // Workflow-only slot — same rationale as `deliverable-specs`.
963
- if (!isWorkflowSurface) return [];
964
- const refs = ctx.deliverablesRef
965
- ? Array.isArray(ctx.deliverablesRef)
966
- ? ctx.deliverablesRef
967
- : [ctx.deliverablesRef]
968
- : [];
969
- return refs.map((contractKey) => ({
970
- source: { kind: 'deliverables', contractKey },
971
- relPath: contractKey,
972
- mountKey: `deliverables:${contractKey}`,
973
- }));
974
- }
975
- case 'attachments':
976
- if (!ctx.sessionId) return [];
977
- return [
978
- {
979
- source: { kind: 'session-attachment', sessionId: ctx.sessionId },
980
- relPath: '',
981
- mountKey: `attachments:${ctx.sessionId}`,
982
- },
983
- ];
984
- default:
985
- return [];
986
- }
987
- }
988
-
989
- /**
990
- * Project `briefcase.uploads[]` onto `artifact-version` MountSources
991
- * under the `uploads` AWP slot. Each upload becomes one file at
992
- * `/workspace/uploads/<filename>`. Empty filenames or paths containing
993
- * traversal segments fail fast — the dispatch endpoint already
994
- * validates these but we re-check at compose time so an in-memory
995
- * mutation can never write outside the slot.
996
- */
997
- function extractBriefcaseUploadMounts(
998
- uploads: readonly BriefcaseUpload[] | undefined,
999
- ): readonly ExtractedMountSource[] {
1000
- if (!uploads || uploads.length === 0) return [];
1001
- return uploads.map((u) => {
1002
- const fileName = u.filename.trim();
1003
- if (
1004
- fileName.length === 0 ||
1005
- fileName.includes('..') ||
1006
- fileName.startsWith('/')
1007
- ) {
1008
- throw new Error(
1009
- `WorkspaceImageComposer: briefcase upload filename '${u.filename}' is invalid — must be a non-empty relative path without '..' segments.`,
1010
- );
1011
- }
1012
- return {
1013
- source: {
1014
- kind: 'artifact-version',
1015
- artifactId: u.artifact.artifactId,
1016
- version: u.artifact.version,
1017
- fileName,
1018
- ...(u.contentType !== null ? { contentType: u.contentType } : {}),
1019
- },
1020
- relPath: fileName,
1021
- mountKey: `briefcase-upload:${u.artifact.artifactId}:v${u.artifact.version}`,
1022
- } satisfies ExtractedMountSource;
1023
- });
1024
- }
1025
-
1026
- /**
1027
- * Project `briefcase.references[]` onto MountSources under the
1028
- * `references` AWP slot. Each reference kind maps to the
1029
- * already-supported MountSource kind:
1030
- *
1031
- * - `kb_page` → `kb-pages` (single page, ref is `<spaceId>/<slug>`)
1032
- * - `kb_space` → `kb-space` (whole space, ref is `<spaceId>`)
1033
- * - `artifact` → `artifact-version` (ref is `<artifactId>@<version>`)
1034
- * - `scm_repo` → `scm-repo` (ref is `<repoRef>@<gitRef>` or `<repoRef>`)
1035
- * - `external_url` → skipped (no on-disk file; surfaces via
1036
- * context.json only — agents see it under workflowInputs)
1037
- */
1038
- function extractBriefcaseReferenceMounts(
1039
- references: readonly BriefcaseReference[] | undefined,
1040
- ): readonly ExtractedMountSource[] {
1041
- if (!references || references.length === 0) return [];
1042
- const out: ExtractedMountSource[] = [];
1043
- for (const ref of references) {
1044
- const entry = briefcaseReferenceToMount(ref);
1045
- if (entry !== null) out.push(entry);
1046
- }
1047
- return out;
1048
- }
1049
-
1050
- function briefcaseReferenceToMount(
1051
- ref: BriefcaseReference,
1052
- ): ExtractedMountSource | null {
1053
- switch (ref.kind) {
1054
- case BriefcaseReferenceKind.KB_SPACE:
1055
- return {
1056
- source: { kind: 'kb-space', spaceId: ref.ref },
1057
- relPath: `kb/${ref.ref}`,
1058
- mountKey: `briefcase-reference:kb-space:${ref.ref}`,
1059
- };
1060
- case BriefcaseReferenceKind.KB_PAGE: {
1061
- // ref shape: `<spaceId>/<pageSlug>` (matches the canonical
1062
- // /kb/spaces/:slug/pages/:pageSlug route used elsewhere).
1063
- const sep = ref.ref.indexOf('/');
1064
- if (sep <= 0 || sep === ref.ref.length - 1) {
1065
- throw new Error(
1066
- `briefcase reference kind=kb_page must encode ref as '<spaceId>/<pageSlug>' (got '${ref.ref}').`,
1067
- );
1068
- }
1069
- const spaceId = ref.ref.slice(0, sep);
1070
- const pageSlug = ref.ref.slice(sep + 1);
1071
- return {
1072
- source: { kind: 'kb-pages', spaceId, pageSlugs: [pageSlug] },
1073
- relPath: `kb/${spaceId}`,
1074
- mountKey: `briefcase-reference:kb-page:${spaceId}:${pageSlug}`,
1075
- };
1076
- }
1077
- case BriefcaseReferenceKind.ARTIFACT: {
1078
- // ref shape: `<artifactId>@<version>` (integer version, matches
1079
- // the artifact-store address scheme).
1080
- const at = ref.ref.lastIndexOf('@');
1081
- if (at <= 0) {
1082
- throw new Error(
1083
- `briefcase reference kind=artifact must encode ref as '<artifactId>@<version>' (got '${ref.ref}').`,
1084
- );
1085
- }
1086
- const artifactId = ref.ref.slice(0, at);
1087
- const version = Number.parseInt(ref.ref.slice(at + 1), 10);
1088
- if (!Number.isInteger(version) || version < 1) {
1089
- throw new Error(
1090
- `briefcase reference kind=artifact has invalid version in '${ref.ref}' — must be a positive integer.`,
1091
- );
1092
- }
1093
- const trimmedTitle = ref.title?.trim() ?? '';
1094
- const fileName =
1095
- trimmedTitle.length > 0 ? trimmedTitle : `${artifactId}-v${version}`;
1096
- return {
1097
- source: {
1098
- kind: 'artifact-version',
1099
- artifactId,
1100
- version,
1101
- fileName,
1102
- },
1103
- relPath: fileName,
1104
- mountKey: `briefcase-reference:artifact:${artifactId}:v${version}`,
1105
- };
1106
- }
1107
- case BriefcaseReferenceKind.SCM_REPO: {
1108
- // ref shape: `<repoRef>` or `<repoRef>@<gitRef>`. The repoRef is
1109
- // opaque to the composer; the SCM resolver decodes it.
1110
- const at = ref.ref.indexOf('@');
1111
- const repoRef = at < 0 ? ref.ref : ref.ref.slice(0, at);
1112
- const gitRef = at < 0 ? 'HEAD' : ref.ref.slice(at + 1);
1113
- return {
1114
- source: { kind: 'scm-repo', repoRef, ref: gitRef },
1115
- relPath: `scm/${repoRef}`,
1116
- mountKey: `briefcase-reference:scm:${repoRef}:${gitRef}`,
1117
- };
1118
- }
1119
- case BriefcaseReferenceKind.EXTERNAL_URL:
1120
- // No on-disk mount — the URL surfaces via context.json's
1121
- // workflowInputs projection. Audit trail logs the ref.
1122
- return null;
1123
- default:
1124
- throw new Error(
1125
- `Unhandled briefcase reference kind '${(ref as { kind: string }).kind}'.`,
1126
- );
1127
- }
1128
- }
1129
-
1130
- interface ManifestLogicalSlot {
1131
- readonly slot: LogicalWorkspaceSlot;
1132
- readonly defaultMode: 'read-only' | 'read-write';
1133
- }
1134
-
1135
- const MANIFEST_LOGICAL_SLOTS: Readonly<Record<string, ManifestLogicalSlot>> =
1136
- Object.freeze({
1137
- inputs: { slot: LogicalWorkspaceSlot.Inputs, defaultMode: 'read-only' },
1138
- tmp: { slot: LogicalWorkspaceSlot.Temporary, defaultMode: 'read-write' },
1139
- deliverables: {
1140
- slot: LogicalWorkspaceSlot.Deliverables,
1141
- defaultMode: 'read-write',
1142
- },
1143
- 'deliverable-specs': {
1144
- slot: LogicalWorkspaceSlot.DeliverableSpecs,
1145
- defaultMode: 'read-only',
1146
- },
1147
- references: {
1148
- slot: LogicalWorkspaceSlot.References,
1149
- defaultMode: 'read-only',
1150
- },
1151
- repos: {
1152
- slot: LogicalWorkspaceSlot.Repositories,
1153
- defaultMode: 'read-write',
1154
- },
1155
- uploads: {
1156
- slot: LogicalWorkspaceSlot.Uploads,
1157
- defaultMode: 'read-only',
1158
- },
1159
- });
1160
-
1161
- function requireLogicalSlot(
1162
- key: string,
1163
- source:
1164
- | 'manifest.mounts'
1165
- | 'manifest.seedFiles'
1166
- | 'rendered.mounts'
1167
- | 'inputArtifacts',
1168
- manifestSlug: string,
1169
- manifestVersion: string,
1170
- ): ManifestLogicalSlot {
1171
- const slot = MANIFEST_LOGICAL_SLOTS[key];
1172
- if (!slot) {
1173
- throw new UnknownSlotError(key, source, manifestSlug, manifestVersion);
1174
- }
1175
- return slot;
1176
- }
1177
-
1178
- /**
1179
- * Drop the keys the composer itself consumes (subagents, skills, mount
1180
- * routing hints, etc.) so the remainder is what the caller passed as
1181
- * actual workflow inputs (e.g. the user's request text). Returns null
1182
- * when nothing remains so the rendered-context-json source can omit the
1183
- * field entirely instead of carrying an empty object.
1184
- */
1185
- function pickWorkflowInputs(
1186
- agentContext: Readonly<Record<string, unknown>>,
1187
- ): Readonly<Record<string, unknown>> | null {
1188
- const COMPOSER_RESERVED = new Set<string>([
1189
- 'delegatedSubAgents',
1190
- 'skills',
1191
- 'instructions',
1192
- 'runtimeConfig',
1193
- 'mcpConfig',
1194
- 'inputsJsonBase64',
1195
- 'kbSpaceIds',
1196
- 'kbPageMounts',
1197
- 'externalProjectIds',
1198
- 'repoRef',
1199
- 'repoRevision',
1200
- 'deliverableSpecRef',
1201
- 'deliverablesRef',
1202
- 'sessionId',
1203
- 'inputArtifacts',
1204
- ]);
1205
- const out: Record<string, unknown> = {};
1206
- for (const [k, v] of Object.entries(agentContext)) {
1207
- if (COMPOSER_RESERVED.has(k)) continue;
1208
- if (v === undefined) continue;
1209
- out[k] = v;
1210
- }
1211
- return Object.keys(out).length === 0 ? null : out;
1212
- }
1213
-
1214
- /**
1215
- * Project the manifest's mount declarations into the wire shape the
1216
- * llm-registry renderer consumes — `{ mount: '/workspace/<slot>', mode }`.
1217
- * The renderer derives `context.json.authority.mayWriteWorkspace[]` from
1218
- * this list; the system overlay surfaces it to the agent as the
1219
- * authoritative writable-paths declaration. Disabled mounts are dropped.
1220
- *
1221
- * Unknown slot keys throw `UnknownSlotError` — same fail-fast contract
1222
- * as the user-data loop, so a manifest typo or stale slot name
1223
- * surfaces at the first compose call instead of materializing an
1224
- * incomplete `mayWriteWorkspace[]`.
1225
- */
1226
- /**
1227
- * Project the workflow-supplied input artifacts into the renderer
1228
- * wire shape: an absolute workspace path the agent reads from, plus the
1229
- * artifact lineage for traceability. The renderer surfaces these in
1230
- * AGENTS.md (`# Inputs`) and `context.json.workflow.inputArtifacts[]`,
1231
- * so the agent doesn't have to ls slot directories to discover them.
1232
- */
1233
- function buildRenderedInputArtifacts(
1234
- inputArtifacts: readonly InputArtifactRef[] | undefined,
1235
- ): ReadonlyArray<{
1236
- readonly path: string;
1237
- readonly contentType?: string;
1238
- readonly sourceArtifactId: string;
1239
- readonly sourceVersion: number;
1240
- }> {
1241
- if (!inputArtifacts || inputArtifacts.length === 0) return [];
1242
- const out: Array<{
1243
- path: string;
1244
- contentType?: string;
1245
- sourceArtifactId: string;
1246
- sourceVersion: number;
1247
- }> = [];
1248
- for (const ia of inputArtifacts) {
1249
- const slotKey = ia.slot ?? 'inputs';
1250
- const slotDef = MANIFEST_LOGICAL_SLOTS[slotKey];
1251
- if (!slotDef) continue;
1252
- out.push({
1253
- path: `${slotDef.slot}/${ia.fileName}`,
1254
- ...(ia.contentType !== undefined && { contentType: ia.contentType }),
1255
- sourceArtifactId: ia.artifactId,
1256
- sourceVersion: ia.version,
1257
- });
1258
- }
1259
- return out;
1260
- }
1261
-
1262
- /**
1263
- * Project compiled working-file bindings onto the render-request wire shape
1264
- * so llm-registry can surface them on `context.json.workingFiles`.
1265
- */
1266
- function buildRenderedWorkingFiles(
1267
- workingFiles: readonly CompiledWorkingFile[] | undefined,
1268
- ): ReadonlyArray<{
1269
- readonly slug: string;
1270
- readonly path: string;
1271
- readonly format: 'markdown' | 'html' | 'json' | 'yaml' | 'text';
1272
- readonly syncDirection: 'down-only' | 'up-only' | 'bidirectional';
1273
- readonly sourceKind: string;
1274
- readonly sourceRef: Readonly<Record<string, string>>;
1275
- }> {
1276
- if (!workingFiles || workingFiles.length === 0) return [];
1277
- return workingFiles.map((wf) => {
1278
- if (
1279
- wf.path.startsWith('/') ||
1280
- /(^|\/)\.opencode(\/|$)/.test(wf.path)
1281
- ) {
1282
- throw new Error(`NON_LOGICAL_WORKING_FILE_PATH:${wf.slug}:${wf.path}`);
1283
- }
1284
- return {
1285
- slug: wf.slug,
1286
- path: wf.path,
1287
- format: wf.format,
1288
- syncDirection: wf.syncDirection,
1289
- sourceKind: wf.sourceKind,
1290
- sourceRef: wf.sourceRef,
1291
- };
1292
- });
1293
- }
1294
-
1295
- function buildRenderedMounts(
1296
- mounts: Readonly<
1297
- Record<string, { enabled: boolean; mode?: 'read-only' | 'read-write' }>
1298
- >,
1299
- manifestSlug: string,
1300
- manifestVersion: string,
1301
- ): ReadonlyArray<{ mount: string; mode: 'read-only' | 'read-write' }> {
1302
- const out: { mount: string; mode: 'read-only' | 'read-write' }[] = [];
1303
- for (const [slotKey, decl] of Object.entries(mounts)) {
1304
- if (!decl.enabled) continue;
1305
- const slotDef = requireLogicalSlot(
1306
- slotKey,
1307
- 'rendered.mounts',
1308
- manifestSlug,
1309
- manifestVersion,
1310
- );
1311
- const mode = decl.mode ?? slotDef.defaultMode;
1312
- out.push({ mount: slotDef.slot, mode });
1313
- }
1314
- return out;
1315
- }