Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4c8f899
test: a caller opens WS /v1/leases/{id}/input and gets 0x83 then 0x82…
V3RON Oct 26, 2026
b73ea27
feat(input): a lease's input socket opens through a gateway and says …
V3RON Oct 26, 2026
8e27343
test: a caller opens the input socket on a standalone worker and thro…
V3RON Oct 26, 2026
124226c
feat(input): a standalone worker takes the input socket, and a gatewa…
V3RON Oct 26, 2026
ada88d4
test: a client following T3 Code's order drives a faked device throug…
V3RON Oct 26, 2026
6d8123d
feat(input): sockets of a lease share one device input, and their mes…
V3RON Oct 26, 2026
394f97a
test: a client sending a bad message keeps its socket and nothing rea…
V3RON Oct 26, 2026
834fb48
feat(input): a bad client message is dropped and a hinge control requ…
V3RON Oct 26, 2026
3452fdc
test: a worker that gives no head in 10 s answers the gateway's input…
V3RON Oct 26, 2026
28adc98
feat(input): the gateway gives a worker 10 s to answer an input socke…
V3RON Oct 26, 2026
82f6fa9
test: a 9th socket and a failed device input close 1013, and a reconn…
V3RON Oct 26, 2026
bf64c0e
feat(input): at most 8 sockets share a lease's device input, a failed…
V3RON Oct 26, 2026
7a2b733
test: an input socket closes 4001 with the reason when its lease ends…
V3RON Oct 26, 2026
2c2a2f8
feat(input): a lease's end closes its input sockets 4001 with the rea…
V3RON Oct 26, 2026
70912c5
test: through a gateway the worker's reason closes the socket 4001 an…
V3RON Oct 26, 2026
0ae7846
test: an input socket closes when the worker goes quiet or away, the …
V3RON Oct 26, 2026
d81d9cc
feat(input): the gateway closes a socket 1011 when its worker goes, 4…
V3RON Oct 26, 2026
cdf04ea
test: the worker sends a heartbeat every 5 s while its channel is ope…
V3RON Oct 26, 2026
b4fe3b9
feat(input): a worker's input session sends the gateway a heartbeat e…
V3RON Oct 26, 2026
f68eeab
feat(input): a stopping worker closes its input sockets, and the gate…
V3RON Oct 26, 2026
d85ec2b
feat(input): the input socket's OpenAPI route, website pages, manual …
V3RON Oct 26, 2026
1854aaa
refactor(input): keep the input surface to what a caller reads, and t…
V3RON Oct 26, 2026
c39c658
feat(http): a proxy-signed identity needs Sec-Fetch-Site same-origin …
V3RON Oct 26, 2026
a3307da
test(input): probe-driven fixes: ambient upgrade proved end to end, b…
V3RON Oct 26, 2026
7a0f715
fix(input): the worker hands modules an explicit openInput, and the c…
V3RON Oct 26, 2026
0df8cd8
chore: merge task/1016 into task/1017 (#1017)
V3RON Oct 26, 2026
6afe780
fix(input): a last socket leaving does not close an input a joiner sh…
V3RON Oct 26, 2026
1dad0b5
Merge remote-tracking branch 'origin/task/1016' into HEAD
V3RON Oct 26, 2026
635b721
fix(ports): a client reset while the module resolves no longer crashe…
V3RON Oct 26, 2026
2cca75a
fix(daemon): the uplink comment starts with why: (#1017)
V3RON Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dependency-cruiser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ export default {
"^src/stream/",
{ path: "^src/(http|gateway|drivers|daemon|cli|mcp|leasing)/" },
),
direction(
"not-input",
"core, leasing, the drivers, the gateway and the contract import nothing from the input module",
"^src/(core|leasing|drivers|gateway|contract)/",
{ path: "^src/input/" },
),
direction(
"input-not-edges",
"the input module is a module the host loads: it imports no frontend, gateway, driver or leasing",
"^src/input/",
{ path: "^src/(http|gateway|drivers|daemon|cli|mcp|leasing)/" },
),
direction(
"host-load-imports-ports-only",
"host-load reads the machine through the HostLoadProbe port and imports no other component",
Expand Down
3 changes: 3 additions & 0 deletions docs/internal/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ worker daemon ── uplink (WebSocket to /v1/uplink, worker dials) ──┘
| link | Byte channels over the uplink | [link](../../src/link/AGENTS.md) |
| agent-device | Optional edge: the two agent-device daemons (simulators, phones) beside a worker, its HTTP front | [agent-device](../../src/agent-device/AGENTS.md) |
| stream | Optional edge: a running device's live screen over HTTP, relayed by a gateway | [stream](../../src/stream/AGENTS.md) |
| input | Optional edge: a lease holder's taps and keys over a WebSocket, relayed by a gateway | [input](../../src/input/AGENTS.md) |
| auth | Credential → identity: tokens, admin secret, `hello`, uplink join token, HTTP strategies | [auth](../../src/auth/AGENTS.md) |
| http | HTTP frontend: routes, SSE | [http](../../src/http/AGENTS.md) |
| cli | Operator command surface | [cli](../../src/cli/AGENTS.md) |
Expand Down Expand Up @@ -86,6 +87,7 @@ Arrows point from the optional to the required (architecture rule 15).
| `host-load-imports-ports-only`, `not-host-load` | host-load stands alone |
| `alerts-imports-bus-ports-core-only`, `not-alerts` | alerts imports the bus, ports and core only; only `src/daemon/main.ts` imports it |
| `not-stream`, `stream-not-edges` | Nothing required imports stream; stream imports no frontend, gateway or driver |
| `not-input`, `input-not-edges` | Nothing required imports input; input imports no frontend, gateway or driver |
| `gateway-not-edges`, `gateway-only-dispatch` | Gateway imports no driver or frontend, and from daemon only `src/daemon/dispatch.ts` |
| `only-main-knows-gateway` | In daemon, only `src/daemon/main.ts` imports the gateway |
| `link-is-portable`, `link-imports-port-types-only` | The link uses no Node module or package, and only the types of ports |
Expand All @@ -105,6 +107,7 @@ never import each other, directly or through a third.
| `device.exec`, local and through a gateway | [device-exec](flows/device-exec.md) |
| agent-device through simlock | [agent-device](../../src/agent-device/AGENTS.md) |
| A screen stream, local and through a gateway | [stream](../../src/stream/AGENTS.md) |
| A lease's input socket, local and through a gateway | [input](../../src/input/AGENTS.md) |

## Rules that cut across modules

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ by one.

A request whose identity is `ambient`, with a method other than `GET`,
`HEAD` or `OPTIONS`, must carry `Sec-Fetch-Site: same-origin`. Any other
value, or no header, is `403 FORBIDDEN`. A bearer token is not ambient, so
value, or no header, is `403 FORBIDDEN`. A WebSocket upgrade (a `GET` with
`Upgrade: websocket`) is held to the same rule, since a page on another site
can open one with the person's cookies. A bearer token is not ambient, so
agents and scripts are not affected. Browsers send the header only to an
`https://` address or to loopback, so behind a proxy served over plain HTTP
on a network name every such request is refused; the console sends none
Expand Down
58 changes: 29 additions & 29 deletions src/core/AGENTS.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions src/core/driver-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
PhysicalDriverDevice,
VirtualDriverDevice,
} from "./driver.js";
import type { DeviceInput, UnavailableDeviceInput } from "./input.js";
import { type ScreenFeed, ScreenNotSupportedError, type ScreenSettings } from "./screen.js";
import { stableError } from "./stable-error.js";

Expand Down Expand Up @@ -106,6 +107,16 @@ export class DriverCatalog {
return await capture.open(device, settings, signal);
}

async openInput(
platform: Platform,
device: VirtualDriverDevice | PhysicalDriverDevice,
signal: AbortSignal,
): Promise<DeviceInput | UnavailableDeviceInput> {
const factory = this.#drivers.get(platform)?.input;
if (factory === undefined) return { unavailable: `${platform} devices take no input` };
return await factory.open(device, signal);
}

async screenAvailability(): Promise<readonly ScreenAvailability[]> {
return await Promise.all(
[...this.#drivers.values()].flatMap((driver) =>
Expand Down
4 changes: 4 additions & 0 deletions src/core/driver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { EventMap } from "../bus/index.js";
import type { Filesystem, SqliteDatabase, SqliteSchemaOwner } from "../ports/index.js";
import type { RootRejectionReason } from "./device-root.js";
import type { DeviceInputFactory } from "./input.js";
import type { ScreenCapture } from "./screen.js";
import type {
AbsentReason,
Expand Down Expand Up @@ -302,6 +303,9 @@ export interface Driver {
// why: optional because a platform whose screen cannot be taken leaves it out, and the stream
// module learns that from its absence rather than from a platform name.
readonly screenCapture?: ScreenCapture | undefined;
// why: optional like `screenCapture`: a platform whose device takes no input leaves it out, and a
// lease's input socket then says the device is unavailable.
readonly input?: DeviceInputFactory | undefined;
/** why: `device.exec` has no pseudo-terminal (ADR 0005 §19c), so a driver may refuse there what it allows locally. */
passthrough?(
tool: string,
Expand Down
71 changes: 71 additions & 0 deletions src/core/fake-device-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type {
DeviceInput,
DeviceInputFactory,
InputMessage,
InputScreenConfig,
UnavailableDeviceInput,
} from "./input.js";

// why: a device input for tests: it records what reached it and plays the device's changes.
class FakeDeviceInput implements DeviceInput {
readonly sent: InputMessage[] = [];
closes = 0;
readonly #configListeners = new Set<(config: InputScreenConfig) => void>();
readonly #failedListeners = new Set<(error: Error) => void>();
#config: InputScreenConfig;

constructor(config: InputScreenConfig) {
this.#config = config;
}

send(message: InputMessage): void {
this.sent.push(message);
}

onConfig(listener: (config: InputScreenConfig) => void): () => void {
this.#configListeners.add(listener);
listener(this.#config);
return () => this.#configListeners.delete(listener);
}

onFailed(listener: (error: Error) => void): () => void {
this.#failedListeners.add(listener);
return () => this.#failedListeners.delete(listener);
}

close(): Promise<void> {
this.closes += 1;
return Promise.resolve();
}

fail(error: Error): void {
for (const listener of this.#failedListeners) listener(error);
}

changeConfig(config: InputScreenConfig): void {
this.#config = config;
for (const listener of this.#configListeners) listener(config);
}
}

// why: a driver's `input` member for tests: `opened` holds every device input it handed out, so a test
// can tell one shared device input from one per socket.
export class FakeInputFactory implements DeviceInputFactory {
readonly opened: FakeDeviceInput[] = [];
// why: a test sets this to make the next opens fail, as a controller that cannot start does.
failWith: Error | undefined;
// why: a test sets this to hold every open until it resolves, to end a lease while a socket is still opening.
gate: Promise<void> | undefined;
opens = 0;

constructor(private readonly config: InputScreenConfig) {}

async open(): Promise<FakeDeviceInput | UnavailableDeviceInput> {
this.opens += 1;
await this.gate;
if (this.failWith !== undefined) throw this.failWith;
const input = new FakeDeviceInput(this.config);
this.opened.push(input);
return input;
}
}
4 changes: 4 additions & 0 deletions src/core/fake-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
UnknownModelError,
uninstallPlan,
} from "./driver.js";
import type { DeviceInputFactory } from "./input.js";
import type { ScreenCapture } from "./screen.js";

export type FakeDriverOperation =
Expand Down Expand Up @@ -129,6 +130,7 @@ export interface FakeDriverOptions {
/** why: absent by default, so two fakes in one catalog do not both answer to the same tool. */
readonly passthroughTools?: readonly string[];
readonly screenCapture?: ScreenCapture;
readonly input?: DeviceInputFactory;
readonly passthrough?: (
tool: string,
device: VirtualDriverDevice | PhysicalDriverDevice | undefined,
Expand Down Expand Up @@ -197,6 +199,7 @@ export class FakeDriver implements Driver {
readonly #leaseEnvironment: Readonly<Record<string, string>>;
readonly passthroughTools: readonly string[] | undefined;
readonly screenCapture: ScreenCapture | undefined;
readonly input: DeviceInputFactory | undefined;
readonly #passthrough: FakeDriverOptions["passthrough"];
#nextDeviceNumber = 1;
readonly #pendingMakeReady: (() => void)[] = [];
Expand Down Expand Up @@ -237,6 +240,7 @@ export class FakeDriver implements Driver {
this.#legacyDevices = new Map(Object.entries(options.legacyDevices ?? {}));
this.passthroughTools = options.passthroughTools;
this.screenCapture = options.screenCapture;
this.input = options.input;
this.#passthrough = options.passthrough;
this.platform = options.platform;
this.deviceRoot = options.deviceRoot ?? `/fake/${options.platform}`;
Expand Down
12 changes: 12 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ export {
type ScreenAvailability,
UnknownPassthroughToolError,
} from "./driver-catalog.js";
export {
closeReason,
type DeviceInput,
encodeInputMessage,
INPUT_TAG,
MAX_INPUT_MESSAGE_BYTES,
isUnavailableInput,
parseClientMessage,
type InputScreenConfig,
type UnavailableDeviceInput,
UNAVAILABLE_INPUT_CONFIG,
} from "./input.js";
export {
encodeScreenFrame,
encodeScreenMeta,
Expand Down
Loading
Loading