@xemahq/opencode-xema-plugin 0.1.3 → 0.1.5

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.
@@ -0,0 +1,175 @@
1
+ import { readdirSync, statSync, type Dirent } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Renders the actual depth-1 contents of `/workspace` as a Markdown
6
+ * tree, grouped by AWP top-level slot. Appended to the system prompt
7
+ * by the chat-system-transform hook so the LLM sees the real layout —
8
+ * actual filenames, repo slugs, prior-turn deliverables — without
9
+ * having to spend a tool call on `glob` or `list`.
10
+ *
11
+ * Design constraints:
12
+ *
13
+ * - **Read-only, side-effect free.** Pure fs reads. No caching state,
14
+ * no writes, no network.
15
+ * - **Hides platform-private slots.** `.xema/` is platform-private —
16
+ * the LLM has no business reading or grep'ing it (its contents are
17
+ * already in the system prompt overlay). `.opencode/` is OpenCode
18
+ * runtime state (agent + skill bundles, plugin tree, snapshot
19
+ * metadata) — also hidden because operating on it is never the
20
+ * user's intent.
21
+ * - **Bounded output.** Each directory's listing is capped at
22
+ * {@link MAX_ENTRIES_PER_DIR}; overflow is summarised as
23
+ * `(N more entries — use \`glob\`/\`list\` to enumerate)` so the
24
+ * system prompt never balloons under sessions that accumulate
25
+ * hundreds of files in `deliverables/`.
26
+ * - **Stable ordering.** Entries are sorted alphabetically with
27
+ * directories first, so identical filesystem state produces
28
+ * identical bytes and the trailing prompt-cache window stays warm
29
+ * turn-to-turn when nothing changed.
30
+ * - **Cache positioning.** This block goes at the very END of the
31
+ * system prompt because it is the most volatile part. Anthropic +
32
+ * OpenAI prefix-cache the system prompt; everything ABOVE this
33
+ * block stays cached, only this trailing block recomputes.
34
+ */
35
+ export const MAX_ENTRIES_PER_DIR = 50;
36
+
37
+ /**
38
+ * Top-level entries hidden from the listing. Keep tight: anything not
39
+ * in this set is rendered. The check matches on the immediate child
40
+ * name under `/workspace/`.
41
+ */
42
+ const HIDDEN_TOP_LEVEL: ReadonlySet<string> = new Set(['.xema', '.opencode']);
43
+
44
+ /**
45
+ * Top-level directories whose child entries are intentionally not expanded.
46
+ *
47
+ * These locations are usually implicit/noisy (dependency caches, VCS internals,
48
+ * virtual environments) and rarely useful in the prompt body. We still show the
49
+ * top-level path so the model can request targeted tooling if needed.
50
+ */
51
+ const SKIP_CHILD_EXPANSION_TOP_LEVEL: ReadonlySet<string> = new Set([
52
+ '.git',
53
+ 'node_modules',
54
+ '.venv',
55
+ 'venv',
56
+ '__pycache__',
57
+ '.mypy_cache',
58
+ '.pytest_cache',
59
+ '.ruff_cache',
60
+ '.tox',
61
+ '.nox',
62
+ '.eggs',
63
+ ]);
64
+
65
+ interface ListedEntry {
66
+ readonly name: string;
67
+ readonly isDirectory: boolean;
68
+ }
69
+
70
+ export function renderWorkspaceListing(workspaceRoot: string): string {
71
+ const lines: string[] = [
72
+ '## Current workspace contents (depth 1)',
73
+ '',
74
+ 'This is a snapshot of `/workspace/` taken at the start of this turn. Use it to orient before reaching for `glob`, `list`, or `read`. Hidden: platform-private slots (`.xema/`, `.opencode/`).',
75
+ '',
76
+ ];
77
+
78
+ let topLevel: ListedEntry[];
79
+ try {
80
+ topLevel = readDirSorted(workspaceRoot);
81
+ } catch (err) {
82
+ // The hook is best-effort orientation. If the workspace can't be
83
+ // listed (extremely unusual — the workspace must exist for the
84
+ // session to have started), surface it inline rather than failing
85
+ // the whole chat call. The static overlay above this block already
86
+ // tells the LLM the canonical layout.
87
+ lines.push(`_Listing unavailable: ${(err as Error).message}_`);
88
+ return lines.join('\n');
89
+ }
90
+
91
+ const visible = topLevel.filter((entry) => !HIDDEN_TOP_LEVEL.has(entry.name));
92
+ if (visible.length === 0) {
93
+ lines.push('_(workspace is empty)_');
94
+ return lines.join('\n');
95
+ }
96
+
97
+ for (const entry of visible) {
98
+ lines.push(...renderTopLevelEntry(workspaceRoot, entry));
99
+ }
100
+
101
+ return lines.join('\n');
102
+ }
103
+
104
+ function renderTopLevelEntry(workspaceRoot: string, entry: ListedEntry): string[] {
105
+ const relPath = entry.name;
106
+ const absPath = join(workspaceRoot, entry.name);
107
+ if (!entry.isDirectory) {
108
+ return [`- \`/workspace/${relPath}\``];
109
+ }
110
+ if (SKIP_CHILD_EXPANSION_TOP_LEVEL.has(entry.name)) {
111
+ return [
112
+ `- \`/workspace/${relPath}/\` _(children omitted: known implicit/noisy directory — use \`glob\`/\`list\` if needed)_`,
113
+ ];
114
+ }
115
+
116
+ let children: ListedEntry[];
117
+ try {
118
+ children = readDirSorted(absPath);
119
+ } catch (err) {
120
+ return [`- \`/workspace/${relPath}/\` _(unreadable: ${(err as Error).message})_`];
121
+ }
122
+ if (children.length === 0) {
123
+ return [`- \`/workspace/${relPath}/\` _(empty)_`];
124
+ }
125
+
126
+ const shown = children.slice(0, MAX_ENTRIES_PER_DIR);
127
+ const childLines = shown.map((child) => {
128
+ const suffix = child.isDirectory ? '/' : '';
129
+ return ` - \`${child.name}${suffix}\``;
130
+ });
131
+ const overflow = children.length - shown.length;
132
+ const overflowLine =
133
+ overflow > 0
134
+ ? [` - _(${overflow} more entr${overflow === 1 ? 'y' : 'ies'} — use \`glob\`/\`list\` to enumerate)_`]
135
+ : [];
136
+
137
+ return [`- \`/workspace/${relPath}/\``, ...childLines, ...overflowLine];
138
+ }
139
+
140
+ /**
141
+ * `readdirSync` with deterministic ordering: directories first, then
142
+ * files, each group sorted by name (case-sensitive — workspace paths
143
+ * are case-sensitive on the worker filesystem). Symlinks resolve to
144
+ * their target's type via `statSync` so a symlinked dir lists like a
145
+ * dir, not a file.
146
+ */
147
+ function readDirSorted(dir: string): ListedEntry[] {
148
+ const dirents: Dirent[] = readdirSync(dir, { withFileTypes: true });
149
+ const entries: ListedEntry[] = dirents.map((d) => ({
150
+ name: d.name,
151
+ isDirectory: resolveIsDirectory(dir, d),
152
+ }));
153
+ entries.sort((a, b) => {
154
+ if (a.isDirectory !== b.isDirectory) {
155
+ return a.isDirectory ? -1 : 1;
156
+ }
157
+ if (a.name < b.name) {return -1;}
158
+ if (a.name > b.name) {return 1;}
159
+ return 0;
160
+ });
161
+ return entries;
162
+ }
163
+
164
+ function resolveIsDirectory(parent: string, dirent: Dirent): boolean {
165
+ if (dirent.isSymbolicLink()) {
166
+ try {
167
+ return statSync(join(parent, dirent.name)).isDirectory();
168
+ } catch {
169
+ // Broken symlink: classify as file so we surface its presence
170
+ // without crashing the listing.
171
+ return false;
172
+ }
173
+ }
174
+ return dirent.isDirectory();
175
+ }
package/src/index.ts ADDED
@@ -0,0 +1,163 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── @xemahq/opencode-xema-plugin ──
3
+ //
4
+ // Opencode plugin providing Xema-namespaced runtime tools (xema_memory_*)
5
+ // and the dynamic system-prompt overlay hook.
6
+ //
7
+ // Lifecycle:
8
+ // OpenCode evaluates this plugin once at `opencode serve` startup. At that
9
+ // point `/workspace/context.json` does NOT yet exist — it is written per
10
+ // invocation by the orchestrator's XemaRuntimeMounter before the agent
11
+ // starts. The plugin therefore registers all tools unconditionally and
12
+ // defers role validation to tool-execution time, where each tool reads
13
+ // `context.json` from the worker filesystem.
14
+ //
15
+ // Each tool's `execute()` loads session state at call time via
16
+ // `loadSessionStateForTool`, which reads the role's `allowedXemaTools`
17
+ // list straight from `context.json`'s `authority.allowedXemaTools`
18
+ // field (populated by llm-registry-api from the
19
+ // `RoleCapabilityProfile` YAMLs at
20
+ // `biomes/kernel/runtime/role-capabilities/`). Throws a typed error
21
+ // (surfaced to the LLM as a tool error) when the invoking role is not
22
+ // allowed.
23
+ //
24
+ // Path-policy enforcement (where the agent may write, what the agent
25
+ // may read) is intentionally NOT done here. It lives in the rails
26
+ // that genuinely enforce it: workspace-proxy's symlink-aware boundary
27
+ // check, the harvester's manifest-driven read (off-manifest writes
28
+ // are orphan bytes), deliverable-specs content validation, the
29
+ // per-allocation gateway JWT, and pod isolation. A process-local
30
+ // tool.execute.before hook is trivially bypassable via `bash` and
31
+ // would be theatre over those rails.
32
+ //
33
+ // See also:
34
+ // - workspace protocol: runtime AGENTS.md written by the orchestrator
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+
37
+ import { existsSync } from 'node:fs';
38
+ import { join } from 'node:path';
39
+
40
+ import { buildSessionIdleAutocommit } from './hooks/session-idle-autocommit.js';
41
+ import { buildSystemPromptOverlay } from './hooks/system-prompt-overlay.js';
42
+ import { info, isDebug } from './logger.js';
43
+ import type { Hooks, Plugin, ToolDefinition } from './opencode-types.js';
44
+
45
+ import {
46
+ buildXemaMemoryGetTool,
47
+ buildXemaMemorySetTool,
48
+ } from './tools/xema-memory.js';
49
+
50
+ /**
51
+ * Defensive idempotency cache — keyed by workspace directory.
52
+ *
53
+ * The plugin's header contract says OpenCode calls this factory once at
54
+ * `opencode serve` startup. Out of an abundance of caution (and because
55
+ * `buildSystemPromptOverlay` builds closures that read disk on each
56
+ * invocation), we cache the resolved `Hooks` per directory. Concurrent
57
+ * factory calls return the SAME in-flight promise instead of racing.
58
+ *
59
+ * This guard is precautionary — there is no confirmed observed
60
+ * double-boot in source today. Cost is two map operations; value is
61
+ * that any future OpenCode change that calls the factory twice won't
62
+ * double-register tools or run boot-time side effects twice.
63
+ */
64
+ const bootCache = new Map<string, Promise<Hooks>>();
65
+
66
+ export const XemaPlugin: Plugin = async (ctx) => {
67
+ const { directory } = ctx;
68
+ const cached = bootCache.get(directory);
69
+ if (cached !== undefined) {
70
+ return cached;
71
+ }
72
+ const booting = doBoot(directory);
73
+ bootCache.set(directory, booting);
74
+ try {
75
+ return await booting;
76
+ } catch (err) {
77
+ // Clear on failure so the next attempt can retry instead of
78
+ // re-returning the rejected promise forever.
79
+ bootCache.delete(directory);
80
+ throw err;
81
+ }
82
+ };
83
+
84
+ async function doBoot(directory: string): Promise<Hooks> {
85
+ const tools = buildAllTools(directory);
86
+ const toolNames = Object.keys(tools).sort((a, b) => a.localeCompare(b));
87
+ const contextPath = join(directory, 'context.json');
88
+ const contextPresentAtBoot = existsSync(contextPath);
89
+
90
+ info(
91
+ `boot: directory=${directory} tools=${toolNames.length} [${toolNames.join(', ')}] ` +
92
+ `context.json=${contextPresentAtBoot ? 'present' : 'absent'} ` +
93
+ `debug=${isDebug() ? 'on' : 'off'}`,
94
+ );
95
+
96
+ const hooks: Hooks = {
97
+ tool: tools,
98
+ 'experimental.chat.system.transform': buildSystemPromptOverlay(directory),
99
+ // Per-turn auto-commit. Listens for `session.idle` via the
100
+ // generic `event` channel — opencode 1.15.x does not expose a
101
+ // dedicated `session.idle` hook, only the event union. The handler
102
+ // is a no-op for sessions whose context.json does not opt in
103
+ // (`git.autoCommit !== 'enabled'`).
104
+ event: buildSessionIdleAutocommit(directory),
105
+ };
106
+ return hooks;
107
+ }
108
+
109
+ /**
110
+ * Test-only: clear the boot cache so unit tests can observe boot()
111
+ * being called fresh. Not exported from the package barrel.
112
+ */
113
+ export function __resetBootCacheForTests(): void {
114
+ bootCache.clear();
115
+ }
116
+
117
+ function buildAllTools(workspaceDir: string): Record<string, ToolDefinition> {
118
+ return {
119
+ xema_memory_set: buildXemaMemorySetTool(workspaceDir),
120
+ xema_memory_get: buildXemaMemoryGetTool(workspaceDir),
121
+ // Document Buddy has no propose tool: both kinds (MARKDOWN_DOCUMENT
122
+ // and RICH_DOCUMENT) edit a real working file at
123
+ // `/workspace/document/<slug>.<ext>` and the platform derives the
124
+ // reviewable diff (see the design notes).
125
+ // emit_review / emit_review_brief: deleted. Reviewers now write
126
+ // `deliverables/review.json` via the standard `write` tool — the
127
+ // deliverable-specs framework Zod-validates at harvest using the
128
+ // `reviewer-output` kernel-tier deliverable spec at
129
+ // `biomes/kernel/runtime/deliverable-specs/specs/schema/reviewer-output/`
130
+ // — the single source of truth for the reviewer-agent output shape
131
+ // consumed by `xema/review@v3`.
132
+ //
133
+ // output_surface_rescan + output_surface_status: owned by the kernel
134
+ // output-surface-control plugin (biomes/kernel/output-surface-control/
135
+ // opencode-tools/output-surface-control.ts) and registered as standalone
136
+ // custom tools auto-discovered by OpenCode from .opencode/tools/. The xema plugin no longer owns them.
137
+ };
138
+ }
139
+
140
+ export default XemaPlugin;
141
+
142
+ // Named exports for consumers that want programmatic access (tests, etc.)
143
+ export { readInvocationContext } from './context.js';
144
+ export type { InvocationContext, InvocationRole } from './context.js';
145
+ export * from './errors.js';
146
+ export {
147
+ loadSessionState,
148
+ loadSessionStateForTool,
149
+ ensureRoleAllowed,
150
+ ContextMissingError,
151
+ RoleNotAllowedError,
152
+ type SessionState,
153
+ } from './session-state.js';
154
+ // Role-tool allowlist now lives in the per-invocation context.json
155
+ // (`authority.allowedXemaTools`), populated by llm-registry-api from the
156
+ // `RoleCapabilityProfile` YAMLs at biomes/kernel/runtime/role-capabilities/.
157
+ // No re-export from workflow-contracts: the registry constant is gone.
158
+
159
+ // Gate-review contract lives in `@xemahq/kernel-contracts/workflow`
160
+ // (`ReviewSchema`, `ReviewObjectSchema`, `Review`, enum value types).
161
+ // Consumers should import from there directly — the plugin bundles it
162
+ // into its own `plugin.mjs` at build time but does not re-publish the
163
+ // shape on its own surface.
package/src/logger.ts ADDED
@@ -0,0 +1,40 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── opencode-xema-plugin logger ──
3
+ //
4
+ // Prefixed stderr/stdout logger. `info` / `warn` / `error` are always
5
+ // emitted so operators have baseline visibility of plugin boot and
6
+ // context-read failures without needing a flag.
7
+ // `debug` is env-gated to avoid flooding long sessions; set
8
+ // `XEMA_PLUGIN_DEBUG=1` on the worker (already a first-class env var on
9
+ // the opencode-pool container envelope) to see per-hook / per-tool traces.
10
+ //
11
+ // All messages carry the `[opencode-xema-plugin]` prefix so they survive
12
+ // `docker logs` grep-filtering alongside opencode's own stderr and the
13
+ // workspace-proxy's access log.
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+
16
+ const PREFIX = '[opencode-xema-plugin]';
17
+
18
+ const DEBUG_ENABLED =
19
+ process.env.XEMA_PLUGIN_DEBUG === '1' ||
20
+ process.env.XEMA_PLUGIN_DEBUG === 'true';
21
+
22
+ export function info(msg: string): void {
23
+ // eslint-disable-next-line no-console
24
+ console.log(`${PREFIX} ${msg}`);
25
+ }
26
+
27
+ export function warn(msg: string): void {
28
+ // eslint-disable-next-line no-console
29
+ console.warn(`${PREFIX} ${msg}`);
30
+ }
31
+
32
+ export function debug(msg: string): void {
33
+ if (!DEBUG_ENABLED) return;
34
+ // eslint-disable-next-line no-console
35
+ console.log(`${PREFIX} [debug] ${msg}`);
36
+ }
37
+
38
+ export function isDebug(): boolean {
39
+ return DEBUG_ENABLED;
40
+ }
@@ -0,0 +1,107 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── Minimal opencode plugin type shim ──
3
+ //
4
+ // We re-declare only the types we consume at the plugin boundary so this
5
+ // package does not require the full @opencode-ai/plugin package at install
6
+ // time. The runtime contract (exported default function returning Hooks)
7
+ // matches opencode's loader — see /tmp/opencode/packages/plugin/src/index.ts.
8
+ // ═══════════════════════════════════════════════════════════════════════════
9
+
10
+ import type { z } from 'zod';
11
+
12
+ interface ToolContext {
13
+ sessionID: string;
14
+ messageID: string;
15
+ agent: string;
16
+ directory: string;
17
+ worktree: string;
18
+ abort: AbortSignal;
19
+ metadata(input: { title?: string; metadata?: Record<string, unknown> }): void;
20
+ }
21
+
22
+ export interface ToolDefinition<Args extends z.ZodRawShape = z.ZodRawShape> {
23
+ description: string;
24
+ args: Args;
25
+ execute(
26
+ args: z.infer<z.ZodObject<Args>>,
27
+ context: ToolContext,
28
+ ): Promise<string>;
29
+ }
30
+
31
+ /**
32
+ * Subset of opencode's `Model` shape passed to `experimental.chat.system.transform`.
33
+ * The hook fires before each chat call so we can append a Xema platform
34
+ * overlay to the system array; the model is provided to support per-
35
+ * provider tuning later.
36
+ */
37
+ export interface ExperimentalChatModel {
38
+ providerID?: string;
39
+ modelID?: string;
40
+ }
41
+
42
+ export interface ExperimentalChatSystemTransformInput {
43
+ sessionID?: string;
44
+ model: ExperimentalChatModel;
45
+ }
46
+
47
+ export interface ExperimentalChatSystemTransformOutput {
48
+ system: string[];
49
+ }
50
+
51
+ /**
52
+ * Generic OpenCode event delivered to the `event` hook. The upstream
53
+ * `Event` type is a discriminated union; we type only the variants we
54
+ * consume (currently just `session.idle`) and accept the rest as
55
+ * `{ type: string; properties?: Record<string, unknown> }` so the
56
+ * plugin survives new event types added by opencode without a typings
57
+ * bump.
58
+ */
59
+ export type OpencodeSessionIdleEvent = {
60
+ type: 'session.idle';
61
+ properties: { sessionID: string };
62
+ };
63
+
64
+ export type OpencodeEvent =
65
+ | OpencodeSessionIdleEvent
66
+ | { type: string; properties?: Record<string, unknown> };
67
+
68
+ export interface Hooks {
69
+ tool?: Record<string, ToolDefinition>;
70
+ /**
71
+ * Fires once per chat call. We append the rendered Xema system overlay
72
+ * to `output.system` so the LLM sees the AWP base layer + deliverable
73
+ * contract + authority + retry context as authoritative platform
74
+ * context — separate from the project-level `/workspace/AGENTS.md`.
75
+ */
76
+ 'experimental.chat.system.transform'?: (
77
+ input: ExperimentalChatSystemTransformInput,
78
+ output: ExperimentalChatSystemTransformOutput,
79
+ ) => Promise<void> | void;
80
+ /**
81
+ * Generic opencode event hook. OpenCode 1.15.x exposes session
82
+ * lifecycle as discriminated events on this channel rather than as
83
+ * dedicated hooks. The xema plugin uses it to react to
84
+ * `session.idle` (per-turn auto-commit). All other event types are
85
+ * ignored.
86
+ */
87
+ event?: (input: { event: OpencodeEvent }) => Promise<void> | void;
88
+ }
89
+
90
+ interface PluginInput {
91
+ directory: string;
92
+ worktree: string;
93
+ serverUrl: URL;
94
+ }
95
+
96
+ export type Plugin = (input: PluginInput) => Promise<Hooks>;
97
+
98
+ /**
99
+ * The opencode `tool()` helper is a pass-through identity function. We
100
+ * re-declare it here so plugin authors can use `tool({...})` exactly as
101
+ * they would via @opencode-ai/plugin.
102
+ */
103
+ export function tool<Args extends z.ZodRawShape>(
104
+ input: ToolDefinition<Args>,
105
+ ): ToolDefinition<Args> {
106
+ return input;
107
+ }
@@ -0,0 +1,22 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── OpenCode plugin entry point ──
3
+ //
4
+ // OpenCode v1.4.x's plugin loader inspects the ESM module's exports and
5
+ // expects to find a single plugin factory. When the module re-exports
6
+ // additional identifiers (error classes, constants, helper utilities), the
7
+ // loader's heuristic picks the wrong export and fails with
8
+ // "Plugin export is not a function". The tools then silently fail to
9
+ // register — the agent boots, the model is still resolvable, but every
10
+ // prompt that references a `xema_*` tool dies at tool-dispatch time
11
+ // with "Model tried to call unavailable tool".
12
+ //
13
+ // This file is the bundle entry for the worker-image build. It re-exports
14
+ // ONLY the plugin factory (as the default export AND as a named export for
15
+ // loaders that look for either). Everything else — errors, runtime helpers,
16
+ // the role-capability registry — stays available to TypeScript consumers via
17
+ // the package's regular `index.ts` barrel, which is the consumer-facing
18
+ // entry (not the runtime plugin entry). The two entry points let the
19
+ // programmatic API stay rich without breaking OpenCode's plugin contract.
20
+ // ═══════════════════════════════════════════════════════════════════════════
21
+
22
+ export { XemaPlugin as default, XemaPlugin } from './index.js';
@@ -0,0 +1,92 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── Session state + role allowlist ──
3
+ //
4
+ // The xema plugin is evaluated once at `opencode serve` startup. That is
5
+ // BEFORE the orchestrator writes /workspace/context.json (which is done per
6
+ // invocation by XemaRuntimeMounter). Every tool and hook therefore reads
7
+ // context.json lazily at call time through this module.
8
+ //
9
+ // Role-tool allowlist: source of truth is the `RoleCapabilityProfile`
10
+ // YAMLs at `biomes/kernel/runtime/role-capabilities/`, resolved by
11
+ // llm-registry-api's `agent-run-context.service.ts` and written into the
12
+ // `authority.allowedXemaTools` field of context.json. The plugin reads
13
+ // the list straight off context — adding/changing a role's tool list
14
+ // happens in the YAML, never here.
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+
17
+ import {
18
+ readInvocationContext,
19
+ type InvocationContext,
20
+ type InvocationRole,
21
+ } from './context.js';
22
+
23
+ export interface SessionState {
24
+ ctx: InvocationContext;
25
+ }
26
+
27
+ export class ContextMissingError extends Error {
28
+ readonly code = 'XEMA_CONTEXT_MISSING';
29
+ constructor(public readonly workspaceDir: string) {
30
+ super(
31
+ `/workspace/context.json not found in ${workspaceDir}. The xema plugin ` +
32
+ `cannot operate without invocation context — the orchestrator must ` +
33
+ `write context.json before the agent starts.`,
34
+ );
35
+ }
36
+ }
37
+
38
+ export class RoleNotAllowedError extends Error {
39
+ readonly code = 'XEMA_ROLE_NOT_ALLOWED';
40
+ constructor(
41
+ public readonly role: InvocationRole,
42
+ public readonly toolName: string,
43
+ public readonly allowedTools: string[],
44
+ ) {
45
+ super(
46
+ `Role "${role}" is not allowed to call "${toolName}". Allowed tools ` +
47
+ `for this role: [${allowedTools.join(', ')}].`,
48
+ );
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Read /workspace/context.json from the worker filesystem. Throws
54
+ * ContextMissingError if context.json is not present — callers inside a
55
+ * tool.execute should let this propagate (opencode turns it into a tool
56
+ * error). Hook callers should treat a missing context as a non-xema
57
+ * session instead.
58
+ */
59
+ export function loadSessionState(workspaceDir: string): SessionState {
60
+ const ctx = readInvocationContext(workspaceDir);
61
+ if (!ctx) {
62
+ throw new ContextMissingError(workspaceDir);
63
+ }
64
+ return { ctx };
65
+ }
66
+
67
+ export function ensureRoleAllowed(
68
+ ctx: InvocationContext,
69
+ toolName: string,
70
+ ): void {
71
+ const allowed = ctx.authority.allowedXemaTools;
72
+ if (!allowed.includes(toolName)) {
73
+ throw new RoleNotAllowedError(
74
+ ctx.invocation.role,
75
+ toolName,
76
+ [...allowed].sort((a, b) => a.localeCompare(b)),
77
+ );
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Load session state and assert the invoking role may call the given tool.
83
+ * Used by every Xema-namespaced tool at the top of its execute().
84
+ */
85
+ export function loadSessionStateForTool(
86
+ workspaceDir: string,
87
+ toolName: string,
88
+ ): SessionState {
89
+ const state = loadSessionState(workspaceDir);
90
+ ensureRoleAllowed(state.ctx, toolName);
91
+ return state;
92
+ }
@@ -0,0 +1,7 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── Tool runtime helpers ──
3
+ // Re-export from the root session-state module. Kept as a thin barrel so
4
+ // each Xema-namespaced tool has a stable import path.
5
+ // ═══════════════════════════════════════════════════════════════════════════
6
+
7
+ export { loadSessionStateForTool } from '../session-state.js';
@@ -0,0 +1,68 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── Worker service-token reader ──
3
+ //
4
+ // The opencode worker authenticates against platform services with a
5
+ // per-allocation Keycloak service JWT. The JWT carries the `xema_org_id`
6
+ // org-scope claim that `ServiceTokenGuard` cross-checks against
7
+ // the tenant header (`X-Xema-Org-Id`, legacy `X-Org-Id`) on every
8
+ // downstream call — replay against a different tenant is rejected
9
+ // fail-fast at the receiving service.
10
+ // workspace-proxy — running in the same pod — writes that JWT to a file
11
+ // and refreshes it mid-session via `PUT /control/service-token`.
12
+ //
13
+ // The `xema_*` tools read this file FRESH on every call (never cache it):
14
+ // a cached value would go stale the moment workspace-proxy swaps in a
15
+ // refreshed token, silently producing 401s for the rest of the session.
16
+ // ═══════════════════════════════════════════════════════════════════════════
17
+
18
+ import { readFileSync } from 'node:fs';
19
+
20
+ /**
21
+ * Path the worker service token is written to. Configurable via
22
+ * `XEMA_SERVICE_TOKEN_PATH`; defaults to `/run/xema/service-token`. Must
23
+ * stay aligned with `serviceTokenPath()` in `biomes/workspace-proxy/api/workspace-proxy`.
24
+ */
25
+ function serviceTokenPath(): string {
26
+ return process.env['XEMA_SERVICE_TOKEN_PATH']?.trim() || '/run/xema/service-token';
27
+ }
28
+
29
+ /**
30
+ * Read the worker service JWT from disk, fresh. Throws a clear fail-fast
31
+ * error when the file is missing or empty — the worker cannot reach
32
+ * platform services without it, and silently sending no `Authorization`
33
+ * header would just surface as opaque 401s deeper in the stack.
34
+ */
35
+ export function readServiceToken(): string {
36
+ const path = serviceTokenPath();
37
+ let raw: string;
38
+ try {
39
+ raw = readFileSync(path, 'utf8');
40
+ } catch (err) {
41
+ const code = (err as NodeJS.ErrnoException).code;
42
+ if (code === 'ENOENT') {
43
+ throw new Error(
44
+ `Worker service token file is missing at ${path}. workspace-proxy ` +
45
+ 'must write the per-allocation Keycloak JWT (via the session ' +
46
+ 'bundle or PUT /control/service-token) before xema_* tools run.',
47
+ );
48
+ }
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ throw new Error(`Failed to read worker service token at ${path}: ${message}`);
51
+ }
52
+ const token = raw.trim();
53
+ if (!token) {
54
+ throw new Error(
55
+ `Worker service token file at ${path} is empty. workspace-proxy ` +
56
+ 'must write a non-empty per-allocation Keycloak JWT.',
57
+ );
58
+ }
59
+ return token;
60
+ }
61
+
62
+ /**
63
+ * Convenience wrapper returning the `Authorization: Bearer <jwt>` header
64
+ * value for the worker service token.
65
+ */
66
+ export function serviceTokenAuthorizationHeader(): string {
67
+ return `Bearer ${readServiceToken()}`;
68
+ }