@nylorun/runtime 0.15.1-beta → 0.16.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 +82 -0
- package/README.md +33 -6
- package/dist/adapters/execution/restate.d.ts +9 -1
- package/dist/adapters/execution/restate.js +64 -5
- package/dist/adapters/replication/pgoutput.js +3 -1
- package/dist/adapters/sandbox/local.d.ts +10 -0
- package/dist/adapters/sandbox/local.js +195 -0
- package/dist/adapters/sandbox/virtual.js +36 -1
- package/dist/admin-openapi.json +37 -3
- package/dist/api/a2a/tasks.js +6 -1
- package/dist/api/ag-ui/history.js +6 -1
- package/dist/api/ag-ui/routes.js +5 -1
- package/dist/api/components.d.ts +2673 -655
- package/dist/api/components.js +17 -1
- package/dist/api/gate/keys.d.ts +4 -3
- package/dist/api/gate/routes.d.ts +19 -3
- package/dist/api/gate/routes.js +162 -53
- package/dist/api/http/app.js +4 -0
- package/dist/api/http/define.d.ts +10 -0
- package/dist/api/http/define.js +2 -1
- package/dist/api/http/routes/artifacts.d.ts +11 -0
- package/dist/api/http/routes/artifacts.js +518 -0
- package/dist/api/http/routes/sandboxes.d.ts +9 -0
- package/dist/api/http/routes/sandboxes.js +152 -0
- package/dist/api/http/routes/sessions.js +3 -1
- package/dist/api/http/routes/tenant.js +36 -2
- package/dist/artifacts/config.d.ts +23 -0
- package/dist/artifacts/config.js +27 -0
- package/dist/artifacts/export.d.ts +8 -0
- package/dist/artifacts/export.js +205 -0
- package/dist/artifacts/files.d.ts +36 -0
- package/dist/artifacts/files.js +45 -0
- package/dist/artifacts/folders.d.ts +60 -0
- package/dist/artifacts/folders.js +209 -0
- package/dist/artifacts/links.d.ts +28 -0
- package/dist/artifacts/links.js +92 -0
- package/dist/artifacts/media-types.d.ts +13 -0
- package/dist/artifacts/media-types.js +80 -0
- package/dist/artifacts/parts.d.ts +34 -0
- package/dist/artifacts/parts.js +38 -0
- package/dist/artifacts/service.d.ts +74 -0
- package/dist/artifacts/service.js +301 -0
- package/dist/artifacts/workspace.d.ts +60 -0
- package/dist/artifacts/workspace.js +57 -0
- package/dist/artifacts/zip.d.ts +10 -0
- package/dist/artifacts/zip.js +126 -0
- package/dist/blob/fs.d.ts +13 -0
- package/dist/blob/fs.js +194 -0
- package/dist/blob/index.d.ts +9 -0
- package/dist/blob/index.js +9 -0
- package/dist/blob/s3.d.ts +42 -0
- package/dist/blob/s3.js +366 -0
- package/dist/blob/types.d.ts +108 -0
- package/dist/blob/types.js +85 -0
- package/dist/core/sandbox-routes.d.ts +2 -2
- package/dist/core/sandbox-routes.js +4 -3
- package/dist/execution/memory.d.ts +11 -1
- package/dist/execution/memory.js +77 -3
- package/dist/execution/types.d.ts +54 -1
- package/dist/execution/types.js +31 -0
- package/dist/gates/contract.d.ts +14 -13
- package/dist/gates/contract.js +13 -12
- package/dist/gates/egress.d.ts +72 -0
- package/dist/gates/egress.js +319 -0
- package/dist/gates/handler.d.ts +3 -0
- package/dist/gates/handler.js +3 -2
- package/dist/gates/http-client.d.ts +3 -2
- package/dist/gates/http-client.js +17 -6
- package/dist/gates/in-process.d.ts +13 -1
- package/dist/gates/in-process.js +11 -0
- package/dist/gates/inflight.d.ts +23 -4
- package/dist/gates/inflight.js +18 -4
- package/dist/gates/model-gate.d.ts +6 -2
- package/dist/gates/tool-calls.d.ts +9 -5
- package/dist/gates/tool-calls.js +6 -6
- package/dist/gates/tool-client.d.ts +9 -2
- package/dist/gates/tool-client.js +56 -22
- package/dist/gates/tool-contract.d.ts +12 -7
- package/dist/gates/tool-contract.js +10 -4
- package/dist/gates/tool-gate.d.ts +1 -0
- package/dist/harness/calls.d.ts +52 -0
- package/dist/harness/calls.js +113 -0
- package/dist/harness/executors.d.ts +22 -0
- package/dist/harness/executors.js +96 -0
- package/dist/harness/main.d.ts +6 -0
- package/dist/harness/main.js +103 -0
- package/dist/harness/pod.d.ts +21 -0
- package/dist/harness/pod.js +119 -0
- package/dist/harness/run-tokens.d.ts +18 -0
- package/dist/harness/run-tokens.js +15 -0
- package/dist/harness/service.d.ts +46 -0
- package/dist/harness/service.js +179 -0
- package/dist/harness/ws-client.d.ts +43 -0
- package/dist/harness/ws-client.js +122 -0
- package/dist/harness/ws-port.d.ts +22 -0
- package/dist/harness/ws-port.js +78 -0
- package/dist/harness-api/in-process.d.ts +24 -0
- package/dist/harness-api/in-process.js +119 -0
- package/dist/harness-api/record.d.ts +44 -0
- package/dist/harness-api/record.js +250 -0
- package/dist/harness-api/renew.d.ts +37 -0
- package/dist/harness-api/renew.js +77 -0
- package/dist/harness-api/server.d.ts +104 -0
- package/dist/harness-api/server.js +408 -0
- package/dist/harness-api/start.d.ts +36 -0
- package/dist/harness-api/start.js +118 -0
- package/dist/harness-api/workspace.d.ts +53 -0
- package/dist/harness-api/workspace.js +201 -0
- package/dist/harness-api/ws-server.d.ts +34 -0
- package/dist/harness-api/ws-server.js +227 -0
- package/dist/host/app.js +23 -2
- package/dist/host/gates.d.ts +27 -2
- package/dist/host/gates.js +46 -1
- package/dist/host/http.d.ts +4 -1
- package/dist/host/http.js +6 -1
- package/dist/host/main.js +135 -27
- package/dist/host/stack-config.d.ts +101 -1
- package/dist/host/stack-config.js +199 -8
- package/dist/mcp/snapshot.d.ts +8 -1
- package/dist/mcp/snapshot.js +28 -0
- package/dist/media.d.ts +5 -24
- package/dist/media.js +5 -0
- package/dist/model/pi-model.d.ts +6 -2
- package/dist/model/pi-model.js +39 -22
- package/dist/node/index.d.ts +1 -1
- package/dist/node/index.js +1 -1
- package/dist/openapi.json +15245 -8850
- package/dist/record/index.d.ts +1 -0
- package/dist/record/index.js +1 -0
- package/dist/record/sandbox.d.ts +25 -0
- package/dist/record/sandbox.js +43 -0
- package/dist/sandbox/capability.d.ts +3 -0
- package/dist/sandbox/capability.js +10 -0
- package/dist/sandbox/egress-token.d.ts +26 -0
- package/dist/sandbox/egress-token.js +8 -0
- package/dist/sandbox/join.d.ts +37 -0
- package/dist/sandbox/join.js +165 -0
- package/dist/sandbox/manager.d.ts +99 -34
- package/dist/sandbox/manager.js +216 -84
- package/dist/sandbox/placement.d.ts +27 -0
- package/dist/sandbox/placement.js +44 -0
- package/dist/sandbox/pods/client.d.ts +90 -0
- package/dist/sandbox/pods/client.js +79 -0
- package/dist/sandbox/pods/lifecycle.d.ts +91 -0
- package/dist/sandbox/pods/lifecycle.js +178 -0
- package/dist/sandbox/pods/name.d.ts +1 -0
- package/dist/sandbox/pods/name.js +28 -0
- package/dist/sandbox/pods/network-gate.d.ts +25 -0
- package/dist/sandbox/pods/network-gate.js +71 -0
- package/dist/sandbox/pods/reconcile.d.ts +11 -0
- package/dist/sandbox/pods/reconcile.js +152 -0
- package/dist/sandbox/pods/spec.d.ts +45 -0
- package/dist/sandbox/pods/spec.js +62 -0
- package/dist/sandbox/records.d.ts +29 -0
- package/dist/sandbox/records.js +79 -0
- package/dist/sandbox/resolve.d.ts +5 -0
- package/dist/sandbox/resolve.js +3 -2
- package/dist/sandbox/session-sandbox.js +7 -5
- package/dist/sandbox/share.d.ts +11 -0
- package/dist/sandbox/share.js +10 -0
- package/dist/sandbox/tenant-config.d.ts +16 -1
- package/dist/sandbox/tenant-config.js +9 -0
- package/dist/sandbox/types.d.ts +29 -1
- package/dist/sandbox/types.js +13 -1
- package/dist/store/postgres/drizzle/0004_sandbox_resources.sql +20 -0
- package/dist/store/postgres/drizzle/0005_artifacts.sql +27 -0
- package/dist/store/postgres/drizzle/0006_folder_artifacts.sql +12 -0
- package/dist/store/postgres/drizzle/0007_sandbox_pods.sql +14 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
- package/dist/store/postgres/record-writer.d.ts +6 -1
- package/dist/store/postgres/record-writer.js +20 -2
- package/dist/store/postgres/schema.d.ts +894 -0
- package/dist/store/postgres/schema.js +130 -2
- package/dist/store/postgres/store.js +289 -4
- package/dist/store/types.d.ts +143 -6
- package/dist/streams/control.d.ts +2 -0
- package/dist/streams/control.js +5 -0
- package/dist/streams/types.d.ts +10 -1
- package/dist/tenant/advance.js +218 -160
- package/dist/tenant/artifact-tool.d.ts +5 -0
- package/dist/tenant/artifact-tool.js +119 -0
- package/dist/tenant/commands.js +30 -5
- package/dist/tenant/context.d.ts +64 -5
- package/dist/tenant/effects.d.ts +19 -13
- package/dist/tenant/effects.js +5 -382
- package/dist/tenant/history.d.ts +13 -23
- package/dist/tenant/history.js +14 -62
- package/dist/tenant/host-token.d.ts +92 -0
- package/dist/tenant/host-token.js +179 -0
- package/dist/tenant/module.js +1 -0
- package/dist/tenant/paths.d.ts +2 -1
- package/dist/tenant/paths.js +3 -1
- package/dist/tenant/reset.d.ts +5 -2
- package/dist/tenant/reset.js +22 -2
- package/dist/tenant/run-grants.d.ts +49 -0
- package/dist/tenant/run-grants.js +62 -0
- package/dist/tenant/run-token.d.ts +70 -0
- package/dist/tenant/run-token.js +138 -0
- package/dist/tenant/runtime.d.ts +38 -0
- package/dist/tenant/runtime.js +120 -32
- package/dist/tenant/sandboxes.d.ts +119 -0
- package/dist/tenant/sandboxes.js +551 -0
- package/dist/tenant/session.d.ts +2 -13
- package/dist/tenant/session.js +4 -28
- package/dist/tenant/sessions.d.ts +5 -0
- package/dist/tenant/sessions.js +28 -1
- package/dist/tenant/status.d.ts +8 -3
- package/dist/tenant/status.js +5 -0
- package/dist/tenant/streams.d.ts +7 -0
- package/dist/tenant/streams.js +24 -1
- package/dist/tenant/sweep.js +37 -4
- package/dist/tenant/tokens.js +9 -2
- package/dist/tenant/types.d.ts +18 -1
- package/dist/tenant/worker.d.ts +11 -5
- package/dist/tenant/worker.js +12 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +13 -10
- package/dist/adapters/media.d.ts +0 -24
- package/dist/adapters/media.js +0 -111
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { isWorkflowManifest } from "../core/flow-host.js";
|
|
2
|
+
import { sandboxWorkspaceOf } from "../sandbox/share.js";
|
|
3
|
+
import { ownedTx } from "../store/ownership.js";
|
|
4
|
+
import { sandboxLookup } from "../tenant/context.js";
|
|
5
|
+
import { checkParity, foldTranscriptWithCursor, leanState, transcriptOf, transcriptUpdates, withTranscript, } from "../tenant/history.js";
|
|
6
|
+
import { usesFixtureModel } from "../tenant/model-setting.js";
|
|
7
|
+
import { rebaseSessionState, sessionToolsOf, turnManifestOf } from "../tenant/session.js";
|
|
8
|
+
import { bulkOutcomes } from "./record.js";
|
|
9
|
+
/** How long a run waits in its lease for a pending Action's outcome (F6.2), by default. */
|
|
10
|
+
const ACTION_HOLD_MS = 5 * 60_000;
|
|
11
|
+
/** Segment rollover defaults (Model Calls §10), well inside the advance deadline. */
|
|
12
|
+
const ROLLOVER_STEPS = 50;
|
|
13
|
+
const ROLLOVER_MS = 20 * 60_000;
|
|
14
|
+
/** The segment's starting transaction and fold. */
|
|
15
|
+
export async function startSegment(ctx, lease,
|
|
16
|
+
/** For a harness: also the segment's outcomes and the sandbox's owner. */
|
|
17
|
+
options = {}) {
|
|
18
|
+
const id = lease.sessionId;
|
|
19
|
+
const started = await ownedTx(ctx.store, id, lease.epoch, async (t, current) => {
|
|
20
|
+
if (!isWorkflowManifest(current.manifest) && current.checkpoint) {
|
|
21
|
+
rebaseSessionState(current, current.checkpoint.manifestHash);
|
|
22
|
+
await adoptStoredTranscript(t, current);
|
|
23
|
+
const cp = current.checkpoint;
|
|
24
|
+
current.checkpoint = { ...cp, state: current.state };
|
|
25
|
+
await t.put("sessions", id, current);
|
|
26
|
+
}
|
|
27
|
+
const fixtureModel = await usesFixtureModel(t);
|
|
28
|
+
if (!options.harness)
|
|
29
|
+
return { current, fixtureModel, outcomes: [], sandbox: { ownerId: id } };
|
|
30
|
+
const sandbox = sandboxWorkspaceOf(current, await sandboxLookup(t, current.id));
|
|
31
|
+
const resource = sandbox.sandboxId === undefined ? undefined : await t.sandboxResource(sandbox.sandboxId);
|
|
32
|
+
return {
|
|
33
|
+
current,
|
|
34
|
+
fixtureModel,
|
|
35
|
+
outcomes: current.checkpoint ? await bulkOutcomes(t, current, current.checkpoint) : [],
|
|
36
|
+
sandbox,
|
|
37
|
+
...(resource?.pod ? { pod: { id: resource.id, pod: resource.pod } } : {}),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
// One history (P0.3): the engine resumes from the transcript folded from the record.
|
|
41
|
+
const { current } = started;
|
|
42
|
+
const cp = current.checkpoint;
|
|
43
|
+
if (isWorkflowManifest(current.manifest) || !cp?.state)
|
|
44
|
+
return { ...started, transcript: [], cursor: -1 };
|
|
45
|
+
const { transcript, cursor } = await foldSession(ctx, current);
|
|
46
|
+
checkParity(id, transcript, transcriptOf(cp.state));
|
|
47
|
+
return { ...started, transcript, cursor };
|
|
48
|
+
}
|
|
49
|
+
/** The run's `turn.start`. */
|
|
50
|
+
export function buildTurnStart(ctx, segment) {
|
|
51
|
+
const { current } = segment;
|
|
52
|
+
const flow = isWorkflowManifest(current.manifest);
|
|
53
|
+
const cp = current.checkpoint;
|
|
54
|
+
const input = cp.input;
|
|
55
|
+
return {
|
|
56
|
+
type: !flow && (input?.kind === "approve" || input?.kind === "respond") ? "approval.answer" : "turn.start",
|
|
57
|
+
engine: flow ? "flow" : "agent",
|
|
58
|
+
manifest: flow ? current.manifest : turnManifestOf(current),
|
|
59
|
+
checkpoint: !flow && cp.state
|
|
60
|
+
? { ...cp, state: withTranscript(cp.state, []) }
|
|
61
|
+
: cp,
|
|
62
|
+
...(flow ? {} : { sessionTools: sessionToolsOf(current.mcpSnapshot) ?? [] }),
|
|
63
|
+
outcomes: segment.outcomes,
|
|
64
|
+
transcript: { cursor: segment.cursor },
|
|
65
|
+
options: {
|
|
66
|
+
...(flow
|
|
67
|
+
? { flowLimits: ctx.flowLimits }
|
|
68
|
+
: { yieldAfter: yieldAfterOf(ctx) }),
|
|
69
|
+
fixtureModel: segment.fixtureModel,
|
|
70
|
+
holdMs: ctx.config.actionHoldMs ?? ACTION_HOLD_MS,
|
|
71
|
+
},
|
|
72
|
+
routing: {
|
|
73
|
+
rootManifest: current.manifest,
|
|
74
|
+
pluginRoots: current.pluginRoots ?? {},
|
|
75
|
+
...(current.mcpSnapshot ? { mcpSnapshot: current.mcpSnapshot } : {}),
|
|
76
|
+
sandbox: segment.sandbox,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** The engine's yield budget for a segment. */
|
|
81
|
+
export function yieldAfterOf(ctx) {
|
|
82
|
+
return {
|
|
83
|
+
steps: ctx.config.rollover?.steps ?? ROLLOVER_STEPS,
|
|
84
|
+
ms: ctx.config.rollover?.ms ?? ROLLOVER_MS,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A session written before transcripts were recorded keeps its transcript on its row: record
|
|
89
|
+
* it once (the turn's starting transcript, then the turn so far), and store the row lean.
|
|
90
|
+
*/
|
|
91
|
+
async function adoptStoredTranscript(t, s) {
|
|
92
|
+
if (s.history)
|
|
93
|
+
return;
|
|
94
|
+
const now = transcriptOf(s.state);
|
|
95
|
+
const start = s.activeTurnId ? transcriptOf(s.turnStartState) : now;
|
|
96
|
+
if (now.length === 0 && start.length === 0)
|
|
97
|
+
return;
|
|
98
|
+
let first;
|
|
99
|
+
const write = async (turnId, updates) => {
|
|
100
|
+
for (const update of updates) {
|
|
101
|
+
const event = await t.event(s.id, turnId, "transcript.updated", update);
|
|
102
|
+
first ??= event.seq;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
await write(null, transcriptUpdates([], start));
|
|
106
|
+
if (s.activeTurnId)
|
|
107
|
+
await write(s.activeTurnId, transcriptUpdates(start, now));
|
|
108
|
+
s.history = { from: first ?? 0, snapshot: first ?? 0 };
|
|
109
|
+
s.state = leanState(s.state);
|
|
110
|
+
s.turnStartState = leanState(s.turnStartState);
|
|
111
|
+
}
|
|
112
|
+
/** The session's transcript, folded from its record from `history.from`, and its cursor. */
|
|
113
|
+
async function foldSession(ctx, s) {
|
|
114
|
+
const rows = await ctx.store
|
|
115
|
+
.record()
|
|
116
|
+
.readRange(ctx.store.tenantId, s.id, s.history?.from ?? 0, Number.MAX_SAFE_INTEGER);
|
|
117
|
+
return foldTranscriptWithCursor(rows.map((row) => row.body));
|
|
118
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CapabilityManifest, SandboxToolName } from "@nylorun/core/define";
|
|
2
|
+
import type { SandboxManager, SandboxSessionRef } from "../sandbox/manager.js";
|
|
3
|
+
import type { SandboxSelectionReport } from "../sandbox/select.js";
|
|
4
|
+
import type { SandboxToolOutcome } from "../sandbox/tools.js";
|
|
5
|
+
import type { SessionStore } from "../store/types.js";
|
|
6
|
+
import type { Logger } from "../tenant/types.js";
|
|
7
|
+
import { type HarnessApiServer } from "./server.js";
|
|
8
|
+
/** The sandbox selection, as `GET /v1/tenant/sandbox` and Tenant status report it. */
|
|
9
|
+
export type WorkspaceReport = SandboxSelectionReport & {
|
|
10
|
+
readonly defaultImage: string;
|
|
11
|
+
};
|
|
12
|
+
type Exists = (id: string) => boolean | Promise<boolean>;
|
|
13
|
+
export interface WorkspacePort {
|
|
14
|
+
/** Runs one sandbox tool in the workspace of `session`. */
|
|
15
|
+
run(session: SandboxSessionRef, capability: CapabilityManifest, toolName: SandboxToolName, input: unknown, signal: AbortSignal): Promise<SandboxToolOutcome>;
|
|
16
|
+
/** Lists the files under `dir` of the workspace of `session` (the turn-end export, F8.2). */
|
|
17
|
+
listFiles(session: SandboxSessionRef, capability: CapabilityManifest, dir: string, maxEntries: number, signal: AbortSignal): ReturnType<SandboxManager["listFiles"]>;
|
|
18
|
+
/** Reads a file of the workspace of `session` as bytes, at most `maxBytes` (`save_artifact`). */
|
|
19
|
+
readBytes(session: SandboxSessionRef, capability: CapabilityManifest, path: string, maxBytes: number, signal: AbortSignal): ReturnType<SandboxManager["readBytes"]>;
|
|
20
|
+
report(): Promise<WorkspaceReport>;
|
|
21
|
+
/** One Tenant sweep pass: stop idle workspaces, remove those whose owner is gone. */
|
|
22
|
+
sweep(options: {
|
|
23
|
+
now?: number;
|
|
24
|
+
sessionExists: Exists;
|
|
25
|
+
sandboxExists: Exists;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
/** Deletes sandbox resource `sandboxId`'s workspace (the resource was deleted). */
|
|
28
|
+
removeSandbox(sandboxId: string): Promise<void>;
|
|
29
|
+
/** Deletes every workspace with its files (a sandboxes reset). */
|
|
30
|
+
removeAll(): Promise<void>;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
/** The Tenant's own SandboxManager. */
|
|
34
|
+
export declare function localWorkspace(manager: SandboxManager): WorkspacePort;
|
|
35
|
+
export interface RemoteWorkspaceOptions {
|
|
36
|
+
readonly server: HarnessApiServer;
|
|
37
|
+
readonly store: SessionStore;
|
|
38
|
+
readonly logger: Logger;
|
|
39
|
+
/** The sandbox backend preference, reported while no harness serves workspaces. */
|
|
40
|
+
readonly preference: string;
|
|
41
|
+
/** The pod sandbox whose host serves these workspaces (F7.2). */
|
|
42
|
+
readonly pod?: string;
|
|
43
|
+
}
|
|
44
|
+
/** The workspaces of the harness that serves them. */
|
|
45
|
+
export declare function remoteWorkspace(options: RemoteWorkspaceOptions): WorkspacePort;
|
|
46
|
+
/**
|
|
47
|
+
* Routes the workspaces of sessions attached to a pod sandbox to that sandbox's engine (F7.2);
|
|
48
|
+
* everything else goes to `base`. `podOf` says whether a sandbox resource is a pod sandbox.
|
|
49
|
+
*/
|
|
50
|
+
export declare function withPodWorkspaces(base: WorkspacePort, options: Omit<RemoteWorkspaceOptions, "pod"> & {
|
|
51
|
+
readonly isPod: (sandboxId: string) => Promise<boolean>;
|
|
52
|
+
}): WorkspacePort;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace capability (F6.2): what core does with the Tenant's sandbox workspaces outside a
|
|
3
|
+
* run. `ctx.sandbox` is a `WorkspacePort`: the sandbox tool routes (`POST
|
|
4
|
+
* /v1/sessions/:id/sandbox/:tool` and an Action endpoint's `POST /v1/actions/:id/sandbox/:tool`),
|
|
5
|
+
* Tenant status, the Tenant sweep, a sandbox resource's deletion and a sandboxes reset.
|
|
6
|
+
*
|
|
7
|
+
* - **In process** (`localWorkspace`): the Tenant's own SandboxManager, which its in-process
|
|
8
|
+
* harness also runs sandbox tools with.
|
|
9
|
+
* - **Remote** (`remoteWorkspace`): the harness that declared `workspace` in its `hello` holds
|
|
10
|
+
* the workspaces. `bash` becomes `workspace.exec`, `read`/`grep`/`glob` `workspace.read`,
|
|
11
|
+
* `write`/`edit` `workspace.write`; a request is aborted when its HTTP client leaves. With no
|
|
12
|
+
* such harness connected a tool call is `503 request_rejected`. The sweep asks the harness to
|
|
13
|
+
* stop idle workspaces and list them (`workspace.sweep`), decides which owners are gone, and
|
|
14
|
+
* removes theirs (`workspace.remove`); it also keeps the `sandboxes` table in step with the
|
|
15
|
+
* list.
|
|
16
|
+
* - **Pod sandboxes** (F7.2, `withPodWorkspaces`): a session attached to a pod sandbox has its
|
|
17
|
+
* workspace in the pod, served by the sandbox's engine (its host connection). Its tool calls,
|
|
18
|
+
* listings and reads go there whatever serves the other workspaces; with the pod stopped or
|
|
19
|
+
* starting they are `409 sandbox_unavailable`.
|
|
20
|
+
*/
|
|
21
|
+
import { HarnessApiError } from "@nylorun/core/harness-api";
|
|
22
|
+
import { DEFAULT_SANDBOX_IMAGE } from "../sandbox/policy.js";
|
|
23
|
+
import { fail } from "../tenant/http.js";
|
|
24
|
+
import { NoWorkspaceHarness } from "./server.js";
|
|
25
|
+
/** The Tenant's own SandboxManager. */
|
|
26
|
+
export function localWorkspace(manager) {
|
|
27
|
+
return {
|
|
28
|
+
run: (session, capability, toolName, input, signal) => manager.run(session, capability, toolName, input, signal),
|
|
29
|
+
listFiles: (session, capability, dir, maxEntries, signal) => manager.listFiles(session, capability, dir, maxEntries, signal),
|
|
30
|
+
readBytes: (session, capability, path, maxBytes, signal) => manager.readBytes(session, capability, path, maxBytes, signal),
|
|
31
|
+
report: () => manager.report(),
|
|
32
|
+
sweep: (options) => manager.sweep(options),
|
|
33
|
+
removeSandbox: (sandboxId) => manager.removeSandbox(sandboxId),
|
|
34
|
+
removeAll: () => manager.reconcile(() => false, () => false),
|
|
35
|
+
close: () => manager.close(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const METHODS = {
|
|
39
|
+
bash: "workspace.exec",
|
|
40
|
+
read: "workspace.read",
|
|
41
|
+
grep: "workspace.read",
|
|
42
|
+
glob: "workspace.read",
|
|
43
|
+
write: "workspace.write",
|
|
44
|
+
edit: "workspace.write",
|
|
45
|
+
};
|
|
46
|
+
function sessionOf(session) {
|
|
47
|
+
return {
|
|
48
|
+
ownerId: session.id,
|
|
49
|
+
...(session.sandboxId === undefined ? {} : { sandboxId: session.sandboxId }),
|
|
50
|
+
activeTurnId: session.activeTurnId,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The workspaces of the harness that serves them. */
|
|
54
|
+
export function remoteWorkspace(options) {
|
|
55
|
+
const { store } = options;
|
|
56
|
+
const target = options.pod === undefined ? undefined : { pod: options.pod };
|
|
57
|
+
const unavailable = (error) => error.pod === undefined
|
|
58
|
+
? fail(503, error.message, { code: "request_rejected" })
|
|
59
|
+
: fail(409, error.message, { code: "sandbox_unavailable" });
|
|
60
|
+
/** The harness's answer, or `undefined` when none serves workspaces. */
|
|
61
|
+
const ask = async (method, params, signal) => {
|
|
62
|
+
try {
|
|
63
|
+
return await options.server.workspace(method, params, signal);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error instanceof NoWorkspaceHarness)
|
|
67
|
+
return undefined;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const sweep = async (sessionExists, sandboxExists, now) => {
|
|
72
|
+
const answer = await ask("workspace.sweep", now === undefined ? {} : { now });
|
|
73
|
+
if (!answer)
|
|
74
|
+
return;
|
|
75
|
+
const { workspaces } = answer;
|
|
76
|
+
const gone = [];
|
|
77
|
+
for (const record of workspaces) {
|
|
78
|
+
const kept = record.sandboxId === undefined
|
|
79
|
+
? await sessionExists(record.sessionId)
|
|
80
|
+
: await sandboxExists(record.sandboxId);
|
|
81
|
+
if (!kept)
|
|
82
|
+
gone.push(record.key);
|
|
83
|
+
}
|
|
84
|
+
if (gone.length > 0)
|
|
85
|
+
await ask("workspace.remove", { keys: gone });
|
|
86
|
+
// The `sandboxes` table follows the harness: what it no longer runs is stopped, what it
|
|
87
|
+
// removed is gone.
|
|
88
|
+
const listed = new Map(workspaces.map((record) => [record.key, record]));
|
|
89
|
+
await store.tx(async (t) => {
|
|
90
|
+
for (const row of await t.listSandboxes()) {
|
|
91
|
+
const record = listed.get(row.key);
|
|
92
|
+
if (!record || gone.includes(row.key))
|
|
93
|
+
await t.delete("sandboxes", row.key);
|
|
94
|
+
else if (record.state !== row.state)
|
|
95
|
+
await t.put("sandboxes", row.key, { ...row, state: record.state });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
async run(session, capability, toolName, input, signal) {
|
|
101
|
+
try {
|
|
102
|
+
return (await options.server.workspace(METHODS[toolName], {
|
|
103
|
+
session: sessionOf(session),
|
|
104
|
+
spec: capability.sandbox ?? {},
|
|
105
|
+
tool: toolName,
|
|
106
|
+
input: input ?? {},
|
|
107
|
+
}, signal, target));
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error instanceof NoWorkspaceHarness)
|
|
111
|
+
return unavailable(error);
|
|
112
|
+
if (error instanceof HarnessApiError && error.code === "unavailable" && !signal.aborted)
|
|
113
|
+
return fail(503, `The harness serving workspaces went away: ${error.message}`, {
|
|
114
|
+
code: "request_rejected",
|
|
115
|
+
});
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
async listFiles(session, capability, dir, maxEntries, signal) {
|
|
120
|
+
try {
|
|
121
|
+
const answer = (await options.server.workspace("workspace.read", { session: sessionOf(session), spec: capability.sandbox ?? {}, list: { dir, maxEntries } }, signal, target));
|
|
122
|
+
if (answer.kind === "listed")
|
|
123
|
+
return {
|
|
124
|
+
kind: "listed",
|
|
125
|
+
path: String(answer.path),
|
|
126
|
+
listing: answer.listing,
|
|
127
|
+
};
|
|
128
|
+
if (answer.kind === "missing")
|
|
129
|
+
return { kind: "missing", path: String(answer.path) };
|
|
130
|
+
return { kind: "failed", code: String(answer.code), message: String(answer.message) };
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// No harness holds the workspaces: there is nothing to export.
|
|
134
|
+
if (error instanceof NoWorkspaceHarness)
|
|
135
|
+
return { kind: "missing", path: dir };
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
async readBytes(session, capability, path, maxBytes, signal) {
|
|
140
|
+
try {
|
|
141
|
+
const answer = (await options.server.workspace("workspace.read", {
|
|
142
|
+
session: sessionOf(session),
|
|
143
|
+
spec: capability.sandbox ?? {},
|
|
144
|
+
bytes: { path, maxBytes },
|
|
145
|
+
}, signal, target));
|
|
146
|
+
if (answer.kind === "read")
|
|
147
|
+
return { kind: "read", path: String(answer.path), bytes: new Uint8Array(Buffer.from(String(answer.base64), "base64")) };
|
|
148
|
+
if (answer.kind === "missing")
|
|
149
|
+
return { kind: "missing", path: String(answer.path) };
|
|
150
|
+
return { kind: "failed", code: String(answer.code), message: String(answer.message) };
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof NoWorkspaceHarness)
|
|
154
|
+
return { kind: "failed", code: "sandbox.unavailable", message: error.message };
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
async report() {
|
|
159
|
+
const answer = await ask("workspace.report", {}).catch(() => undefined);
|
|
160
|
+
if (answer)
|
|
161
|
+
return answer;
|
|
162
|
+
return {
|
|
163
|
+
preference: options.preference === "virtual" ? "virtual" : "auto",
|
|
164
|
+
backend: null,
|
|
165
|
+
isolation: null,
|
|
166
|
+
reason: "No harness serving workspaces is connected",
|
|
167
|
+
probes: [],
|
|
168
|
+
defaultImage: DEFAULT_SANDBOX_IMAGE,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
sweep: (pass) => sweep(pass.sessionExists, pass.sandboxExists, pass.now),
|
|
172
|
+
async removeSandbox(sandboxId) {
|
|
173
|
+
// With no harness connected, the next sweep removes it: its resource is gone.
|
|
174
|
+
await ask("workspace.remove", { sandboxIds: [sandboxId] });
|
|
175
|
+
await store.tx(async (t) => {
|
|
176
|
+
for (const row of await t.listSandboxes())
|
|
177
|
+
if (row.sandboxId === sandboxId)
|
|
178
|
+
await t.delete("sandboxes", row.key);
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
async removeAll() {
|
|
182
|
+
await ask("workspace.remove", { all: true });
|
|
183
|
+
},
|
|
184
|
+
async close() { },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Routes the workspaces of sessions attached to a pod sandbox to that sandbox's engine (F7.2);
|
|
189
|
+
* everything else goes to `base`. `podOf` says whether a sandbox resource is a pod sandbox.
|
|
190
|
+
*/
|
|
191
|
+
export function withPodWorkspaces(base, options) {
|
|
192
|
+
const routed = async (session) => session.sandboxId !== undefined && (await options.isPod(session.sandboxId))
|
|
193
|
+
? remoteWorkspace({ ...options, pod: session.sandboxId })
|
|
194
|
+
: base;
|
|
195
|
+
return {
|
|
196
|
+
...base,
|
|
197
|
+
run: async (session, capability, toolName, input, signal) => (await routed(session)).run(session, capability, toolName, input, signal),
|
|
198
|
+
listFiles: async (session, capability, dir, maxEntries, signal) => (await routed(session)).listFiles(session, capability, dir, maxEntries, signal),
|
|
199
|
+
readBytes: async (session, capability, path, maxBytes, signal) => (await routed(session)).readBytes(session, capability, path, maxBytes, signal),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
import { type HarnessChannel } from "@nylorun/core/harness-api";
|
|
3
|
+
import { type WsPortOptions } from "../harness/ws-port.js";
|
|
4
|
+
import { type HostAuthority } from "../sandbox/join.js";
|
|
5
|
+
import type { HarnessPeer } from "./server.js";
|
|
6
|
+
/** Where a pod's engine exchanges its join token, and renews its host token. */
|
|
7
|
+
export declare const HOST_JOIN_PATH = "/nylorun/harness/v1/host/join";
|
|
8
|
+
export declare const HOST_RENEW_PATH = "/nylorun/harness/v1/host/renew";
|
|
9
|
+
/** Attaches a harness's channel to the open Tenant; returns a function that detaches it. */
|
|
10
|
+
export type HarnessAttach = (channel: HarnessChannel, peer: HarnessPeer) => () => void;
|
|
11
|
+
export interface HarnessListenerOptions {
|
|
12
|
+
readonly host: string;
|
|
13
|
+
readonly port: number;
|
|
14
|
+
/** Exact `Host` values accepted (lowercase `name:port`); the loopback forms of the bound port are added. */
|
|
15
|
+
readonly allowedHosts: readonly string[];
|
|
16
|
+
/** `NYLORUN_HARNESS_TOKEN`; absent, only sandbox pods' host tokens are accepted. */
|
|
17
|
+
readonly token?: string;
|
|
18
|
+
/** The open Tenant's `attachHarness`, or undefined while there is none. */
|
|
19
|
+
readonly attach: () => Promise<HarnessAttach | undefined>;
|
|
20
|
+
/** The open Tenant's host authority (sandbox pods, F7.2), or undefined without one. */
|
|
21
|
+
readonly hosts?: () => Promise<HostAuthority | undefined>;
|
|
22
|
+
readonly logger: {
|
|
23
|
+
info(message: string, fields?: Record<string, unknown>): void;
|
|
24
|
+
warn(message: string, fields?: Record<string, unknown>): void;
|
|
25
|
+
};
|
|
26
|
+
readonly liveness?: Omit<WsPortOptions, "ping">;
|
|
27
|
+
}
|
|
28
|
+
export interface HarnessListener {
|
|
29
|
+
/** `ws://host:port/nylorun/harness/v1`. */
|
|
30
|
+
readonly url: string;
|
|
31
|
+
readonly server: Server;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export declare function startHarnessListener(options: HarnessListenerOptions): Promise<HarnessListener>;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Harness API listener (F6.2): core's own port for harnesses (`NYLORUN_HARNESS_LISTEN_*`,
|
|
3
|
+
* default 4200), apart from the Tenant and Admin APIs. It accepts the harness credential
|
|
4
|
+
* (`NYLORUN_HARNESS_TOKEN`), and nothing else does; nothing else is served here.
|
|
5
|
+
*
|
|
6
|
+
* An upgrade is accepted only on `/nylorun/harness/v1`, with a `Host` in the allowlist (421),
|
|
7
|
+
* `Authorization: Bearer <token>` (compared in constant time; 401) and `Nylorun-Harness-Api: 1`
|
|
8
|
+
* (426), while the Tenant is open (503). Frames are at most 64 MiB and every one is validated
|
|
9
|
+
* (`createChannel({ validate: true })`). Core pings every 30 s and drops a harness silent for
|
|
10
|
+
* 90 s; the connection then counts as lost (`connection.lost`).
|
|
11
|
+
*
|
|
12
|
+
* Sandbox pods (F7.2, D42) reach this listener too, on the Docker host's published port, and hold
|
|
13
|
+
* no harness token. A pod's engine exchanges its join token for a host token with `POST
|
|
14
|
+
* /nylorun/harness/v1/host/join` (`{ sandboxId, podUid, joinToken }`), renews it with `POST
|
|
15
|
+
* .../host/renew` (`Authorization: Bearer <host token>`), and upgrades with the host token as
|
|
16
|
+
* its bearer: that connection hosts its sandbox alone (`HarnessPeer.host`). Both answer
|
|
17
|
+
* `{ hostToken, egressToken, sandboxId, epoch, expiresAt }`; every refusal is the same 401. The
|
|
18
|
+
* harness token is never accepted as a host token, nor a host token anywhere but here.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
21
|
+
import { createServer } from "node:http";
|
|
22
|
+
import { createChannel } from "@nylorun/core/harness-api";
|
|
23
|
+
import { WebSocketServer } from "ws";
|
|
24
|
+
import { HARNESS_API_HEADER, HARNESS_API_PATH, HARNESS_MAX_PAYLOAD, wsPort, } from "../harness/ws-port.js";
|
|
25
|
+
import { tokenType } from "../tenant/jwt.js";
|
|
26
|
+
import { HOST_TOKEN_TYP } from "../tenant/host-token.js";
|
|
27
|
+
import { HostAuthError } from "../sandbox/join.js";
|
|
28
|
+
/** Where a pod's engine exchanges its join token, and renews its host token. */
|
|
29
|
+
export const HOST_JOIN_PATH = `${HARNESS_API_PATH}/host/join`;
|
|
30
|
+
export const HOST_RENEW_PATH = `${HARNESS_API_PATH}/host/renew`;
|
|
31
|
+
/** A join's body is small. */
|
|
32
|
+
const MAX_JOIN_BODY = 8 * 1024;
|
|
33
|
+
function refuse(socket, status, text, headers = {}) {
|
|
34
|
+
const lines = [
|
|
35
|
+
`HTTP/1.1 ${status} ${text}`,
|
|
36
|
+
"Connection: close",
|
|
37
|
+
"Content-Length: 0",
|
|
38
|
+
...Object.entries(headers).map(([name, value]) => `${name}: ${value}`),
|
|
39
|
+
];
|
|
40
|
+
socket.end(`${lines.join("\r\n")}\r\n\r\n`);
|
|
41
|
+
}
|
|
42
|
+
function bearerOf(header) {
|
|
43
|
+
return /^Bearer\s+(\S+)$/i.exec(header?.trim() ?? "")?.[1];
|
|
44
|
+
}
|
|
45
|
+
function bearerMatches(header, token) {
|
|
46
|
+
const presented = bearerOf(header);
|
|
47
|
+
if (presented === undefined || token === undefined)
|
|
48
|
+
return false;
|
|
49
|
+
const a = createHash("sha256").update(presented).digest();
|
|
50
|
+
const b = createHash("sha256").update(token).digest();
|
|
51
|
+
return timingSafeEqual(a, b);
|
|
52
|
+
}
|
|
53
|
+
export async function startHarnessListener(options) {
|
|
54
|
+
let closing = false;
|
|
55
|
+
// A log line must never throw out of a socket callback (a closed Tenant's log is gone).
|
|
56
|
+
const log = (level) => (message, fields) => {
|
|
57
|
+
if (closing)
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
options.logger[level](message, fields);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* nowhere to log */
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const logger = { info: log("info"), warn: log("warn") };
|
|
67
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: HARNESS_MAX_PAYLOAD });
|
|
68
|
+
let allowed = new Set(options.allowedHosts.map((host) => host.toLowerCase()));
|
|
69
|
+
const reply = (response, status, body, headers = {}) => {
|
|
70
|
+
const text = body === undefined ? "" : JSON.stringify(body);
|
|
71
|
+
response.writeHead(status, {
|
|
72
|
+
connection: "close",
|
|
73
|
+
"cache-control": "no-store",
|
|
74
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
75
|
+
...headers,
|
|
76
|
+
});
|
|
77
|
+
response.end(text);
|
|
78
|
+
};
|
|
79
|
+
const readBody = (request) => new Promise((resolve, reject) => {
|
|
80
|
+
let size = 0;
|
|
81
|
+
const chunks = [];
|
|
82
|
+
request.on("data", (chunk) => {
|
|
83
|
+
size += chunk.length;
|
|
84
|
+
if (size > MAX_JOIN_BODY) {
|
|
85
|
+
reject(new HostAuthError(400, "The body is too large"));
|
|
86
|
+
request.destroy();
|
|
87
|
+
}
|
|
88
|
+
else
|
|
89
|
+
chunks.push(chunk);
|
|
90
|
+
});
|
|
91
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
92
|
+
request.on("error", reject);
|
|
93
|
+
});
|
|
94
|
+
/** `host/join` and `host/renew` (F7.2); everything else here is the WebSocket upgrade. */
|
|
95
|
+
const serveHost = async (request, response, path) => {
|
|
96
|
+
if (request.method !== "POST")
|
|
97
|
+
return reply(response, 405, undefined, { allow: "POST" });
|
|
98
|
+
if (!allowed.has(String(request.headers.host ?? "").toLowerCase()))
|
|
99
|
+
return reply(response, 421);
|
|
100
|
+
const authority = await options.hosts?.().catch(() => undefined);
|
|
101
|
+
if (!authority || closing)
|
|
102
|
+
return reply(response, 503, { error: { code: "unavailable", message: "Try again" } });
|
|
103
|
+
try {
|
|
104
|
+
const answer = path === HOST_JOIN_PATH
|
|
105
|
+
? await authority.join(parseJoin(await readBody(request)))
|
|
106
|
+
: await authority.renew(bearerOf(request.headers.authorization) ?? "");
|
|
107
|
+
return reply(response, 200, answer);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error instanceof HostAuthError) {
|
|
111
|
+
if (error.reason)
|
|
112
|
+
logger.warn("sandbox_host_refused", { path, reason: error.reason });
|
|
113
|
+
return reply(response, error.status, { error: { code: error.status === 401 ? "unauthorized" : "invalid", message: error.message } }, error.status === 401 ? { "www-authenticate": "Bearer" } : {});
|
|
114
|
+
}
|
|
115
|
+
logger.warn("sandbox_host_failed", { path, message: error instanceof Error ? error.message : String(error) });
|
|
116
|
+
return reply(response, 503, { error: { code: "unavailable", message: "Try again" } });
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
// Plain requests get nothing but the pods' join and renewal: the listener speaks the Harness API.
|
|
120
|
+
const server = createServer((request, response) => {
|
|
121
|
+
const path = new URL(request.url ?? "/", "http://harness.local").pathname;
|
|
122
|
+
if (closing)
|
|
123
|
+
return reply(response, 503);
|
|
124
|
+
if ((path === HOST_JOIN_PATH || path === HOST_RENEW_PATH) && options.hosts) {
|
|
125
|
+
void serveHost(request, response, path);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
response.writeHead(426, { connection: "close", upgrade: "websocket" });
|
|
129
|
+
response.end();
|
|
130
|
+
});
|
|
131
|
+
server.on("upgrade", (request, socket, head) => {
|
|
132
|
+
socket.on("error", () => undefined);
|
|
133
|
+
const path = new URL(request.url ?? "/", "http://harness.local").pathname;
|
|
134
|
+
if (closing)
|
|
135
|
+
return refuse(socket, 503, "Service Unavailable");
|
|
136
|
+
if (path !== HARNESS_API_PATH)
|
|
137
|
+
return refuse(socket, 404, "Not Found");
|
|
138
|
+
if (!allowed.has(String(request.headers.host ?? "").toLowerCase()))
|
|
139
|
+
return refuse(socket, 421, "Misdirected Request");
|
|
140
|
+
const bearer = bearerOf(request.headers.authorization);
|
|
141
|
+
// A pod's host token (F7.2): checked against its sandbox before the upgrade.
|
|
142
|
+
const hostToken = bearer !== undefined && options.hosts && tokenType(bearer) === HOST_TOKEN_TYP;
|
|
143
|
+
if (!hostToken && !bearerMatches(request.headers.authorization, options.token))
|
|
144
|
+
return refuse(socket, 401, "Unauthorized", { "WWW-Authenticate": "Bearer" });
|
|
145
|
+
if (request.headers[HARNESS_API_HEADER] !== "1")
|
|
146
|
+
return refuse(socket, 426, "Upgrade Required", { [HARNESS_API_HEADER]: "1" });
|
|
147
|
+
const host = hostToken
|
|
148
|
+
? options.hosts().then(async (authority) => {
|
|
149
|
+
if (!authority)
|
|
150
|
+
return undefined;
|
|
151
|
+
const claims = await authority.verify(bearer);
|
|
152
|
+
return { sandboxId: claims.sandboxId, epoch: claims.epoch };
|
|
153
|
+
})
|
|
154
|
+
: Promise.resolve(undefined);
|
|
155
|
+
void Promise.all([options.attach(), host]).then(([attach, hosted]) => {
|
|
156
|
+
if (!attach || closing || (hostToken && !hosted))
|
|
157
|
+
return refuse(socket, 503, "Service Unavailable");
|
|
158
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
159
|
+
const peer = {
|
|
160
|
+
name: hosted
|
|
161
|
+
? `sandbox ${hosted.sandboxId}@${request.socket.remoteAddress ?? "unknown"}`
|
|
162
|
+
: `harness@${request.socket.remoteAddress ?? "unknown"}`,
|
|
163
|
+
...(hosted ? { host: hosted } : {}),
|
|
164
|
+
};
|
|
165
|
+
const channel = createChannel(wsPort(ws, { ...options.liveness, ping: true }), { validate: true });
|
|
166
|
+
const detach = attach(channel, peer);
|
|
167
|
+
channel.onClose((reason) => {
|
|
168
|
+
detach();
|
|
169
|
+
logger.info("harness_disconnected", { peer: peer.name, reason });
|
|
170
|
+
});
|
|
171
|
+
logger.info("harness_connected", { peer: peer.name });
|
|
172
|
+
});
|
|
173
|
+
}, (error) => {
|
|
174
|
+
if (error instanceof HostAuthError) {
|
|
175
|
+
if (error.reason)
|
|
176
|
+
logger.warn("sandbox_host_refused", { path, reason: error.reason });
|
|
177
|
+
return refuse(socket, 401, "Unauthorized", { "WWW-Authenticate": "Bearer" });
|
|
178
|
+
}
|
|
179
|
+
return refuse(socket, 503, "Service Unavailable");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
await new Promise((resolve, reject) => {
|
|
183
|
+
server.once("error", reject);
|
|
184
|
+
server.listen(options.port, options.host, () => {
|
|
185
|
+
server.off("error", reject);
|
|
186
|
+
server.on("error", (error) => logger.warn("harness_listener_error", { message: error.message }));
|
|
187
|
+
resolve();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
const port = server.address().port;
|
|
191
|
+
allowed = new Set([...allowed, `localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]);
|
|
192
|
+
const host = options.host.includes(":") ? `[${options.host}]` : options.host;
|
|
193
|
+
return {
|
|
194
|
+
url: `ws://${host}:${port}${HARNESS_API_PATH}`,
|
|
195
|
+
server,
|
|
196
|
+
async close() {
|
|
197
|
+
closing = true;
|
|
198
|
+
for (const client of wss.clients)
|
|
199
|
+
client.close(1001, "core is stopping");
|
|
200
|
+
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
201
|
+
server.closeAllConnections();
|
|
202
|
+
setTimeout(() => {
|
|
203
|
+
for (const client of wss.clients)
|
|
204
|
+
client.terminate();
|
|
205
|
+
}, 1_000).unref();
|
|
206
|
+
await closed;
|
|
207
|
+
wss.close();
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** A join's body, or a 400. */
|
|
212
|
+
function parseJoin(text) {
|
|
213
|
+
let body;
|
|
214
|
+
try {
|
|
215
|
+
body = JSON.parse(text);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw new HostAuthError(400, "A join is JSON: { sandboxId, podUid, joinToken }");
|
|
219
|
+
}
|
|
220
|
+
const { sandboxId, podUid, joinToken, volumeId } = (body ?? {});
|
|
221
|
+
if (typeof sandboxId !== "string" ||
|
|
222
|
+
typeof podUid !== "string" ||
|
|
223
|
+
typeof joinToken !== "string" ||
|
|
224
|
+
(volumeId !== undefined && typeof volumeId !== "string"))
|
|
225
|
+
throw new HostAuthError(400, "A join is JSON: { sandboxId, podUid, joinToken, volumeId? }");
|
|
226
|
+
return { sandboxId, podUid, joinToken, ...(volumeId === undefined ? {} : { volumeId }) };
|
|
227
|
+
}
|