@unoverse-platform/studio 0.1.115 → 0.1.116

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 (37) hide show
  1. package/local/catalog.ts +3 -0
  2. package/local/plugin.ts +12 -9
  3. package/package.json +1 -1
  4. package/screens/AgentChain.tsx +216 -0
  5. package/screens/AgentsView.tsx +45 -6
  6. package/screens/SkillsView.tsx +21 -3
  7. package/screens/index.ts +3 -0
  8. package/vendor/base/src/agent-mcp/discovery.ts +4 -1
  9. package/vendor/base/src/agent-mcp/invoke.ts +155 -25
  10. package/vendor/base/src/agent-mcp/tools.ts +17 -10
  11. package/vendor/base/src/agent-mcp/types.ts +8 -0
  12. package/vendor/base/src/auth/agentSwitch.ts +52 -0
  13. package/vendor/base/src/auth/agentToken.ts +142 -0
  14. package/vendor/base/src/auth/auth.ts +7 -0
  15. package/vendor/base/src/definitions/load/load.ts +6 -1
  16. package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
  17. package/vendor/base/src/definitions/registry/objects.ts +16 -0
  18. package/vendor/base/src/definitions/registry/pipelines.ts +13 -5
  19. package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
  20. package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
  21. package/vendor/base/src/lint/design/index.mjs +10 -2
  22. package/vendor/base/src/lint/objects/index.d.mts +1 -0
  23. package/vendor/base/src/lint/objects/index.mjs +120 -2
  24. package/vendor/base/src/lint/objects/rules.mjs +16 -5
  25. package/vendor/base/src/manifests/executor/toolBridge.ts +10 -3
  26. package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
  27. package/vendor/base/src/manifests/runtime/http/request.ts +4 -1
  28. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +58 -23
  29. package/vendor/base/src/manifests/runtime/usage.ts +13 -5
  30. package/vendor/base/src/platform/agentBudget.ts +65 -0
  31. package/vendor/base/src/platform/agentCalendar.ts +87 -0
  32. package/vendor/base/src/platform/taskChanged.ts +25 -0
  33. package/vendor/base/src/platform/tokenCost.ts +395 -0
  34. package/vendor/base/src/security/permissions.ts +10 -2
  35. package/vendor/sdk/appEngine.ts +42 -1
  36. package/vendor/sdk/lib/dom/markdown.ts +6 -2
  37. package/vendor/sdk/pageInterfaces.ts +109 -8
@@ -11,6 +11,7 @@
11
11
  * Node-MCP rows still route in-process via the caller's `api.callService`: they have a
12
12
  * node to run. Renamed from `invokeComponentAppNative` when workflow tasks joined it.
13
13
  */
14
+ import { taskToolName } from "../definitions/registry/taskUri.js";
14
15
  import type { AppInvocationContext, DiscoveredMCP } from "./types.js";
15
16
 
16
17
  /**
@@ -73,35 +74,166 @@ export async function connectPlatformMcp(
73
74
  };
74
75
  }
75
76
 
77
+ /** The tasks extension's id (`io.modelcontextprotocol/tasks`), as the official library names it. */
78
+ const TASKS_EXTENSION = "io.modelcontextprotocol/tasks";
79
+ const MODERN = "2026-07-28";
80
+
81
+ type CallResult = {
82
+ isError?: boolean;
83
+ content?: Array<{ type: string; text?: string }>;
84
+ structuredContent?: Record<string, any>;
85
+ };
86
+
87
+ /**
88
+ * ONE CALL, FOLLOWED AS AN MCP TASK WHEN IT IS ONE (docs/mcp/MCP_TASKS.md steps 6 and 7). Every
89
+ * Task is an MCP task: a workflow, a form, an app that asks. The harness calls as a 2026-07-28
90
+ * client that declared the tasks extension, so a Task answers at once with its task and the
91
+ * official client (`@modelcontextprotocol/ext-tasks/client`) follows it to its result; a call
92
+ * with nothing to wait for answers at once, through the same client. Several calls in one turn
93
+ * each follow their own task, side by side.
94
+ *
95
+ * The one piece of host code the library asks for is `rawDispatch`: it sends one JSON-RPC request
96
+ * on the 2026 framing and hands back the response. The host owns request ids, so it adds them.
97
+ */
98
+ async function callFollowingTasks(toolId: string, args: Record<string, unknown>, ctx: AppInvocationContext): Promise<CallResult> {
99
+ const { Client, StreamableHTTPClientTransport } = await import("@modelcontextprotocol/client");
100
+ const { createTaskSessionFromClient, resultFromTaskOutcome } = await import("@modelcontextprotocol/ext-tasks/client");
101
+ const base = (ctx.baseUrl || process.env.UNOVERSE_SERVICE_URL || "http://localhost:4106").replace(/\/$/, "");
102
+ const auth: Record<string, string> = ctx.accessToken ? { Authorization: `Bearer ${ctx.accessToken}` } : {};
103
+ const clientInfo = { name: "unoverse-agent-mcp", version: "1.0.0" };
104
+ const clientCapabilities = { extensions: { [TASKS_EXTENSION]: {} } };
105
+ const client = new Client(clientInfo, { versionNegotiation: { mode: { pin: MODERN } }, capabilities: clientCapabilities } as never);
106
+ await client.connect(
107
+ new StreamableHTTPClientTransport(new URL(`${base}/mcp`), {
108
+ fetch: (url: string | URL, init?: RequestInit) => fetch(url, { ...init, headers: { ...Object.fromEntries(new Headers(init?.headers)), ...auth } }),
109
+ }),
110
+ );
111
+ let next = 0;
112
+ const rawDispatch = async (request: { method: string; params?: Record<string, unknown> }) => {
113
+ const id = `task-${++next}`;
114
+ const name = request.method === "tools/call" ? request.params?.name : request.method.startsWith("tasks/") ? request.params?.taskId : undefined;
115
+ const res = await fetch(`${base}/mcp`, {
116
+ method: "POST",
117
+ headers: {
118
+ "content-type": "application/json",
119
+ accept: "application/json, text/event-stream",
120
+ "mcp-protocol-version": MODERN,
121
+ "mcp-method": request.method,
122
+ ...(typeof name === "string" ? { "mcp-name": name } : {}),
123
+ ...auth,
124
+ },
125
+ body: JSON.stringify({ jsonrpc: "2.0", id, ...request }),
126
+ });
127
+ const text = await res.text();
128
+ // A JSON answer, or a stream whose last message for this id is the answer.
129
+ if (!text.includes("data: ")) return JSON.parse(text);
130
+ const answers = text.split("\n").filter((l) => l.startsWith("data: ")).map((l) => JSON.parse(l.slice(6)));
131
+ return answers.filter((m) => m.id === id).pop() ?? answers.pop();
132
+ };
133
+ try {
134
+ const session = createTaskSessionFromClient(client as never, {
135
+ endpointId: `${base}/mcp`,
136
+ rawDispatch: rawDispatch as never,
137
+ v2RequestFraming: { protocolVersion: MODERN, clientInfo, clientCapabilities },
138
+ } as never);
139
+ const execution = await session.callTool(
140
+ // THE TOOL NAME FROM THE TASK'S ONE URI (docs/mcp/MCP_TASKS.md § 2a): a task URI or its short
141
+ // form `<org>/<name>` is called as `<org>.<name>`; anything else (a bare app id) as given.
142
+ taskToolName(toolId) ?? toolId,
143
+ args as never,
144
+ {
145
+ ...(ctx.trace?.executionId && ctx.trace?.nodeId ? { metadata: { [TRACE_META]: ctx.trace as never } } : {}),
146
+ // A person completing a form, or an Agent working: either may take many minutes; the run's own cap ends it first.
147
+ requestTimeoutMs: 24 * 60 * 60 * 1000,
148
+ },
149
+ );
150
+ if (execution.kind === "task") console.log(`[unoverse:task] FOLLOW ${toolId}: followed as an MCP task to its result`);
151
+ // THE CALLING RUN STOPPED: the task it follows is cancelled (native `tasks/cancel`), so it does
152
+ // not run on for nobody. The outcome then reads cancelled, and the call fails with it.
153
+ const stop = () => {
154
+ if (execution.kind !== "task") return;
155
+ console.log(`[unoverse:task] CANCEL ${toolId}: its caller's run stopped`);
156
+ void execution.cancel().catch(() => {});
157
+ };
158
+ if (ctx.signal?.aborted) stop();
159
+ else ctx.signal?.addEventListener("abort", stop, { once: true });
160
+ try {
161
+ // WHAT THE TASK IS DOING, AS IT CHANGES (docs/mcp/MCP_TASKS.md § 5). The extension's own
162
+ // settle hands every task update to `onEvent`; each new statusMessage goes to the caller,
163
+ // once per change, and a caller that did not ask for them is never told.
164
+ let lastStatus = "";
165
+ const onEvent = ctx.onStatus
166
+ ? (event: { type: string; task?: { statusMessage?: string } }) => {
167
+ const message = event.type === "task" ? String(event.task?.statusMessage ?? "").trim() : "";
168
+ if (!message || message === lastStatus) return;
169
+ lastStatus = message;
170
+ try {
171
+ ctx.onStatus!(message);
172
+ } catch {
173
+ /* a listener's failure never fails the task it watches */
174
+ }
175
+ }
176
+ : undefined;
177
+ const { outcome } = await execution.settle(onEvent ? ({ onEvent } as never) : undefined);
178
+ return resultFromTaskOutcome(outcome) as CallResult;
179
+ } finally {
180
+ ctx.signal?.removeEventListener("abort", stop);
181
+ }
182
+ } finally {
183
+ await client.close().catch(() => {});
184
+ }
185
+ }
186
+
187
+ /** A workflow Task: its result comes back as `output.result`, the run's result as text. */
188
+ export async function invokeWorkflowTask(
189
+ toolId: string,
190
+ ctx: AppInvocationContext,
191
+ ): Promise<{ status: "completed"; output?: Record<string, unknown>; conversationId?: string; message?: string }> {
192
+ const result = await callFollowingTasks(
193
+ toolId,
194
+ {
195
+ message: ctx.message ?? "",
196
+ ...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
197
+ ...(ctx.userId ? { userId: ctx.userId } : {}),
198
+ ...(ctx.chatId ? { chatId: ctx.chatId } : {}),
199
+ ...((ctx.args ?? {}) as Record<string, never>),
200
+ },
201
+ ctx,
202
+ );
203
+ const text = result.content?.find((c) => c.type === "text")?.text ?? "";
204
+ if (result.isError) throw new Error(text || `${toolId} did not complete`);
205
+ return {
206
+ status: "completed",
207
+ output: { result: text },
208
+ conversationId: ctx.conversationId,
209
+ message:
210
+ "The Agent completed. `output.result` is what it found, already shown to the person on screen. " +
211
+ "Continue the task with it: answer from it, and do not repeat it verbatim unless asked.",
212
+ };
213
+ }
214
+
76
215
  export async function invokeTaskNative(
77
216
  toolId: string,
78
217
  ctx: AppInvocationContext,
79
218
  ): Promise<{ status: "completed"; output?: Record<string, unknown>; conversationId?: string; action?: string; message?: string }> {
80
- const { client, close } = await connectPlatformMcp(ctx);
81
- try {
82
- const result = (await client.callTool(
219
+ {
220
+ const result = (await callFollowingTasks(
221
+ toolId,
83
222
  {
84
- // Org-qualified refs are served as dotted aliases (no "/" in MCP tool names).
85
- name: toolId.replace(/\//g, "."),
86
- arguments: {
87
- message: ctx.message ?? "",
88
- ...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
89
- ...(ctx.userId ? { userId: ctx.userId } : {}),
90
- ...(ctx.chatId ? { chatId: ctx.chatId } : {}),
91
- ...(ctx.props ? { props: ctx.props } : {}),
92
- ...(ctx.instanceId ? { instanceId: ctx.instanceId } : {}),
93
- ...(ctx.fromRow ? { fromRow: true } : {}),
94
- ...(ctx.shell ? { shell: true } : {}),
95
- ...(typeof ctx.rank === "number" ? { rank: ctx.rank } : {}),
96
- ...(ctx.batch ? { batch: ctx.batch } : {}),
97
- // A workflow task's own arguments, as the model wrote them against the task's schema.
98
- ...(ctx.args ?? {}),
99
- },
100
- ...(ctx.trace?.executionId && ctx.trace?.nodeId ? { _meta: { [TRACE_META]: ctx.trace } } : {}),
223
+ message: ctx.message ?? "",
224
+ ...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
225
+ ...(ctx.userId ? { userId: ctx.userId } : {}),
226
+ ...(ctx.chatId ? { chatId: ctx.chatId } : {}),
227
+ ...(ctx.props ? { props: ctx.props } : {}),
228
+ ...(ctx.instanceId ? { instanceId: ctx.instanceId } : {}),
229
+ ...(ctx.fromRow ? { fromRow: true } : {}),
230
+ ...(ctx.shell ? { shell: true } : {}),
231
+ ...(typeof ctx.rank === "number" ? { rank: ctx.rank } : {}),
232
+ ...(ctx.batch ? { batch: ctx.batch } : {}),
233
+ // A workflow task's own arguments, as the model wrote them against the task's schema.
234
+ ...(ctx.args ?? {}),
101
235
  },
102
- undefined,
103
- // A human is completing the wizard, or an Agent is working — lift the SDK's 60s default to 24h.
104
- { timeout: 24 * 60 * 60 * 1000 },
236
+ ctx,
105
237
  )) as {
106
238
  isError?: boolean;
107
239
  content?: Array<{ type: string; text?: string }>;
@@ -167,8 +299,6 @@ export async function invokeTaskNative(
167
299
  "The component is now displayed on the user's screen. Continue your answer for the user now — the render is a visual aid, not your reply.",
168
300
  }),
169
301
  };
170
- } finally {
171
- await close();
172
302
  }
173
303
  }
174
304
 
@@ -5,7 +5,7 @@
5
5
  * the row at all, so its input schema is read LIVE from the app's own resource at mint time.
6
6
  */
7
7
  import type { AgentToolDef, DiscoveredMCP, AppInvocationContext, OfferedFunction } from "./types.js";
8
- import { connectPlatformMcp, invokeTaskNative } from "./invoke.js";
8
+ import { connectPlatformMcp, invokeTaskNative, invokeWorkflowTask } from "./invoke.js";
9
9
  import { APP_ETIQUETTE } from "./discovery.js";
10
10
 
11
11
  /**
@@ -109,30 +109,35 @@ export function isBindinglessComponentApp(mcp: DiscoveredMCP): boolean {
109
109
  return !!mcp.component && !mcp.workflowId;
110
110
  }
111
111
 
112
- /** A workflow task (2026-09-06): a task-augmented native tool, held until the Agent's run completes. */
112
+ /** A workflow task (2026-09-06): a native tool the harness follows as an MCP task to its result (step 6). */
113
113
  export function isWorkflowTask(mcp: DiscoveredMCP): boolean {
114
114
  return !!mcp.taskTool;
115
115
  }
116
116
 
117
+ /** What a caller may hear while a task it called runs: its status, each time it changes. */
118
+ export type TaskCallHooks = Pick<AppInvocationContext, "onStatus">;
119
+
117
120
  /**
118
121
  * THE ONE workflow-task adapter every agent family wires for a discovered workflow task.
119
122
  * The model's arguments go to the tool as they are (the task's own input schema shaped
120
123
  * them); the identity rides beside them so the server pushes the face into THIS
121
- * conversation and runs the Agent as THIS caller. The held call resolves with the run's
122
- * result in `output.result`.
124
+ * conversation and runs the Agent as THIS caller. The task is followed to its result
125
+ * (docs/mcp/MCP_TASKS.md step 6), which comes back in `output.result`.
123
126
  */
124
127
  export function workflowTaskInvoker(
125
128
  mcp: DiscoveredMCP,
126
- identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl" | "trace">,
127
- ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
129
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl" | "trace" | "signal">,
130
+ ): (input: unknown, hooks?: TaskCallHooks) => ReturnType<typeof invokeWorkflowTask> {
128
131
  const toolId = mcp.taskTool as string;
129
- return (input: unknown) => {
132
+ return (input: unknown, hooks?: TaskCallHooks) => {
130
133
  const { message, ...rest } = (input ?? {}) as Record<string, unknown>;
131
- return invokeTaskNative(toolId, {
134
+ return invokeWorkflowTask(toolId, {
135
+ ...(hooks?.onStatus ? { onStatus: hooks.onStatus } : {}),
132
136
  conversationId: identity.conversationId,
133
137
  userId: identity.userId,
134
138
  accessToken: identity.accessToken,
135
139
  chatId: identity.chatId,
140
+ ...(identity.signal ? { signal: identity.signal } : {}),
136
141
  ...(identity.trace ? { trace: { ...identity.trace, kind: "task" } } : {}),
137
142
  ...(identity.baseUrl ? { baseUrl: identity.baseUrl } : {}),
138
143
  message: typeof message === "string" ? message : "",
@@ -157,7 +162,7 @@ export function workflowTaskInvoker(
157
162
  */
158
163
  export function componentAppInvoker(
159
164
  mcp: DiscoveredMCP,
160
- identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "trace">,
165
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "trace" | "signal">,
161
166
  ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
162
167
  const toolId = mcp.component as string;
163
168
  return (input: unknown) => {
@@ -167,6 +172,7 @@ export function componentAppInvoker(
167
172
  userId: identity.userId,
168
173
  accessToken: identity.accessToken,
169
174
  chatId: identity.chatId,
175
+ ...(identity.signal ? { signal: identity.signal } : {}),
170
176
  ...(identity.trace ? { trace: { ...identity.trace, kind: mcp.task ? "task" : "tool" } } : {}),
171
177
  message: typeof message === "string" ? message : "",
172
178
  ...(Object.keys(hydrated).length ? { props: hydrated } : {}),
@@ -192,7 +198,7 @@ export function componentAppInvoker(
192
198
  */
193
199
  export function workflowAppInvoker(
194
200
  mcp: DiscoveredMCP,
195
- identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl" | "trace">,
201
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl" | "trace" | "signal">,
196
202
  ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
197
203
  const toolId = String(mcp.methodName);
198
204
  return (input: unknown) => {
@@ -202,6 +208,7 @@ export function workflowAppInvoker(
202
208
  userId: identity.userId,
203
209
  accessToken: identity.accessToken,
204
210
  chatId: identity.chatId,
211
+ ...(identity.signal ? { signal: identity.signal } : {}),
205
212
  ...(identity.trace ? { trace: { ...identity.trace, kind: mcp.task ? "task" : "tool" } } : {}),
206
213
  ...(identity.baseUrl ? { baseUrl: identity.baseUrl } : {}),
207
214
  message: typeof message === "string" ? message : "",
@@ -111,6 +111,14 @@ export interface AppInvocationContext {
111
111
  * Sent as the call's `_meta`, never as an argument; absent for a call no model made.
112
112
  */
113
113
  trace?: { executionId?: string; nodeId?: string; kind?: "tool" | "task" };
114
+ /** The calling run's stop signal: when the run stops, a task it follows is cancelled (`tasks/cancel`). */
115
+ signal?: AbortSignal;
116
+ /**
117
+ * WHAT THE TASK IS DOING NOW, as it changes: each new `statusMessage` of a task this call
118
+ * follows, from the tasks extension's own updates (docs/mcp/MCP_TASKS.md § 5, "what reaches
119
+ * the model"). The caller decides what to do with it; an Agent narrates it.
120
+ */
121
+ onStatus?: (statusMessage: string) => void;
114
122
  /** The caller's stream session — where the wizard renders and elicitation is answered. */
115
123
  conversationId?: string;
116
124
  userId?: string;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * SWITCHING AN AGENT ON (docs/architecture/signal/AGENTS.md § 7, § 7a; UNO-221).
3
+ *
4
+ * An Agent folder says the most it may ever do (`access`). It does nothing until a person switches
5
+ * it on in this environment; that person is responsible for it (`act` on its token). Three rules:
6
+ *
7
+ * - no daily budget, no Agent (§ 9): switching on is refused;
8
+ * - the person must hold everything in `access`: refused, naming what they lack;
9
+ * - its token carries `access` capped at what the owner holds, never more.
10
+ *
11
+ * Pure: who the person is and what they hold come in; the engine keeps the switch
12
+ * (`engine/src/agentKeys/agentSwitch.ts`).
13
+ */
14
+ import { grantedTo } from "../security/permissions.js";
15
+
16
+ /** What switching on needs from the Agent's folder. */
17
+ export interface SwitchableAgent {
18
+ /** `<org>/<name>`. */
19
+ ref: string;
20
+ access: string[];
21
+ budget?: { perDay?: string };
22
+ }
23
+
24
+ /** The person switching it on, as the gate read them. */
25
+ export interface SwitchingPerson {
26
+ id: string;
27
+ roles?: string[];
28
+ permissions?: string[];
29
+ }
30
+
31
+ export type SwitchCheck =
32
+ | { ok: true; ownerPermissions: string[] }
33
+ | { ok: false; reason: "no-budget" | "lacks-access"; missing: string[]; message: string };
34
+
35
+ /** May this person switch this Agent on? When yes, what they hold is kept as the cap. */
36
+ export function checkSwitchOn(agent: SwitchableAgent, person: SwitchingPerson): SwitchCheck {
37
+ if (!agent.budget?.perDay?.trim()) {
38
+ return { ok: false, reason: "no-budget", missing: [], message: `${agent.ref} has no daily budget: set budget.perDay in its agent.yaml` };
39
+ }
40
+ const held = grantedTo(person);
41
+ const missing = agent.access.filter((p) => !held.has(p as never));
42
+ if (missing.length) {
43
+ return { ok: false, reason: "lacks-access", missing, message: `To switch on ${agent.ref} you need ${missing.join(", ")}` };
44
+ }
45
+ return { ok: true, ownerPermissions: [...held].sort() };
46
+ }
47
+
48
+ /** What its token carries: its `access`, never more than the owner holds. */
49
+ export function agentPermissions(access: string[], ownerPermissions: string[]): string[] {
50
+ const owner = new Set(ownerPermissions);
51
+ return access.filter((p) => owner.has(p));
52
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * AN AGENT'S TOKEN: an Agent working with nobody signed in runs as itself.
3
+ *
4
+ * Design: docs/auth-security/IDENTITY.md, Agents; docs/architecture/signal/AGENTS.md § 7. The
5
+ * Agent is its folder, `agent:<org>/<name>`; its token is signed by THIS CELL, not by the login
6
+ * provider, so it works the same whatever provider signs people in. Its permissions are its
7
+ * folder's `access`, capped at what its owner holds (`agentSwitch.ts`); `act` names the owner.
8
+ *
9
+ * No database here. The engine keeps the key (`engine/src/agentKeys/`) and hands it in; a
10
+ * test can drive every branch with a key it made itself. The one network read is
11
+ * `engineAgentKeys`, the public half, fetched from the engine when a token is checked.
12
+ */
13
+
14
+ import { SignJWT, jwtVerify, decodeJwt, importJWK, generateKeyPair, exportJWK, createRemoteJWKSet, type JWK, type JWTPayload, type JWTVerifyGetKey } from "jose";
15
+
16
+ /** `iss` on every Agent token. A cell-local name: each cell signs with its own key, so a
17
+ * token from another cell carries the same name and still fails the signature. */
18
+ export const AGENT_ISSUER = "unoverse:agent";
19
+
20
+ /** Every Agent's user id starts with this, so a node can tell one from a person. */
21
+ export const AGENT_ID_PREFIX = "agent:";
22
+
23
+ export const AGENT_ALG = "ES256";
24
+
25
+ /** The longest a run is expected to take. A token is signed per run. */
26
+ export const AGENT_TOKEN_TTL_SECONDS = 3600;
27
+
28
+ export interface AgentClaims {
29
+ /** The Agent, `<org>/<name>`: its folder. */
30
+ agent: string;
31
+ /** Its title, for the run history. */
32
+ name: string;
33
+ /** The person who switched it on, and is accountable for it. */
34
+ ownerId: string;
35
+ /** Its `access`, already capped at the owner's own. */
36
+ permissions: string[];
37
+ }
38
+
39
+ export function isAgentId(id: unknown): id is string {
40
+ return typeof id === "string" && id.startsWith(AGENT_ID_PREFIX);
41
+ }
42
+
43
+ /** A cell's signing key as plain JSON: what the engine stores and hands in. */
44
+ export interface AgentKeyPair {
45
+ kid: string;
46
+ publicJwk: JWK;
47
+ privateJwk: JWK;
48
+ }
49
+
50
+ /** Make a new key pair. The engine calls this once per cell and stores the result. */
51
+ export async function newAgentKeyPair(kid: string): Promise<AgentKeyPair> {
52
+ const { publicKey, privateKey } = await generateKeyPair(AGENT_ALG, { extractable: true });
53
+ const publicJwk = { ...(await exportJWK(publicKey)), kid, alg: AGENT_ALG, use: "sig" };
54
+ const privateJwk = { ...(await exportJWK(privateKey)), kid, alg: AGENT_ALG };
55
+ return { kid, publicJwk, privateJwk };
56
+ }
57
+
58
+ /** Sign one run's token. `audience` is the cell's `AUTH_AUDIENCE`, the same as a person's. */
59
+ export async function signAgentToken(
60
+ claims: AgentClaims,
61
+ key: { kid: string; privateJwk: JWK },
62
+ audience: string | undefined,
63
+ now: Date = new Date(),
64
+ ): Promise<string> {
65
+ const iat = Math.floor(now.getTime() / 1000);
66
+ const jwt = new SignJWT({
67
+ name: claims.name,
68
+ permissions: claims.permissions,
69
+ act: { sub: claims.ownerId },
70
+ })
71
+ .setProtectedHeader({ alg: AGENT_ALG, kid: key.kid, typ: "at+jwt" })
72
+ .setIssuer(AGENT_ISSUER)
73
+ .setSubject(`${AGENT_ID_PREFIX}${claims.agent}`)
74
+ .setIssuedAt(iat)
75
+ .setExpirationTime(iat + AGENT_TOKEN_TTL_SECONDS);
76
+ if (audience) jwt.setAudience(audience);
77
+ return jwt.sign(await importJWK(key.privateJwk, AGENT_ALG));
78
+ }
79
+
80
+ /** Is this token one this platform signed for an Agent? Read, not verified: it only picks
81
+ * which check to run. */
82
+ export function isAgentToken(token: string): boolean {
83
+ try {
84
+ return decodeJwt(token).iss === AGENT_ISSUER;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ export interface VerifiedAgent {
91
+ id: string;
92
+ name?: string;
93
+ ownerId?: string;
94
+ permissions: string[];
95
+ raw: JWTPayload;
96
+ }
97
+
98
+ /**
99
+ * Check an Agent's token: signature against the cell's keys, issuer, expiry, audience.
100
+ * Null when any of them fails. FAIL CLOSED: a token without a `permissions` list, or whose
101
+ * subject is not an Agent id, is refused rather than read as "no permissions".
102
+ */
103
+ export async function verifyAgentToken(
104
+ token: string,
105
+ keys: JWTVerifyGetKey,
106
+ audience: string | undefined,
107
+ ): Promise<VerifiedAgent | null> {
108
+ try {
109
+ const { payload } = await jwtVerify(token, keys, {
110
+ issuer: AGENT_ISSUER,
111
+ algorithms: [AGENT_ALG],
112
+ ...(audience ? { audience } : {}),
113
+ });
114
+ if (!isAgentId(payload.sub) || !Array.isArray(payload.permissions)) return null;
115
+ const act = payload.act as { sub?: unknown } | undefined;
116
+ return {
117
+ id: payload.sub,
118
+ name: typeof payload.name === "string" ? payload.name : undefined,
119
+ ownerId: typeof act?.sub === "string" ? act.sub : undefined,
120
+ permissions: (payload.permissions as unknown[]).filter((p): p is string => typeof p === "string"),
121
+ raw: payload,
122
+ };
123
+ } catch (error) {
124
+ const code = (error as { code?: string }).code;
125
+ console.warn(`[auth] Agent token refused: ${code ?? (error as Error).message}`);
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * The cell's public Agent keys, from the engine's internal listener. The same address the
132
+ * server and the memory service already use to reach the engine (`WORKFLOW_SERVICE_URL`).
133
+ * jose caches the set and refetches it when a token names a key it has not seen.
134
+ */
135
+ let engineKeys: JWTVerifyGetKey | null = null;
136
+ export function engineAgentKeys(): JWTVerifyGetKey {
137
+ if (!engineKeys) {
138
+ const engine = (process.env.WORKFLOW_SERVICE_URL || "http://localhost:4101").replace(/\/$/, "");
139
+ engineKeys = createRemoteJWKSet(new URL(`${engine}/agent-keys/jwks.json`));
140
+ }
141
+ return engineKeys;
142
+ }
@@ -14,6 +14,7 @@
14
14
  import { jwtVerify, createRemoteJWKSet, JWTPayload } from "jose";
15
15
  import { isAuthEnabled, devIdentity } from "./authConfig.js";
16
16
  import { grantedTo } from "../security/permissions.js";
17
+ import { isAgentToken, verifyAgentToken, engineAgentKeys } from "./agentToken.js";
17
18
 
18
19
  // Read env lazily (not at module load) so it's correct regardless of when .env loads.
19
20
  //
@@ -145,6 +146,12 @@ export async function validateJWT(token: string): Promise<AuthUser | null> {
145
146
  }
146
147
 
147
148
  async function verifyJWT(token: string): Promise<AuthUser | null> {
149
+ // AN AGENT'S TOKEN is signed by this cell, not the login provider (IDENTITY.md, Agents):
150
+ // checked against the cell's own keys, never the provider's. No email, no roles.
151
+ if (isAgentToken(token)) {
152
+ const agent = await verifyAgentToken(token, engineAgentKeys(), getAudience());
153
+ return agent ? { id: agent.id, roles: [], permissions: agent.permissions, raw: agent.raw } : null;
154
+ }
148
155
  const jwksSet = await getJWKS();
149
156
  const issuers = getIssuerCandidates();
150
157
  if (!jwksSet || issuers.length === 0) {
@@ -184,7 +184,12 @@ export function loadDefinition(
184
184
  if (m.preview && typeof m.preview === "object") def.preview = m.preview as Record<string, string[]>;
185
185
  // THE WORKFLOW THAT WORKS IT (UNOVERSE_HOST_PAGE.md rule 4): the manifest names it,
186
186
  // and the page reads it here to start a call on it.
187
- if (m.binding && typeof m.binding === "object") def.binding = m.binding as { workflow?: string; trigger?: string; pipeline?: string };
187
+ if (m.binding && typeof m.binding === "object") def.binding = m.binding as { workflow?: string; trigger?: string; pipeline?: string; autoTrigger?: boolean };
188
+ // THE TASK IT CARRIES (docs/design/lifecycle-hooks.md): which moments have a hook, so a
189
+ // page that draws it knows to ask for its task. Only the moments ride; the calls stay
190
+ // on the server, in the template's folder.
191
+ if (Array.isArray(m.lifecycle))
192
+ (def as unknown as Record<string, unknown>).lifecycle = (m.lifecycle as Array<Record<string, unknown>>).map((e) => ({ phase: e?.phase }));
188
193
  // SPATIAL-CAPABLE, exactly as a component is (§9c, ruled 2026-08-30): a
189
194
  // template is found by what a guest says, through the same projection, the
190
195
  // same registry switch and the same embedded text. The difference is only
@@ -0,0 +1,113 @@
1
+ /**
2
+ * AGENTS AS FOLDERS, READ (docs/architecture/signal/AGENTS.md § 3, § 3a).
3
+ *
4
+ * One shape for every Agent: `agents/<name>/agent.yaml` and its soul (`SOUL.md`). This reads them
5
+ * for whoever shows or runs an Agent (Studio's Agents lane, Signal, Spatial). A flat file directly
6
+ * in `agents/` is the older Agent file (agents.ts), read there until it is converted.
7
+ *
8
+ * Read as written: the lint (`lintObjects`) is what refuses a broken one; here a folder that cannot
9
+ * be read is left out with a warning, never taking the org's other Agents with it
10
+ * (UNOVERSE_ORGANISATION.md § 11).
11
+ */
12
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { parse } from "yaml";
15
+ import { listOrgs, projectDir } from "./homes.js";
16
+ import { isolated } from "../isolated.js";
17
+
18
+ /** One Agent trigger, as the file writes it: what wakes it, and the node behind it. */
19
+ export interface AgentTrigger {
20
+ name: string;
21
+ node?: string;
22
+ wakes?: string;
23
+ config?: Record<string, unknown>;
24
+ }
25
+
26
+ /** An Agent folder: its file as written, its soul's words, where it is. */
27
+ export interface AgentFolder {
28
+ /** `<org>/<name>`. */
29
+ ref: string;
30
+ org: string;
31
+ name: string;
32
+ title?: string;
33
+ role?: string;
34
+ description?: string;
35
+ whenToUse?: string;
36
+ category?: string;
37
+ version?: string;
38
+ avatar?: string;
39
+ skills: string[];
40
+ model?: { use?: string; reasoning?: string; fallback?: string };
41
+ budget?: { perDay?: string; perRun?: string };
42
+ tasks: string[];
43
+ access: string[];
44
+ triggers: AgentTrigger[];
45
+ checkIn?: string;
46
+ memory?: { agent?: string };
47
+ askWhenUnsure?: string;
48
+ /** The soul's own words (character only), empty when the file is missing. */
49
+ soul: string;
50
+ /** The folder, relative to the org repo. */
51
+ path: string;
52
+ }
53
+
54
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v.trim() : undefined);
55
+ const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && !!x.trim()) : []);
56
+ const rec = (v: unknown): Record<string, unknown> | undefined => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined);
57
+
58
+ /** One Agent folder of an org, or null when it is not one. */
59
+ export function readAgentFolder(root: string, org: string, name: string): AgentFolder | null {
60
+ const dir = join(root, "agents", name);
61
+ const file = join(dir, "agent.yaml");
62
+ if (!existsSync(file)) return null;
63
+ const doc = (parse(readFileSync(file, "utf8")) as Record<string, unknown>) ?? {};
64
+ if (doc.type !== "agent") return null;
65
+ const soulName = str(doc.soul) ?? "SOUL.md";
66
+ const soulFile = join(dir, soulName);
67
+ const model = rec(doc.model);
68
+ const budget = rec(doc.budget);
69
+ const memory = rec(doc.memory);
70
+ return {
71
+ ref: `${org}/${name}`,
72
+ org,
73
+ name,
74
+ title: str(doc.title),
75
+ role: str(doc.role),
76
+ description: str(doc.description),
77
+ whenToUse: str(doc.whenToUse),
78
+ category: str(doc.category),
79
+ version: str(doc.version),
80
+ avatar: str(doc.avatar),
81
+ skills: strs(doc.skills),
82
+ ...(model ? { model: { use: str(model.use), reasoning: str(model.reasoning), fallback: str(model.fallback) } } : {}),
83
+ ...(budget ? { budget: { perDay: str(budget.perDay), perRun: str(budget.perRun) } } : {}),
84
+ tasks: strs(doc.tasks),
85
+ access: strs(doc.access),
86
+ triggers: (Array.isArray(doc.triggers) ? doc.triggers : [])
87
+ .map(rec)
88
+ .filter((c): c is Record<string, unknown> => !!c && !!str(c.name))
89
+ .map((c) => ({ name: str(c.name)!, node: str(c.node), wakes: str(c.wakes), ...(rec(c.config) ? { config: rec(c.config) } : {}) })),
90
+ checkIn: str(doc.checkIn),
91
+ ...(memory ? { memory: { agent: str(memory.agent) } } : {}),
92
+ askWhenUnsure: str(doc.askWhenUnsure),
93
+ soul: existsSync(soulFile) ? readFileSync(soulFile, "utf8") : "",
94
+ path: `agents/${name}`,
95
+ };
96
+ }
97
+
98
+ /** Every Agent folder of one org, by name. */
99
+ export function listAgentFoldersIn(root: string, org: string): AgentFolder[] {
100
+ const dir = join(root, "agents");
101
+ if (!existsSync(dir)) return [];
102
+ return readdirSync(dir, { withFileTypes: true })
103
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !e.name.startsWith("_"))
104
+ .map((e) => isolated(`agent ${org}/${e.name}`, () => readAgentFolder(root, org, e.name)) ?? null)
105
+ .filter((a): a is AgentFolder => a !== null)
106
+ .sort((a, b) => a.name.localeCompare(b.name));
107
+ }
108
+
109
+ /** Every Agent folder across the workspace, or one org's. */
110
+ export function listAgentFolders(org?: string): AgentFolder[] {
111
+ const orgs = org && org !== "all" ? [org] : listOrgs();
112
+ return orgs.flatMap((o) => listAgentFoldersIn(projectDir(o), o));
113
+ }