@capybara-hq/kernel 0.1.0-rc.7 → 0.1.0-rc.9

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.
Files changed (118) hide show
  1. package/dist/background/finish.js +2 -1
  2. package/dist/background/launch.d.ts +13 -7
  3. package/dist/background/launch.js +15 -8
  4. package/dist/background/poll.job.d.ts +1 -1
  5. package/dist/background/poll.job.js +8 -3
  6. package/dist/background/session.d.ts +2 -2
  7. package/dist/background/session.js +3 -2
  8. package/dist/background/status.d.ts +1 -1
  9. package/dist/background/status.js +1 -1
  10. package/dist/background/stop.js +4 -3
  11. package/dist/background/store.d.ts +6 -1
  12. package/dist/background/store.js +5 -1
  13. package/dist/background/tasks.service.d.ts +1 -1
  14. package/dist/background/tasks.service.js +4 -4
  15. package/dist/catalog/marketplaces.service.d.ts +1 -1
  16. package/dist/config/docs.js +2 -0
  17. package/dist/config/schema.d.ts +7 -0
  18. package/dist/config/schema.js +45 -0
  19. package/dist/conversations/event-log.d.ts +4 -3
  20. package/dist/conversations/event-log.js +4 -3
  21. package/dist/conversations/events.d.ts +9 -0
  22. package/dist/conversations/events.js +14 -0
  23. package/dist/db/kernel-schema.d.ts +26 -1
  24. package/dist/devices/device-auth.controller.d.ts +42 -0
  25. package/dist/devices/device-auth.controller.js +116 -0
  26. package/dist/devices/device-auth.d.ts +119 -0
  27. package/dist/devices/device-auth.js +403 -0
  28. package/dist/devices/device-auth.module.d.ts +7 -0
  29. package/dist/devices/device-auth.module.js +25 -0
  30. package/dist/devices/device-gateway.d.ts +128 -0
  31. package/dist/devices/device-gateway.js +530 -0
  32. package/dist/devices/device-provider.d.ts +89 -0
  33. package/dist/devices/device-provider.js +151 -0
  34. package/dist/devices/device-routes.d.ts +36 -0
  35. package/dist/devices/device-routes.js +90 -0
  36. package/dist/devices/devices.module.d.ts +16 -0
  37. package/dist/devices/devices.module.js +44 -0
  38. package/dist/devices/dto.d.ts +66 -0
  39. package/dist/devices/dto.js +44 -0
  40. package/dist/devices/keys.d.ts +25 -0
  41. package/dist/devices/keys.js +25 -0
  42. package/dist/devices/machines.d.ts +106 -0
  43. package/dist/devices/machines.js +301 -0
  44. package/dist/devices/relay.d.ts +100 -0
  45. package/dist/devices/relay.js +373 -0
  46. package/dist/devices/targets.d.ts +28 -0
  47. package/dist/devices/targets.js +73 -0
  48. package/dist/environments/access.d.ts +11 -6
  49. package/dist/environments/access.js +23 -8
  50. package/dist/environments/conversation-environments.d.ts +20 -6
  51. package/dist/environments/conversation-environments.js +70 -9
  52. package/dist/environments/environments.controller.d.ts +4 -1
  53. package/dist/environments/environments.controller.js +15 -1
  54. package/dist/environments/environments.module.js +3 -1
  55. package/dist/environments/environments.service.d.ts +54 -7
  56. package/dist/environments/environments.service.js +104 -20
  57. package/dist/environments/legacy-cleanup.job.d.ts +1 -1
  58. package/dist/environments/legacy-cleanup.job.js +4 -4
  59. package/dist/errors.d.ts +24 -0
  60. package/dist/errors.js +24 -0
  61. package/dist/evals/recorded-tools.d.ts +9 -0
  62. package/dist/evals/recorded-tools.js +27 -0
  63. package/dist/http/api.module.js +4 -0
  64. package/dist/http/gateway-routes.d.ts +7 -3
  65. package/dist/http/gateway-routes.js +6 -3
  66. package/dist/identity/me.controller.d.ts +1 -3
  67. package/dist/identity/me.controller.js +1 -9
  68. package/dist/identity/me.schemas.d.ts +14 -0
  69. package/dist/identity/me.schemas.js +17 -0
  70. package/dist/infra/config/kernel-config.service.js +2 -0
  71. package/dist/infra/throttler/named-throttlers.d.ts +5 -0
  72. package/dist/infra/throttler/named-throttlers.js +38 -0
  73. package/dist/kernel.module.js +3 -0
  74. package/dist/lifecycle/kernel-lifecycle.js +5 -0
  75. package/dist/platform/exclusions.js +33 -10
  76. package/dist/platform/operation.js +1 -1
  77. package/dist/platform/ops/environments.ops.js +21 -3
  78. package/dist/platform/ops/platform.ops.d.ts +7 -2
  79. package/dist/platform/ops/platform.ops.js +50 -3
  80. package/dist/platform/route-schemas.js +3 -0
  81. package/dist/roles/gateway-role.d.ts +14 -3
  82. package/dist/roles/gateway-role.js +25 -5
  83. package/dist/roles/gateway.module.d.ts +2 -1
  84. package/dist/roles/gateway.module.js +6 -3
  85. package/dist/runtime/context/build.d.ts +2 -0
  86. package/dist/runtime/context/build.js +14 -0
  87. package/dist/runtime/context/compaction.js +1 -0
  88. package/dist/runtime/context/environment-paths.d.ts +40 -0
  89. package/dist/runtime/context/environment-paths.js +48 -0
  90. package/dist/runtime/model-call.js +2 -0
  91. package/dist/sandbox/providers.d.ts +29 -7
  92. package/dist/sandbox/providers.js +43 -8
  93. package/dist/sandbox/sandbox.service.d.ts +70 -18
  94. package/dist/sandbox/sandbox.service.js +176 -59
  95. package/dist/tools/builtins/sandbox/bash.d.ts +2 -2
  96. package/dist/tools/builtins/sandbox/bash.js +39 -27
  97. package/dist/tools/builtins/sandbox/common.d.ts +60 -25
  98. package/dist/tools/builtins/sandbox/common.js +121 -30
  99. package/dist/tools/builtins/sandbox/deliver.js +9 -6
  100. package/dist/tools/builtins/sandbox/edit.js +40 -37
  101. package/dist/tools/builtins/sandbox/glob.js +13 -8
  102. package/dist/tools/builtins/sandbox/grep.js +10 -6
  103. package/dist/tools/builtins/sandbox/index.d.ts +6 -4
  104. package/dist/tools/builtins/sandbox/index.js +6 -4
  105. package/dist/tools/builtins/sandbox/read.js +9 -6
  106. package/dist/tools/builtins/sandbox/task-output.d.ts +7 -3
  107. package/dist/tools/builtins/sandbox/task-output.js +14 -10
  108. package/dist/tools/builtins/sandbox/task-stop.js +3 -3
  109. package/dist/tools/builtins/sandbox/write.js +33 -30
  110. package/dist/tools/builtins/skill-load.d.ts +8 -5
  111. package/dist/tools/builtins/skill-load.js +21 -15
  112. package/dist/tools/builtins/skill-write.js +2 -2
  113. package/dist/tools/pipeline.js +2 -0
  114. package/dist/tools/registry.d.ts +21 -4
  115. package/dist/tools/registry.js +10 -2
  116. package/package.json +6 -3
  117. package/src/migrations/0093_connected_environments.sql +37 -0
  118. package/src/migrations/0094_environment_paths_idx.sql +7 -0
@@ -0,0 +1,89 @@
1
+ import type { ExecResult, FileSystemHandle, SandboxHandle, SandboxLimits, SandboxProvider, SandboxRoots } from "@capybara-hq/plugin-sdk";
2
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
3
+ import { SandboxService } from "../sandbox/sandbox.service.js";
4
+ import { DeviceRelay } from "./relay.js";
5
+ import { DeviceTargets } from "./targets.js";
6
+ /** Extra time the machine gets beyond a command's own timeout (spawn, output, the trip back). */
7
+ export declare const EXEC_DEADLINE_MARGIN_MS = 5000;
8
+ /** How long the machine may take for one non-exec operation. */
9
+ export declare const OP_DEADLINE_MS = 30000;
10
+ /**
11
+ * Room left in a frame for everything around a `readFile` result's base64 data (the `res`
12
+ * envelope): a read is clamped so the machine's answer fits `environments.device.maxFrameBytes`
13
+ * (ruling R26).
14
+ */
15
+ export declare const FRAME_ENVELOPE_BYTES: number;
16
+ /** The most raw bytes whose base64 plus {@link FRAME_ENVELOPE_BYTES} fit in `maxFrameBytes`. */
17
+ export declare const maxReadBytes: (maxFrameBytes: number) => number;
18
+ /**
19
+ * The handle of an instance on a machine: the environment id (ruling R1: the relay is keyed by
20
+ * environment), the roots the machine reported at `create`, and the org's effective limits at
21
+ * creation (for the record: every `exec` carries the org's limits read at that moment, ruling
22
+ * R27, so the machine intersects its local policy with the current egress).
23
+ */
24
+ export interface DeviceSandboxHandle extends SandboxHandle {
25
+ roots: SandboxRoots;
26
+ limits: SandboxLimits;
27
+ }
28
+ /** The environment id of a `cap-env-<environmentId>` name; undefined for any other name. */
29
+ export declare const environmentIdOfName: (name: string) => string | undefined;
30
+ /**
31
+ * The kernel-internal provider `@device` (environments spec §13.5): every operation runs on the
32
+ * environment's connected machine through the {@link DeviceRelay}. The file system is the
33
+ * machine's environment directory (the program creates it on first use), so `createFileSystem`
34
+ * touches nothing; `suspend` and `destroy` do nothing (the machine is not the platform's to stop);
35
+ * deleting the environment leaves the files on the machine where they are. Nothing here throws `environment.offline` on the cleanup paths (ruling R9).
36
+ *
37
+ * The files on a machine are its owner's: `destroyFileSystem` (a files reset, the deletion sweep)
38
+ * does nothing; the deletion's `stop` comes from `MachinesService.revokeActive` (ruling R24).
39
+ */
40
+ export declare class DeviceSandboxProvider implements SandboxProvider {
41
+ private readonly relay;
42
+ private readonly targets;
43
+ private readonly sandbox;
44
+ private readonly config;
45
+ readonly concurrentExec: true;
46
+ readonly sessionIsolation = false;
47
+ constructor(relay: DeviceRelay, targets: Pick<DeviceTargets, "orgOf" | "machineRoots">, sandbox: Pick<SandboxService, "orgLimits">, config: Pick<KernelConfigService, "get">);
48
+ createFileSystem(args: {
49
+ name: string;
50
+ limits: SandboxLimits;
51
+ signal: AbortSignal;
52
+ }): Promise<FileSystemHandle>;
53
+ /** The files are the machine owner's: nothing to do (ruling R24; never a `stop`). */
54
+ destroyFileSystem(_fs: FileSystemHandle, signal: AbortSignal): Promise<void>;
55
+ destroyFileSystemByName(_name: string, signal: AbortSignal): Promise<void>;
56
+ create(args: {
57
+ name: string;
58
+ image?: string;
59
+ limits: SandboxLimits;
60
+ fileSystem: FileSystemHandle;
61
+ signal: AbortSignal;
62
+ }): Promise<DeviceSandboxHandle>;
63
+ /**
64
+ * Needs the machine online. `gone` (the kernel recreates the instance) when the active machine
65
+ * last reported other roots than the instance was created with (a new login into another
66
+ * directory or mode, ruling R23).
67
+ */
68
+ resume(h: SandboxHandle, signal: AbortSignal): Promise<"resumed" | "gone">;
69
+ suspend(_h: SandboxHandle, signal: AbortSignal): Promise<void>;
70
+ destroy(_h: SandboxHandle, signal: AbortSignal): Promise<void>;
71
+ destroyByName(_name: string, signal: AbortSignal): Promise<void>;
72
+ killAll(h: SandboxHandle, signal: AbortSignal): Promise<void>;
73
+ killTagged(h: SandboxHandle, tags: string[], signal: AbortSignal): Promise<void>;
74
+ exec(h: SandboxHandle, args: Parameters<SandboxProvider["exec"]>[1]): Promise<ExecResult>;
75
+ readFile(h: SandboxHandle, path: string, opts: {
76
+ maxBytes: number;
77
+ signal: AbortSignal;
78
+ }): Promise<{
79
+ data: Uint8Array;
80
+ truncated: boolean;
81
+ } | null>;
82
+ writeFile(h: SandboxHandle, path: string, data: Uint8Array, signal: AbortSignal): Promise<void>;
83
+ stat(h: SandboxHandle, path: string, signal: AbortSignal): Promise<{
84
+ size: number;
85
+ isDir: boolean;
86
+ } | null>;
87
+ /** The org's effective limits now (ruling R27); a deleted environment is not found. */
88
+ private currentLimits;
89
+ }
@@ -0,0 +1,151 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
+ return function (target, key) { decorator(target, key, paramIndex); }
9
+ };
10
+ import { Inject, Injectable } from "@nestjs/common";
11
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
12
+ import { environmentNotFound, SandboxService } from "../sandbox/sandbox.service.js";
13
+ import { DeviceRelay, environmentOffline } from "./relay.js";
14
+ import { DeviceTargets, sameRoots } from "./targets.js";
15
+ /** Extra time the machine gets beyond a command's own timeout (spawn, output, the trip back). */
16
+ export const EXEC_DEADLINE_MARGIN_MS = 5000;
17
+ /** How long the machine may take for one non-exec operation. */
18
+ export const OP_DEADLINE_MS = 30_000;
19
+ /**
20
+ * Room left in a frame for everything around a `readFile` result's base64 data (the `res`
21
+ * envelope): a read is clamped so the machine's answer fits `environments.device.maxFrameBytes`
22
+ * (ruling R26).
23
+ */
24
+ export const FRAME_ENVELOPE_BYTES = 64 * 1024;
25
+ /** The most raw bytes whose base64 plus {@link FRAME_ENVELOPE_BYTES} fit in `maxFrameBytes`. */
26
+ export const maxReadBytes = (maxFrameBytes) => Math.max(0, Math.floor((maxFrameBytes - FRAME_ENVELOPE_BYTES) / 4) * 3);
27
+ const NAME_PREFIX = "cap-env-";
28
+ /** The environment id of a `cap-env-<environmentId>` name; undefined for any other name. */
29
+ export const environmentIdOfName = (name) => name.startsWith(NAME_PREFIX) && name.length > NAME_PREFIX.length
30
+ ? name.slice(NAME_PREFIX.length)
31
+ : undefined;
32
+ const rootsOf = (h) => h.roots;
33
+ const bytesOf = (b64) => new Uint8Array(Buffer.from(b64, "base64"));
34
+ /**
35
+ * The kernel-internal provider `@device` (environments spec §13.5): every operation runs on the
36
+ * environment's connected machine through the {@link DeviceRelay}. The file system is the
37
+ * machine's environment directory (the program creates it on first use), so `createFileSystem`
38
+ * touches nothing; `suspend` and `destroy` do nothing (the machine is not the platform's to stop);
39
+ * deleting the environment leaves the files on the machine where they are. Nothing here throws `environment.offline` on the cleanup paths (ruling R9).
40
+ *
41
+ * The files on a machine are its owner's: `destroyFileSystem` (a files reset, the deletion sweep)
42
+ * does nothing; the deletion's `stop` comes from `MachinesService.revokeActive` (ruling R24).
43
+ */
44
+ let DeviceSandboxProvider = class DeviceSandboxProvider {
45
+ relay;
46
+ targets;
47
+ sandbox;
48
+ config;
49
+ concurrentExec = true;
50
+ sessionIsolation = false;
51
+ constructor(relay, targets, sandbox, config) {
52
+ this.relay = relay;
53
+ this.targets = targets;
54
+ this.sandbox = sandbox;
55
+ this.config = config;
56
+ }
57
+ async createFileSystem(args) {
58
+ args.signal.throwIfAborted();
59
+ const id = environmentIdOfName(args.name);
60
+ if (id === undefined)
61
+ throw new Error(`not an environment name: ${args.name}`);
62
+ return { id };
63
+ }
64
+ /** The files are the machine owner's: nothing to do (ruling R24; never a `stop`). */
65
+ async destroyFileSystem(_fs, signal) {
66
+ signal.throwIfAborted();
67
+ }
68
+ async destroyFileSystemByName(_name, signal) {
69
+ signal.throwIfAborted();
70
+ }
71
+ async create(args) {
72
+ const id = args.fileSystem.id;
73
+ const r = await this.relay.call(id, "create", { limits: args.limits }, { deadlineMs: OP_DEADLINE_MS, signal: args.signal });
74
+ return { id, roots: r.roots, limits: args.limits };
75
+ }
76
+ /**
77
+ * Needs the machine online. `gone` (the kernel recreates the instance) when the active machine
78
+ * last reported other roots than the instance was created with (a new login into another
79
+ * directory or mode, ruling R23).
80
+ */
81
+ async resume(h, signal) {
82
+ signal.throwIfAborted();
83
+ if (!(await this.relay.holderLive(h.id)))
84
+ throw environmentOffline();
85
+ const had = rootsOf(h);
86
+ const now = await this.targets.machineRoots(h.id);
87
+ if (had === undefined || (now !== null && !sameRoots(had, now)))
88
+ return "gone";
89
+ return "resumed";
90
+ }
91
+ async suspend(_h, signal) {
92
+ signal.throwIfAborted();
93
+ }
94
+ async destroy(_h, signal) {
95
+ signal.throwIfAborted();
96
+ }
97
+ async destroyByName(_name, signal) {
98
+ signal.throwIfAborted();
99
+ }
100
+ async killAll(h, signal) {
101
+ await this.relay.call(h.id, "killAll", {}, { deadlineMs: OP_DEADLINE_MS, signal });
102
+ }
103
+ async killTagged(h, tags, signal) {
104
+ await this.relay.call(h.id, "killTagged", { tags }, { deadlineMs: OP_DEADLINE_MS, signal });
105
+ }
106
+ async exec(h, args) {
107
+ const { signal, ...rest } = args;
108
+ signal.throwIfAborted();
109
+ const limits = await this.currentLimits(h.id);
110
+ signal.throwIfAborted();
111
+ try {
112
+ return await this.relay.call(h.id, "exec", { ...rest, limits }, { deadlineMs: args.timeoutMs + EXEC_DEADLINE_MARGIN_MS, signal });
113
+ }
114
+ catch (e) {
115
+ // the contract: an exec aborted after it was sent resolves with exitCode null (the relay
116
+ // queued a cancel, which kills the command on the machine); the output stays there
117
+ if (signal.aborted && e === signal.reason) {
118
+ return { exitCode: null, stdout: "", stderr: "", truncated: false, timedOut: false };
119
+ }
120
+ throw e;
121
+ }
122
+ }
123
+ async readFile(h, path, opts) {
124
+ // the answer must fit one frame: a larger file comes back truncated (Deliver: too large)
125
+ const maxBytes = Math.min(opts.maxBytes, maxReadBytes(this.config.get().environments.device.maxFrameBytes));
126
+ const r = await this.relay.call(h.id, "readFile", { path, maxBytes }, { deadlineMs: OP_DEADLINE_MS, signal: opts.signal });
127
+ return r === null ? null : { data: bytesOf(r.dataB64), truncated: r.truncated };
128
+ }
129
+ async writeFile(h, path, data, signal) {
130
+ const dataB64 = Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("base64");
131
+ await this.relay.call(h.id, "writeFile", { path, dataB64 }, { deadlineMs: OP_DEADLINE_MS, signal });
132
+ }
133
+ async stat(h, path, signal) {
134
+ return this.relay.call(h.id, "stat", { path }, { deadlineMs: OP_DEADLINE_MS, signal });
135
+ }
136
+ /** The org's effective limits now (ruling R27); a deleted environment is not found. */
137
+ async currentLimits(environmentId) {
138
+ const orgId = await this.targets.orgOf(environmentId);
139
+ if (orgId === null)
140
+ throw environmentNotFound();
141
+ return this.sandbox.orgLimits(orgId);
142
+ }
143
+ };
144
+ DeviceSandboxProvider = __decorate([
145
+ Injectable(),
146
+ __param(0, Inject(DeviceRelay)),
147
+ __param(1, Inject(DeviceTargets)),
148
+ __param(2, Inject(SandboxService)),
149
+ __param(3, Inject(KernelConfigService))
150
+ ], DeviceSandboxProvider);
151
+ export { DeviceSandboxProvider };
@@ -0,0 +1,36 @@
1
+ import { type WebSocket } from "@fastify/websocket";
2
+ import type { FastifyInstance, FastifyRequest } from "fastify";
3
+ import type { MachineIdentity } from "./machines.js";
4
+ /** The device connection route (environments spec §13.4). */
5
+ export declare const DEVICE_CONNECT_ROUTE = "/device/connect";
6
+ /** The header carrying the program's protocol version. */
7
+ export declare const DEVICE_PROTOCOL_HEADER = "x-capybara-device-protocol";
8
+ /** What the route needs from the gateway (`DeviceGateway`). */
9
+ export interface DeviceRoutesGateway {
10
+ authenticate(credential: string): Promise<MachineIdentity | null>;
11
+ accept(socket: WebSocket, identity: MachineIdentity, credential: string): void;
12
+ }
13
+ export interface DeviceRoutesOptions {
14
+ gateway: DeviceRoutesGateway;
15
+ /** `environments.device.maxFrameBytes`: a larger incoming frame closes the socket (1009). */
16
+ maxFrameBytes: number;
17
+ }
18
+ /**
19
+ * A WebSocket upgrade: `Upgrade` lists `websocket` and `Connection` lists `upgrade` (both
20
+ * case-insensitive). Other upgrades (`h2c`, …) are ordinary requests here.
21
+ */
22
+ export declare function isWebSocketUpgrade(req: FastifyRequest): boolean;
23
+ /**
24
+ * Registers `GET /device/connect` (the gateway role only) in its own encapsulated scope with
25
+ * `@fastify/websocket`: a raw Fastify route outside Nest, so neither the API's `AuthGuard` nor its
26
+ * CSRF check applies; the route checks the machine credential itself, before the upgrade (401
27
+ * `device.credential_invalid`), then the protocol header (426 `environment.program_outdated`).
28
+ * Must be called before Nest `app.init()`, on the root instance.
29
+ *
30
+ * `@fastify/websocket` sends every upgrade request of the server through the Fastify router; the
31
+ * root hooks below keep that from reaching any other route: a WebSocket upgrade to another path
32
+ * is answered 404 and its socket closed once answered (Node would have dropped it without a
33
+ * listener). Other upgrades (`h2c`) reach their route as ordinary requests; their socket, which
34
+ * no HTTP connection owns any more, is closed once answered as well (the upgrade is declined).
35
+ */
36
+ export declare function registerDeviceConnectRoute(fastify: FastifyInstance, opts: DeviceRoutesOptions): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import { DEVICE_PROTOCOL_VERSION } from "@capybara-hq/plugin-sdk";
2
+ import websocket, {} from "@fastify/websocket";
3
+ import { errorBody } from "../errors.js";
4
+ /** The device connection route (environments spec §13.4). */
5
+ export const DEVICE_CONNECT_ROUTE = "/device/connect";
6
+ /** The header carrying the program's protocol version. */
7
+ export const DEVICE_PROTOCOL_HEADER = "x-capybara-device-protocol";
8
+ /** The comma-separated tokens of a header, lowercased. */
9
+ function tokens(v) {
10
+ return [v ?? []]
11
+ .flat()
12
+ .flatMap((x) => x.split(","))
13
+ .map((t) => t.trim().toLowerCase());
14
+ }
15
+ /**
16
+ * A WebSocket upgrade: `Upgrade` lists `websocket` and `Connection` lists `upgrade` (both
17
+ * case-insensitive). Other upgrades (`h2c`, …) are ordinary requests here.
18
+ */
19
+ export function isWebSocketUpgrade(req) {
20
+ return viaUpgrade(req) && tokens(req.headers.upgrade).includes("websocket");
21
+ }
22
+ /**
23
+ * A request Node handed to the server's `upgrade` listeners (`Connection: upgrade` and an
24
+ * `Upgrade` header, any protocol): its socket belongs to no HTTP connection any more.
25
+ */
26
+ function viaUpgrade(req) {
27
+ return req.headers.upgrade !== undefined && tokens(req.headers.connection).includes("upgrade");
28
+ }
29
+ /** The `Bearer` credential of the request, or "". */
30
+ function bearer(req) {
31
+ const h = req.headers.authorization;
32
+ const m = typeof h === "string" ? /^Bearer\s+(\S+)\s*$/i.exec(h) : null;
33
+ return m?.[1] ?? "";
34
+ }
35
+ /**
36
+ * Registers `GET /device/connect` (the gateway role only) in its own encapsulated scope with
37
+ * `@fastify/websocket`: a raw Fastify route outside Nest, so neither the API's `AuthGuard` nor its
38
+ * CSRF check applies; the route checks the machine credential itself, before the upgrade (401
39
+ * `device.credential_invalid`), then the protocol header (426 `environment.program_outdated`).
40
+ * Must be called before Nest `app.init()`, on the root instance.
41
+ *
42
+ * `@fastify/websocket` sends every upgrade request of the server through the Fastify router; the
43
+ * root hooks below keep that from reaching any other route: a WebSocket upgrade to another path
44
+ * is answered 404 and its socket closed once answered (Node would have dropped it without a
45
+ * listener). Other upgrades (`h2c`) reach their route as ordinary requests; their socket, which
46
+ * no HTTP connection owns any more, is closed once answered as well (the upgrade is declined).
47
+ */
48
+ export async function registerDeviceConnectRoute(fastify, opts) {
49
+ fastify.addHook("onRequest", async (req, reply) => {
50
+ if (!isWebSocketUpgrade(req) || req.routeOptions.url === DEVICE_CONNECT_ROUTE)
51
+ return;
52
+ return reply
53
+ .code(404)
54
+ .send(errorBody("not_found", "This path does not accept a protocol upgrade"));
55
+ });
56
+ fastify.addHook("onResponse", async (req) => {
57
+ if (viaUpgrade(req))
58
+ req.raw.socket?.destroy();
59
+ });
60
+ await fastify.register(async (scope) => {
61
+ await scope.register(websocket, { options: { maxPayload: opts.maxFrameBytes } });
62
+ const identities = new WeakMap();
63
+ scope.get(DEVICE_CONNECT_ROUTE, {
64
+ websocket: true,
65
+ preValidation: async (req, reply) => {
66
+ const cred = bearer(req);
67
+ const identity = cred === "" ? null : await opts.gateway.authenticate(cred);
68
+ if (identity === null) {
69
+ return reply
70
+ .code(401)
71
+ .send(errorBody("device.credential_invalid", "The machine credential is unknown or revoked. Run capybara-device logout, then capybara-device login."));
72
+ }
73
+ if (req.headers[DEVICE_PROTOCOL_HEADER] !== String(DEVICE_PROTOCOL_VERSION)) {
74
+ return reply
75
+ .code(426)
76
+ .send(errorBody("environment.program_outdated", `This kernel speaks device protocol ${DEVICE_PROTOCOL_VERSION}. ` +
77
+ "Update capybara-device."));
78
+ }
79
+ identities.set(req, { identity, cred });
80
+ },
81
+ }, (socket, req) => {
82
+ const found = identities.get(req);
83
+ if (!found) {
84
+ socket.close(4400, "not authenticated");
85
+ return;
86
+ }
87
+ opts.gateway.accept(socket, found.identity, found.cred);
88
+ });
89
+ });
90
+ }
@@ -0,0 +1,16 @@
1
+ import { type OnModuleInit } from "@nestjs/common";
2
+ import { SandboxProviders } from "../sandbox/providers.js";
3
+ import { DeviceSandboxProvider } from "./device-provider.js";
4
+ /**
5
+ * Connected machines (environments spec §6, §13): the caller side of the device relay and the
6
+ * kernel-internal `@device` sandbox provider, registered with {@link SandboxProviders} on init, and
7
+ * the machines and their credentials ({@link MachinesService}).
8
+ * Imported on every role: any replica may run a sandbox operation on a connected environment
9
+ * (the socket itself is held by the gateway role).
10
+ */
11
+ export declare class DevicesModule implements OnModuleInit {
12
+ private readonly providers;
13
+ private readonly device;
14
+ constructor(providers: SandboxProviders, device: DeviceSandboxProvider);
15
+ onModuleInit(): void;
16
+ }
@@ -0,0 +1,44 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
+ return function (target, key) { decorator(target, key, paramIndex); }
9
+ };
10
+ import { Inject, Module } from "@nestjs/common";
11
+ import { DEVICE_PROVIDER, SandboxProviders } from "../sandbox/providers.js";
12
+ import { SandboxModule } from "../sandbox/sandbox.module.js";
13
+ import { DeviceSandboxProvider } from "./device-provider.js";
14
+ import { MachinesService } from "./machines.js";
15
+ import { DeviceRelay } from "./relay.js";
16
+ import { DeviceTargets } from "./targets.js";
17
+ /**
18
+ * Connected machines (environments spec §6, §13): the caller side of the device relay and the
19
+ * kernel-internal `@device` sandbox provider, registered with {@link SandboxProviders} on init, and
20
+ * the machines and their credentials ({@link MachinesService}).
21
+ * Imported on every role: any replica may run a sandbox operation on a connected environment
22
+ * (the socket itself is held by the gateway role).
23
+ */
24
+ let DevicesModule = class DevicesModule {
25
+ providers;
26
+ device;
27
+ constructor(providers, device) {
28
+ this.providers = providers;
29
+ this.device = device;
30
+ }
31
+ onModuleInit() {
32
+ this.providers.register({ plugin: DEVICE_PROVIDER, impl: this.device });
33
+ }
34
+ };
35
+ DevicesModule = __decorate([
36
+ Module({
37
+ imports: [SandboxModule],
38
+ providers: [DeviceTargets, DeviceRelay, DeviceSandboxProvider, MachinesService],
39
+ exports: [DeviceRelay, DeviceSandboxProvider, MachinesService],
40
+ }),
41
+ __param(0, Inject(SandboxProviders)),
42
+ __param(1, Inject(DeviceSandboxProvider))
43
+ ], DevicesModule);
44
+ export { DevicesModule };
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ /** `POST /api/v1/device/authorize`: the machine as the program describes it. */
3
+ export declare const DeviceAuthorizeBody: z.ZodObject<{
4
+ name: z.ZodString;
5
+ os: z.ZodEnum<{
6
+ darwin: "darwin";
7
+ linux: "linux";
8
+ }>;
9
+ arch: z.ZodString;
10
+ programVersion: z.ZodString;
11
+ }, z.core.$strict>;
12
+ /** `POST /api/v1/device/token`: the secret device code from `authorize`. */
13
+ export declare const DeviceTokenBody: z.ZodObject<{
14
+ deviceCode: z.ZodString;
15
+ }, z.core.$strict>;
16
+ export declare const DeviceUserCodeParams: z.ZodObject<{
17
+ userCode: z.ZodString;
18
+ }, z.core.$strip>;
19
+ export declare const DeviceUserCodeBody: z.ZodObject<{
20
+ userCode: z.ZodString;
21
+ }, z.core.$strict>;
22
+ /**
23
+ * `POST /api/v1/device/confirm`: the org and exactly one of an existing device environment of the
24
+ * person's (`environmentId`) or the name of a new one (`name`).
25
+ */
26
+ export declare const DeviceConfirmBody: z.ZodObject<{
27
+ userCode: z.ZodString;
28
+ orgId: z.ZodString;
29
+ environmentId: z.ZodOptional<z.ZodUUID>;
30
+ name: z.ZodOptional<z.ZodString>;
31
+ }, z.core.$strict>;
32
+ declare const DeviceAuthorizeBodyDto_base: import("../infra/validation/create-zod-dto.js").ZodDtoClass<z.ZodObject<{
33
+ name: z.ZodString;
34
+ os: z.ZodEnum<{
35
+ darwin: "darwin";
36
+ linux: "linux";
37
+ }>;
38
+ arch: z.ZodString;
39
+ programVersion: z.ZodString;
40
+ }, z.core.$strict>>;
41
+ export declare class DeviceAuthorizeBodyDto extends DeviceAuthorizeBodyDto_base {
42
+ }
43
+ declare const DeviceTokenBodyDto_base: import("../infra/validation/create-zod-dto.js").ZodDtoClass<z.ZodObject<{
44
+ deviceCode: z.ZodString;
45
+ }, z.core.$strict>>;
46
+ export declare class DeviceTokenBodyDto extends DeviceTokenBodyDto_base {
47
+ }
48
+ declare const DeviceUserCodeParamsDto_base: import("../infra/validation/create-zod-dto.js").ZodDtoClass<z.ZodObject<{
49
+ userCode: z.ZodString;
50
+ }, z.core.$strip>>;
51
+ export declare class DeviceUserCodeParamsDto extends DeviceUserCodeParamsDto_base {
52
+ }
53
+ declare const DeviceUserCodeBodyDto_base: import("../infra/validation/create-zod-dto.js").ZodDtoClass<z.ZodObject<{
54
+ userCode: z.ZodString;
55
+ }, z.core.$strict>>;
56
+ export declare class DeviceUserCodeBodyDto extends DeviceUserCodeBodyDto_base {
57
+ }
58
+ declare const DeviceConfirmBodyDto_base: import("../infra/validation/create-zod-dto.js").ZodDtoClass<z.ZodObject<{
59
+ userCode: z.ZodString;
60
+ orgId: z.ZodString;
61
+ environmentId: z.ZodOptional<z.ZodUUID>;
62
+ name: z.ZodOptional<z.ZodString>;
63
+ }, z.core.$strict>>;
64
+ export declare class DeviceConfirmBodyDto extends DeviceConfirmBodyDto_base {
65
+ }
66
+ export {};
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { EnvironmentName } from "../environments/dto.js";
3
+ import { createZodDto } from "../infra/validation/create-zod-dto.js";
4
+ /** `POST /api/v1/device/authorize`: the machine as the program describes it. */
5
+ export const DeviceAuthorizeBody = z
6
+ .object({
7
+ name: z.string().trim().min(1).max(64),
8
+ os: z.enum(["darwin", "linux"]),
9
+ arch: z.string().trim().min(1).max(32),
10
+ programVersion: z.string().trim().min(1).max(64),
11
+ })
12
+ .strict();
13
+ /** `POST /api/v1/device/token`: the secret device code from `authorize`. */
14
+ export const DeviceTokenBody = z.object({ deviceCode: z.string().min(1).max(200) }).strict();
15
+ /** A user code as typed (`XXXX-XXXX`; case, spaces and the dash are forgiven). */
16
+ const UserCode = z.string().min(1).max(32);
17
+ export const DeviceUserCodeParams = z.object({ userCode: UserCode });
18
+ export const DeviceUserCodeBody = z.object({ userCode: UserCode }).strict();
19
+ /**
20
+ * `POST /api/v1/device/confirm`: the org and exactly one of an existing device environment of the
21
+ * person's (`environmentId`) or the name of a new one (`name`).
22
+ */
23
+ export const DeviceConfirmBody = z
24
+ .object({
25
+ userCode: UserCode,
26
+ orgId: z.string().min(1).max(100),
27
+ environmentId: z.uuid().optional(),
28
+ name: EnvironmentName.optional(),
29
+ })
30
+ .strict()
31
+ .refine((b) => (b.environmentId === undefined) !== (b.name === undefined), {
32
+ message: "Give exactly one of environmentId and name",
33
+ path: ["environmentId"],
34
+ });
35
+ export class DeviceAuthorizeBodyDto extends createZodDto(DeviceAuthorizeBody, "DeviceAuthorizeBody") {
36
+ }
37
+ export class DeviceTokenBodyDto extends createZodDto(DeviceTokenBody, "DeviceTokenBody") {
38
+ }
39
+ export class DeviceUserCodeParamsDto extends createZodDto(DeviceUserCodeParams, "DeviceUserCodeParams") {
40
+ }
41
+ export class DeviceUserCodeBodyDto extends createZodDto(DeviceUserCodeBody, "DeviceUserCodeBody") {
42
+ }
43
+ export class DeviceConfirmBodyDto extends createZodDto(DeviceConfirmBody, "DeviceConfirmBody") {
44
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Redis keys and bus topics of the connected-machine relay (environments spec §6, §13). Every key
3
+ * is per environment (or per request); writers set a TTL on each.
4
+ */
5
+ /** The lease of the replica holding the machine's connection: `capk:device-lease:<environmentId>`. */
6
+ export declare const deviceLeaseKey: (environmentId: string) => string;
7
+ /** The list of requests waiting for the machine: `capk:device-req:<environmentId>`. */
8
+ export declare const deviceReqKey: (environmentId: string) => string;
9
+ /** The result of one request: `capk:device-res:<requestId>`. */
10
+ export declare const deviceResKey: (requestId: string) => string;
11
+ /** The prefix of {@link deviceTakenKey} (the holder's Lua script builds the key from it). */
12
+ export declare const DEVICE_TAKEN_PREFIX = "capk:device-taken:";
13
+ /**
14
+ * A request the holder forwarded to a machine: `capk:device-taken:<requestId>` → the environment
15
+ * id. Only a result for a taken request of the socket's own environment is accepted.
16
+ */
17
+ export declare const deviceTakenKey: (requestId: string) => string;
18
+ /** A new request for the machine of the environment. */
19
+ export declare const deviceReqTopic: (environmentId: string) => string;
20
+ /** The result of one request is ready. */
21
+ export declare const deviceResTopic: (requestId: string) => string;
22
+ /** Another replica took the machine's connection over. */
23
+ export declare const deviceTakeoverTopic: (environmentId: string) => string;
24
+ /** The machine's credential was revoked: its connection is closed. */
25
+ export declare const deviceRevokedTopic: (environmentId: string) => string;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Redis keys and bus topics of the connected-machine relay (environments spec §6, §13). Every key
3
+ * is per environment (or per request); writers set a TTL on each.
4
+ */
5
+ /** The lease of the replica holding the machine's connection: `capk:device-lease:<environmentId>`. */
6
+ export const deviceLeaseKey = (environmentId) => `capk:device-lease:${environmentId}`;
7
+ /** The list of requests waiting for the machine: `capk:device-req:<environmentId>`. */
8
+ export const deviceReqKey = (environmentId) => `capk:device-req:${environmentId}`;
9
+ /** The result of one request: `capk:device-res:<requestId>`. */
10
+ export const deviceResKey = (requestId) => `capk:device-res:${requestId}`;
11
+ /** The prefix of {@link deviceTakenKey} (the holder's Lua script builds the key from it). */
12
+ export const DEVICE_TAKEN_PREFIX = "capk:device-taken:";
13
+ /**
14
+ * A request the holder forwarded to a machine: `capk:device-taken:<requestId>` → the environment
15
+ * id. Only a result for a taken request of the socket's own environment is accepted.
16
+ */
17
+ export const deviceTakenKey = (requestId) => `${DEVICE_TAKEN_PREFIX}${requestId}`;
18
+ /** A new request for the machine of the environment. */
19
+ export const deviceReqTopic = (environmentId) => `device.req.${environmentId}`;
20
+ /** The result of one request is ready. */
21
+ export const deviceResTopic = (requestId) => `device.res.${requestId}`;
22
+ /** Another replica took the machine's connection over. */
23
+ export const deviceTakeoverTopic = (environmentId) => `device.takeover.${environmentId}`;
24
+ /** The machine's credential was revoked: its connection is closed. */
25
+ export const deviceRevokedTopic = (environmentId) => `device.revoked.${environmentId}`;