@capybara-hq/kernel 0.1.0-rc.8 → 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 (110) 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/infra/config/kernel-config.service.js +2 -0
  67. package/dist/infra/throttler/named-throttlers.d.ts +5 -0
  68. package/dist/infra/throttler/named-throttlers.js +38 -0
  69. package/dist/kernel.module.js +3 -0
  70. package/dist/lifecycle/kernel-lifecycle.js +5 -0
  71. package/dist/platform/exclusions.js +33 -0
  72. package/dist/platform/ops/environments.ops.js +21 -3
  73. package/dist/roles/gateway-role.d.ts +14 -3
  74. package/dist/roles/gateway-role.js +25 -5
  75. package/dist/roles/gateway.module.d.ts +2 -1
  76. package/dist/roles/gateway.module.js +6 -3
  77. package/dist/runtime/context/build.d.ts +2 -0
  78. package/dist/runtime/context/build.js +14 -0
  79. package/dist/runtime/context/compaction.js +1 -0
  80. package/dist/runtime/context/environment-paths.d.ts +40 -0
  81. package/dist/runtime/context/environment-paths.js +48 -0
  82. package/dist/runtime/model-call.js +2 -0
  83. package/dist/sandbox/providers.d.ts +29 -7
  84. package/dist/sandbox/providers.js +43 -8
  85. package/dist/sandbox/sandbox.service.d.ts +70 -18
  86. package/dist/sandbox/sandbox.service.js +176 -59
  87. package/dist/tools/builtins/sandbox/bash.d.ts +2 -2
  88. package/dist/tools/builtins/sandbox/bash.js +39 -27
  89. package/dist/tools/builtins/sandbox/common.d.ts +60 -25
  90. package/dist/tools/builtins/sandbox/common.js +121 -30
  91. package/dist/tools/builtins/sandbox/deliver.js +9 -6
  92. package/dist/tools/builtins/sandbox/edit.js +40 -37
  93. package/dist/tools/builtins/sandbox/glob.js +13 -8
  94. package/dist/tools/builtins/sandbox/grep.js +10 -6
  95. package/dist/tools/builtins/sandbox/index.d.ts +6 -4
  96. package/dist/tools/builtins/sandbox/index.js +6 -4
  97. package/dist/tools/builtins/sandbox/read.js +9 -6
  98. package/dist/tools/builtins/sandbox/task-output.d.ts +7 -3
  99. package/dist/tools/builtins/sandbox/task-output.js +14 -10
  100. package/dist/tools/builtins/sandbox/task-stop.js +3 -3
  101. package/dist/tools/builtins/sandbox/write.js +33 -30
  102. package/dist/tools/builtins/skill-load.d.ts +8 -5
  103. package/dist/tools/builtins/skill-load.js +21 -15
  104. package/dist/tools/builtins/skill-write.js +2 -2
  105. package/dist/tools/pipeline.js +2 -0
  106. package/dist/tools/registry.d.ts +21 -4
  107. package/dist/tools/registry.js +10 -2
  108. package/package.json +5 -2
  109. package/src/migrations/0093_connected_environments.sql +37 -0
  110. package/src/migrations/0094_environment_paths_idx.sql +7 -0
@@ -0,0 +1,116 @@
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 { Controller, Get, HttpCode, Inject, Post, Req, Res } from "@nestjs/common";
11
+ import { CurrentPrincipal, Public, requireUser, SessionOnly, } from "../identity/principal.js";
12
+ import { CredentialThrottler } from "../infra/throttler/credential-throttler.js";
13
+ import { Throttled } from "../infra/throttler/throttler.guard.js";
14
+ import { ZBody, ZParam } from "../infra/validation/zod-params.js";
15
+ import { DeviceAuthService, } from "./device-auth.js";
16
+ import { DeviceAuthorizeBodyDto, DeviceConfirmBodyDto, DeviceTokenBodyDto, DeviceUserCodeBodyDto, DeviceUserCodeParamsDto, } from "./dto.js";
17
+ /** The throttler of every route that takes a user code (guessing another machine's code). */
18
+ const CODE_THROTTLER = ["device-code-account"];
19
+ /**
20
+ * Device sign-in (`/api/v1/device`, environments spec §13.3; the shape of RFC 8628).
21
+ *
22
+ * The program's routes are public (it has no credential yet); like every public unsafe route
23
+ * they need `X-Capybara-Csrf: 1`, which `capybara-device` sends:
24
+ * - `POST /authorize { name, os, arch, programVersion }` → `{ deviceCode, userCode,
25
+ * verificationUri, verificationUriComplete, interval, expiresIn }` (`device-authorize-ip`);
26
+ * - `POST /token { deviceCode }` → `{ credential, orgId, environmentId, connectUrl,
27
+ * reconnectGraceSeconds, maxFrameBytes }` once, else `device.authorization_pending` / `device.slow_down` /
28
+ * `device.denied` / `device.expired` (`device-token-ip`).
29
+ *
30
+ * The person's routes need a web session (the confirmation page `/device`); each try of a user
31
+ * code counts on `device-code-account` (refunded on success):
32
+ * - `GET /pending/:userCode` → `{ machine, expiresAt }`;
33
+ * - `POST /confirm { userCode, orgId, environmentId | name }` → `{ environmentId }`
34
+ * (`device.expired` / `device.unavailable` when the credential could not be handed over: the
35
+ * machine is revoked again and the person runs login again);
36
+ * - `POST /deny { userCode }` → 204.
37
+ */
38
+ let DeviceAuthController = class DeviceAuthController {
39
+ auth;
40
+ throttler;
41
+ constructor(auth, throttler) {
42
+ this.auth = auth;
43
+ this.throttler = throttler;
44
+ }
45
+ async authorize(body) {
46
+ return this.auth.authorize(body);
47
+ }
48
+ async token(body) {
49
+ return this.auth.token(body.deviceCode);
50
+ }
51
+ async pending(principal, params, req, reply) {
52
+ requireUser(principal);
53
+ return this.counted(req, reply, () => this.auth.pending(params.userCode));
54
+ }
55
+ async confirm(principal, body, req, reply) {
56
+ const p = requireUser(principal);
57
+ return this.counted(req, reply, () => this.auth.confirm(p, body));
58
+ }
59
+ async deny(principal, body, req, reply) {
60
+ requireUser(principal);
61
+ await this.counted(req, reply, () => this.auth.deny(body.userCode));
62
+ }
63
+ /** Runs `fn` as one counted try of a user code; a success gives the try back. */
64
+ async counted(req, reply, fn) {
65
+ await this.throttler.reserve(CODE_THROTTLER, req, reply);
66
+ const out = await fn();
67
+ await this.throttler.succeeded(CODE_THROTTLER, req);
68
+ return out;
69
+ }
70
+ };
71
+ __decorate([
72
+ Post("authorize"),
73
+ Public(),
74
+ HttpCode(200),
75
+ Throttled("device-authorize-ip"),
76
+ __param(0, ZBody(DeviceAuthorizeBodyDto))
77
+ ], DeviceAuthController.prototype, "authorize", null);
78
+ __decorate([
79
+ Post("token"),
80
+ Public(),
81
+ HttpCode(200),
82
+ Throttled("device-token-ip"),
83
+ __param(0, ZBody(DeviceTokenBodyDto))
84
+ ], DeviceAuthController.prototype, "token", null);
85
+ __decorate([
86
+ Get("pending/:userCode"),
87
+ SessionOnly(),
88
+ __param(0, CurrentPrincipal()),
89
+ __param(1, ZParam(DeviceUserCodeParamsDto)),
90
+ __param(2, Req()),
91
+ __param(3, Res({ passthrough: true }))
92
+ ], DeviceAuthController.prototype, "pending", null);
93
+ __decorate([
94
+ Post("confirm"),
95
+ SessionOnly(),
96
+ HttpCode(200),
97
+ __param(0, CurrentPrincipal()),
98
+ __param(1, ZBody(DeviceConfirmBodyDto)),
99
+ __param(2, Req()),
100
+ __param(3, Res({ passthrough: true }))
101
+ ], DeviceAuthController.prototype, "confirm", null);
102
+ __decorate([
103
+ Post("deny"),
104
+ SessionOnly(),
105
+ HttpCode(204),
106
+ __param(0, CurrentPrincipal()),
107
+ __param(1, ZBody(DeviceUserCodeBodyDto)),
108
+ __param(2, Req()),
109
+ __param(3, Res({ passthrough: true }))
110
+ ], DeviceAuthController.prototype, "deny", null);
111
+ DeviceAuthController = __decorate([
112
+ Controller("api/v1/device"),
113
+ __param(0, Inject(DeviceAuthService)),
114
+ __param(1, Inject(CredentialThrottler))
115
+ ], DeviceAuthController);
116
+ export { DeviceAuthController };
@@ -0,0 +1,119 @@
1
+ import type { Redis } from "ioredis";
2
+ import { EnvironmentsService } from "../environments/environments.service.js";
3
+ import type { UserPrincipal } from "../identity/principal.js";
4
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
5
+ import { KernelDb } from "../infra/database/kernel-db.js";
6
+ import { TransactionHost } from "../infra/database/transaction-host.js";
7
+ import type { KernelLogger } from "../log.js";
8
+ import { MachinesService } from "./machines.js";
9
+ /** The 20 consonants a user code is made of (no vowels: no words; no look-alike letters). */
10
+ export declare const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
11
+ /** What `slow_down` adds to the poll interval (RFC 8628 §3.5). */
12
+ export declare const SLOW_DOWN_STEP_SECONDS = 5;
13
+ /** The web page that confirms a code: `<http.publicUrl>/device`. */
14
+ export declare const DEVICE_PAGE_PATH = "/device";
15
+ /** The machine's WebSocket route: `ws(s)://<http.publicUrl host>/device/connect`. */
16
+ export declare const DEVICE_CONNECT_PATH = "/device/connect";
17
+ /** The sign-in state of one device code: `capk:device-auth:<sha256(deviceCode)>` (a hash). */
18
+ export declare const deviceAuthKey: (deviceCode: string) => string;
19
+ /** The user code's pointer to its device code's hash: `capk:device-user:<XXXX-XXXX>`. */
20
+ export declare const deviceUserKey: (userCode: string) => string;
21
+ /** The machine as `authorize` described it. */
22
+ export interface DeviceMachineInfo {
23
+ name: string;
24
+ os: "darwin" | "linux";
25
+ arch: string;
26
+ programVersion: string;
27
+ }
28
+ interface Grant {
29
+ credential: string;
30
+ orgId: string;
31
+ environmentId: string;
32
+ }
33
+ export interface DeviceAuthorization {
34
+ deviceCode: string;
35
+ userCode: string;
36
+ verificationUri: string;
37
+ verificationUriComplete: string;
38
+ interval: number;
39
+ expiresIn: number;
40
+ }
41
+ export interface DeviceGrant extends Grant {
42
+ connectUrl: string;
43
+ reconnectGraceSeconds: number;
44
+ /** `environments.device.maxFrameBytes`: the program never sends a larger frame (ruling R26). */
45
+ maxFrameBytes: number;
46
+ }
47
+ /** A user code as typed → `XXXX-XXXX`, or null when it cannot be one. */
48
+ export declare function normalizeUserCode(input: string): string | null;
49
+ /**
50
+ * Device authorization (environments spec §6.2, §13.3; the shape of RFC 8628). The program asks
51
+ * for a code pair (`authorize`), the signed-in person confirms the user code on the web page
52
+ * `/device` (`pending`, `confirm` or `deny`), and the program polls `token` until it gets the
53
+ * machine credential, once. State lives only in Redis, both keys with the code's TTL
54
+ * (`environments.device.authCodeTtlSeconds`): `capk:device-auth:<sha256(deviceCode)>` (a hash:
55
+ * `info` JSON, `status`, `interval`, `lastPollAt`, the parked `grant`) and
56
+ * `capk:device-user:<userCode>` → the device code's hash. The device code itself is never stored.
57
+ */
58
+ export declare class DeviceAuthService {
59
+ private readonly db;
60
+ readonly txHost: TransactionHost;
61
+ private readonly redis;
62
+ private readonly config;
63
+ private readonly environments;
64
+ private readonly machines;
65
+ private readonly log;
66
+ constructor(db: KernelDb, txHost: TransactionHost, redis: Redis, config: Pick<KernelConfigService, "get">, environments: Pick<EnvironmentsService, "createDevice">, machines: Pick<MachinesService, "issue" | "revokeMachine">, log: Pick<KernelLogger, "warn">);
67
+ /** A new code pair for a machine. Needs `http.publicUrl` (the page and the connection URL). */
68
+ authorize(machine: DeviceMachineInfo): Promise<DeviceAuthorization>;
69
+ /**
70
+ * The program's poll: the grant once the person confirmed (the code is consumed), else
71
+ * `device.authorization_pending`, `device.slow_down` (details `[{ interval }]`, the grown
72
+ * interval), `device.denied` or `device.expired` (also for an unknown code).
73
+ */
74
+ token(deviceCode: string): Promise<DeviceGrant>;
75
+ /** The machine waiting behind a user code, for the confirmation page. */
76
+ pending(userCode: string): Promise<{
77
+ machine: DeviceMachineInfo;
78
+ expiresAt: string;
79
+ }>;
80
+ /**
81
+ * The person confirms a user code: in an org where they may use agents (`agents.use`), into
82
+ * one of their own live device environments or a new one named `name` (` 2`, ` 3`, … appended
83
+ * while the name is taken). One transaction creates the environment if needed, issues the
84
+ * machine (revoking the environment's previous one) and audits
85
+ * `environment.machine_connected` (retried once when a concurrent confirmation took the new
86
+ * name); after the commit the credential is parked for the poll ({@link park}). The code is
87
+ * claimed first, so two confirmations of one code cannot both issue a machine; a confirmation
88
+ * that fails before the commit leaves it pending.
89
+ */
90
+ confirm(principal: UserPrincipal, input: {
91
+ userCode: string;
92
+ orgId: string;
93
+ environmentId?: string | undefined;
94
+ name?: string | undefined;
95
+ }): Promise<{
96
+ environmentId: string;
97
+ }>;
98
+ /**
99
+ * Parks the committed machine's credential for the poll. When that fails (the code expired
100
+ * meanwhile, or Redis failed) the machine is revoked again, so no credential nobody holds stays
101
+ * valid, and the person is told to start over: `device.expired` / `device.unavailable`.
102
+ */
103
+ private park;
104
+ /** Revokes a machine whose credential never reached the program (see {@link park}). */
105
+ private abandon;
106
+ /** The person refuses a user code: the program's next poll gets `device.denied`. */
107
+ deny(userCode: string): Promise<void>;
108
+ private confirmIn;
109
+ /** `name`, or `name 2`, `name 3`, … : the first that is none of the person's live names. */
110
+ private freeName;
111
+ /** The code behind a user code with status `status`, else `device.code_invalid`. */
112
+ private lookup;
113
+ private transition;
114
+ /** `http.publicUrl` without trailing slashes; unset → `environment.unavailable`. */
115
+ private publicUrl;
116
+ /** The machine's WebSocket URL: the public URL with `ws` / `wss` and `/device/connect`. */
117
+ private connectUrl;
118
+ }
119
+ export {};
@@ -0,0 +1,403 @@
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 { randomBytes } from "node:crypto";
11
+ import { Inject, Injectable } from "@nestjs/common";
12
+ import { EnvironmentsService } from "../environments/environments.service.js";
13
+ import { ApiError } from "../errors.js";
14
+ import { audit } from "../identity/audit.js";
15
+ import { sha256Hex } from "../identity/hash.js";
16
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
17
+ import { KernelDb } from "../infra/database/kernel-db.js";
18
+ import { TransactionHost } from "../infra/database/transaction-host.js";
19
+ import { Transactional } from "../infra/database/transactional.decorator.js";
20
+ import { KERNEL_LOGGER, REDIS } from "../infra/tokens.js";
21
+ import { authorizeOrgAction } from "../orgs/org.guard.js";
22
+ import { DEVICE_PROVIDER } from "../sandbox/providers.js";
23
+ import { environmentNotFound } from "../sandbox/sandbox.service.js";
24
+ import { MachinesService } from "./machines.js";
25
+ /** The 20 consonants a user code is made of (no vowels: no words; no look-alike letters). */
26
+ export const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
27
+ const USER_CODE_LENGTH = 8;
28
+ const DEVICE_CODE_PREFIX = "cap_dvc_";
29
+ const DEVICE_CODE_RE = /^cap_dvc_[A-Za-z0-9_-]{43}$/;
30
+ /** What `slow_down` adds to the poll interval (RFC 8628 §3.5). */
31
+ export const SLOW_DOWN_STEP_SECONDS = 5;
32
+ /** Polls this much earlier than the interval are still on time (network jitter). */
33
+ const POLL_JITTER_MS = 500;
34
+ /** The web page that confirms a code: `<http.publicUrl>/device`. */
35
+ export const DEVICE_PAGE_PATH = "/device";
36
+ /** The machine's WebSocket route: `ws(s)://<http.publicUrl host>/device/connect`. */
37
+ export const DEVICE_CONNECT_PATH = "/device/connect";
38
+ /** The sign-in state of one device code: `capk:device-auth:<sha256(deviceCode)>` (a hash). */
39
+ export const deviceAuthKey = (deviceCode) => authKeyOf(sha256Hex(deviceCode));
40
+ const authKeyOf = (deviceCodeHash) => `capk:device-auth:${deviceCodeHash}`;
41
+ /** The user code's pointer to its device code's hash: `capk:device-user:<XXXX-XXXX>`. */
42
+ export const deviceUserKey = (userCode) => `capk:device-user:${userCode}`;
43
+ /**
44
+ * The poll (`ARGV[1]` now in ms, `ARGV[2]` jitter ms, `ARGV[3]` slow-down step s). Missing →
45
+ * `expired`. Earlier than the interval → `slow_down` with the grown interval. Approved → the
46
+ * parked grant, and the code is deleted (get-and-delete: the credential is handed out once).
47
+ * Otherwise the status (`pending`, `claimed`, `denied`). Every poll records its time.
48
+ */
49
+ const POLL = `
50
+ if redis.call('EXISTS', KEYS[1]) == 0 then return {'expired'} end
51
+ local now = tonumber(ARGV[1])
52
+ local interval = tonumber(redis.call('HGET', KEYS[1], 'interval'))
53
+ local last = redis.call('HGET', KEYS[1], 'lastPollAt')
54
+ redis.call('HSET', KEYS[1], 'lastPollAt', ARGV[1])
55
+ if last and now - tonumber(last) < interval * 1000 - tonumber(ARGV[2]) then
56
+ interval = interval + tonumber(ARGV[3])
57
+ redis.call('HSET', KEYS[1], 'interval', interval)
58
+ return {'slow_down', tostring(interval)}
59
+ end
60
+ local status = redis.call('HGET', KEYS[1], 'status')
61
+ if status == 'approved' then
62
+ local grant = redis.call('HGET', KEYS[1], 'grant')
63
+ redis.call('DEL', KEYS[1])
64
+ return {'approved', grant}
65
+ end
66
+ return {status}
67
+ `;
68
+ /** Writes the user code's pointer (`ARGV[1]`, TTL `ARGV[2]` s) unless the code is in use. */
69
+ const CREATE_POINTER = `
70
+ if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
71
+ redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
72
+ return 1
73
+ `;
74
+ /** Moves the status from `ARGV[1]` to `ARGV[2]` (setting field `ARGV[3]` to `ARGV[4]` if given). */
75
+ const TRANSITION = `
76
+ if redis.call('HGET', KEYS[1], 'status') ~= ARGV[1] then return 0 end
77
+ redis.call('HSET', KEYS[1], 'status', ARGV[2])
78
+ if ARGV[3] then redis.call('HSET', KEYS[1], ARGV[3], ARGV[4]) end
79
+ return 1
80
+ `;
81
+ const codeInvalid = () => new ApiError("device.code_invalid", "This code is unknown or has expired; check the code shown by capybara-device, or run capybara-device login again");
82
+ /** A user code as typed → `XXXX-XXXX`, or null when it cannot be one. */
83
+ export function normalizeUserCode(input) {
84
+ const s = input.toUpperCase().replace(/[\s-]/g, "");
85
+ if (s.length !== USER_CODE_LENGTH)
86
+ return null;
87
+ for (const c of s)
88
+ if (!USER_CODE_ALPHABET.includes(c))
89
+ return null;
90
+ return `${s.slice(0, 4)}-${s.slice(4)}`;
91
+ }
92
+ /** A uniformly random user code (rejection sampling keeps every letter equally likely). */
93
+ function newUserCode() {
94
+ const n = USER_CODE_ALPHABET.length;
95
+ const limit = 256 - (256 % n);
96
+ let out = "";
97
+ while (out.length < USER_CODE_LENGTH) {
98
+ for (const b of randomBytes(USER_CODE_LENGTH * 2)) {
99
+ if (b < limit && out.length < USER_CODE_LENGTH)
100
+ out += USER_CODE_ALPHABET[b % n];
101
+ }
102
+ }
103
+ return `${out.slice(0, 4)}-${out.slice(4)}`;
104
+ }
105
+ /**
106
+ * Device authorization (environments spec §6.2, §13.3; the shape of RFC 8628). The program asks
107
+ * for a code pair (`authorize`), the signed-in person confirms the user code on the web page
108
+ * `/device` (`pending`, `confirm` or `deny`), and the program polls `token` until it gets the
109
+ * machine credential, once. State lives only in Redis, both keys with the code's TTL
110
+ * (`environments.device.authCodeTtlSeconds`): `capk:device-auth:<sha256(deviceCode)>` (a hash:
111
+ * `info` JSON, `status`, `interval`, `lastPollAt`, the parked `grant`) and
112
+ * `capk:device-user:<userCode>` → the device code's hash. The device code itself is never stored.
113
+ */
114
+ let DeviceAuthService = class DeviceAuthService {
115
+ db;
116
+ txHost;
117
+ redis;
118
+ config;
119
+ environments;
120
+ machines;
121
+ log;
122
+ constructor(db, txHost, redis, config, environments, machines, log) {
123
+ this.db = db;
124
+ this.txHost = txHost;
125
+ this.redis = redis;
126
+ this.config = config;
127
+ this.environments = environments;
128
+ this.machines = machines;
129
+ this.log = log;
130
+ }
131
+ /** A new code pair for a machine. Needs `http.publicUrl` (the page and the connection URL). */
132
+ async authorize(machine) {
133
+ const web = this.publicUrl();
134
+ const { authCodeTtlSeconds: ttl, pollIntervalSeconds: interval } = this.config.get().environments.device;
135
+ const deviceCode = DEVICE_CODE_PREFIX + randomBytes(32).toString("base64url");
136
+ const hash = sha256Hex(deviceCode);
137
+ for (let attempt = 0;; attempt++) {
138
+ const userCode = newUserCode();
139
+ // the pointer first: a user code is never shared by two device codes
140
+ const free = await this.redis.eval(CREATE_POINTER, 1, deviceUserKey(userCode), hash, ttl);
141
+ if (free !== 1) {
142
+ if (attempt >= 4)
143
+ throw new Error("could not find a free device user code");
144
+ continue;
145
+ }
146
+ const info = {
147
+ machine,
148
+ userCode,
149
+ expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
150
+ };
151
+ await this.redis
152
+ .multi()
153
+ .hset(authKeyOf(hash), {
154
+ info: JSON.stringify(info),
155
+ status: "pending",
156
+ interval: String(interval),
157
+ })
158
+ .expire(authKeyOf(hash), ttl)
159
+ .exec();
160
+ return {
161
+ deviceCode,
162
+ userCode,
163
+ verificationUri: `${web}${DEVICE_PAGE_PATH}`,
164
+ verificationUriComplete: `${web}${DEVICE_PAGE_PATH}?code=${userCode}`,
165
+ interval,
166
+ expiresIn: ttl,
167
+ };
168
+ }
169
+ }
170
+ /**
171
+ * The program's poll: the grant once the person confirmed (the code is consumed), else
172
+ * `device.authorization_pending`, `device.slow_down` (details `[{ interval }]`, the grown
173
+ * interval), `device.denied` or `device.expired` (also for an unknown code).
174
+ */
175
+ async token(deviceCode) {
176
+ const connectUrl = this.connectUrl();
177
+ if (!DEVICE_CODE_RE.test(deviceCode))
178
+ throw expired();
179
+ const r = (await this.redis.eval(POLL, 1, deviceAuthKey(deviceCode), String(Date.now()), String(POLL_JITTER_MS), String(SLOW_DOWN_STEP_SECONDS)));
180
+ switch (r[0]) {
181
+ case "approved": {
182
+ const grant = JSON.parse(r[1]);
183
+ const device = this.config.get().environments.device;
184
+ return {
185
+ ...grant,
186
+ connectUrl,
187
+ reconnectGraceSeconds: device.reconnectGraceSeconds,
188
+ maxFrameBytes: device.maxFrameBytes,
189
+ };
190
+ }
191
+ case "slow_down":
192
+ throw new ApiError("device.slow_down", `Polling too fast; wait ${r[1]} seconds between polls`, [{ interval: Number(r[1]) }]);
193
+ case "denied":
194
+ throw new ApiError("device.denied", "The sign-in was denied in the browser");
195
+ case "pending":
196
+ case "claimed":
197
+ throw new ApiError("device.authorization_pending", "Waiting for the code to be confirmed in the browser");
198
+ default:
199
+ throw expired();
200
+ }
201
+ }
202
+ /** The machine waiting behind a user code, for the confirmation page. */
203
+ async pending(userCode) {
204
+ const { info } = await this.lookup(userCode, "pending");
205
+ return { machine: info.machine, expiresAt: info.expiresAt };
206
+ }
207
+ /**
208
+ * The person confirms a user code: in an org where they may use agents (`agents.use`), into
209
+ * one of their own live device environments or a new one named `name` (` 2`, ` 3`, … appended
210
+ * while the name is taken). One transaction creates the environment if needed, issues the
211
+ * machine (revoking the environment's previous one) and audits
212
+ * `environment.machine_connected` (retried once when a concurrent confirmation took the new
213
+ * name); after the commit the credential is parked for the poll ({@link park}). The code is
214
+ * claimed first, so two confirmations of one code cannot both issue a machine; a confirmation
215
+ * that fails before the commit leaves it pending.
216
+ */
217
+ async confirm(principal, input) {
218
+ await authorizeOrgAction(this.db.db, principal, input.orgId, "agents.use");
219
+ const { info, hash } = await this.lookup(input.userCode, "pending");
220
+ if (!(await this.transition(hash, "pending", "claimed")))
221
+ throw codeInvalid();
222
+ let issued;
223
+ try {
224
+ issued = await this.confirmIn(principal.account.id, input, info).catch((e) => {
225
+ // a concurrent confirmation took the same new name between our read and our insert
226
+ if (input.name !== undefined &&
227
+ e instanceof ApiError &&
228
+ e.code === "environment.name_taken") {
229
+ return this.confirmIn(principal.account.id, input, info);
230
+ }
231
+ throw e;
232
+ });
233
+ }
234
+ catch (e) {
235
+ await this.transition(hash, "claimed", "pending").catch(() => undefined);
236
+ throw e;
237
+ }
238
+ await this.park(hash, info, issued);
239
+ return { environmentId: issued.environmentId };
240
+ }
241
+ /**
242
+ * Parks the committed machine's credential for the poll. When that fails (the code expired
243
+ * meanwhile, or Redis failed) the machine is revoked again, so no credential nobody holds stays
244
+ * valid, and the person is told to start over: `device.expired` / `device.unavailable`.
245
+ */
246
+ async park(hash, info, issued) {
247
+ let parked;
248
+ let failure;
249
+ try {
250
+ parked = await this.transition(hash, "claimed", "approved", JSON.stringify(issued.grant));
251
+ }
252
+ catch (e) {
253
+ parked = false;
254
+ failure = e;
255
+ }
256
+ if (parked) {
257
+ await this.redis.del(deviceUserKey(info.userCode)).catch((err) => {
258
+ this.log.warn("could not delete a used device user code", { err });
259
+ });
260
+ return;
261
+ }
262
+ this.log.warn("device credential could not be parked; the machine is revoked", {
263
+ environmentId: issued.environmentId,
264
+ machineId: issued.machineId,
265
+ err: failure,
266
+ });
267
+ await this.abandon(issued);
268
+ // the program's next poll then ends with device.expired instead of waiting out the TTL
269
+ await this.redis.del(authKeyOf(hash), deviceUserKey(info.userCode)).catch(() => undefined);
270
+ if (failure === undefined)
271
+ throw expired();
272
+ throw new ApiError("device.unavailable", "The machine could not be connected; run capybara-device login again");
273
+ }
274
+ /** Revokes a machine whose credential never reached the program (see {@link park}). */
275
+ async abandon(issued) {
276
+ const { grant, machineId } = issued;
277
+ if (!(await this.machines.revokeMachine(machineId)))
278
+ return;
279
+ await audit(this.db.db, {
280
+ actorAccountId: issued.accountId,
281
+ orgId: grant.orgId,
282
+ action: "environment.machine_revoked",
283
+ target: grant.environmentId,
284
+ detail: { machineId, reason: "sign_in_incomplete" },
285
+ });
286
+ }
287
+ /** The person refuses a user code: the program's next poll gets `device.denied`. */
288
+ async deny(userCode) {
289
+ const { info, hash } = await this.lookup(userCode, "pending");
290
+ if (!(await this.transition(hash, "pending", "denied")))
291
+ throw codeInvalid();
292
+ await this.redis.del(deviceUserKey(info.userCode));
293
+ }
294
+ async confirmIn(accountId, input, info) {
295
+ const { orgId } = input;
296
+ let environmentId;
297
+ if (input.name !== undefined) {
298
+ const name = await this.freeName(orgId, accountId, input.name);
299
+ environmentId = (await this.environments.createDevice(orgId, accountId, name)).id;
300
+ }
301
+ else {
302
+ const env = await this.db
303
+ .selectFrom("environments")
304
+ .select("id")
305
+ .where("id", "=", input.environmentId)
306
+ .where("org_id", "=", orgId)
307
+ .where("owner_kind", "=", "personal")
308
+ .where("owner_account_id", "=", accountId)
309
+ .where("source", "=", "device")
310
+ .where("provider", "=", DEVICE_PROVIDER)
311
+ .where("deleted_at", "is", null)
312
+ .forNoKeyUpdate()
313
+ .executeTakeFirst();
314
+ if (!env)
315
+ throw environmentNotFound();
316
+ environmentId = env.id;
317
+ }
318
+ const { machine } = info;
319
+ const { machineId, credential } = await this.machines.issue({
320
+ orgId,
321
+ environmentId,
322
+ accountId,
323
+ ...machine,
324
+ });
325
+ await audit(this.db.db, {
326
+ actorAccountId: accountId,
327
+ orgId,
328
+ action: "environment.machine_connected",
329
+ target: environmentId,
330
+ detail: { machineId, ...machine },
331
+ });
332
+ return {
333
+ environmentId,
334
+ machineId,
335
+ accountId,
336
+ grant: { credential, orgId, environmentId },
337
+ };
338
+ }
339
+ /** `name`, or `name 2`, `name 3`, … : the first that is none of the person's live names. */
340
+ async freeName(orgId, accountId, name) {
341
+ const taken = new Set((await this.db
342
+ .selectFrom("environments")
343
+ .select("name")
344
+ .where("org_id", "=", orgId)
345
+ .where("owner_account_id", "=", accountId)
346
+ .where("deleted_at", "is", null)
347
+ .where("name", "is not", null)
348
+ .execute()).map((r) => r.name.toLowerCase()));
349
+ let candidate = name;
350
+ for (let n = 2; taken.has(candidate.toLowerCase()); n++) {
351
+ const suffix = ` ${n}`;
352
+ candidate = `${name.slice(0, 64 - suffix.length)}${suffix}`;
353
+ }
354
+ return candidate;
355
+ }
356
+ /** The code behind a user code with status `status`, else `device.code_invalid`. */
357
+ async lookup(userCode, status) {
358
+ const code = normalizeUserCode(userCode);
359
+ if (code === null)
360
+ throw codeInvalid();
361
+ const hash = await this.redis.get(deviceUserKey(code));
362
+ if (hash === null)
363
+ throw codeInvalid();
364
+ const [info, current] = await this.redis.hmget(authKeyOf(hash), "info", "status");
365
+ if (info === null || info === undefined || current !== status)
366
+ throw codeInvalid();
367
+ return { info: JSON.parse(info), hash };
368
+ }
369
+ async transition(hash, from, to, grant) {
370
+ const args = grant === undefined ? [from, to] : [from, to, "grant", grant];
371
+ return (await this.redis.eval(TRANSITION, 1, authKeyOf(hash), ...args)) === 1;
372
+ }
373
+ /** `http.publicUrl` without trailing slashes; unset → `environment.unavailable`. */
374
+ publicUrl() {
375
+ const url = this.config.get().http.publicUrl;
376
+ if (!url) {
377
+ throw new ApiError("environment.unavailable", "Connecting machines needs http.publicUrl to be configured; ask the super admin");
378
+ }
379
+ return url.replace(/\/+$/, "");
380
+ }
381
+ /** The machine's WebSocket URL: the public URL with `ws` / `wss` and `/device/connect`. */
382
+ connectUrl() {
383
+ return `${this.publicUrl().replace(/^http/i, "ws")}${DEVICE_CONNECT_PATH}`;
384
+ }
385
+ };
386
+ __decorate([
387
+ Transactional()
388
+ ], DeviceAuthService.prototype, "abandon", null);
389
+ __decorate([
390
+ Transactional()
391
+ ], DeviceAuthService.prototype, "confirmIn", null);
392
+ DeviceAuthService = __decorate([
393
+ Injectable(),
394
+ __param(0, Inject(KernelDb)),
395
+ __param(1, Inject(TransactionHost)),
396
+ __param(2, Inject(REDIS)),
397
+ __param(3, Inject(KernelConfigService)),
398
+ __param(4, Inject(EnvironmentsService)),
399
+ __param(5, Inject(MachinesService)),
400
+ __param(6, Inject(KERNEL_LOGGER))
401
+ ], DeviceAuthService);
402
+ export { DeviceAuthService };
403
+ const expired = () => new ApiError("device.expired", "This sign-in code has expired or was already used; run capybara-device login again");
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Device sign-in (environments spec §13.3): {@link DeviceAuthService} behind
3
+ * `DeviceAuthController`. Kept apart from {@link DevicesModule}, which `EnvironmentsModule`
4
+ * imports (deleting an environment revokes its machine), while sign-in needs both.
5
+ */
6
+ export declare class DeviceAuthModule {
7
+ }
@@ -0,0 +1,25 @@
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
+ import { Module } from "@nestjs/common";
8
+ import { EnvironmentsModule } from "../environments/environments.module.js";
9
+ import { DeviceAuthService } from "./device-auth.js";
10
+ import { DevicesModule } from "./devices.module.js";
11
+ /**
12
+ * Device sign-in (environments spec §13.3): {@link DeviceAuthService} behind
13
+ * `DeviceAuthController`. Kept apart from {@link DevicesModule}, which `EnvironmentsModule`
14
+ * imports (deleting an environment revokes its machine), while sign-in needs both.
15
+ */
16
+ let DeviceAuthModule = class DeviceAuthModule {
17
+ };
18
+ DeviceAuthModule = __decorate([
19
+ Module({
20
+ imports: [DevicesModule, EnvironmentsModule],
21
+ providers: [DeviceAuthService],
22
+ exports: [DeviceAuthService],
23
+ })
24
+ ], DeviceAuthModule);
25
+ export { DeviceAuthModule };