@nylorun/runtime 0.37.0-beta → 0.39.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 +91 -0
- package/dist/api/components.d.ts +1732 -80
- package/dist/api/http/routes/agents.js +3 -3
- package/dist/core/turn-manifest.d.ts +23 -0
- package/dist/core/turn-manifest.js +36 -1
- package/dist/gates/policy.d.ts +13 -3
- package/dist/gates/policy.js +17 -4
- package/dist/harness/executors.d.ts +4 -2
- package/dist/harness/executors.js +26 -4
- package/dist/harness/image-engine.d.ts +14 -0
- package/dist/harness/image-engine.js +16 -0
- package/dist/harness/pod.d.ts +2 -0
- package/dist/harness/pod.js +5 -0
- package/dist/harness-api/record.js +4 -2
- package/dist/harness-api/server.js +37 -13
- package/dist/harness-api/start.js +43 -5
- package/dist/harness-api/ws-server.js +7 -1
- package/dist/keys/keys.d.ts +6 -3
- package/dist/keys/keys.js +1 -1
- package/dist/management-openapi.json +38 -4
- package/dist/mcp/pool.d.ts +5 -0
- package/dist/mcp/pool.js +1 -1
- package/dist/mcp/snapshot.d.ts +3 -1
- package/dist/mcp/snapshot.js +5 -3
- package/dist/openapi.json +1636 -563
- package/dist/sandbox/join.d.ts +7 -1
- package/dist/sandbox/join.js +5 -1
- package/dist/sandbox/pods/builtin.d.ts +18 -0
- package/dist/sandbox/pods/builtin.js +33 -0
- package/dist/sandbox/pods/client.d.ts +5 -4
- package/dist/sandbox/pods/spec.js +14 -1
- package/dist/store/postgres/drizzle/0042_worker_engine_image.sql +1 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
- package/dist/store/postgres/schema.d.ts +19 -0
- package/dist/store/postgres/schema.js +2 -0
- package/dist/store/postgres/store.js +16 -0
- package/dist/store/types.d.ts +18 -0
- package/dist/tenant/agent-versions.d.ts +13 -5
- package/dist/tenant/agent-versions.js +30 -6
- package/dist/tenant/agents.d.ts +14 -2
- package/dist/tenant/agents.js +51 -13
- package/dist/tenant/commands.js +16 -8
- package/dist/tenant/context.d.ts +30 -1
- package/dist/tenant/session-model.d.ts +35 -6
- package/dist/tenant/session-model.js +77 -9
- package/dist/tenant/sessions.js +13 -3
- package/dist/vault/service.d.ts +44 -3
- package/dist/vault/service.js +68 -27
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/dist/sandbox/join.d.ts
CHANGED
|
@@ -20,7 +20,13 @@ export declare class HostAuthError extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
/** The Tenant's side of the exchange, as the Harness API listener calls it. */
|
|
22
22
|
export interface HostAuthority {
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* `imageEngine`: the Runtime version an engine the workload image carries reported (ADR-99);
|
|
25
|
+
* absent for an engine copied in.
|
|
26
|
+
*/
|
|
27
|
+
join(request: HostJoinRequest, from?: {
|
|
28
|
+
imageEngine?: string;
|
|
29
|
+
}): Promise<HostJoinAnswer>;
|
|
24
30
|
renew(hostToken: string): Promise<HostJoinAnswer>;
|
|
25
31
|
/** The claims of a current host's token, for a connection; refuses a stale one. */
|
|
26
32
|
verify(hostToken: string): Promise<HostClaims>;
|
package/dist/sandbox/join.js
CHANGED
|
@@ -85,7 +85,7 @@ export function hostAuthority(ctx) {
|
|
|
85
85
|
return verdict.claims;
|
|
86
86
|
};
|
|
87
87
|
return {
|
|
88
|
-
async join(request) {
|
|
88
|
+
async join(request, from) {
|
|
89
89
|
const { sandboxId, podUid, joinToken, volumeId } = request;
|
|
90
90
|
if (!isAgentId(sandboxId) ||
|
|
91
91
|
typeof podUid !== "string" ||
|
|
@@ -146,6 +146,10 @@ export function hostAuthority(ctx) {
|
|
|
146
146
|
hostEpoch: epoch,
|
|
147
147
|
podUid,
|
|
148
148
|
...(pod.volumeId === undefined && volumeId !== undefined ? { volumeId } : {}),
|
|
149
|
+
// The engine the image carries, against the digest this pod runs (ADR-99).
|
|
150
|
+
...(from?.imageEngine === undefined
|
|
151
|
+
? {}
|
|
152
|
+
: { engineImage: { version: from.imageEngine, ...(pod.imageDigest ? { digest: pod.imageDigest } : {}) } }),
|
|
149
153
|
...(pod.desired === "running" ? { observed: "running", reason: null } : {}),
|
|
150
154
|
}, now);
|
|
151
155
|
if (pod.podUid !== podUid) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { BUILTIN_ENGINE_ROOT, ENGINE_VERSION_PATTERN, IMAGE_ENGINE_HEADER } from "../../harness/image-engine.js";
|
|
2
|
+
/** The engine an image carries, as the pod's record keeps it: its version, seen on `digest`. */
|
|
3
|
+
export interface EngineImage {
|
|
4
|
+
readonly version: string;
|
|
5
|
+
readonly digest?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Whether a pod of `image` (its pinned digest, or as asked) runs the engine the image carries:
|
|
9
|
+
* the Tenant lists its repository, and the engine recorded for that digest is this Runtime's
|
|
10
|
+
* version or not known yet.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runsBuiltinEngine(input: {
|
|
13
|
+
readonly image: string | undefined;
|
|
14
|
+
readonly imageDigest: string | undefined;
|
|
15
|
+
readonly engineImage: EngineImage | undefined;
|
|
16
|
+
readonly listed: readonly string[] | undefined;
|
|
17
|
+
readonly runtimeVersion?: string;
|
|
18
|
+
}): boolean;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An engine built into the workload's image (ADR-99): the Runtime image's tini, node and bundled
|
|
3
|
+
* engine under `/opt/nylorun`, which the sandboxes service runs with `entrypoint: "builtin"` and
|
|
4
|
+
* nothing copied in.
|
|
5
|
+
*
|
|
6
|
+
* The Tenant lists the images that carry one (`engine.images`, by repository). Such an engine is
|
|
7
|
+
* whichever Runtime version the image was built from, so it reports that version when it joins
|
|
8
|
+
* (`IMAGE_ENGINE_HEADER`, sent only by an engine running from `/opt/nylorun`), and the pod's
|
|
9
|
+
* record keeps it beside the digest it ran (`engineImage`). A pod runs the image's engine while
|
|
10
|
+
* that version is the Runtime's own, or not known yet for the digest it runs (a first start, which
|
|
11
|
+
* the Harness API version check at `hello` still guards); otherwise the engine is copied in, so a
|
|
12
|
+
* Runtime upgrade never leaves a pod on an older engine for more than that first turn.
|
|
13
|
+
*/
|
|
14
|
+
import { RUNTIME_VERSION } from "../../version.js";
|
|
15
|
+
import { repositoryOf } from "./lifecycle.js";
|
|
16
|
+
export { BUILTIN_ENGINE_ROOT, ENGINE_VERSION_PATTERN, IMAGE_ENGINE_HEADER } from "../../harness/image-engine.js";
|
|
17
|
+
/**
|
|
18
|
+
* Whether a pod of `image` (its pinned digest, or as asked) runs the engine the image carries:
|
|
19
|
+
* the Tenant lists its repository, and the engine recorded for that digest is this Runtime's
|
|
20
|
+
* version or not known yet.
|
|
21
|
+
*/
|
|
22
|
+
export function runsBuiltinEngine(input) {
|
|
23
|
+
const image = input.imageDigest ?? input.image;
|
|
24
|
+
if (!image || !input.listed?.length)
|
|
25
|
+
return false;
|
|
26
|
+
const repository = repositoryOf(image);
|
|
27
|
+
if (!input.listed.some((entry) => repositoryOf(entry) === repository))
|
|
28
|
+
return false;
|
|
29
|
+
const known = input.engineImage !== undefined && input.imageDigest !== undefined && input.engineImage.digest === input.imageDigest
|
|
30
|
+
? input.engineImage.version
|
|
31
|
+
: undefined;
|
|
32
|
+
return known === undefined || known === (input.runtimeVersion ?? RUNTIME_VERSION);
|
|
33
|
+
}
|
|
@@ -5,11 +5,12 @@ export interface PodSpec {
|
|
|
5
5
|
/** The workload image; the service defaults it to `python:3.13-slim`. */
|
|
6
6
|
readonly image?: string;
|
|
7
7
|
/**
|
|
8
|
-
* What the workload runs: `engine` (default), our engine copied from `harnessImage
|
|
9
|
-
* `
|
|
8
|
+
* What the workload runs: `engine` (default), our engine copied from `harnessImage`;
|
|
9
|
+
* `builtin`, our engine the image carries under `/opt/nylorun` (ADR-99); or `image`, the image's
|
|
10
|
+
* own entrypoint, for a custom harness built into it (R4.4).
|
|
10
11
|
*/
|
|
11
|
-
readonly entrypoint?: "engine" | "image";
|
|
12
|
-
/** The Runtime image the engine is copied from; not with `entrypoint: "image"`. */
|
|
12
|
+
readonly entrypoint?: "engine" | "builtin" | "image";
|
|
13
|
+
/** The Runtime image the engine is copied from; not with `entrypoint: "builtin"` or `"image"`. */
|
|
13
14
|
readonly harnessImage?: string;
|
|
14
15
|
/** Replaces the engine command (diagnostics). */
|
|
15
16
|
readonly command?: readonly string[];
|
|
@@ -8,6 +8,7 @@ import { DEFAULT_HARNESS } from "@nylorun/core/contracts";
|
|
|
8
8
|
import { idleMsOf, memoryMiBOf } from "../policy.js";
|
|
9
9
|
import { podTuningEnv } from "../../tuning.js";
|
|
10
10
|
import { opIdOf } from "./lifecycle.js";
|
|
11
|
+
import { runsBuiltinEngine } from "./builtin.js";
|
|
11
12
|
/** The pod's grace period to stop when the Tenant sets none (D34). */
|
|
12
13
|
export const DEFAULT_STOP_GRACE_SECONDS = 10;
|
|
13
14
|
/** The volume's size when the sandbox asks for none. */
|
|
@@ -40,12 +41,24 @@ export function podSpecOf(input) {
|
|
|
40
41
|
// Pinned by digest once a pod ran it (Q14).
|
|
41
42
|
const image = pod.imageDigest ?? spec.image;
|
|
42
43
|
const ours = (input.harness ?? DEFAULT_HARNESS) === DEFAULT_HARNESS;
|
|
44
|
+
// Our engine, built into an image the Tenant lists, while it is this Runtime's (ADR-99).
|
|
45
|
+
const builtin = ours &&
|
|
46
|
+
runsBuiltinEngine({
|
|
47
|
+
image: spec.image,
|
|
48
|
+
imageDigest: pod.imageDigest,
|
|
49
|
+
engineImage: pod.engineImage,
|
|
50
|
+
listed: config.engine?.images,
|
|
51
|
+
});
|
|
43
52
|
return {
|
|
44
53
|
opId: opIdOf(pod),
|
|
45
54
|
mode: input.mode,
|
|
46
55
|
...(image ? { image } : {}),
|
|
47
56
|
// A custom harness is its image: the pod runs the image's entrypoint, nothing copied in.
|
|
48
|
-
...(ours
|
|
57
|
+
...(!ours
|
|
58
|
+
? { entrypoint: "image" }
|
|
59
|
+
: builtin
|
|
60
|
+
? { entrypoint: "builtin" }
|
|
61
|
+
: { harnessImage: input.harnessImage }),
|
|
49
62
|
cpus: spec.resources?.cpus ?? 1,
|
|
50
63
|
memoryMiB: memoryMiBOf(spec),
|
|
51
64
|
storageGiB: storageGiBOf(spec),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "nylorun"."agents" ADD COLUMN "engine_image" json;
|
|
@@ -295,6 +295,13 @@
|
|
|
295
295
|
"when": 1793448353003,
|
|
296
296
|
"tag": "0041_turn_index_backfill",
|
|
297
297
|
"breakpoints": true
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"idx": 42,
|
|
301
|
+
"version": "7",
|
|
302
|
+
"when": 1793509192153,
|
|
303
|
+
"tag": "0042_worker_engine_image",
|
|
304
|
+
"breakpoints": true
|
|
298
305
|
}
|
|
299
306
|
]
|
|
300
307
|
}
|
|
@@ -1302,6 +1302,25 @@ export declare const agents: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
|
1302
1302
|
}, {}, {
|
|
1303
1303
|
pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
|
|
1304
1304
|
}>;
|
|
1305
|
+
engineImage: import("drizzle-orm/pg-core").PgColumn<{
|
|
1306
|
+
name: "engineImage";
|
|
1307
|
+
tableName: "agents";
|
|
1308
|
+
dataType: "custom";
|
|
1309
|
+
columnType: "PgCustomColumn";
|
|
1310
|
+
data: unknown;
|
|
1311
|
+
driverParam: unknown;
|
|
1312
|
+
notNull: false;
|
|
1313
|
+
hasDefault: false;
|
|
1314
|
+
isPrimaryKey: false;
|
|
1315
|
+
isAutoincrement: false;
|
|
1316
|
+
hasRuntimeDefault: false;
|
|
1317
|
+
enumValues: undefined;
|
|
1318
|
+
baseColumn: never;
|
|
1319
|
+
identity: undefined;
|
|
1320
|
+
generated: undefined;
|
|
1321
|
+
}, {}, {
|
|
1322
|
+
pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
|
|
1323
|
+
}>;
|
|
1305
1324
|
diskOp: import("drizzle-orm/pg-core").PgColumn<{
|
|
1306
1325
|
name: "diskOp";
|
|
1307
1326
|
tableName: "agents";
|
|
@@ -171,6 +171,8 @@ export const agents = nylorun.table("agents", {
|
|
|
171
171
|
volumeId: text(),
|
|
172
172
|
/** The workload image by digest, from the first ready pod on (Q14): later pods run it. */
|
|
173
173
|
imageDigest: textC(),
|
|
174
|
+
/** The engine the image carries (ADR-99): `{ version, digest? }`, from a join of its engine. */
|
|
175
|
+
engineImage: jsonText(),
|
|
174
176
|
/**
|
|
175
177
|
* The disk lease (R2d step 3): `{ kind, version?, startedAt, until }` while a snapshot, a
|
|
176
178
|
* restore or a seed copies the disk; turns are refused until it ends or `until` passes.
|
|
@@ -283,6 +283,7 @@ function environmentOf(row) {
|
|
|
283
283
|
...(row.retiring !== null ? { retiring: row.retiring } : {}),
|
|
284
284
|
...(row.volumeId !== null ? { volumeId: row.volumeId } : {}),
|
|
285
285
|
...(row.imageDigest !== null ? { imageDigest: row.imageDigest } : {}),
|
|
286
|
+
...(row.engineImage ? { engineImage: row.engineImage } : {}),
|
|
286
287
|
},
|
|
287
288
|
}
|
|
288
289
|
: {}),
|
|
@@ -317,6 +318,7 @@ function podColumns(patch) {
|
|
|
317
318
|
retiring: patch.retiring,
|
|
318
319
|
volumeId: patch.volumeId,
|
|
319
320
|
imageDigest: patch.imageDigest,
|
|
321
|
+
engineImage: patch.engineImage,
|
|
320
322
|
};
|
|
321
323
|
return Object.fromEntries(Object.entries(columns).filter(([, value]) => value !== undefined));
|
|
322
324
|
}
|
|
@@ -1458,6 +1460,20 @@ class PostgresTx {
|
|
|
1458
1460
|
.orderBy(desc(memoryVersions.id))
|
|
1459
1461
|
.limit(options.limit);
|
|
1460
1462
|
}
|
|
1463
|
+
async memoryWritesSince(agentId, after, sessionId) {
|
|
1464
|
+
this.check();
|
|
1465
|
+
const conditions = [eq(memoryVersions.agentId, agentId)];
|
|
1466
|
+
if (after !== undefined)
|
|
1467
|
+
conditions.push(gt(memoryVersions.id, after));
|
|
1468
|
+
const [row] = await this.db
|
|
1469
|
+
.select({
|
|
1470
|
+
newest: sql `max(${memoryVersions.id})`,
|
|
1471
|
+
others: sql `coalesce(bool_or(${memoryVersions.sessionId} is distinct from ${sessionId}), false)`,
|
|
1472
|
+
})
|
|
1473
|
+
.from(memoryVersions)
|
|
1474
|
+
.where(and(...conditions));
|
|
1475
|
+
return { ...(row?.newest ? { newest: row.newest } : {}), others: row?.others === true };
|
|
1476
|
+
}
|
|
1461
1477
|
async memoryVersion(agentId, id) {
|
|
1462
1478
|
this.check();
|
|
1463
1479
|
const [row] = await this.db
|
package/dist/store/types.d.ts
CHANGED
|
@@ -260,6 +260,15 @@ export interface SandboxPodState {
|
|
|
260
260
|
* Environment is ready (Q14): every later pod, after a stop, a relaunch or a reset, runs it.
|
|
261
261
|
*/
|
|
262
262
|
imageDigest?: string;
|
|
263
|
+
/**
|
|
264
|
+
* The engine the workload image carries (ADR-99, `sandbox/pods/builtin.ts`): its Runtime
|
|
265
|
+
* version, as its engine reported at join, and the digest that pod ran. It counts only while
|
|
266
|
+
* that digest is `imageDigest`.
|
|
267
|
+
*/
|
|
268
|
+
engineImage?: {
|
|
269
|
+
version: string;
|
|
270
|
+
digest?: string;
|
|
271
|
+
};
|
|
263
272
|
}
|
|
264
273
|
/** A change to a pod's columns: `null` clears an optional one. */
|
|
265
274
|
export type SandboxPodPatch = {
|
|
@@ -794,6 +803,15 @@ export interface Tx {
|
|
|
794
803
|
before?: string;
|
|
795
804
|
limit: number;
|
|
796
805
|
}): Promise<MemoryVersionRow[]>;
|
|
806
|
+
/**
|
|
807
|
+
* Of Agent `agentId`'s memory versions after version id `after` (every one without it): the
|
|
808
|
+
* newest's id, and whether any was written other than by session `sessionId` (another session,
|
|
809
|
+
* or the API).
|
|
810
|
+
*/
|
|
811
|
+
memoryWritesSince(agentId: string, after: string | undefined, sessionId: string): Promise<{
|
|
812
|
+
newest?: string;
|
|
813
|
+
others: boolean;
|
|
814
|
+
}>;
|
|
797
815
|
memoryVersion(agentId: string, id: string): Promise<MemoryVersionRow | undefined>;
|
|
798
816
|
/** Copies Agent `from`'s memories to new Agent `to`, each at version 1, with no history (a fork). */
|
|
799
817
|
copyMemories(from: string, to: string, now: string): Promise<number>;
|
|
@@ -21,8 +21,11 @@
|
|
|
21
21
|
* - **A turn takes the Agent's version.** A turn's first segment re-pins its session to the
|
|
22
22
|
* version the Agent runs when that changed since the session's last turn (`repinTurn`), before
|
|
23
23
|
* anything of the turn has run: no effect is journaled for it yet. The turn keeps that version
|
|
24
|
-
* through every later segment, approval and rollover.
|
|
25
|
-
*
|
|
24
|
+
* through every later segment, approval and rollover. What its message asked of the turn's
|
|
25
|
+
* manifest (`Session.turnRequest`) applies again to the new version: its `removeTools`, and its
|
|
26
|
+
* variant, validated again against the new version. A variant that is no variant of it keeps
|
|
27
|
+
* the turn on the version it pinned (`turn.version_kept`), so a variant never widens what the
|
|
28
|
+
* turn may do. A flow's sessions keep the version they pinned.
|
|
26
29
|
*
|
|
27
30
|
* Re-pinning at the first segment, not when the message is accepted, keeps a turn that waits for
|
|
28
31
|
* its compute to start on a version the Agent has at that start.
|
|
@@ -64,9 +67,14 @@ export declare function applyPin(session: Session, pin: SessionPin): void;
|
|
|
64
67
|
* active turn is about to run its first segment and nothing of the turn has run yet: the turn
|
|
65
68
|
* then runs that version. Returns whether it changed the session (the caller saves it).
|
|
66
69
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
+
* The turn's manifest on the new version is what its message asked (`Session.turnRequest`):
|
|
71
|
+
* its variant, validated against the new version, without the tools its `removeTools` names.
|
|
72
|
+
*
|
|
73
|
+
* Leaves the session as it is for a flow and a flow's linked session, a later segment, a turn
|
|
74
|
+
* with any journaled effect, a version whose harness is not the session's (an Agent's harness is
|
|
75
|
+
* fixed for its life, D73), a turn on a variant with no request recorded (accepted by an older
|
|
76
|
+
* Runtime), and a variant that is no variant of the new version: then the turn runs on the
|
|
77
|
+
* version it pinned, and `turn.version_kept` says why.
|
|
70
78
|
*/
|
|
71
79
|
export declare function repinTurn(t: Tx, session: Session): Promise<boolean>;
|
|
72
80
|
/** What a move of `agent` to `target` would do, and the target's runtime resolved. */
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { hashManifest } from "@nylorun/core/compatibility";
|
|
2
2
|
import { loopManifestOf, runtimeOf } from "@nylorun/core/define";
|
|
3
3
|
import { isWorkflowManifest } from "../core/flow-host.js";
|
|
4
|
+
import { turnManifestFor } from "../core/turn-manifest.js";
|
|
5
|
+
import { variantStore } from "./session.js";
|
|
4
6
|
import { canonical } from "../store/canonical.js";
|
|
5
7
|
import { resolveRecipe, sandboxSpecOf } from "../sandbox/resolve.js";
|
|
6
8
|
import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
|
|
@@ -60,9 +62,14 @@ export function applyPin(session, pin) {
|
|
|
60
62
|
* active turn is about to run its first segment and nothing of the turn has run yet: the turn
|
|
61
63
|
* then runs that version. Returns whether it changed the session (the caller saves it).
|
|
62
64
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
65
|
+
* The turn's manifest on the new version is what its message asked (`Session.turnRequest`):
|
|
66
|
+
* its variant, validated against the new version, without the tools its `removeTools` names.
|
|
67
|
+
*
|
|
68
|
+
* Leaves the session as it is for a flow and a flow's linked session, a later segment, a turn
|
|
69
|
+
* with any journaled effect, a version whose harness is not the session's (an Agent's harness is
|
|
70
|
+
* fixed for its life, D73), a turn on a variant with no request recorded (accepted by an older
|
|
71
|
+
* Runtime), and a variant that is no variant of the new version: then the turn runs on the
|
|
72
|
+
* version it pinned, and `turn.version_kept` says why.
|
|
66
73
|
*/
|
|
67
74
|
export async function repinTurn(t, session) {
|
|
68
75
|
const id = session.environmentId;
|
|
@@ -73,8 +80,9 @@ export async function repinTurn(t, session) {
|
|
|
73
80
|
return false;
|
|
74
81
|
if (!checkpoint || checkpoint.segment !== 0 || checkpoint.turnId !== session.activeTurnId)
|
|
75
82
|
return false;
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
const request = session.turnRequest?.turnId === session.activeTurnId ? session.turnRequest : undefined;
|
|
84
|
+
// A variant whose request is not recorded cannot be validated against another version.
|
|
85
|
+
if (checkpoint.manifestHash !== session.manifestHash && request === undefined)
|
|
78
86
|
return false;
|
|
79
87
|
const agent = await t.environment(id);
|
|
80
88
|
if (!agent)
|
|
@@ -86,10 +94,26 @@ export async function repinTurn(t, session) {
|
|
|
86
94
|
const { pin } = await pinOf(t, agent);
|
|
87
95
|
if (pin.harness !== harnessOf(session))
|
|
88
96
|
return false;
|
|
97
|
+
const turn = request
|
|
98
|
+
? turnManifestFor({ pinned: pin.manifest, pinnedHash: pin.manifestHash, request })
|
|
99
|
+
: { ok: true, manifest: pin.manifest, hash: pin.manifestHash };
|
|
100
|
+
if (!turn.ok) {
|
|
101
|
+
const from = { ...session.definition };
|
|
102
|
+
const to = { name: agent.manifestName, version: agent.manifestVersion };
|
|
103
|
+
await t.event(session.id, session.activeTurnId, "turn.version_kept", {
|
|
104
|
+
reason: "variant_invalid",
|
|
105
|
+
from,
|
|
106
|
+
to,
|
|
107
|
+
message: `The turn manifest the message carried is no variant of ${to.name}@${to.version}, the version Agent ${agent.id} runs now, so this turn runs on ${from.name}@${from.version}. Send a variant of the new version, or name the tools the turn may not use with removeTools.`,
|
|
108
|
+
});
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
89
111
|
const changed = pin.manifestHash !== session.manifestHash;
|
|
90
112
|
applyPin(session, pin);
|
|
91
113
|
session.environmentVersion = agent.version;
|
|
92
|
-
|
|
114
|
+
if (turn.hash !== pin.manifestHash)
|
|
115
|
+
variantStore(session).put(turn.hash, turn.manifest);
|
|
116
|
+
session.checkpoint = { ...session.checkpoint, manifestHash: turn.hash };
|
|
93
117
|
// MCP discovery is per loop manifest: the next one is the new version's.
|
|
94
118
|
if (changed) {
|
|
95
119
|
delete session.mcpSnapshot;
|
package/dist/tenant/agents.d.ts
CHANGED
|
@@ -56,13 +56,25 @@ export declare function planAgentPut(ctx: TenantContext, id: string, body: PutAg
|
|
|
56
56
|
* they are set (track T3). Best effort: the write stands either way.
|
|
57
57
|
*/
|
|
58
58
|
export declare function secretWritten(ctx: TenantContext, id: string): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* A secret write as the vault takes it (`Keys.putEnvironmentSecret`): the typed request, and
|
|
61
|
+
* whether its `via` is the slot's (`viaFromSlot`), so a session follows the slot of the version
|
|
62
|
+
* its Agent runs, rather than this installation's own `via` (Host feature `secret-routing`).
|
|
63
|
+
*/
|
|
64
|
+
export interface SecretWrite {
|
|
65
|
+
readonly request: PutAgentSecretRequest;
|
|
66
|
+
readonly viaFromSlot: boolean;
|
|
67
|
+
}
|
|
59
68
|
/**
|
|
60
69
|
* The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
|
|
61
70
|
* slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
|
|
62
71
|
* `{ headers }` for a `headers` slot) with the binding from the slot; a full typed body is
|
|
63
|
-
* accepted only when its binding is the slot's. Without slots, today's typed body.
|
|
72
|
+
* accepted only when its binding is the slot's. Without slots, today's typed body. Either way a
|
|
73
|
+
* `bearer` or `headers` slot's write may add this installation's routing (Host feature
|
|
74
|
+
* `secret-routing`): `via`, which wins over the slot's, and the gateway's `toolPrefix`, which
|
|
75
|
+
* gives way to the slot's (a different one is accepted, with a warning in the log).
|
|
64
76
|
*/
|
|
65
|
-
export declare function secretRequestFor(ctx: TenantContext, id: string, name: string, raw: unknown): Promise<
|
|
77
|
+
export declare function secretRequestFor(ctx: TenantContext, id: string, name: string, raw: unknown): Promise<SecretWrite>;
|
|
66
78
|
/** What a create does for a fork (`POST .../fork`); `copied` is filled in by the create. */
|
|
67
79
|
interface Fork {
|
|
68
80
|
source: string;
|
package/dist/tenant/agents.js
CHANGED
|
@@ -560,15 +560,18 @@ export async function secretWritten(ctx, id) {
|
|
|
560
560
|
* The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
|
|
561
561
|
* slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
|
|
562
562
|
* `{ headers }` for a `headers` slot) with the binding from the slot; a full typed body is
|
|
563
|
-
* accepted only when its binding is the slot's. Without slots, today's typed body.
|
|
563
|
+
* accepted only when its binding is the slot's. Without slots, today's typed body. Either way a
|
|
564
|
+
* `bearer` or `headers` slot's write may add this installation's routing (Host feature
|
|
565
|
+
* `secret-routing`): `via`, which wins over the slot's, and the gateway's `toolPrefix`, which
|
|
566
|
+
* gives way to the slot's (a different one is accepted, with a warning in the log).
|
|
564
567
|
*/
|
|
565
568
|
export async function secretRequestFor(ctx, id, name, raw) {
|
|
566
|
-
const
|
|
569
|
+
const write = await parseSecretRequest(ctx, id, name, raw);
|
|
567
570
|
// Within the Tenant's Runtime limits `secretValueChars` and `secretHosts`, read for the write.
|
|
568
|
-
const issues = secretRequestIssues(request, await ctx.store.tx((t) => readRuntimeLimits(t)));
|
|
571
|
+
const issues = secretRequestIssues(write.request, await ctx.store.tx((t) => readRuntimeLimits(t)));
|
|
569
572
|
if (issues.length > 0)
|
|
570
573
|
fail(400, issues.join(" "));
|
|
571
|
-
return
|
|
574
|
+
return write;
|
|
572
575
|
}
|
|
573
576
|
async function parseSecretRequest(ctx, id, name, raw) {
|
|
574
577
|
const { environment, slots } = await ctx.store.tx(async (t) => {
|
|
@@ -576,38 +579,73 @@ async function parseSecretRequest(ctx, id, name, raw) {
|
|
|
576
579
|
return { environment, slots: environment && (await slotsAhead(t, environment)) };
|
|
577
580
|
});
|
|
578
581
|
if (!environment || slots === undefined)
|
|
579
|
-
return PutAgentSecretRequestSchema.parse(raw);
|
|
582
|
+
return { request: PutAgentSecretRequestSchema.parse(raw), viaFromSlot: false };
|
|
580
583
|
const named = `${environment.manifestName}@${environment.targetVersion ?? environment.manifestVersion}`;
|
|
581
584
|
const slot = slots[name] ??
|
|
582
585
|
fail(409, `Agent ${id}'s manifest (${named}) declares no secret ${name}: add the slot to the manifest's runtime section, then move the Agent to the new version.`, { code: "secret_not_in_manifest", details: { declared: Object.keys(slots).sort() } });
|
|
583
586
|
const full = slotRequest(slot);
|
|
587
|
+
const slotVia = typeof full.via === "string" ? full.via : undefined;
|
|
588
|
+
const routed = slot.type === "bearer" || slot.type === "headers";
|
|
589
|
+
/** The write's prefix gives way to the slot's: said in the log when they differ. */
|
|
590
|
+
const prefixed = (toolPrefix) => {
|
|
591
|
+
if (routed && toolPrefix !== undefined && slot.toolPrefix !== undefined && toolPrefix !== slot.toolPrefix)
|
|
592
|
+
ctx.config.logger.warn("secret tool prefix gives way to the manifest's", {
|
|
593
|
+
environmentId: id,
|
|
594
|
+
secret: name,
|
|
595
|
+
manifest: named,
|
|
596
|
+
});
|
|
597
|
+
};
|
|
584
598
|
if (raw !== null && typeof raw === "object" && "type" in raw) {
|
|
585
599
|
const body = PutAgentSecretRequestSchema.parse(raw);
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
600
|
+
// This installation's routing (`gateway-credentials`, `secret-routing`): its via and the
|
|
601
|
+
// gateway's tool prefix are its own to add.
|
|
602
|
+
const { requestId: _, value: _v, headers: _h, toolPrefix, via, ...binding } = body;
|
|
603
|
+
const { via: _s, ...slotBinding } = full;
|
|
604
|
+
if (canonical(binding) !== canonical(slotBinding))
|
|
589
605
|
fail(409, `Secret ${name}'s type and binding come from Agent ${id}'s manifest: send only its value ({ "value": "…" }).`, {
|
|
590
606
|
code: "secret_not_in_manifest",
|
|
591
607
|
});
|
|
592
|
-
|
|
608
|
+
prefixed(toolPrefix);
|
|
609
|
+
if (!routed || (via !== undefined && via !== slotVia))
|
|
610
|
+
return { request: body, viaFromSlot: false };
|
|
611
|
+
// No via of its own: the slot's, which a session reads from the version its Agent runs.
|
|
612
|
+
return {
|
|
613
|
+
request: PutAgentSecretRequestSchema.parse({ ...body, ...(slotVia === undefined ? {} : { via: slotVia }) }),
|
|
614
|
+
viaFromSlot: slotVia !== undefined,
|
|
615
|
+
};
|
|
593
616
|
}
|
|
594
|
-
const { requestId, value, headers, toolPrefix } = PutAgentSecretValueRequestSchema.parse(raw);
|
|
617
|
+
const { requestId, value, headers, via, toolPrefix } = PutAgentSecretValueRequestSchema.parse(raw);
|
|
618
|
+
if (!routed && (via !== undefined || toolPrefix !== undefined))
|
|
619
|
+
fail(400, `Secret ${name} is an ${slot.type}: via and toolPrefix are for a bearer or headers secret.`);
|
|
620
|
+
if (toolPrefix !== undefined && via === undefined && slotVia === undefined)
|
|
621
|
+
fail(400, `toolPrefix needs via: Secret ${name}'s slot names none, so send one with the value.`);
|
|
622
|
+
prefixed(toolPrefix);
|
|
595
623
|
const withId = {
|
|
596
624
|
...(requestId === undefined ? {} : { requestId }),
|
|
597
625
|
...(toolPrefix === undefined ? {} : { toolPrefix }),
|
|
598
626
|
};
|
|
627
|
+
// The write's via wins over the slot's (`secret-routing`); the URL still picks the credential.
|
|
628
|
+
const routing = via === undefined ? {} : { via };
|
|
629
|
+
const viaFromSlot = via === undefined && slotVia !== undefined;
|
|
599
630
|
if (slot.type === "headers") {
|
|
600
631
|
if (headers === undefined)
|
|
601
632
|
fail(400, `Secret ${name} is a headers credential: send { "headers": { … } }.`);
|
|
602
|
-
return PutAgentSecretRequestSchema.parse({ ...withId, ...full, headers });
|
|
633
|
+
return { request: PutAgentSecretRequestSchema.parse({ ...withId, ...full, ...routing, headers }), viaFromSlot };
|
|
603
634
|
}
|
|
604
635
|
if (value === undefined)
|
|
605
636
|
fail(400, `Secret ${name} takes a value: send { "value": "…" }.`);
|
|
606
|
-
return PutAgentSecretRequestSchema.parse({ ...withId, ...full, value });
|
|
637
|
+
return { request: PutAgentSecretRequestSchema.parse({ ...withId, ...full, ...routing, value }), viaFromSlot };
|
|
607
638
|
}
|
|
608
|
-
/**
|
|
639
|
+
/**
|
|
640
|
+
* A slot's type and binding, as a secret write's fields (without the value). A slot's
|
|
641
|
+
* `toolPrefix` stays the slot's: a session reads it from the version its Agent runs.
|
|
642
|
+
*/
|
|
609
643
|
function slotRequest(slot) {
|
|
610
644
|
const { required: _r, description: _d, ...binding } = slot;
|
|
645
|
+
if ("toolPrefix" in binding) {
|
|
646
|
+
const { toolPrefix: _p, ...rest } = binding;
|
|
647
|
+
return rest;
|
|
648
|
+
}
|
|
611
649
|
return binding;
|
|
612
650
|
}
|
|
613
651
|
/**
|
package/dist/tenant/commands.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { randomUUID } from "node:crypto";
|
|
25
25
|
import { createDurableCheckpoint, createFlowCheckpoint, resumeFlowCheckpoint, } from "@nylorun/harness/run";
|
|
26
26
|
import { commandKey, cancelQueuedEffects, flowInteractionOf, foreignInteractionConflict, isWorkflowManifest, planCancelCascade, wakeLinkedWorkflow, } from "../core/flow-host.js";
|
|
27
|
-
import { resolveMessageManifest } from "../core/turn-manifest.js";
|
|
27
|
+
import { resolveMessageManifest, turnManifestFor, turnManifestRequestOf } from "../core/turn-manifest.js";
|
|
28
28
|
import { canonical } from "../store/canonical.js";
|
|
29
29
|
import { lockedSession, } from "./context.js";
|
|
30
30
|
import { fail, HttpError } from "./http.js";
|
|
@@ -388,14 +388,21 @@ async function beginMessageTurn(t, s, id, messages) {
|
|
|
388
388
|
input: first.input,
|
|
389
389
|
});
|
|
390
390
|
else {
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
pinnedHash: s.manifestHash,
|
|
394
|
-
messageManifest: first.command.manifest,
|
|
395
|
-
store: variantStore(s),
|
|
396
|
-
});
|
|
391
|
+
const request = turnManifestRequestOf(first.command);
|
|
392
|
+
const resolved = turnManifestFor({ pinned: s.manifest, pinnedHash: s.manifestHash, request: request ?? {} });
|
|
397
393
|
if (!resolved.ok)
|
|
398
394
|
return fail(400, resolved.message);
|
|
395
|
+
if (resolved.hash !== s.manifestHash)
|
|
396
|
+
variantStore(s).put(resolved.hash, resolved.manifest);
|
|
397
|
+
// A turn's first segment applies the request again to the version it re-pins (`repinTurn`).
|
|
398
|
+
if (request)
|
|
399
|
+
s.turnRequest = {
|
|
400
|
+
turnId,
|
|
401
|
+
...(resolved.variant === undefined ? {} : { manifest: resolved.variant }),
|
|
402
|
+
...(request.removeTools === undefined ? {} : { removeTools: [...request.removeTools] }),
|
|
403
|
+
};
|
|
404
|
+
else
|
|
405
|
+
delete s.turnRequest;
|
|
399
406
|
checkpoint = createDurableCheckpoint({
|
|
400
407
|
manifest: resolved.manifest,
|
|
401
408
|
sessionId: id,
|
|
@@ -456,7 +463,8 @@ export async function startQueuedTurn(ctx, t, s, id) {
|
|
|
456
463
|
const queued = await t.queuedMessages(id);
|
|
457
464
|
if (queued.length === 0)
|
|
458
465
|
return undefined;
|
|
459
|
-
|
|
466
|
+
// Messages that ask the same of their turn's manifest (a variant, `removeTools`) share a turn.
|
|
467
|
+
const manifestOf = (row) => canonical(turnManifestRequestOf(row.command) ?? null);
|
|
460
468
|
const batch = isWorkflowManifest(s.manifest) ? [queued[0]] : [];
|
|
461
469
|
if (batch.length === 0)
|
|
462
470
|
for (const row of queued) {
|
package/dist/tenant/context.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* under ownership (§10.6); `abortLocal` aborts an advance running on this process.
|
|
16
16
|
*/
|
|
17
17
|
import type { SessionHistory } from "./history.js";
|
|
18
|
-
import type { IssuerScope, SessionModelChoice, SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
|
|
18
|
+
import type { IssuerScope, SessionModelChoice, SessionModelRecord, SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
|
|
19
19
|
import type { DurableCheckpoint, FlowCheckpoint } from "@nylorun/harness/run";
|
|
20
20
|
import type { AgentManifest, JsonValue, SandboxManifest } from "@nylorun/core/define";
|
|
21
21
|
import type { OutboxRequest, SessionStore, Tx } from "../store/types.js";
|
|
@@ -63,6 +63,17 @@ export interface Session {
|
|
|
63
63
|
loop?: Record<string, unknown>;
|
|
64
64
|
/** Validated turn-manifest variants, keyed by hash (loops.md §4.2). */
|
|
65
65
|
variants?: Record<string, AgentManifest>;
|
|
66
|
+
/**
|
|
67
|
+
* What the active turn's first message asked of its manifest: a variant (`manifest`) and the
|
|
68
|
+
* tools it may not use (`removeTools`, Host feature `remove-tools`). A turn's first segment that
|
|
69
|
+
* re-pins the session to its Agent's version applies it again to that version (`repinTurn`).
|
|
70
|
+
* Absent when the message asked for neither.
|
|
71
|
+
*/
|
|
72
|
+
turnRequest?: {
|
|
73
|
+
turnId: string;
|
|
74
|
+
manifest?: AgentManifest;
|
|
75
|
+
removeTools?: string[];
|
|
76
|
+
};
|
|
66
77
|
info?: any;
|
|
67
78
|
status: string;
|
|
68
79
|
activeTurnId: string | null;
|
|
@@ -147,6 +158,24 @@ export interface Session {
|
|
|
147
158
|
* place of the Tenant's while its provider is connected. Absent: the Tenant's model.
|
|
148
159
|
*/
|
|
149
160
|
model?: SessionModelChoice;
|
|
161
|
+
/**
|
|
162
|
+
* The Agent's memory as the session's last turn started (track T5): the newest memory version
|
|
163
|
+
* then (`seen`), and whether that turn's first segment asked the harness to pin the index again
|
|
164
|
+
* (`refresh`: someone else wrote the memory since the turn before). Absent before its first turn.
|
|
165
|
+
*/
|
|
166
|
+
memoryIndex?: {
|
|
167
|
+
turnId: string;
|
|
168
|
+
seen?: string;
|
|
169
|
+
refresh: boolean;
|
|
170
|
+
};
|
|
171
|
+
/** Where `model` came from: `manifest` when its Agent's version named it (`harness.model`). Absent: chosen. */
|
|
172
|
+
modelSource?: "manifest";
|
|
173
|
+
/**
|
|
174
|
+
* The model the session's turns call and why (`SessionModelRecord`, `tenant/session-model.ts`):
|
|
175
|
+
* as the session opened or chose it, and at each segment's start since. Absent while the
|
|
176
|
+
* Tenant has no model, and on a session opened before it was kept.
|
|
177
|
+
*/
|
|
178
|
+
modelRecord?: SessionModelRecord;
|
|
150
179
|
}
|
|
151
180
|
export type AuthScope = {
|
|
152
181
|
kind: "application";
|