@nylorun/runtime 0.11.0-beta → 0.13.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,76 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - 5278b4e: **Flow agents on workflow manifest v2.** A flow agent compiles to `workflowSchemaVersion: 2` and runs on the new `flow-2` engine; v1 workflows (`Chain`, `Switch`, `Parallel`, `Map`, `Loop`) keep running on `flow-1`, so in-flight runs finish as they began.
8
+
9
+ - **Manifest (core).** Any node may carry `id` and `input`; there are no slots. `chain` and `parallel` are bare collections, a Map is `{ map: { each } }` over its input, and a Loop has `max?` and `decide?`. The header adds `name`, `description`, `metadata`, `inputSchema` and `outputSchema`, and `agents` embeds every agent the flow runs, so one manifest hash covers the whole flow. `Agent.from(json, { nodes, agents })` rebuilds a flow agent. Path and key helpers (`leafPath`, `stageKey`, `forEachFlowNode`, `embeddedAgent`, …) are exported from `@nylorun/core/define`.
10
+ - **Paths (harness).** Linked agent sessions are named by the agent: its id, `[i]` per Map item, and a nested flow agent's id in front. Control stages add nothing, so wrapping a step in a Loop keeps its session. Functions are bound under stage keys (`route:on`, `@1.default.1:input`) and all receive `{ input, results, flowInput }`; `flowInput` works inside nested `flow()`. A Loop without `decide` retries with the verifier's feedback up to `max` and then fails with `loop.exhausted`. Nested flow agents run inline.
11
+ - **Runtime.** Leaves of a v2 workflow resolve from its embedded `agents` (with the workflow's plugin roots, keyed `<agent>/<capability>`) instead of the registry. `loop.iteration` is emitted only for a Loop's own agent turns.
12
+ - **Agents SDK.** `saveAgent(flowAgent)` PUTs one document; application mode registers the flow's executor with its manifest hash. An executor leaves `fn`, `verify` and tool actions of a v2 flow unclaimed when they belong to another manifest hash, because stage keys can shift between deploys; it reports each skipped action once through `onError`.
13
+ - **Studio.** Draws v2 manifests: agents and tools at their session paths, control stages at their stage keys, nested flow agents inline, and lights a Map's agent from its item sessions.
14
+ - Build diagnostics: `flow.duplicate-leaf`, `flow.duplicate-id`, `flow.agent-conflict`, `flow.v1-workflow`, `loop.invalid-verify`, `workflow.flow-agent-child`. Also fixes v1 flows passing an agent step's output to the next step still wrapped in the runtime's turn marker.
15
+
16
+ - 426fd27: **Flow agents as subagents.** `.subagents(flowAgent)` lets a ReAct agent delegate to a flow agent. The flow's workflow manifest v2 is inlined in the delegating tool (`ToolManifest.agent` may be a workflow manifest), so it is saved with the parent and served by the parent's executor, plugin roots included (`<flow>/<agent>/<capability>`).
17
+
18
+ - **Engine (harness).** A call to a flow subagent is one durable `agent` effect (`role: "delegate"`) between the delegation's start and settle points; the flow's output is the tool result, and a failed flow is a failed tool result. Local `run()` refuses flow subagents, which need the Runtime.
19
+ - **Runtime.** The effect starts a linked flow session from the parent's pinned manifest, fresh per call, and settles when the flow's turn ends. Cancelling any session now cascades to the linked sessions it started, not only a workflow's.
20
+ - **Core.** `delegation.flow-unsupported` now only reports a workflow built with `Chain`, `Switch`, `Parallel`, `Map` or `Loop`. New helpers: `flowDelegatesOf`, `flowDelegateManifest`, `isFlowDelegate`; `delegatesOf` lists only the ReAct agents run in-process.
21
+
22
+ ### Patch Changes
23
+
24
+ - Pin core to the tested release.
25
+ - Pin harness to the tested release.
26
+ - Updated dependencies [e537a82]
27
+ - Updated dependencies [5278b4e]
28
+ - Updated dependencies [426fd27]
29
+ - Updated dependencies [8cda500]
30
+ - Updated dependencies
31
+ - @nylorun/core@0.8.0-beta
32
+ - @nylorun/harness@0.20.0-beta
33
+
34
+ ## 0.12.0-beta
35
+
36
+ ### Minor Changes
37
+
38
+ - a322696: **Derived principals** (optional Host feature `derived-principals`): a client that holds the admin key no longer needs to store an application key.
39
+
40
+ - `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.
41
+ - `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.
42
+ - `createTenant` with `principals` throws `incompatible_host` before sending anything to a Host without the feature.
43
+
44
+ - 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`).
45
+
46
+ - `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"]`.
47
+ - 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.
48
+ - 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.
49
+ - Core exports `SUBJECT_HEADER`, `SCOPES_HEADER`, `SUBJECT_SCOPES` and `parseSubjectHeaders`. Postgres Tenant schemas migrate to version 2 (an indexed session owner column).
50
+
51
+ Requests without `Nylorun-Subject` are unchanged.
52
+
53
+ - a322696: **The session log now carries what a chat UI shows** (optional Host feature `transcript-events`).
54
+
55
+ - `message.assistant` for each completed model step: `{ invocationId, text, toolCalls: [{ callId, name, input }], agent? }`.
56
+ - `tool.completed` for an MCP or sandbox tool: `{ invocationId, callId, capabilityId, toolName, output }`, or `error: { code, message }` for a tool error.
57
+ - Tool `action.pending` and `action.completed` events, and `delegation.started` / `delegation.completed`, carry the model's `callId` (and `invocationId` on actions).
58
+ - Events are written in the transaction that completes the effect, so a replay writes none.
59
+ - `@nylorun/core/contracts` adds payload schemas and `parseTranscriptEvent(event)`; `LiveEvent.payload` stays `unknown`.
60
+
61
+ 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.
62
+
63
+ ### Patch Changes
64
+
65
+ - Pin core to the tested release.
66
+ - Pin harness to the tested release.
67
+ - Updated dependencies [a322696]
68
+ - Updated dependencies [844bff3]
69
+ - Updated dependencies [a322696]
70
+ - Updated dependencies
71
+ - @nylorun/core@0.7.0-beta
72
+ - @nylorun/harness@0.19.2-beta
73
+
3
74
  ## 0.11.0-beta
4
75
 
5
76
  ### Minor Changes
@@ -61,6 +61,9 @@ export function deriveSessionId(workflowSessionId, path, ...parts) {
61
61
  * (path, turnId, iterations) so prior verdicts cannot leak across Loop iterations.
62
62
  */
63
63
  export function deriveAgentEffectSessionId(workflowSessionId, path, request) {
64
+ // A flow agent used as a tool starts fresh on every call, like any agent used as a tool.
65
+ if (request.context?.role === "delegate")
66
+ return deriveSessionId(workflowSessionId, path, "delegate", String(request.context.delegationId ?? request.turnId));
64
67
  if (request.context?.role === "verify-agent") {
65
68
  const iterations = request.iterations ?? "-";
66
69
  const iterParts = iterations !== "-" && iterations.length > 0 ? iterations.split(".") : [];
@@ -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") {
@@ -183,13 +192,13 @@ export async function command(ctx, id, input, scope) {
183
192
  else if (command.type === "cancel") {
184
193
  cancelledTurnId = s.activeTurnId;
185
194
  const workflowCancel = isWorkflowManifest(s.manifest);
186
- const cascade = workflowCancel
187
- ? await planCancelCascade({
188
- t,
189
- workflowSessionId: id,
190
- turnId: cancelledTurnId,
191
- })
192
- : null;
195
+ // Linked sessions this one started: a workflow's agents, or flow agents an agent
196
+ // uses as tools.
197
+ const cascade = await planCancelCascade({
198
+ t,
199
+ workflowSessionId: id,
200
+ turnId: cancelledTurnId,
201
+ });
193
202
  s.status = "cancelled";
194
203
  if (workflowCancel) {
195
204
  await fenceWorkflowActions({
@@ -236,15 +245,13 @@ export async function command(ctx, id, input, scope) {
236
245
  s.error = undefined;
237
246
  // Cascade: cancel linked agent sessions deepest-first (after fencing this session), each
238
247
  // in its own transaction after this one commits (child sessions are never locked here).
239
- if (cascade) {
240
- for (const agentId of cascade.agentSessionIds) {
241
- const agent = await t.get("sessions", agentId);
242
- if (!agent ||
243
- !agent.activeTurnId ||
244
- ["idle", "completed", "failed", "cancelled"].includes(agent.status))
245
- continue;
246
- cascadeCancelIds.push(agentId);
247
- }
248
+ for (const agentId of cascade.agentSessionIds) {
249
+ const agent = await t.get("sessions", agentId);
250
+ if (!agent ||
251
+ !agent.activeTurnId ||
252
+ ["idle", "completed", "failed", "cancelled"].includes(agent.status))
253
+ continue;
254
+ cascadeCancelIds.push(agentId);
248
255
  }
249
256
  }
250
257
  else {
@@ -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