@nylorun/runtime 0.11.0-beta → 0.12.0-beta

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/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - a322696: **Derived principals** (optional Host feature `derived-principals`): a client that holds the admin key no longer needs to store an application key.
8
+
9
+ - `admin.createTenant({ name, principals: ["babai"] })` registers each named principal by the hash of its derived key, and `admin.deriveTenantKey(tenantId, principalId)` (or `deriveTenantKey(adminKey, tenantId, principalId)`) recomputes the key when needed.
10
+ - `POST /v1/admin/tenants` accepts `derivedPrincipals: [{ id, credentialHash }]`. Ids match `^[a-z][a-z0-9-]{0,31}$` and `studio` is reserved; duplicate ids or credentials answer `400`. A retried create must name the same principals.
11
+ - `createTenant` with `principals` throws `incompatible_host` before sending anything to a Host without the feature.
12
+
13
+ - 844bff3: **Act for a person: `Nylorun-Subject` and `Nylorun-Scopes`.** An app server that holds the Tenant key can name the person each request is for, and the Runtime enforces it (optional Host feature `subject-headers`).
14
+
15
+ - `client.as(subject, { scopes })` in `@nylorun/agents` sends both headers on every call, event streams included. Scopes: `agents:read`, `agents:write`, `sessions:own`, `vaults:own`, `tenant:settings`; default `["sessions:own"]`.
16
+ - The Runtime limits a subject to the routes its scopes allow (`403 scope_required`) and to its own sessions and vaults: another owner's session, vault or sandbox is the same `404` as a missing one, including `PUT` on its session id (was `409`). Reset, config seed, executors, actions and the sandbox tool routes are open to no subject. Only application keys may send the headers.
17
+ - The AG-UI handler calls the Runtime as each person and requires `subject-headers`; new optional `scopes` option. The host's `session()` parameters can no longer replace a session's id, agent or owner.
18
+ - Core exports `SUBJECT_HEADER`, `SCOPES_HEADER`, `SUBJECT_SCOPES` and `parseSubjectHeaders`. Postgres Tenant schemas migrate to version 2 (an indexed session owner column).
19
+
20
+ Requests without `Nylorun-Subject` are unchanged.
21
+
22
+ - a322696: **The session log now carries what a chat UI shows** (optional Host feature `transcript-events`).
23
+
24
+ - `message.assistant` for each completed model step: `{ invocationId, text, toolCalls: [{ callId, name, input }], agent? }`.
25
+ - `tool.completed` for an MCP or sandbox tool: `{ invocationId, callId, capabilityId, toolName, output }`, or `error: { code, message }` for a tool error.
26
+ - Tool `action.pending` and `action.completed` events, and `delegation.started` / `delegation.completed`, carry the model's `callId` (and `invocationId` on actions).
27
+ - Events are written in the transaction that completes the effect, so a replay writes none.
28
+ - `@nylorun/core/contracts` adds payload schemas and `parseTranscriptEvent(event)`; `LiveEvent.payload` stays `unknown`.
29
+
30
+ Fix: a tool with both `approval` and an `output` schema now pauses for approval. The Runtime validated its `interaction-required` result against the output schema and failed the tool with `tool.invalid-output`; `denied`, `interaction-required` and `deferred` results are no longer validated.
31
+
32
+ ### Patch Changes
33
+
34
+ - Pin core to the tested release.
35
+ - Pin harness to the tested release.
36
+ - Updated dependencies [a322696]
37
+ - Updated dependencies [844bff3]
38
+ - Updated dependencies [a322696]
39
+ - Updated dependencies
40
+ - @nylorun/core@0.7.0-beta
41
+ - @nylorun/harness@0.19.2-beta
42
+
3
43
  ## 0.11.0-beta
4
44
 
5
45
  ### Minor Changes
@@ -37,4 +37,8 @@ export declare function validateSandboxAttach(body: {
37
37
  sandbox?: {
38
38
  session: string;
39
39
  };
40
- }, newManifest: SessionSandboxRef["manifest"], lookup: (id: string) => SessionSandboxRef | undefined): string | undefined;
40
+ }, newManifest: SessionSandboxRef["manifest"], lookup: (id: string) => SessionSandboxRef | undefined,
41
+ /** Acting for a subject: another owner's session is the 404 of a missing one. */
42
+ options?: {
43
+ opaque?: boolean;
44
+ }): string | undefined;
@@ -85,12 +85,14 @@ export async function handleActionSandboxTool(deps, actionId, toolName, body, si
85
85
  return runTool(deps, read, tool, toolInput(body), signal);
86
86
  }
87
87
  /** Validate PutSession.sandbox attach: same owner, identical specs. */
88
- export function validateSandboxAttach(body, newManifest, lookup) {
88
+ export function validateSandboxAttach(body, newManifest, lookup,
89
+ /** Acting for a subject: another owner's session is the 404 of a missing one. */
90
+ options = {}) {
89
91
  const share = body.sandbox?.session;
90
92
  if (!share)
91
93
  return undefined;
92
94
  const owner = lookup(share);
93
- if (!owner)
95
+ if (!owner || (options.opaque && owner.ownerUserId !== body.ownerUserId))
94
96
  throw new SandboxRouteError(404, "Sandbox session not found");
95
97
  if (owner.ownerUserId !== body.ownerUserId)
96
98
  throw new SandboxRouteError(403, "Sandbox session must belong to the same owner");
@@ -96,6 +96,9 @@ export function createHost(options) {
96
96
  ...(body.studioCredentialHash
97
97
  ? { studioCredentialHash: body.studioCredentialHash }
98
98
  : {}),
99
+ ...(body.derivedPrincipals?.length
100
+ ? { derivedPrincipals: body.derivedPrincipals }
101
+ : {}),
99
102
  });
100
103
  return sendJson(response, result.created ? 201 : 200, result.envelope);
101
104
  }
@@ -315,7 +315,9 @@ class MemoryTx {
315
315
  }
316
316
  async listSessions(filter = {}) {
317
317
  this.check();
318
- return this.sessions().filter((s) => filter.agentId === undefined || s.agentId === filter.agentId);
318
+ return this.sessions().filter((s) => (filter.agentId === undefined || s.agentId === filter.agentId) &&
319
+ (filter.ownerUserId === undefined ||
320
+ s.ownerUserId === filter.ownerUserId));
319
321
  }
320
322
  async listDefinitions() {
321
323
  this.check();
@@ -0,0 +1,6 @@
1
+ import type { Migration } from "./index.js";
2
+ /**
3
+ * The session owner (`ownerUserId`) as a stored generated column with an index, so a request
4
+ * acting for a subject lists only that subject's sessions. Not the lease column `owner`.
5
+ */
6
+ export declare const sessionOwner: Migration;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The session owner (`ownerUserId`) as a stored generated column with an index, so a request
3
+ * acting for a subject lists only that subject's sessions. Not the lease column `owner`.
4
+ */
5
+ export const sessionOwner = {
6
+ version: 2,
7
+ name: "session_owner",
8
+ up: (s) => `
9
+ ALTER TABLE ${s}.sessions
10
+ ADD COLUMN owner_user_id text GENERATED ALWAYS AS (${s}.doc(body)->>'ownerUserId') STORED;
11
+ CREATE INDEX sessions_owner_user ON ${s}.sessions (owner_user_id);
12
+ `,
13
+ };
@@ -1,7 +1,8 @@
1
1
  import { quarantine } from "../../../tenant/quarantine.js";
2
2
  import { quoteIdentifier, tenantIdFromSchema } from "../names.js";
3
3
  import { initial } from "./001_initial.js";
4
- export const MIGRATIONS = [initial];
4
+ import { sessionOwner } from "./002_session_owner.js";
5
+ export const MIGRATIONS = [initial, sessionOwner];
5
6
  /** The schema version this Runtime writes and expects. */
6
7
  export const POSTGRES_SCHEMA_VERSION = MIGRATIONS.at(-1).version;
7
8
  /** Checks that versions run 1, 2, 3, … Throws otherwise. */
@@ -420,9 +420,16 @@ class PostgresTx {
420
420
  }
421
421
  async listSessions(filter = {}) {
422
422
  this.check();
423
- return this.sessionRows(filter.agentId === undefined
423
+ const sql = this.sql;
424
+ const conditions = [
425
+ ...(filter.agentId === undefined ? [] : [sql `agent_id = ${filter.agentId}`]),
426
+ ...(filter.ownerUserId === undefined
427
+ ? []
428
+ : [sql `owner_user_id = ${filter.ownerUserId}`]),
429
+ ];
430
+ return this.sessionRows(conditions.length === 0
424
431
  ? undefined
425
- : this.sql `agent_id = ${filter.agentId}`);
432
+ : conditions.reduce((all, next) => sql `${all} AND ${next}`));
426
433
  }
427
434
  async bodies(table) {
428
435
  const rows = await this.sql `SELECT body FROM ${this.t(table)} ORDER BY id`;
@@ -58,14 +58,17 @@ export function createPostgresTenantCatalog(options) {
58
58
  const schema = tenantSchemaName(id);
59
59
  const rows = await q `
60
60
  SELECT id, token_hash, idempotency_key FROM ${q(`${schema}.principals`)}
61
- WHERE role = 'application' AND id IN (${bootstrap.principalId}, ${STUDIO_PRINCIPAL_ID})`;
61
+ WHERE role = 'application'`;
62
62
  const app = rows.find((row) => row.id === bootstrap.principalId);
63
63
  if (!app ||
64
64
  app.token_hash !== bootstrap.credentialHash ||
65
65
  app.idempotency_key !== bootstrap.idempotencyKey)
66
66
  return false;
67
67
  const studio = rows.find((row) => row.id === STUDIO_PRINCIPAL_ID);
68
- return studio?.token_hash === bootstrap.studioCredentialHash;
68
+ if (studio?.token_hash !== bootstrap.studioCredentialHash)
69
+ return false;
70
+ return (bootstrap.derivedPrincipals ?? []).every((principal) => rows.find((row) => row.id === principal.id)?.token_hash ===
71
+ principal.credentialHash);
69
72
  }
70
73
  const catalog = {
71
74
  async listTenantIds() {
@@ -122,6 +125,10 @@ export function createPostgresTenantCatalog(options) {
122
125
  INSERT INTO ${tx(`${schema}.principals`)} (id, role, token_hash, idempotency_key, created_at)
123
126
  VALUES (${STUDIO_PRINCIPAL_ID}, 'application', ${principals.studioCredentialHash},
124
127
  NULL, ${createdAt})`;
128
+ for (const derived of principals.derivedPrincipals ?? [])
129
+ await tx `
130
+ INSERT INTO ${tx(`${schema}.principals`)} (id, role, token_hash, idempotency_key, created_at)
131
+ VALUES (${derived.id}, 'application', ${derived.credentialHash}, NULL, ${createdAt})`;
125
132
  return { created: true, envelope: stored };
126
133
  });
127
134
  if (outcome.created)
@@ -347,8 +347,10 @@ export interface Tx {
347
347
  * ended at or before `now`, oldest lease first. The sweep re-wakes these.
348
348
  */
349
349
  orphanedSessions<T extends SessionDoc = SessionDoc>(now: Date, limit: number): Promise<StoredSession<T>[]>;
350
+ /** Sessions, optionally of one agent and one owner (`ownerUserId`), by id. */
350
351
  listSessions<T extends SessionDoc = SessionDoc>(filter?: {
351
352
  agentId?: string;
353
+ ownerUserId?: string;
352
354
  }): Promise<StoredSession<T>[]>;
353
355
  listDefinitions<T extends DefinitionDoc = DefinitionDoc>(): Promise<T[]>;
354
356
  listSandboxes<T extends SandboxDoc = SandboxDoc>(): Promise<T[]>;
@@ -1,11 +1,32 @@
1
1
  /**
2
2
  * Tenant request authentication and authorization: a bearer token resolves to an application
3
- * principal or a registered executor; anything else is the opaque 404 (D5).
3
+ * principal or a registered executor; anything else is the opaque 404 (D5). An application
4
+ * principal may act for a subject (`Nylorun-Subject`, `Nylorun-Scopes`); `authorize` then
5
+ * limits the request to the routes its scopes allow, decided from the route alone.
4
6
  */
5
7
  import type { IncomingMessage } from "node:http";
6
- import type { Action } from "@nylorun/core/contracts";
8
+ import { type Action, type SubjectScope } from "@nylorun/core/contracts";
7
9
  import type { AuthScope, TenantContext } from "./context.js";
8
10
  export declare function authenticate(ctx: TenantContext, request: IncomingMessage): Promise<AuthScope>;
11
+ /** What a subject needs for a route: any one of the scopes, or nothing grants it. */
12
+ export type RouteAccess = readonly SubjectScope[] | "never";
13
+ /**
14
+ * The scopes a subject needs for `method path` (`path` starts with `v1`). `undefined` for a
15
+ * route that does not exist. Operator and executor routes are `never`: reset, config seed,
16
+ * executors, actions and the sandbox tool routes.
17
+ */
18
+ export declare function routeAccess(method: string | undefined, path: readonly string[]): RouteAccess | undefined;
19
+ /**
20
+ * Limits a subject to the routes its scopes allow. Decided from the route alone, before any
21
+ * lookup, so a refusal reveals nothing about which resources exist. A route the table does not
22
+ * know is a 404 for a subject: new routes stay closed until they are added here. Application
23
+ * and executor scopes pass through to the checks each route makes today.
24
+ */
25
+ export declare function authorize(scope: AuthScope, method: string | undefined, path: readonly string[]): void;
26
+ /** The subject whose sessions and vaults the request is limited to, if it acts for one. */
27
+ export declare function ownerOf(scope: AuthScope): string | undefined;
28
+ /** The application principal behind the request, acting for a subject or not. */
29
+ export declare function requirePrincipal(scope: AuthScope): string;
9
30
  /** The executor scope must belong to the Action's agent. */
10
31
  export declare function scoped(scope: AuthScope, action: Action): void;
11
32
  export declare function requireApplication(scope: AuthScope): string;
@@ -1,5 +1,19 @@
1
+ import { SCOPES_HEADER, SUBJECT_HEADER, } from "@nylorun/core/compatibility";
2
+ import { parseSubjectHeaders, } from "@nylorun/core/contracts";
1
3
  import { hashToken } from "../core/executors.js";
2
4
  import { fail, failOpaque } from "./http.js";
5
+ const SUBJECT_INVALID = { code: "subject_invalid" };
6
+ /** One header's value, or `undefined`; a header sent twice is refused. */
7
+ function singleHeader(request, name) {
8
+ const values = request.headersDistinct?.[name.toLowerCase()];
9
+ if (!values) {
10
+ const raw = request.headers[name.toLowerCase()];
11
+ return Array.isArray(raw) ? fail(400, `${name} must be sent once`, SUBJECT_INVALID) : raw;
12
+ }
13
+ if (values.length > 1)
14
+ return fail(400, `${name} must be sent once`, SUBJECT_INVALID);
15
+ return values[0];
16
+ }
3
17
  export async function authenticate(ctx, request) {
4
18
  const header = request.headers.authorization;
5
19
  const token = header?.startsWith("Bearer ") ? header.slice(7) : "";
@@ -11,16 +25,137 @@ export async function authenticate(ctx, request) {
11
25
  }
12
26
  const tokenHash = hashToken(token);
13
27
  const principal = await ctx.store.tx((t) => t.principalByTokenHash(tokenHash));
14
- if (principal)
15
- return { kind: "application", principalId: principal.id };
16
- const executor = ctx.registry.find(tokenHash);
17
- if (!executor) {
28
+ const executor = principal ? undefined : ctx.registry.find(tokenHash);
29
+ if (!principal && !executor) {
18
30
  ctx.config.logger.warn("credential rejected", {
19
31
  reason: "unknown_token",
20
32
  });
21
33
  return failOpaque();
22
34
  }
23
- return { kind: "executor", executor };
35
+ // Read only after authentication, so an unknown caller sees the opaque 404 either way.
36
+ const subject = singleHeader(request, SUBJECT_HEADER);
37
+ const scopes = singleHeader(request, SCOPES_HEADER);
38
+ if (executor) {
39
+ if (subject !== undefined || scopes !== undefined)
40
+ fail(403, "An executor credential cannot act for a subject");
41
+ return { kind: "executor", executor };
42
+ }
43
+ if (subject === undefined) {
44
+ if (scopes !== undefined)
45
+ fail(400, `${SCOPES_HEADER} requires ${SUBJECT_HEADER}`, SUBJECT_INVALID);
46
+ return { kind: "application", principalId: principal.id };
47
+ }
48
+ const parsed = parseSubjectHeaders(subject, scopes);
49
+ if (!parsed.ok)
50
+ return fail(400, parsed.message, SUBJECT_INVALID);
51
+ return {
52
+ kind: "subject",
53
+ principalId: principal.id,
54
+ subject: parsed.subject,
55
+ scopes: parsed.scopes,
56
+ };
57
+ }
58
+ const SESSIONS = ["sessions:own"];
59
+ const VAULTS = ["vaults:own"];
60
+ const SETTINGS = ["tenant:settings"];
61
+ /**
62
+ * The scopes a subject needs for `method path` (`path` starts with `v1`). `undefined` for a
63
+ * route that does not exist. Operator and executor routes are `never`: reset, config seed,
64
+ * executors, actions and the sandbox tool routes.
65
+ */
66
+ export function routeAccess(method, path) {
67
+ const [v1, resource, id, sub] = path;
68
+ const n = path.length;
69
+ if (v1 !== "v1" || !resource)
70
+ return undefined;
71
+ switch (resource) {
72
+ case "executors":
73
+ case "actions":
74
+ return "never";
75
+ case "agents":
76
+ if (n === 2 && method === "GET")
77
+ return ["agents:read", "agents:write"];
78
+ if (n === 3 && method === "PUT")
79
+ return ["agents:write"];
80
+ return undefined;
81
+ case "sessions":
82
+ if (n === 2 && method === "GET")
83
+ return SESSIONS;
84
+ if (n === 3 && (method === "GET" || method === "PUT"))
85
+ return SESSIONS;
86
+ if (n === 4 && method === "GET" && (sub === "items" || sub === "events"))
87
+ return SESSIONS;
88
+ if (n === 4 && method === "POST" && sub === "commands")
89
+ return SESSIONS;
90
+ if (n === 5 && method === "POST" && sub === "sandbox")
91
+ return "never";
92
+ return undefined;
93
+ case "vaults":
94
+ if (n === 2)
95
+ return method === "GET" || method === "POST" ? VAULTS : undefined;
96
+ if (n === 3)
97
+ return method === "GET" || method === "DELETE" ? VAULTS : undefined;
98
+ if (sub !== "credentials")
99
+ return undefined;
100
+ if (n === 4)
101
+ return method === "GET" || method === "POST" ? VAULTS : undefined;
102
+ if (n === 5)
103
+ return method === "GET" || method === "POST" || method === "DELETE"
104
+ ? VAULTS
105
+ : undefined;
106
+ return undefined;
107
+ case "tenant":
108
+ if (n === 2 && method === "GET")
109
+ return SETTINGS;
110
+ if (n === 3 && id === "reset" && method === "POST")
111
+ return "never";
112
+ if (n === 4 && id === "config" && sub === "seed" && method === "PUT")
113
+ return "never";
114
+ if (n === 3 && method === "GET" && (id === "models" || id === "providers"))
115
+ return ["tenant:settings", "agents:write"];
116
+ if (n === 3 && method === "GET" && id === "sandbox")
117
+ return SETTINGS;
118
+ if (n === 3 && id === "model" && (method === "GET" || method === "PUT"))
119
+ return SETTINGS;
120
+ if (n === 4 && id === "model" && sub === "selection" && method === "PUT")
121
+ return SETTINGS;
122
+ return undefined;
123
+ default:
124
+ return undefined;
125
+ }
126
+ }
127
+ /**
128
+ * Limits a subject to the routes its scopes allow. Decided from the route alone, before any
129
+ * lookup, so a refusal reveals nothing about which resources exist. A route the table does not
130
+ * know is a 404 for a subject: new routes stay closed until they are added here. Application
131
+ * and executor scopes pass through to the checks each route makes today.
132
+ */
133
+ export function authorize(scope, method, path) {
134
+ if (scope.kind !== "subject")
135
+ return;
136
+ const access = routeAccess(method, path);
137
+ if (access === undefined)
138
+ fail(404, "Route not found");
139
+ if (access === "never")
140
+ fail(403, "This route is not available when acting for a subject", {
141
+ code: "scope_required",
142
+ details: { scopes: [] },
143
+ });
144
+ if (!access.some((name) => scope.scopes.has(name)))
145
+ fail(403, `Scope ${access.join(" or ")} required`, {
146
+ code: "scope_required",
147
+ details: { scopes: access },
148
+ });
149
+ }
150
+ /** The subject whose sessions and vaults the request is limited to, if it acts for one. */
151
+ export function ownerOf(scope) {
152
+ return scope.kind === "subject" ? scope.subject : undefined;
153
+ }
154
+ /** The application principal behind the request, acting for a subject or not. */
155
+ export function requirePrincipal(scope) {
156
+ if (scope.kind !== "executor")
157
+ return scope.principalId;
158
+ return fail(403, "Application credential required");
24
159
  }
25
160
  /** The executor scope must belong to the Action's agent. */
26
161
  export function scoped(scope, action) {
@@ -22,15 +22,17 @@ import { resolveMessageManifest } from "../core/turn-manifest.js";
22
22
  import { canonical } from "../store/canonical.js";
23
23
  import { lockedSession, } from "./context.js";
24
24
  import { fail } from "./http.js";
25
- import { scoped } from "./auth.js";
25
+ import { ownerOf, scoped } from "./auth.js";
26
26
  import { actionTarget, rebaseSessionState, turnManifestOf, variantStore, } from "./session.js";
27
27
  import { signalSessionCancel } from "./streams.js";
28
+ import { toolIds } from "./transcript.js";
28
29
  /** A tool result whose output does not match the Action's stored output schema fails the tool. */
29
30
  function acceptedToolResult(action, command) {
30
31
  if (action.kind !== "tool" || !action.outputSchema)
31
32
  return command;
32
33
  const value = command.outcome.value;
33
- if (isFailedToolValue(value))
34
+ // Only a result carries output: failures, denials, interactions and deferrals pass as sent.
35
+ if (!isResultToolValue(value))
34
36
  return command;
35
37
  // Executors wrap successful tool output as `{ kind: "completed", output }`.
36
38
  // Validate the tool payload, not the outcome envelope.
@@ -66,11 +68,17 @@ function completedToolOutput(value) {
66
68
  }
67
69
  return value;
68
70
  }
69
- function isFailedToolValue(value) {
70
- return (!!value &&
71
+ const NON_RESULT_KINDS = new Set([
72
+ "failed",
73
+ "denied",
74
+ "interaction-required",
75
+ "deferred",
76
+ ]);
77
+ function isResultToolValue(value) {
78
+ return !(!!value &&
71
79
  typeof value === "object" &&
72
80
  !Array.isArray(value) &&
73
- value.kind === "failed");
81
+ NON_RESULT_KINDS.has(value.kind));
74
82
  }
75
83
  /** A command's identity for idempotency: everything but the request id. */
76
84
  const semantic = (value) => {
@@ -81,7 +89,7 @@ export async function command(ctx, id, input, scope) {
81
89
  const { store } = ctx;
82
90
  const cascadeCancelIds = [];
83
91
  const response = await store.tx(async (t) => {
84
- const s = await lockedSession(t, id);
92
+ const s = await lockedSession(t, id, ownerOf(scope));
85
93
  let command = input;
86
94
  if (command.type === "action_result") {
87
95
  const a = (await t.get("actions", command.actionId)) ??
@@ -91,7 +99,7 @@ export async function command(ctx, id, input, scope) {
91
99
  scoped(scope, a);
92
100
  command = acceptedToolResult(a, command);
93
101
  }
94
- else if (scope.kind !== "application")
102
+ else if (scope.kind === "executor")
95
103
  fail(403, "Application credential required");
96
104
  const key = commandKey(id, command.idempotencyKey);
97
105
  const existing = await t.get("commands", key);
@@ -135,6 +143,7 @@ export async function command(ctx, id, input, scope) {
135
143
  actionId: action.actionId,
136
144
  ...actionTarget(action),
137
145
  kind: action.kind,
146
+ ...(action.kind === "tool" ? toolIds(action.context) : {}),
138
147
  result: command.outcome.value,
139
148
  });
140
149
  if (action.kind === "verify") {
@@ -13,7 +13,7 @@
13
13
  * `wake` goes to `DurableExecution.wake`, which calls the Tenant's `advance` (`advance.ts`)
14
14
  * under ownership (§10.6); `abortLocal` aborts an advance running on this process.
15
15
  */
16
- import type { TenantEnvelope } from "@nylorun/core/contracts";
16
+ import type { SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
17
17
  import type { DurableCheckpoint, FlowCheckpoint } from "@nylorun/harness/run";
18
18
  import type { AgentManifest, JsonValue } from "@nylorun/core/define";
19
19
  import type { CredentialSelection } from "@nylorun/core/contracts";
@@ -72,6 +72,13 @@ export interface Session {
72
72
  export type AuthScope = {
73
73
  kind: "application";
74
74
  principalId: string;
75
+ }
76
+ /** An application principal acting for `subject` (`Nylorun-Subject`), narrowed to `scopes`. */
77
+ | {
78
+ kind: "subject";
79
+ principalId: string;
80
+ subject: string;
81
+ scopes: ReadonlySet<SubjectScope>;
75
82
  } | {
76
83
  kind: "executor";
77
84
  executor: ExecutorRecord;
@@ -116,9 +123,9 @@ export interface TenantContext {
116
123
  onSweep(hook: () => Promise<void>): () => void;
117
124
  }
118
125
  /** The session, or a 404. Reads without locking. */
119
- export declare function sessionOf(t: Tx, id: string): Promise<Session>;
126
+ export declare function sessionOf(t: Tx, id: string, owner?: string): Promise<Session>;
120
127
  /** Locks the session for the rest of the transaction and returns it, or a 404. */
121
- export declare function lockedSession(t: Tx, id: string): Promise<Session>;
128
+ export declare function lockedSession(t: Tx, id: string, owner?: string): Promise<Session>;
122
129
  /** An advance's hold on its session (§10.6): every write the advance makes presents `epoch`. */
123
130
  export interface Lease {
124
131
  readonly sessionId: string;
@@ -132,7 +139,7 @@ export interface Lease {
132
139
  */
133
140
  export declare function ownedSession(t: Tx, lease: Lease, id: string): Promise<Session>;
134
141
  /** The session read in its own transaction, or a 404. */
135
- export declare function loadSession(ctx: TenantContext, id: string): Promise<Session>;
142
+ export declare function loadSession(ctx: TenantContext, id: string, owner?: string): Promise<Session>;
136
143
  /**
137
144
  * A synchronous lookup over `start` and every session its `sandboxOwnerId` chain names, for
138
145
  * the sandbox-sharing helpers, which walk that chain synchronously.
@@ -1,12 +1,20 @@
1
1
  import { fail } from "./http.js";
2
+ /**
3
+ * `owner`, when set, is the subject the request acts for: another owner's session is the same
4
+ * 404 as a missing one, so a subject cannot learn which session ids exist.
5
+ */
6
+ function owned(session, owner) {
7
+ if (!session || (owner !== undefined && session.ownerUserId !== owner))
8
+ return fail(404, "Session not found");
9
+ return session;
10
+ }
2
11
  /** The session, or a 404. Reads without locking. */
3
- export async function sessionOf(t, id) {
4
- return ((await t.get("sessions", id)) ??
5
- fail(404, "Session not found"));
12
+ export async function sessionOf(t, id, owner) {
13
+ return owned(await t.get("sessions", id), owner);
6
14
  }
7
15
  /** Locks the session for the rest of the transaction and returns it, or a 404. */
8
- export async function lockedSession(t, id) {
9
- return ((await t.lockSession(id)) ?? fail(404, "Session not found"));
16
+ export async function lockedSession(t, id, owner) {
17
+ return owned(await t.lockSession(id), owner);
10
18
  }
11
19
  /**
12
20
  * Locks `id` for the advance holding `lease`. The advance's own session is epoch-checked: a
@@ -19,8 +27,8 @@ export async function ownedSession(t, lease, id) {
19
27
  return lockedSession(t, id);
20
28
  }
21
29
  /** The session read in its own transaction, or a 404. */
22
- export function loadSession(ctx, id) {
23
- return ctx.store.tx((t) => sessionOf(t, id));
30
+ export function loadSession(ctx, id, owner) {
31
+ return ctx.store.tx((t) => sessionOf(t, id, owner));
24
32
  }
25
33
  /**
26
34
  * A synchronous lookup over `start` and every session its `sandboxOwnerId` chain names, for
@@ -12,6 +12,7 @@ import { loadSession, ownedSession, sandboxLookup, sessionOf, } from "./context.
12
12
  import { fail } from "./http.js";
13
13
  import { actionTarget, linkedAgentOutput, manifestFor, mcpToolOf, pinnedTool, } from "./session.js";
14
14
  import { command } from "./commands.js";
15
+ import { assistantMessage, toolCompleted, toolIds } from "./transcript.js";
15
16
  import { abortKind } from "./worker.js";
16
17
  export function invokeModel(ctx, request, signal, model) {
17
18
  if (model)
@@ -51,6 +52,20 @@ async function linkedOutcome(t, effect, agent) {
51
52
  },
52
53
  };
53
54
  }
55
+ /**
56
+ * What an effect's journal row must match on replay. A delegation's context carries only the
57
+ * parent's tool call id, for its events; rows journaled before it existed still match.
58
+ */
59
+ function requestIdentity(request) {
60
+ if (request.kind !== "delegation")
61
+ return request;
62
+ const { context: _, ...identity } = request;
63
+ return identity;
64
+ }
65
+ function delegationCallId(request) {
66
+ const callId = request.context?.callId;
67
+ return typeof callId === "string" ? { callId } : {};
68
+ }
54
69
  export async function resolveEffect(ctx, request, signal, lease, segment = {}) {
55
70
  const { store } = ctx;
56
71
  const journaled = await store.tx(async (t) => {
@@ -65,7 +80,7 @@ export async function resolveEffect(ctx, request, signal, lease, segment = {}) {
65
80
  });
66
81
  const existing = await t.get("effects", request.effectId);
67
82
  if (existing) {
68
- if (canonical(existing.request) !== canonical(request))
83
+ if (canonical(requestIdentity(existing.request)) !== canonical(requestIdentity(request)))
69
84
  throw new Error("Effect identity request drift");
70
85
  if (existing.status === "completed")
71
86
  return resolved({ status: "completed", outcome: existing.outcome });
@@ -104,7 +119,11 @@ export async function resolveEffect(ctx, request, signal, lease, segment = {}) {
104
119
  outcome,
105
120
  });
106
121
  const settled = request.effectId.endsWith(":settled");
107
- await t.event(s.id, s.activeTurnId, settled ? "delegation.completed" : "delegation.started", { agent: request.agent, ...request.input });
122
+ await t.event(s.id, s.activeTurnId, settled ? "delegation.completed" : "delegation.started", {
123
+ agent: request.agent,
124
+ ...delegationCallId(request),
125
+ ...request.input,
126
+ });
108
127
  return resolved({ status: "completed", outcome });
109
128
  }
110
129
  const agentManifest = manifestFor(s.manifest, request.agent);
@@ -166,6 +185,7 @@ export async function resolveEffect(ctx, request, signal, lease, segment = {}) {
166
185
  actionId: action.actionId,
167
186
  kind: action.kind,
168
187
  ...actionTarget(action),
188
+ ...(action.kind === "tool" ? toolIds(request.context) : {}),
169
189
  input: action.input,
170
190
  });
171
191
  t.signalWork();
@@ -198,6 +218,11 @@ export async function resolveEffect(ctx, request, signal, lease, segment = {}) {
198
218
  effect.status = "completed";
199
219
  effect.outcome = { value };
200
220
  await t.put("effects", request.effectId, effect);
221
+ const transcript = invoke === "model"
222
+ ? assistantMessage(request, value)
223
+ : toolCompleted(request, value);
224
+ if (transcript)
225
+ await t.event(s.id, request.turnId, invoke === "model" ? "message.assistant" : "tool.completed", transcript);
201
226
  return { status: "completed", outcome: effect.outcome };
202
227
  });
203
228
  }
@@ -11,11 +11,17 @@ export declare const OPAQUE_NOT_FOUND: {
11
11
  code: "not_found";
12
12
  message: string;
13
13
  };
14
+ /** A rejection's stable code and details, for the ones a client acts on (default `request_rejected`). */
15
+ export interface Rejection {
16
+ readonly code?: string;
17
+ readonly details?: unknown;
18
+ }
14
19
  export declare class HttpError extends Error {
15
20
  readonly status: number;
16
- constructor(status: number, message: string);
21
+ readonly rejection: Rejection;
22
+ constructor(status: number, message: string, rejection?: Rejection);
17
23
  }
18
- export declare const fail: (status: number, message: string) => never;
24
+ export declare const fail: (status: number, message: string, rejection?: Rejection) => never;
19
25
  export declare class OpaqueAuthError extends Error {
20
26
  readonly status = 404;
21
27
  readonly body: {
@@ -6,13 +6,15 @@ export const OPAQUE_NOT_FOUND = {
6
6
  };
7
7
  export class HttpError extends Error {
8
8
  status;
9
- constructor(status, message) {
9
+ rejection;
10
+ constructor(status, message, rejection = {}) {
10
11
  super(message);
11
12
  this.status = status;
13
+ this.rejection = rejection;
12
14
  }
13
15
  }
14
- export const fail = (status, message) => {
15
- throw new HttpError(status, message);
16
+ export const fail = (status, message, rejection) => {
17
+ throw new HttpError(status, message, rejection);
16
18
  };
17
19
  export class OpaqueAuthError extends Error {
18
20
  status = 404;
@@ -152,6 +152,9 @@ export function createTenantModule(options) {
152
152
  ...(input.studioCredentialHash
153
153
  ? { studioCredentialHash: input.studioCredentialHash }
154
154
  : {}),
155
+ ...(input.derivedPrincipals?.length
156
+ ? { derivedPrincipals: input.derivedPrincipals }
157
+ : {}),
155
158
  };
156
159
  // The store records its own schema version.
157
160
  const envelope = envelopeNow({
@@ -9,5 +9,8 @@ export declare const STUDIO_PRINCIPAL_ID = "studio";
9
9
  * Exported for WS-B's `TenantStore.create` (A3).
10
10
  */
11
11
  export declare function bootstrapPrincipal(t: Tx, bootstrap: BootstrapPrincipal, now?: Date): Promise<void>;
12
- /** Whether the stored bootstrap (and Studio) principals equal `bootstrap`. */
12
+ /**
13
+ * Whether the stored bootstrap, Studio and derived principals equal `bootstrap`. A retried
14
+ * create names the same derived principals, so each must be stored with its hash.
15
+ */
13
16
  export declare function bootstrapPrincipalMatches(t: Tx, bootstrap: BootstrapPrincipal): Promise<boolean>;
@@ -22,8 +22,19 @@ export async function bootstrapPrincipal(t, bootstrap, now = new Date()) {
22
22
  idempotencyKey: null,
23
23
  createdAt,
24
24
  });
25
+ for (const principal of bootstrap.derivedPrincipals ?? [])
26
+ await t.insertPrincipal({
27
+ id: principal.id,
28
+ role: "application",
29
+ tokenHash: principal.credentialHash,
30
+ idempotencyKey: null,
31
+ createdAt,
32
+ });
25
33
  }
26
- /** Whether the stored bootstrap (and Studio) principals equal `bootstrap`. */
34
+ /**
35
+ * Whether the stored bootstrap, Studio and derived principals equal `bootstrap`. A retried
36
+ * create names the same derived principals, so each must be stored with its hash.
37
+ */
27
38
  export async function bootstrapPrincipalMatches(t, bootstrap) {
28
39
  const row = await t.principalById(bootstrap.principalId);
29
40
  if (!row ||
@@ -32,6 +43,14 @@ export async function bootstrapPrincipalMatches(t, bootstrap) {
32
43
  row.idempotencyKey !== bootstrap.idempotencyKey)
33
44
  return false;
34
45
  const studio = await t.principalById(STUDIO_PRINCIPAL_ID);
35
- return ((studio?.role === "application" ? studio.tokenHash : undefined) ===
36
- bootstrap.studioCredentialHash);
46
+ if ((studio?.role === "application" ? studio.tokenHash : undefined) !==
47
+ bootstrap.studioCredentialHash)
48
+ return false;
49
+ for (const principal of bootstrap.derivedPrincipals ?? []) {
50
+ const stored = await t.principalById(principal.id);
51
+ if (stored?.role !== "application" ||
52
+ stored.tokenHash !== principal.credentialHash)
53
+ return false;
54
+ }
55
+ return true;
37
56
  }
@@ -2,6 +2,7 @@ import { CreateCredentialRequestSchema, CreateVaultRequestSchema, PutHostModelRe
2
2
  import { hostModelCatalog } from "../model/catalog.js";
3
3
  import { resetTenant } from "./reset.js";
4
4
  import { buildTenantStatus, seedTenantConfig } from "./status.js";
5
+ import { ownerOf } from "./auth.js";
5
6
  import { fail, readBody } from "./http.js";
6
7
  import { clearExecutorStreams, clearObservers } from "./live.js";
7
8
  import { requestStreamCollection, sessionStreamsAbandoned, streamsStatus, } from "./streams.js";
@@ -9,7 +10,8 @@ import { clearWork, drain } from "./scheduler.js";
9
10
  import { usesFixtureModel } from "./model-setting.js";
10
11
  export async function dispatchTenant(ctx, scope, method, path, request) {
11
12
  const { vault } = ctx;
12
- if (scope.kind !== "application") {
13
+ // A subject reaches only the routes `authorize` allowed its scopes.
14
+ if (scope.kind === "executor") {
13
15
  await vault.reject(path.join("/"));
14
16
  fail(403, "Application credential required");
15
17
  }
@@ -83,22 +85,31 @@ export async function dispatchTenant(ctx, scope, method, path, request) {
83
85
  }
84
86
  export async function dispatchVault(ctx, scope, method, path, url, request) {
85
87
  const { vault } = ctx;
86
- if (scope.kind !== "application") {
88
+ if (scope.kind === "executor") {
87
89
  await vault.reject(path.join("/"));
88
90
  fail(403, "Application credential required");
89
91
  }
92
+ // Acting for a subject: only the subject's own vaults, and never on another's behalf.
93
+ const owner = ownerOf(scope);
90
94
  if (path.length === 2 && method === "POST") {
91
95
  const body = CreateVaultRequestSchema.parse(await readBody(request));
96
+ if (owner !== undefined && body.ownerUserId !== owner)
97
+ fail(403, "ownerUserId must be the subject");
92
98
  return vault.createVault(body);
93
99
  }
94
100
  if (path.length === 2 && method === "GET") {
95
101
  const ownerUserId = url.searchParams.get("ownerUserId") ??
102
+ owner ??
96
103
  fail(400, "ownerUserId is required");
104
+ if (owner !== undefined && ownerUserId !== owner)
105
+ fail(403, "ownerUserId must be the subject");
97
106
  return { vaults: await vault.listVaults(ownerUserId) };
98
107
  }
99
108
  const vaultId = path[2];
100
109
  if (!vaultId)
101
110
  fail(404, "Vault not found");
111
+ if (owner !== undefined)
112
+ await vault.assertOwner(vaultId, owner);
102
113
  if (path.length === 3 && method === "GET")
103
114
  return vault.getVault(vaultId);
104
115
  if (path.length === 3 && method === "DELETE")
@@ -3,7 +3,7 @@ import { VaultError } from "../vault/error.js";
3
3
  import { SandboxRouteError, handleSessionSandboxTool, } from "../core/sandbox-routes.js";
4
4
  import { loadSession, sessionOf } from "./context.js";
5
5
  import { HttpError, OpaqueAuthError, fail, readBody, requestAborted, } from "./http.js";
6
- import { authenticate, requireApplication } from "./auth.js";
6
+ import { authenticate, authorize, ownerOf, requireApplication, requirePrincipal, } from "./auth.js";
7
7
  import { command } from "./commands.js";
8
8
  import { actionSandboxTool, deleteExecutor, listExecutors, listPendingActions, registerExecutors, sandboxRouteDeps, updateAction, } from "./actions.js";
9
9
  import { listDefinitions, listSessions, putDefinition, putSession, sessionView, } from "./sessions.js";
@@ -28,6 +28,9 @@ export async function handle(ctx, request, response, _url) {
28
28
  const scope = await authenticate(ctx, request);
29
29
  if (path[0] !== "v1")
30
30
  fail(404, "Route not found");
31
+ authorize(scope, method, path);
32
+ // Set when the request acts for a subject: only the subject's own sessions are reachable.
33
+ const owner = ownerOf(scope);
31
34
  if (path[1] === "executors" && path[2] === "connect" && method === "GET") {
32
35
  if (scope.kind !== "executor")
33
36
  return fail(403, "Executor credential required");
@@ -59,7 +62,7 @@ export async function handle(ctx, request, response, _url) {
59
62
  return json(await dispatchVault(ctx, scope, method, path, url, request));
60
63
  if (path[1] === "tenant")
61
64
  return json(await dispatchTenant(ctx, scope, method, path, request));
62
- const principalId = requireApplication(scope);
65
+ const principalId = requirePrincipal(scope);
63
66
  if (path[1] === "executors" && path.length === 2 && method === "GET")
64
67
  return json(listExecutors(ctx));
65
68
  // The length guard matters: the connect branch above only matches GET, so without it a
@@ -74,7 +77,7 @@ export async function handle(ctx, request, response, _url) {
74
77
  if (path[1] === "agents" && path.length === 2 && method === "GET")
75
78
  return json(await listDefinitions(ctx));
76
79
  if (path[1] === "sessions" && path.length === 2 && method === "GET")
77
- return json(await listSessions(ctx, url.searchParams.get("agentId")));
80
+ return json(await listSessions(ctx, url.searchParams.get("agentId"), owner));
78
81
  if (path[1] === "agents" &&
79
82
  path[2] &&
80
83
  path.length === 3 &&
@@ -84,13 +87,13 @@ export async function handle(ctx, request, response, _url) {
84
87
  const id = path[2];
85
88
  if (method === "PUT" && path.length === 3) {
86
89
  const body = PutSessionRequestSchema.parse(await readBody(request));
87
- const session = await putSession(ctx, id, body);
90
+ const session = await putSession(ctx, id, body, owner);
88
91
  return json(await ctx.store.tx((t) => sessionView(t, session)));
89
92
  }
90
93
  if (method === "GET" && path.length === 3)
91
- return json(await ctx.store.tx(async (t) => sessionView(t, await sessionOf(t, id))));
94
+ return json(await ctx.store.tx(async (t) => sessionView(t, await sessionOf(t, id, owner))));
92
95
  // Every other session route needs the session to exist.
93
- await loadSession(ctx, id);
96
+ await loadSession(ctx, id, owner);
94
97
  const cursor = requestCursor(request, url);
95
98
  if (method === "GET" && path[3] === "items")
96
99
  return json(await readHistory(ctx, id, cursor, url.searchParams.get("agent") ?? undefined));
@@ -133,10 +136,16 @@ export async function handle(ctx, request, response, _url) {
133
136
  error?.message === "Invalid cursor"
134
137
  ? 400
135
138
  : 500;
139
+ const rejection = error instanceof HttpError ? error.rejection : {};
136
140
  json({
137
141
  status: "rejected",
138
- code: status === 500 ? "internal_error" : "request_rejected",
142
+ code: status === 500
143
+ ? "internal_error"
144
+ : rejection.code ?? "request_rejected",
139
145
  message: status === 500 ? "Runtime request failed" : error.message,
146
+ ...(rejection.details === undefined
147
+ ? {}
148
+ : { details: rejection.details }),
140
149
  }, status);
141
150
  }
142
151
  }
@@ -19,7 +19,8 @@ export declare function putDefinition(ctx: TenantContext, agentId: string, body:
19
19
  manifestHash: string;
20
20
  implementationVersion: string;
21
21
  }>;
22
- export declare function listSessions(ctx: TenantContext, agentId: string | null): Promise<{
22
+ /** `owner`, when the request acts for a subject, limits the list to that subject's sessions. */
23
+ export declare function listSessions(ctx: TenantContext, agentId: string | null, owner?: string): Promise<{
23
24
  sessions: {
24
25
  id: string;
25
26
  agentId: string;
@@ -28,7 +29,12 @@ export declare function listSessions(ctx: TenantContext, agentId: string | null)
28
29
  activeTurnId: string | null;
29
30
  }[];
30
31
  }>;
31
- /** Create a session from its definition, or re-attach vaults to an identical one. */
32
- export declare function putSession(ctx: TenantContext, id: string, body: PutSessionRequest): Promise<Session>;
32
+ /**
33
+ * Create a session from its definition, or re-attach vaults to an identical one. `owner`, when
34
+ * the request acts for a subject, must be the session's owner: another owner's session id is
35
+ * the 404 of a missing one (not the 409 of a mismatch), and so are another owner's vaults and
36
+ * sandboxes.
37
+ */
38
+ export declare function putSession(ctx: TenantContext, id: string, body: PutSessionRequest, owner?: string): Promise<Session>;
33
39
  /** The session resource returned by `GET`/`PUT /v1/sessions/:id`. */
34
40
  export declare function sessionView(t: Tx, s: Session): Promise<unknown>;
@@ -42,8 +42,12 @@ export async function putDefinition(ctx, agentId, body) {
42
42
  implementationVersion: body.implementationVersion,
43
43
  };
44
44
  }
45
- export async function listSessions(ctx, agentId) {
46
- const sessions = await ctx.store.tx((t) => t.listSessions(agentId === null ? {} : { agentId }));
45
+ /** `owner`, when the request acts for a subject, limits the list to that subject's sessions. */
46
+ export async function listSessions(ctx, agentId, owner) {
47
+ const sessions = await ctx.store.tx((t) => t.listSessions({
48
+ ...(agentId === null ? {} : { agentId }),
49
+ ...(owner === undefined ? {} : { ownerUserId: owner }),
50
+ }));
47
51
  return {
48
52
  sessions: sessions.map((s) => ({
49
53
  id: s.id,
@@ -54,20 +58,30 @@ export async function listSessions(ctx, agentId) {
54
58
  })),
55
59
  };
56
60
  }
57
- /** Create a session from its definition, or re-attach vaults to an identical one. */
58
- export function putSession(ctx, id, body) {
61
+ /**
62
+ * Create a session from its definition, or re-attach vaults to an identical one. `owner`, when
63
+ * the request acts for a subject, must be the session's owner: another owner's session id is
64
+ * the 404 of a missing one (not the 409 of a mismatch), and so are another owner's vaults and
65
+ * sandboxes.
66
+ */
67
+ export function putSession(ctx, id, body, owner) {
59
68
  const vaultIds = body.vaultIds ?? [];
60
69
  const credentialSelections = body.credentialSelections ?? [];
70
+ const opaque = owner !== undefined;
71
+ if (opaque && body.ownerUserId !== owner)
72
+ fail(403, "ownerUserId must be the subject");
61
73
  return ctx.store.tx(async (t) => {
62
74
  const prior = await t.lockSession(id);
75
+ if (opaque && prior && prior.ownerUserId !== owner)
76
+ fail(404, "Session not found");
63
77
  const definition = prior === undefined || body.sandbox
64
78
  ? (await t.get("definitions", body.agentId)) ??
65
79
  fail(404, "Definition not found")
66
80
  : undefined;
67
81
  const sandboxOwnerId = body.sandbox
68
- ? validateSandboxAttach(body, (definition ?? prior).manifest, await sandboxLookup(t, body.sandbox.session))
82
+ ? validateSandboxAttach(body, (definition ?? prior).manifest, await sandboxLookup(t, body.sandbox.session), { opaque })
69
83
  : undefined;
70
- await ctx.vault.assertAttachment(t, body.ownerUserId, vaultIds, credentialSelections);
84
+ await ctx.vault.assertAttachment(t, body.ownerUserId, vaultIds, credentialSelections, { opaque });
71
85
  if (prior) {
72
86
  if (sessionIdentity(prior.creation) !== sessionIdentity(body))
73
87
  fail(409, "Session already exists with different creation parameters");
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Transcript events (Host feature `transcript-events`): what a chat UI shows, written in the
3
+ * transaction that completes the effect, so a replayed effect never writes them again.
4
+ * Payload schemas: `@nylorun/core/contracts` (`parseTranscriptEvent`).
5
+ */
6
+ import type { HostEffect } from "@nylorun/harness/run";
7
+ /** The model's tool call id and the harness invocation, for tool effects and tool actions. */
8
+ export declare function toolIds(context: unknown): {
9
+ callId: string;
10
+ invocationId: string;
11
+ } | Record<string, never>;
12
+ /**
13
+ * `message.assistant` for a completed model effect: the step's text and tool calls. The
14
+ * provider returns a `ModelCandidate` or a bare string; anything else writes no event.
15
+ */
16
+ export declare function assistantMessage(request: HostEffect, value: unknown): {
17
+ agent: import("@nylorun/core").AgentRef;
18
+ invocationId: string;
19
+ text: string;
20
+ toolCalls: {
21
+ callId: string;
22
+ name: string;
23
+ input: unknown;
24
+ }[];
25
+ } | {
26
+ agent?: undefined;
27
+ invocationId: string;
28
+ text: string;
29
+ toolCalls: {
30
+ callId: string;
31
+ name: string;
32
+ input: unknown;
33
+ }[];
34
+ } | undefined;
35
+ /** `tool.completed` for an MCP or sandbox tool: its output, or the tool error it reported. */
36
+ export declare function toolCompleted(request: HostEffect, value: unknown): {
37
+ agent: import("@nylorun/core").AgentRef;
38
+ error: {
39
+ code: string;
40
+ message: string;
41
+ };
42
+ output?: undefined;
43
+ capabilityId: string;
44
+ toolName: string;
45
+ } | {
46
+ agent?: undefined;
47
+ error: {
48
+ code: string;
49
+ message: string;
50
+ };
51
+ output?: undefined;
52
+ capabilityId: string;
53
+ toolName: string;
54
+ } | {
55
+ agent: import("@nylorun/core").AgentRef;
56
+ error?: undefined;
57
+ output: unknown;
58
+ capabilityId: string;
59
+ toolName: string;
60
+ } | {
61
+ agent?: undefined;
62
+ error?: undefined;
63
+ output: unknown;
64
+ capabilityId: string;
65
+ toolName: string;
66
+ } | {
67
+ callId: string;
68
+ invocationId: string;
69
+ agent: import("@nylorun/core").AgentRef;
70
+ error: {
71
+ code: string;
72
+ message: string;
73
+ };
74
+ output?: undefined;
75
+ capabilityId: string;
76
+ toolName: string;
77
+ } | {
78
+ callId: string;
79
+ invocationId: string;
80
+ agent?: undefined;
81
+ error: {
82
+ code: string;
83
+ message: string;
84
+ };
85
+ output?: undefined;
86
+ capabilityId: string;
87
+ toolName: string;
88
+ } | {
89
+ callId: string;
90
+ invocationId: string;
91
+ agent: import("@nylorun/core").AgentRef;
92
+ error?: undefined;
93
+ output: unknown;
94
+ capabilityId: string;
95
+ toolName: string;
96
+ } | {
97
+ callId: string;
98
+ invocationId: string;
99
+ agent?: undefined;
100
+ error?: undefined;
101
+ output: unknown;
102
+ capabilityId: string;
103
+ toolName: string;
104
+ } | undefined;
@@ -0,0 +1,58 @@
1
+ /** The model's tool call id and the harness invocation, for tool effects and tool actions. */
2
+ export function toolIds(context) {
3
+ const c = context;
4
+ return typeof c?.callId === "string" && typeof c.invocationId === "string"
5
+ ? { callId: c.callId, invocationId: c.invocationId }
6
+ : {};
7
+ }
8
+ const agentOf = (request) => request.agent ? { agent: request.agent } : {};
9
+ /**
10
+ * `message.assistant` for a completed model effect: the step's text and tool calls. The
11
+ * provider returns a `ModelCandidate` or a bare string; anything else writes no event.
12
+ */
13
+ export function assistantMessage(request, value) {
14
+ const invocationId = request.context
15
+ ?.invocationId;
16
+ if (typeof invocationId !== "string")
17
+ return undefined;
18
+ const blocks = typeof value === "string"
19
+ ? [{ type: "text", text: value }]
20
+ : Array.isArray(value?.output)
21
+ ? value.output
22
+ : [];
23
+ let text = "";
24
+ const toolCalls = [];
25
+ for (const block of blocks) {
26
+ if (block?.type === "text" && typeof block.text === "string")
27
+ text += block.text;
28
+ else if (block?.type === "tool-call" &&
29
+ typeof block.id === "string" &&
30
+ typeof block.name === "string")
31
+ toolCalls.push({ callId: block.id, name: block.name, input: block.args });
32
+ }
33
+ if (!text && toolCalls.length === 0)
34
+ return undefined;
35
+ return { invocationId, text, toolCalls, ...agentOf(request) };
36
+ }
37
+ /** `tool.completed` for an MCP or sandbox tool: its output, or the tool error it reported. */
38
+ export function toolCompleted(request, value) {
39
+ const ids = toolIds(request.context);
40
+ if (!("callId" in ids))
41
+ return undefined;
42
+ const outcome = value;
43
+ const result = outcome?.kind === "failed"
44
+ ? {
45
+ error: {
46
+ code: String(outcome.code ?? "tool.failed"),
47
+ message: String(outcome.message ?? ""),
48
+ },
49
+ }
50
+ : { output: outcome?.kind === "completed" ? outcome.output : value };
51
+ return {
52
+ ...ids,
53
+ capabilityId: request.capabilityId,
54
+ toolName: request.toolName,
55
+ ...result,
56
+ ...agentOf(request),
57
+ };
58
+ }
@@ -109,6 +109,12 @@ export interface BootstrapPrincipal {
109
109
  idempotencyKey: string;
110
110
  /** When set, also registers application principal `studio` with this hash. */
111
111
  studioCredentialHash?: string;
112
+ /** Application principals whose keys the admin key derives (feature `derived-principals`). */
113
+ derivedPrincipals?: readonly DerivedPrincipal[];
114
+ }
115
+ export interface DerivedPrincipal {
116
+ id: string;
117
+ credentialHash: string;
112
118
  }
113
119
  /**
114
120
  * The deep module (§8). HTTP, CLI and tests use only this.
@@ -46,6 +46,8 @@ export declare class VaultService {
46
46
  createVault(body: CreateVaultRequest): Promise<VaultInfo>;
47
47
  listVaults(ownerUserId: string): Promise<VaultInfo[]>;
48
48
  getVault(id: string): Promise<VaultInfo>;
49
+ /** Another owner's vault, or the host vault, is the same 404 as a missing one. */
50
+ assertOwner(id: string, ownerUserId: string): Promise<void>;
49
51
  deleteVault(id: string): Promise<{
50
52
  id: string;
51
53
  }>;
@@ -56,8 +58,13 @@ export declare class VaultService {
56
58
  deleteCredential(vaultId: string, id: string): Promise<{
57
59
  id: string;
58
60
  }>;
59
- /** Checks a session's vaults and selections. Run in the session PUT transaction. */
60
- assertAttachment(t: Tx, ownerUserId: string, vaultIds: readonly string[], selections: readonly CredentialSelection[]): Promise<void>;
61
+ /**
62
+ * Checks a session's vaults and selections. Run in the session PUT transaction. `opaque`
63
+ * (a request acting for a subject) reports another owner's vault as the 404 of a missing one.
64
+ */
65
+ assertAttachment(t: Tx, ownerUserId: string, vaultIds: readonly string[], selections: readonly CredentialSelection[], options?: {
66
+ opaque?: boolean;
67
+ }): Promise<void>;
61
68
  /** Writes the `attach` audit row. Run in the session PUT transaction. */
62
69
  recordAttachment(t: Tx, sessionId: string, vaultIds: readonly string[]): Promise<void>;
63
70
  authorize(input: {
@@ -14,6 +14,7 @@
14
14
  * createVault(body: CreateVaultRequest): Promise<VaultInfo>
15
15
  * listVaults(ownerUserId: string): Promise<VaultInfo[]>
16
16
  * getVault(id: string): Promise<VaultInfo>
17
+ * assertOwner(id: string, ownerUserId: string): Promise<void>
17
18
  * deleteVault(id: string): Promise<{ id: string }>
18
19
  * createCredential(vaultId: string, body: CreateCredentialRequest): Promise<CredentialInfo>
19
20
  * listCredentials(vaultId: string): Promise<CredentialInfo[]>
@@ -22,7 +23,7 @@
22
23
  * deleteCredential(vaultId: string, id: string): Promise<{ id: string }>
23
24
  *
24
25
  * // Attachment: run inside the caller's session PUT transaction.
25
- * assertAttachment(t: Tx, ownerUserId: string, vaultIds: readonly string[], selections: readonly CredentialSelection[]): Promise<void>
26
+ * assertAttachment(t: Tx, ownerUserId: string, vaultIds: readonly string[], selections: readonly CredentialSelection[], options?: { opaque?: boolean }): Promise<void>
26
27
  * recordAttachment(t: Tx, sessionId: string, vaultIds: readonly string[]): Promise<void>
27
28
  *
28
29
  * // Use: opens its own transactions; never call it inside one.
@@ -104,6 +105,14 @@ export class VaultService {
104
105
  async getVault(id) {
105
106
  return this.store.tx((t) => this.vaultInfo(t, id));
106
107
  }
108
+ /** Another owner's vault, or the host vault, is the same 404 as a missing one. */
109
+ async assertOwner(id, ownerUserId) {
110
+ await this.store.tx(async (t) => {
111
+ const row = await t.getVault(id);
112
+ if (!row || row.scope !== "user" || row.ownerUserId !== ownerUserId)
113
+ throw new VaultError(404, "Vault not found");
114
+ });
115
+ }
107
116
  async deleteVault(id) {
108
117
  return this.store.tx(async (t) => {
109
118
  await this.vaultInfo(t, id);
@@ -210,8 +219,11 @@ export class VaultService {
210
219
  });
211
220
  }
212
221
  // --- attachment (caller's transaction) --------------------------------------
213
- /** Checks a session's vaults and selections. Run in the session PUT transaction. */
214
- async assertAttachment(t, ownerUserId, vaultIds, selections) {
222
+ /**
223
+ * Checks a session's vaults and selections. Run in the session PUT transaction. `opaque`
224
+ * (a request acting for a subject) reports another owner's vault as the 404 of a missing one.
225
+ */
226
+ async assertAttachment(t, ownerUserId, vaultIds, selections, options = {}) {
215
227
  if (new Set(vaultIds).size !== vaultIds.length)
216
228
  throw new VaultError(400, "Duplicate vault id");
217
229
  if (new Set(selections.map((item) => item.serverName)).size !== selections.length)
@@ -223,7 +235,9 @@ export class VaultService {
223
235
  if (vault.scope === "host")
224
236
  throw new VaultError(400, "Host vault cannot be attached to a session");
225
237
  if (vault.ownerUserId !== ownerUserId)
226
- throw new VaultError(403, "Vault belongs to another user");
238
+ throw options.opaque
239
+ ? new VaultError(404, "Vault not found")
240
+ : new VaultError(403, "Vault belongs to another user");
227
241
  }
228
242
  for (const selection of selections) {
229
243
  const row = await t.getCredential(selection.credentialId);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.11.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.12.0-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/scripts/check-package.mjs, which runs in npm run check.
2
2
  // release:prepare also rewrites this when it bumps @nylorun/runtime.
3
- export const RUNTIME_VERSION = "0.11.0-beta";
3
+ export const RUNTIME_VERSION = "0.12.0-beta";
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.11.0-beta",
3
+ "version": "0.12.0-beta",
4
4
  "description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/nylorun/harness.git",
9
+ "url": "git+https://github.com/nylorun/agents.git",
10
10
  "directory": "runtime"
11
11
  },
12
12
  "engines": {
@@ -58,14 +58,15 @@
58
58
  "@modelcontextprotocol/sdk": "^1.30.0",
59
59
  "@earendil-works/pi-ai": "0.87.0",
60
60
  "@hono/node-server": "^2.1.1",
61
- "@nylorun/harness": "0.19.1-beta",
62
- "@nylorun/core": "0.6.0-beta",
61
+ "@nylorun/harness": "0.19.2-beta",
62
+ "@nylorun/core": "0.7.0-beta",
63
63
  "@restatedev/restate-sdk": "1.16.9",
64
64
  "@s2-dev/streamstore": "0.27.0",
65
65
  "just-bash": "3.4.2",
66
66
  "postgres": "^3.4.9"
67
67
  },
68
68
  "devDependencies": {
69
+ "@ag-ui/client": "1.0.0",
69
70
  "@types/node": "^26.5.0",
70
71
  "@typescript/native": "npm:typescript@^7.0.2",
71
72
  "hono": "^4.13.8",