@intentic/sandbox-contract 1.300.0 → 1.300.1
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/dist/contracts/agent.contract.d.ts +765 -1
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.js +3 -2
- package/dist/contracts/agent.contract.js.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +5 -1
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.js +3 -2
- package/dist/contracts/capabilities.contract.js.map +1 -1
- package/dist/contracts/exit.contract.d.ts +15 -3
- package/dist/contracts/exit.contract.d.ts.map +1 -1
- package/dist/contracts/exit.contract.js +5 -4
- package/dist/contracts/exit.contract.js.map +1 -1
- package/dist/contracts/host.contract.d.ts +24 -2
- package/dist/contracts/host.contract.d.ts.map +1 -1
- package/dist/contracts/host.contract.js +4 -3
- package/dist/contracts/host.contract.js.map +1 -1
- package/dist/contracts/intentic.contract.d.ts +10 -2
- package/dist/contracts/intentic.contract.d.ts.map +1 -1
- package/dist/contracts/intentic.contract.js +4 -3
- package/dist/contracts/intentic.contract.js.map +1 -1
- package/dist/contracts/netdisk.contract.d.ts +5 -1
- package/dist/contracts/netdisk.contract.d.ts.map +1 -1
- package/dist/contracts/netdisk.contract.js +3 -2
- package/dist/contracts/netdisk.contract.js.map +1 -1
- package/dist/contracts/runner.contract.d.ts +505 -2
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.js +4 -3
- package/dist/contracts/runner.contract.js.map +1 -1
- package/dist/contracts/system.contract.d.ts +333 -3
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +5 -4
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/contracts/vpn.contract.d.ts +5 -1
- package/dist/contracts/vpn.contract.d.ts.map +1 -1
- package/dist/contracts/vpn.contract.js +3 -2
- package/dist/contracts/vpn.contract.js.map +1 -1
- package/dist/index.d.ts +1138 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/protocol/routes.d.ts +6 -0
- package/dist/protocol/routes.d.ts.map +1 -1
- package/dist/protocol/routes.js +12 -4
- package/dist/protocol/routes.js.map +1 -1
- package/dist/schemas/devices.d.ts +1 -0
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/devices.js +43 -24
- package/dist/schemas/devices.js.map +1 -1
- package/dist/state/history-state.d.ts.map +1 -1
- package/dist/state/history-state.js +1 -0
- package/dist/state/history-state.js.map +1 -1
- package/package.json +5 -5
- package/src/contracts/agent.contract.ts +3 -2
- package/src/contracts/capabilities.contract.ts +3 -2
- package/src/contracts/exit.contract.ts +5 -4
- package/src/contracts/host.contract.ts +4 -3
- package/src/contracts/intentic.contract.ts +4 -3
- package/src/contracts/netdisk.contract.ts +3 -2
- package/src/contracts/runner.contract.ts +4 -3
- package/src/contracts/system.contract.ts +5 -4
- package/src/contracts/vpn.contract.ts +3 -2
- package/src/protocol/routes.test.ts +51 -18
- package/src/protocol/routes.ts +26 -6
- package/src/schemas/devices.ts +75 -29
- package/src/state/history-state.ts +3 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { oc } from "@orpc/contract";
|
|
2
|
+
import { streamOf } from "../protocol/routes.js";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { SessionTranscriptSchema } from "../events/transcript.js";
|
|
4
5
|
import { SystemEventSchema } from "../events/system-events.js";
|
|
@@ -78,7 +79,7 @@ export const systemContract = {
|
|
|
78
79
|
"A stream held open for as long as you want it, carrying heartbeats so a caller notices the sandbox dying at once, batches of file changes so a tree or an editor can refresh itself, and the roster of who else is looking. Give it an id for this connection to appear in that roster; leave it out and you watch without being seen.",
|
|
79
80
|
})
|
|
80
81
|
.input(z.object({ clientId: z.string().optional() }))
|
|
81
|
-
.output(
|
|
82
|
+
.output(streamOf(SystemEventSchema)),
|
|
82
83
|
// A tab's activity self-report (view/session/file/idle), fanned back out to every member on /events.
|
|
83
84
|
presence: oc
|
|
84
85
|
.route({
|
|
@@ -191,7 +192,7 @@ export const systemContract = {
|
|
|
191
192
|
"Start, stop, restart, update, rebuild, roll back, reshape (its memory and CPU caps, privileged, GPU) or remove a sandbox running on a machine you own, relayed over the connection that machine holds open. The answer is a stream because the slowest of these takes minutes, and it is the same stream whichever you ask for. The daemon adds no opinion: the machine enforces its own permissions and a refusal arrives as the last line, in the machine's words, naming the switch to flip.",
|
|
192
193
|
})
|
|
193
194
|
.input(DeviceSandboxFlowInputSchema)
|
|
194
|
-
.output(
|
|
195
|
+
.output(streamOf(DeviceFlowLineSchema)),
|
|
195
196
|
// Closed set of actions; the daemon builds the command line, not the caller. One sentence is the whole answer.
|
|
196
197
|
runDeviceCommand: oc
|
|
197
198
|
.route({
|
|
@@ -213,5 +214,5 @@ export const systemContract = {
|
|
|
213
214
|
"Updates a machine you own to the current intentic-machine agent, or restarts the loop it is running, over the connection that machine holds open. The answer is a stream of the run's own output — and it normally stops mid-run, because the agent's loop is what carries this connection: the work is detached from it first, so it finishes regardless, and the device's reported version is what confirms it. Takes the machine's \"Run commands\" permission, the same one a command typed there would.",
|
|
214
215
|
})
|
|
215
216
|
.input(DeviceAgentFlowInputSchema)
|
|
216
|
-
.output(
|
|
217
|
+
.output(streamOf(DeviceFlowLineSchema)),
|
|
217
218
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { oc } from "@orpc/contract";
|
|
2
|
+
import { streamOf } from "../protocol/routes.js";
|
|
2
3
|
import { IntenticLineSchema } from "../events/system-events.js";
|
|
3
4
|
import { OkSchema } from "../schemas/shared.js";
|
|
4
5
|
import { ForticlientImportInputSchema, ForticlientImportSchema, VpnConnectInputSchema, VpnIdParamSchema, VpnListSchema } from "../schemas/vpn.js";
|
|
@@ -28,7 +29,7 @@ export const vpnContract = {
|
|
|
28
29
|
"Brings a stored tunnel up, streaming the client's progress as it authenticates and then sets up routing. Streamed because a dial takes seconds and can fail with something you have to read: a wrong password, a gateway certificate nobody trusts, a code it wants. Connecting one that is already up simply says so.",
|
|
29
30
|
})
|
|
30
31
|
.input(VpnConnectInputSchema)
|
|
31
|
-
.output(
|
|
32
|
+
.output(streamOf(IntenticLineSchema)),
|
|
32
33
|
// Drops a tunnel; tolerates one already down, since the contract is "not up," not "it was up."
|
|
33
34
|
disconnect: oc
|
|
34
35
|
.route({
|
|
@@ -2,7 +2,7 @@ import { eventIterator, oc } from "@orpc/contract";
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTE_SHAPES, SANDBOX_ROUTES, sandboxRouteName } from "../index.js";
|
|
5
|
-
import { contractRoutes, routeNameForRequest, routeShapes } from "./routes.js";
|
|
5
|
+
import { contractRoutes, routeNameForRequest, routeShapes, streamOf } from "./routes.js";
|
|
6
6
|
|
|
7
7
|
const fixture = {
|
|
8
8
|
vpn: {
|
|
@@ -111,18 +111,38 @@ describe(`routeShapes`, () => {
|
|
|
111
111
|
expect(routeShapes(shaped(z.object({ a: z.string() })))[`vpn.list`]).not.toBe(shapes[`vpn.list`]);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
it(`
|
|
115
|
-
// An oRPC event iterator wraps its output in an opaque type with no schema, so `watch` here keeps its name but
|
|
116
|
-
// no shape.
|
|
114
|
+
it(`fingerprints a stream through the frames it declares`, () => {
|
|
117
115
|
const withStream = {
|
|
116
|
+
vpn: {
|
|
117
|
+
list: fixture.vpn.list,
|
|
118
|
+
watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(streamOf(z.object({ a: z.string() }))),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
expect(Object.keys(routeShapes(withStream)).toSorted()).toEqual([`vpn.list`, `vpn.watch`]);
|
|
122
|
+
// A changed frame is a changed route, which is the whole point of reaching past the iterator.
|
|
123
|
+
const reframed = { vpn: { watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(streamOf(z.object({ a: z.number() }))) } };
|
|
124
|
+
expect(routeShapes(reframed)[`vpn.watch`]).not.toBe(routeShapes(withStream)[`vpn.watch`]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it(`tells a stream of X apart from a route that answers X`, () => {
|
|
128
|
+
const frame = z.object({ a: z.string() });
|
|
129
|
+
const streamed = { vpn: { watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(streamOf(frame)) } };
|
|
130
|
+
const answered = { vpn: { watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(frame) } };
|
|
131
|
+
expect(routeShapes(streamed)[`vpn.watch`]).not.toBe(routeShapes(answered)[`vpn.watch`]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it(`omits a route whose shape cannot be expressed rather than failing the walk`, () => {
|
|
135
|
+
// oRPC's own eventIterator hides its frames, which is why contracts declare streams with streamOf; one that
|
|
136
|
+
// slipped through keeps its name and loses only its shape, instead of breaking the walk for every other route.
|
|
137
|
+
const withRawIterator = {
|
|
118
138
|
vpn: {
|
|
119
139
|
list: fixture.vpn.list,
|
|
120
140
|
watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(eventIterator(z.object({ a: z.string() }))),
|
|
121
141
|
},
|
|
122
142
|
};
|
|
123
|
-
expect(Object.keys(routeShapes(
|
|
143
|
+
expect(Object.keys(routeShapes(withRawIterator)).toSorted()).toEqual([`vpn.list`]);
|
|
124
144
|
expect(
|
|
125
|
-
contractRoutes(
|
|
145
|
+
contractRoutes(withRawIterator)
|
|
126
146
|
.map((route) => route.name)
|
|
127
147
|
.toSorted(),
|
|
128
148
|
).toEqual([`vpn.list`, `vpn.watch`]);
|
|
@@ -130,34 +150,47 @@ describe(`routeShapes`, () => {
|
|
|
130
150
|
});
|
|
131
151
|
|
|
132
152
|
describe(`the real sandbox contract`, () => {
|
|
133
|
-
it(`fingerprints
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
153
|
+
it(`fingerprints every route, streams included`, () => {
|
|
154
|
+
// Nothing may opt out: a route with no fingerprint is a route the browser can never tell has drifted, and the
|
|
155
|
+
// streaming ones (system.events, agent.attach, the exit dials) carry the frames a stale daemon breaks first.
|
|
156
|
+
expect(SANDBOX_ROUTE_NAMES.filter((name) => !(name in SANDBOX_ROUTE_SHAPES))).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it(`fingerprints the streaming routes eventIterator used to hide`, () => {
|
|
160
|
+
expect(
|
|
161
|
+
[
|
|
162
|
+
`agent.attach`,
|
|
163
|
+
`capabilities.add`,
|
|
164
|
+
`exit.rotate`,
|
|
165
|
+
`exit.start`,
|
|
166
|
+
`exit.use`,
|
|
167
|
+
`intentic.applyEvents`,
|
|
168
|
+
`intentic.run`,
|
|
169
|
+
`netdisk.mount`,
|
|
170
|
+
`system.events`,
|
|
171
|
+
`system.manageDeviceSandbox`,
|
|
172
|
+
`system.runDeviceAgentFlow`,
|
|
173
|
+
`vpn.connect`,
|
|
174
|
+
].filter((name) => name in SANDBOX_ROUTE_SHAPES).toSorted(),
|
|
175
|
+
).toEqual([
|
|
138
176
|
`agent.attach`,
|
|
139
177
|
`capabilities.add`,
|
|
140
|
-
// The three exit moves stream since bringing an exit up dials and verifies over tens of seconds, failing at
|
|
141
|
-
// any step.
|
|
142
178
|
`exit.rotate`,
|
|
143
179
|
`exit.start`,
|
|
144
180
|
`exit.use`,
|
|
145
181
|
`intentic.applyEvents`,
|
|
146
182
|
`intentic.run`,
|
|
147
|
-
// A mount dials a file server and can fail at any step with something to read, like a vpn dial.
|
|
148
183
|
`netdisk.mount`,
|
|
149
184
|
`system.events`,
|
|
150
185
|
`system.manageDeviceSandbox`,
|
|
151
|
-
// The device agent updates or restarts itself; the stream dies with the process, so lines arrive as they
|
|
152
|
-
// happen.
|
|
153
186
|
`system.runDeviceAgentFlow`,
|
|
154
187
|
`vpn.connect`,
|
|
155
188
|
]);
|
|
156
189
|
});
|
|
157
190
|
|
|
158
|
-
it(`fingerprints every
|
|
191
|
+
it(`fingerprints every route exactly once`, () => {
|
|
159
192
|
expect(Object.keys(SANDBOX_ROUTE_SHAPES).every((name) => SANDBOX_ROUTE_NAMES.includes(name))).toBe(true);
|
|
160
|
-
expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length
|
|
193
|
+
expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length);
|
|
161
194
|
});
|
|
162
195
|
|
|
163
196
|
it(`derives a route table with no duplicate names`, () => {
|
package/src/protocol/routes.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
|
+
import { eventIterator } from "@orpc/contract";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
|
|
3
4
|
// Named route surface of the daemon's contract (`<group>.<route>`), derived automatically so nothing here is
|
|
4
5
|
// hand-maintained. The daemon advertises which routes it implements (the /events hello frame); the browser diffs that
|
|
5
6
|
// against its own contract so an old daemon's gap is a named feature check, not a silent 404.
|
|
6
7
|
|
|
8
|
+
// Every streamed route declares its frames through this, never oRPC's `eventIterator` directly: that returns an opaque
|
|
9
|
+
// standard-schema validator holding no reachable inner schema, so a stream's payload would be unfingerprintable and its
|
|
10
|
+
// drift invisible. The frame schema rides along under a symbol, which oRPC never reads and JSON never serializes.
|
|
11
|
+
// Typed as a plain `symbol`, not the inferred unique one: a unique symbol would ride into every contract's inferred
|
|
12
|
+
// type and break declaration emit on a local name nothing outside can refer to.
|
|
13
|
+
const FRAME: symbol = Symbol.for("intentic.contract.frame");
|
|
14
|
+
|
|
15
|
+
export const streamOf = <T extends z.ZodType>(frame: T) => Object.assign(eventIterator(frame), { [FRAME]: frame });
|
|
16
|
+
|
|
17
|
+
// The frame schema behind a streamed route's validator, or undefined for an ordinary request/response schema.
|
|
18
|
+
const frameOf = (schema: unknown): z.ZodType | undefined =>
|
|
19
|
+
typeof schema === "object" && schema !== null && FRAME in schema ? ((schema as Record<symbol, unknown>)[FRAME] as z.ZodType) : undefined;
|
|
20
|
+
|
|
7
21
|
// Structural shape of `~orpc.route`, the metadata oRPC attaches to every `oc.route(...)` procedure; read this way since
|
|
8
22
|
// oRPC's internal types aren't public.
|
|
9
23
|
interface ContractProcedureLike {
|
|
@@ -85,18 +99,24 @@ const fingerprint = (value: unknown): string => {
|
|
|
85
99
|
return hash.toString(36);
|
|
86
100
|
};
|
|
87
101
|
|
|
88
|
-
// One route
|
|
89
|
-
// matters: a `.default()` field is optional in and required out
|
|
102
|
+
// One side of a route as JSON Schema. A streamed side is read through its frame and tagged, so "answers X" and "streams
|
|
103
|
+
// frames of X" can never fingerprint alike. `io` matters: a `.default()` field is optional in and required out.
|
|
104
|
+
const wireShape = (schema: unknown, io: "input" | "output"): unknown => {
|
|
105
|
+
if (schema === undefined) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const frame = frameOf(schema);
|
|
109
|
+
return frame === undefined ? z.toJSONSchema(schema as z.ZodType, { io }) : { stream: z.toJSONSchema(frame, { io }) };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// One route's wire shape, or undefined if it can't be expressed at all.
|
|
90
113
|
const procedureShape = (value: unknown): string | undefined => {
|
|
91
114
|
if (typeof value !== "object" || value === null || !("~orpc" in value)) {
|
|
92
115
|
return undefined;
|
|
93
116
|
}
|
|
94
117
|
const { inputSchema, outputSchema } = (value as ContractSchemasLike)["~orpc"];
|
|
95
118
|
try {
|
|
96
|
-
return fingerprint({
|
|
97
|
-
in: inputSchema === undefined ? undefined : z.toJSONSchema(inputSchema as z.ZodType, { io: "input" }),
|
|
98
|
-
out: outputSchema === undefined ? undefined : z.toJSONSchema(outputSchema as z.ZodType, { io: "output" }),
|
|
99
|
-
});
|
|
119
|
+
return fingerprint({ in: wireShape(inputSchema, "input"), out: wireShape(outputSchema, "output") });
|
|
100
120
|
} catch {
|
|
101
121
|
return undefined;
|
|
102
122
|
}
|
package/src/schemas/devices.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What one of the user's own machines is running.
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { type HostFacts, HostFactsSchema, WslEnvironmentSchema } from "./hosts.js";
|
|
3
|
+
import { hostCardOf, hostEnvironmentOf, type HostFacts, HostFactsSchema, WslEnvironmentSchema } from "./hosts.js";
|
|
4
4
|
import { DEV_VERSION } from "../state/versions.js";
|
|
5
5
|
// Desktop-sync report shape shared by the agent, daemon and browser, produced only by `intentic-machine status --json`.
|
|
6
6
|
// The agent never reports `sandboxes`; the docker half is filled in by whoever reads the report, scoped to the reader's
|
|
@@ -451,54 +451,100 @@ export type DevicesList = z.infer<typeof DevicesListSchema>;
|
|
|
451
451
|
// The physical computer a device is an environment of. Windows and every WSL distro on it are one machine with one
|
|
452
452
|
// Docker engine, one screen and one set of disks, each environment holding its own agent and its own door.
|
|
453
453
|
export interface Machine {
|
|
454
|
-
/**
|
|
454
|
+
/** The card its doors hang off, else the hostname they share, else a lone uncarded device's own key. */
|
|
455
455
|
readonly key: string;
|
|
456
456
|
readonly label: string;
|
|
457
|
-
/** Windows first, then distros by
|
|
457
|
+
/** Windows first, then distros by the name WSL registered: the side that owns the screen leads. */
|
|
458
458
|
readonly environments: readonly Device[];
|
|
459
459
|
}
|
|
460
460
|
|
|
461
461
|
export const deviceHostname = (device: Device): string | undefined => device.facts?.hostname ?? device.report?.hostname;
|
|
462
|
-
export const deviceEnvironment = (device: Device): string | undefined => environmentOf(device.facts, device.report);
|
|
463
|
-
export const isWslDevice = (device: Device): boolean => deviceEnvironment(device)?.startsWith("wsl:") === true;
|
|
464
462
|
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
463
|
+
// Which side of a machine a device is. Its door id names the environment it connected under (`<card>::wsl:<distro>`),
|
|
464
|
+
// and that is all an environment nobody has reached since this daemon booted can say about itself: liveness resets on
|
|
465
|
+
// restart, so facts and reports are absent on a side that is merely asleep.
|
|
466
|
+
export const deviceEnvironment = (device: Device): string | undefined =>
|
|
467
|
+
environmentOf(device.facts, device.report) ?? (device.hostId === undefined ? undefined : hostEnvironmentOf(device.hostId));
|
|
468
|
+
|
|
469
|
+
const WSL_PREFIX = "wsl:";
|
|
470
|
+
export const isWslDevice = (device: Device): boolean => deviceEnvironment(device)?.startsWith(WSL_PREFIX) === true;
|
|
471
|
+
// The distro a device runs, by whatever evidence it holds; absent on a native install and on one that has said nothing.
|
|
472
|
+
export const deviceDistro = (device: Device): string | undefined => {
|
|
473
|
+
const environment = deviceEnvironment(device);
|
|
474
|
+
return environment?.startsWith(WSL_PREFIX) === true ? environment.slice(WSL_PREFIX.length) : undefined;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// Native first, then distros by the name WSL registered them under rather than by their door id, which is that same
|
|
478
|
+
// name behind a card's and so would order a PC's distros by which card each was connected through.
|
|
479
|
+
const byEnvironment = (a: Device, b: Device): number =>
|
|
480
|
+
Number(isWslDevice(a)) - Number(isWslDevice(b)) || (deviceDistro(a) ?? a.label).localeCompare(deviceDistro(b) ?? b.label);
|
|
468
481
|
|
|
469
482
|
const hostnameKey = (device: Device): string | undefined => deviceHostname(device)?.toLowerCase();
|
|
470
483
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
484
|
+
// The card a device's door hangs off: one card is one computer, so two doors naming the same card are one machine
|
|
485
|
+
// whatever either has said about itself — which is the whole of what an environment that has never connected says.
|
|
486
|
+
const cardKey = (device: Device): string | undefined => (device.hostId === undefined ? undefined : hostCardOf(device.hostId));
|
|
487
|
+
|
|
488
|
+
// What makes two devices one computer: a shared card, or a shared hostname where a distro answers to it, since WSL
|
|
489
|
+
// hands a distro the Windows machine's name. Two native installs that merely share a name stay two machines.
|
|
490
|
+
const tokensOf = (device: Device, distroNames: ReadonlySet<string>): string[] => {
|
|
491
|
+
const card = cardKey(device);
|
|
492
|
+
const hostname = hostnameKey(device);
|
|
493
|
+
return [
|
|
494
|
+
...(card === undefined ? [] : [`card:${card.toLowerCase()}`]),
|
|
495
|
+
...(hostname !== undefined && distroNames.has(hostname) ? [`host:${hostname}`] : []),
|
|
496
|
+
];
|
|
475
497
|
};
|
|
476
498
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
return { key: hostname, label: hostname, environments };
|
|
484
|
-
};
|
|
499
|
+
// Components over both joins at once: a device holding a card token and a hostname token is the evidence that merges
|
|
500
|
+
// the machines those tokens named, so a distro connected under a card of its own still lands on the PC it runs on.
|
|
501
|
+
interface Component {
|
|
502
|
+
readonly tokens: Set<string>;
|
|
503
|
+
readonly devices: Device[];
|
|
504
|
+
}
|
|
485
505
|
|
|
486
|
-
|
|
487
|
-
const
|
|
488
|
-
const
|
|
506
|
+
const componentsOf = (devices: readonly Device[]): Component[] => {
|
|
507
|
+
const distroNames = new Set(devices.filter(isWslDevice).map(hostnameKey).filter((name) => name !== undefined));
|
|
508
|
+
const components: Component[] = [];
|
|
489
509
|
for (const device of devices) {
|
|
490
|
-
|
|
510
|
+
const tokens = tokensOf(device, distroNames);
|
|
511
|
+
const [head, ...merged] = components.filter((component) => tokens.some((token) => component.tokens.has(token)));
|
|
512
|
+
if (head === undefined) {
|
|
513
|
+
components.push({ tokens: new Set(tokens), devices: [device] });
|
|
491
514
|
continue;
|
|
492
515
|
}
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
516
|
+
for (const other of merged) {
|
|
517
|
+
other.tokens.forEach((token) => head.tokens.add(token));
|
|
518
|
+
head.devices.push(...other.devices);
|
|
519
|
+
components.splice(components.indexOf(other), 1);
|
|
496
520
|
}
|
|
497
|
-
|
|
521
|
+
for (const token of tokens) {
|
|
522
|
+
head.tokens.add(token);
|
|
523
|
+
}
|
|
524
|
+
head.devices.push(device);
|
|
498
525
|
}
|
|
499
|
-
return
|
|
526
|
+
return components;
|
|
500
527
|
};
|
|
501
528
|
|
|
529
|
+
// Addressed by the card of the side that owns the screen, an address that does not change when a second environment
|
|
530
|
+
// connects — unlike a row key, which is a hostname another environment can take first. An uncarded fold has only the
|
|
531
|
+
// hostname its doors share, and an uncarded lone device its own key. Called what the owner calls it: the name the
|
|
532
|
+
// leading environment carries, unless that is nothing but its door id, which reads as a PC named after one of its
|
|
533
|
+
// own sides.
|
|
534
|
+
const machineOf = (environments: readonly Device[]): Machine => {
|
|
535
|
+
const [first, ...rest] = environments;
|
|
536
|
+
// A component holds the device that made it; the empty case only keeps the type honest.
|
|
537
|
+
if (first === undefined) {
|
|
538
|
+
return { key: "", label: "", environments };
|
|
539
|
+
}
|
|
540
|
+
const named = environments.map(cardKey).find((key) => key !== undefined) ?? (rest.length === 0 ? undefined : deviceHostname(first));
|
|
541
|
+
const label = first.label === first.hostId ? (cardKey(first) ?? first.label) : first.label;
|
|
542
|
+
return { key: named ?? first.key, label, environments };
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
export const machinesOf = (devices: readonly Device[]): Machine[] =>
|
|
546
|
+
componentsOf(devices).map((component) => machineOf(component.devices.toSorted(byEnvironment)));
|
|
547
|
+
|
|
502
548
|
// Every door whose docker reports a given sandbox slug, in list order. More than one is the ordinary case, not a
|
|
503
549
|
// conflict: Windows and the WSL distros on it share one engine, so each door answers for the same containers.
|
|
504
550
|
const doorsRunningSandbox = (devices: readonly Device[], slug: string | undefined): Device[] =>
|
|
@@ -32,6 +32,9 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
32
32
|
{ path: "provider-refusals.json", portability: "carry" },
|
|
33
33
|
// Models refused to this sandbox's credentials; expires daily, same subscriptions apply elsewhere.
|
|
34
34
|
{ path: "model-refusals.json", portability: "carry" },
|
|
35
|
+
// What each account has run out of, for a plan publishing no allowance to poll — the refusal is the reading.
|
|
36
|
+
// Carried like the refusals above: an allowance belongs to the account, not to the machine that spent it.
|
|
37
|
+
{ path: "observed-limits.json", portability: "carry" },
|
|
35
38
|
// First-time dependency setup requests; carried so an interrupted queue survives export, entries self-remove.
|
|
36
39
|
{ path: "dependency-requests.json", portability: "carry" },
|
|
37
40
|
// Which device cards were already offered; carry, not identity, since it holds ids, never a token.
|