@unoverse-platform/studio 0.1.114 → 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.
- package/local/catalog.ts +3 -0
- package/local/plugin.ts +12 -9
- package/package.json +1 -1
- package/screens/AgentChain.tsx +216 -0
- package/screens/AgentsView.tsx +45 -6
- package/screens/SkillsView.tsx +21 -3
- package/screens/index.ts +3 -0
- package/vendor/base/src/agent-mcp/discovery.ts +4 -1
- package/vendor/base/src/agent-mcp/invoke.ts +155 -25
- package/vendor/base/src/agent-mcp/tools.ts +17 -10
- package/vendor/base/src/agent-mcp/types.ts +8 -0
- package/vendor/base/src/auth/agentSwitch.ts +52 -0
- package/vendor/base/src/auth/agentToken.ts +142 -0
- package/vendor/base/src/auth/auth.ts +7 -0
- package/vendor/base/src/definitions/load/load.ts +6 -1
- package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
- package/vendor/base/src/definitions/registry/objects.ts +16 -0
- package/vendor/base/src/definitions/registry/pipelines.ts +13 -5
- package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
- package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
- package/vendor/base/src/lint/design/index.mjs +10 -2
- package/vendor/base/src/lint/objects/index.d.mts +1 -0
- package/vendor/base/src/lint/objects/index.mjs +120 -2
- package/vendor/base/src/lint/objects/rules.mjs +16 -5
- package/vendor/base/src/manifests/executor/toolBridge.ts +10 -3
- package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +4 -1
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +58 -23
- package/vendor/base/src/manifests/runtime/usage.ts +13 -5
- package/vendor/base/src/platform/agentBudget.ts +65 -0
- package/vendor/base/src/platform/agentCalendar.ts +87 -0
- package/vendor/base/src/platform/audioManager.ts +15 -2
- package/vendor/base/src/platform/taskChanged.ts +25 -0
- package/vendor/base/src/platform/tokenCost.ts +395 -0
- package/vendor/base/src/security/permissions.ts +10 -2
- package/vendor/sdk/appEngine.ts +42 -1
- package/vendor/sdk/lib/dom/markdown.ts +6 -2
- 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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
219
|
+
{
|
|
220
|
+
const result = (await callFollowingTasks(
|
|
221
|
+
toolId,
|
|
83
222
|
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
122
|
-
*
|
|
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
|
|
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
|
|
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
|
+
}
|