@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 +40 -0
- package/dist/core/sandbox-routes.d.ts +5 -1
- package/dist/core/sandbox-routes.js +4 -2
- package/dist/host/create-host.js +3 -0
- package/dist/store/memory.js +3 -1
- package/dist/store/postgres/migrations/002_session_owner.d.ts +6 -0
- package/dist/store/postgres/migrations/002_session_owner.js +13 -0
- package/dist/store/postgres/migrations/index.js +2 -1
- package/dist/store/postgres/store.js +9 -2
- package/dist/store/postgres/tenants.js +9 -2
- package/dist/store/types.d.ts +2 -0
- package/dist/tenant/auth.d.ts +23 -2
- package/dist/tenant/auth.js +140 -5
- package/dist/tenant/commands.js +16 -7
- package/dist/tenant/context.d.ts +11 -4
- package/dist/tenant/context.js +15 -7
- package/dist/tenant/effects.js +27 -2
- package/dist/tenant/http.d.ts +8 -2
- package/dist/tenant/http.js +5 -3
- package/dist/tenant/module.js +3 -0
- package/dist/tenant/principals.d.ts +4 -1
- package/dist/tenant/principals.js +22 -3
- package/dist/tenant/routes-tenant.js +13 -2
- package/dist/tenant/routes.js +16 -7
- package/dist/tenant/sessions.d.ts +9 -3
- package/dist/tenant/sessions.js +20 -6
- package/dist/tenant/transcript.d.ts +104 -0
- package/dist/tenant/transcript.js +58 -0
- package/dist/tenant/types.d.ts +6 -0
- package/dist/vault/service.d.ts +9 -2
- package/dist/vault/service.js +18 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -4
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
|
|
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");
|
package/dist/host/create-host.js
CHANGED
|
@@ -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
|
}
|
package/dist/store/memory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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'
|
|
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
|
-
|
|
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)
|
package/dist/store/types.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/tenant/auth.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/tenant/auth.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/tenant/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
|
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") {
|
package/dist/tenant/context.d.ts
CHANGED
|
@@ -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.
|
package/dist/tenant/context.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
package/dist/tenant/effects.js
CHANGED
|
@@ -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", {
|
|
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
|
}
|
package/dist/tenant/http.d.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
package/dist/tenant/http.js
CHANGED
|
@@ -6,13 +6,15 @@ export const OPAQUE_NOT_FOUND = {
|
|
|
6
6
|
};
|
|
7
7
|
export class HttpError extends Error {
|
|
8
8
|
status;
|
|
9
|
-
|
|
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;
|
package/dist/tenant/module.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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")
|
package/dist/tenant/routes.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
32
|
-
|
|
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>;
|
package/dist/tenant/sessions.js
CHANGED
|
@@ -42,8 +42,12 @@ export async function putDefinition(ctx, agentId, body) {
|
|
|
42
42
|
implementationVersion: body.implementationVersion,
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
/**
|
|
58
|
-
|
|
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
|
+
}
|
package/dist/tenant/types.d.ts
CHANGED
|
@@ -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.
|
package/dist/vault/service.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
60
|
-
|
|
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: {
|
package/dist/vault/service.js
CHANGED
|
@@ -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
|
-
/**
|
|
214
|
-
|
|
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
|
|
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.
|
|
1
|
+
export declare const RUNTIME_VERSION = "0.12.0-beta";
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nylorun/runtime",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
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.
|
|
62
|
-
"@nylorun/core": "0.
|
|
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",
|