@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
@@ -12,7 +12,7 @@ import { ApiError } from "../errors.js";
12
12
  import { KernelDb } from "../infra/database/kernel-db.js";
13
13
  import { TransactionHost } from "../infra/database/transaction-host.js";
14
14
  import { Transactional } from "../infra/database/transactional.decorator.js";
15
- import { SandboxProviders } from "../sandbox/providers.js";
15
+ import { isInternalProvider, SandboxProviders } from "../sandbox/providers.js";
16
16
  import { environmentNotFound, sandboxUnavailable } from "../sandbox/sandbox.service.js";
17
17
  import { EnvironmentAccess } from "./access.js";
18
18
  import { EnvironmentsService } from "./environments.service.js";
@@ -45,13 +45,14 @@ let ConversationEnvironments = class ConversationEnvironments {
45
45
  * The environment a new conversation of `accountId` is bound to: the chosen one when it is
46
46
  * usable by the person (else `environment.not_found` / `environment.unavailable`, and nothing
47
47
  * is created); without a choice the IM default (IM only) or the person's default environment.
48
- * Null (unbound) without a sandbox provider and for anonymous IM senders.
48
+ * Null (unbound) for anonymous IM senders, and without a cloud sandbox provider when no usable
49
+ * IM default (a connected machine's environment, say) applies.
49
50
  */
50
51
  async forNew(orgId, accountId, pick = {}) {
51
52
  if (pick.environmentId !== undefined) {
52
53
  return (await this.access.usable(orgId, accountId, pick.environmentId)).id;
53
54
  }
54
- if (this.providers.current() === undefined)
55
+ if (pick.im !== true && this.providers.cloud() === undefined)
55
56
  return null;
56
57
  if (await this.isAnonymous(orgId, accountId))
57
58
  return null;
@@ -62,12 +63,12 @@ let ConversationEnvironments = class ConversationEnvironments {
62
63
  * enters, checked for its owner now. A conversation still unbound (created before
63
64
  * environments, or while no provider existed) is bound first, to the owner's IM default when it
64
65
  * is an IM thread, else to the owner's default environment; a racing call binds it once.
65
- * Throws `sandbox.unavailable` without a provider or for an anonymous IM conversation, and
66
- * `environment.not_found` / `environment.unavailable` when the owner may not use it now.
66
+ * Throws `sandbox.unavailable` for a cloud environment (or none to bind) without a cloud
67
+ * provider and for an anonymous IM conversation, and `environment.not_found` /
68
+ * `environment.unavailable` when the owner may not use it now. A conversation bound to a
69
+ * connected machine's environment (`@device`) does not need a cloud provider.
67
70
  */
68
71
  async forTool(orgId, rootConversationId) {
69
- if (this.providers.current() === undefined)
70
- throw sandboxUnavailable();
71
72
  const conv = await this.db
72
73
  .selectFrom("conversations")
73
74
  .select(["account_id", "environment_id"])
@@ -82,14 +83,62 @@ let ConversationEnvironments = class ConversationEnvironments {
82
83
  if (environmentId === null) {
83
84
  const im = await this.isImThread(rootConversationId);
84
85
  const chosen = await this.defaultFor(orgId, conv.account_id, im);
86
+ if (chosen === null)
87
+ throw sandboxUnavailable();
85
88
  environmentId = await this.bind(rootConversationId, chosen);
86
89
  }
90
+ if (this.providers.cloud() === undefined && !(await this.isInternal(environmentId))) {
91
+ throw sandboxUnavailable();
92
+ }
87
93
  return (await this.access.usable(orgId, conv.account_id, environmentId)).id;
88
94
  }
95
+ /**
96
+ * Whether a sandbox tool call of the tree rooted at `rootConversationId` has a provider to run
97
+ * on, for tool listings (ruling R8; no binding, no access check, never throws): a cloud
98
+ * provider is loaded, or the environment the conversation is bound to (an unbound IM thread:
99
+ * its owner's IM default) runs on a registered kernel provider (`@device`). A forbidden or gone
100
+ * environment still counts: the call then says why.
101
+ */
102
+ async usableNow(orgId, rootConversationId) {
103
+ if (this.providers.cloud() !== undefined)
104
+ return true;
105
+ const conv = await this.db
106
+ .selectFrom("conversations")
107
+ .select(["account_id", "environment_id"])
108
+ .where("id", "=", rootConversationId)
109
+ .where("org_id", "=", orgId)
110
+ .executeTakeFirst();
111
+ if (!conv)
112
+ return false;
113
+ let environmentId = conv.environment_id;
114
+ if (environmentId === null) {
115
+ if (!(await this.isImThread(rootConversationId)))
116
+ return false;
117
+ const row = await this.db
118
+ .selectFrom("environment_defaults")
119
+ .select("environment_id")
120
+ .where("org_id", "=", orgId)
121
+ .where("account_id", "=", conv.account_id)
122
+ .executeTakeFirst();
123
+ if (!row)
124
+ return false;
125
+ environmentId = row.environment_id;
126
+ }
127
+ const env = await this.db
128
+ .selectFrom("environments")
129
+ .select("provider")
130
+ .where("id", "=", environmentId)
131
+ .executeTakeFirst();
132
+ const provider = env?.provider ?? null;
133
+ return (provider !== null &&
134
+ isInternalProvider(provider) &&
135
+ this.providers.forName?.(provider) !== undefined);
136
+ }
89
137
  /**
90
138
  * The person's IM default when `im` and it is still theirs, live, in this org and usable (the
91
- * table has no such guard), else their default environment (created when missing). The owner
92
- * having left the org reads as `environment.not_found`.
139
+ * table has no such guard), else their default environment (created when missing); null when
140
+ * that is needed and there is no cloud provider to make or use it. The owner having left the org
141
+ * reads as `environment.not_found`.
93
142
  */
94
143
  async defaultFor(orgId, accountId, im) {
95
144
  if (im) {
@@ -109,6 +158,8 @@ let ConversationEnvironments = class ConversationEnvironments {
109
158
  }
110
159
  }
111
160
  }
161
+ if (this.providers.cloud() === undefined)
162
+ return null;
112
163
  try {
113
164
  return await this.environments.ensureDefault(orgId, accountId);
114
165
  }
@@ -133,6 +184,16 @@ let ConversationEnvironments = class ConversationEnvironments {
133
184
  .executeTakeFirstOrThrow();
134
185
  return row.environment_id ?? environmentId;
135
186
  }
187
+ /** The environment runs on a kernel-internal provider (`@device`). */
188
+ async isInternal(environmentId) {
189
+ const row = await this.db
190
+ .selectFrom("environments")
191
+ .select("provider")
192
+ .where("id", "=", environmentId)
193
+ .executeTakeFirst();
194
+ const provider = row?.provider ?? null;
195
+ return provider !== null && isInternalProvider(provider);
196
+ }
136
197
  /** The account is the org's anonymous IM guest (no person behind it). */
137
198
  async isAnonymous(orgId, accountId) {
138
199
  const row = await this.db
@@ -13,7 +13,9 @@ import { type EnvironmentList, EnvironmentsService, type EnvironmentView } from
13
13
  * - `POST / { name }` → 201 `{ environment }`; `GET /:id`, `PATCH /:id { name }` → `{ environment }`;
14
14
  * - `DELETE /:id` → 202 (destroyed by a job after the response);
15
15
  * - `POST /:id/files/reset` → 202 (the reset is enqueued: a worker empties the files, the next use
16
- * starts empty).
16
+ * starts empty);
17
+ * - `DELETE /:id/machine` → 204: a device environment's machine is disconnected (its credential
18
+ * revoked; it must sign in again).
17
19
  */
18
20
  export declare class EnvironmentsController {
19
21
  private readonly environments;
@@ -30,4 +32,5 @@ export declare class EnvironmentsController {
30
32
  }>;
31
33
  remove(principal: Principal, org: OrgContext, params: z.infer<typeof EnvironmentParams>): Promise<void>;
32
34
  resetFiles(principal: Principal, org: OrgContext, params: z.infer<typeof EnvironmentParams>): Promise<void>;
35
+ revokeMachine(principal: Principal, org: OrgContext, params: z.infer<typeof EnvironmentParams>): Promise<void>;
33
36
  }
@@ -23,7 +23,9 @@ import { EnvironmentsService, } from "./environments.service.js";
23
23
  * - `POST / { name }` → 201 `{ environment }`; `GET /:id`, `PATCH /:id { name }` → `{ environment }`;
24
24
  * - `DELETE /:id` → 202 (destroyed by a job after the response);
25
25
  * - `POST /:id/files/reset` → 202 (the reset is enqueued: a worker empties the files, the next use
26
- * starts empty).
26
+ * starts empty);
27
+ * - `DELETE /:id/machine` → 204: a device environment's machine is disconnected (its credential
28
+ * revoked; it must sign in again).
27
29
  */
28
30
  let EnvironmentsController = class EnvironmentsController {
29
31
  environments;
@@ -58,6 +60,10 @@ let EnvironmentsController = class EnvironmentsController {
58
60
  const p = requireUser(principal);
59
61
  await this.environments.resetFiles(org.orgId, p.account.id, params.id, patTokenId(p));
60
62
  }
63
+ async revokeMachine(principal, org, params) {
64
+ const p = requireUser(principal);
65
+ await this.environments.revokeMachine(org.orgId, p.account.id, params.id, patTokenId(p));
66
+ }
61
67
  };
62
68
  __decorate([
63
69
  Get(),
@@ -103,6 +109,14 @@ __decorate([
103
109
  __param(1, CurrentOrg()),
104
110
  __param(2, ZParam(EnvironmentParamsDto))
105
111
  ], EnvironmentsController.prototype, "resetFiles", null);
112
+ __decorate([
113
+ Delete(":id/machine"),
114
+ HttpCode(204),
115
+ OrgAction("agents.use"),
116
+ __param(0, CurrentPrincipal()),
117
+ __param(1, CurrentOrg()),
118
+ __param(2, ZParam(EnvironmentParamsDto))
119
+ ], EnvironmentsController.prototype, "revokeMachine", null);
106
120
  EnvironmentsController = __decorate([
107
121
  Controller("api/v1/orgs/:orgId/environments"),
108
122
  __param(0, Inject(EnvironmentsService))
@@ -5,6 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
7
  import { Module } from "@nestjs/common";
8
+ import { DevicesModule } from "../devices/devices.module.js";
8
9
  import { SandboxModule } from "../sandbox/sandbox.module.js";
9
10
  import { EnvironmentAccess } from "./access.js";
10
11
  import { ConversationEnvironments } from "./conversation-environments.js";
@@ -19,7 +20,8 @@ let EnvironmentsModule = class EnvironmentsModule {
19
20
  };
20
21
  EnvironmentsModule = __decorate([
21
22
  Module({
22
- imports: [SandboxModule],
23
+ // DevicesModule: deleting a device environment revokes its machine
24
+ imports: [SandboxModule, DevicesModule],
23
25
  providers: [EnvironmentAccess, EnvironmentsService, ConversationEnvironments],
24
26
  exports: [EnvironmentAccess, EnvironmentsService, ConversationEnvironments],
25
27
  })
@@ -1,7 +1,9 @@
1
1
  import type { SandboxLimits } from "@capybara-hq/plugin-sdk";
2
+ import { MachinesService } from "../devices/machines.js";
2
3
  import { KernelConfigService } from "../infra/config/kernel-config.service.js";
3
4
  import { KernelDb } from "../infra/database/kernel-db.js";
4
5
  import { TransactionHost } from "../infra/database/transaction-host.js";
6
+ import { LockService } from "../infra/lock/lock.service.js";
5
7
  import type { KernelLogger } from "../log.js";
6
8
  import { SandboxJobs } from "../sandbox/jobs.js";
7
9
  import { SandboxProviders } from "../sandbox/providers.js";
@@ -14,11 +16,16 @@ export interface EnvironmentView {
14
16
  name: string | null;
15
17
  isDefault: boolean;
16
18
  ownerKind: "personal";
17
- source: "cloud";
19
+ /** `device`: a connected machine (provider `@device`). */
20
+ source: "cloud" | "device";
18
21
  /** The provider keeping its files, or the one its first use will create them with. */
19
22
  provider: string | null;
20
- /** Its provider is the deployment's current sandbox provider. */
23
+ /** Its provider is there: the deployment's current cloud provider, or `@device` registered. */
21
24
  available: boolean;
25
+ /** A device environment: some replica holds its machine's connection now; null for cloud. */
26
+ online: boolean | null;
27
+ /** A device environment's connected machine (its unrevoked one); null for cloud or none. */
28
+ machine: EnvironmentMachineView | null;
22
29
  sessionIsolation: boolean;
23
30
  /** Its file system is gone at the provider; reset the files to use it again. */
24
31
  filesLost: boolean;
@@ -30,6 +37,15 @@ export interface EnvironmentView {
30
37
  limits: SandboxLimits;
31
38
  createdAt: string;
32
39
  }
40
+ /** The machine connected to a device environment, as its owner sees it. */
41
+ export interface EnvironmentMachineView {
42
+ id: string;
43
+ name: string;
44
+ os: string;
45
+ mode: "os" | "docker" | null;
46
+ programVersion: string | null;
47
+ lastSeenAt: string | null;
48
+ }
33
49
  /** The list response: the person's live environments in the org. */
34
50
  export interface EnvironmentList {
35
51
  items: EnvironmentView[];
@@ -41,7 +57,8 @@ export interface EnvironmentList {
41
57
  cloudAvailable: boolean;
42
58
  }
43
59
  /**
44
- * Personal cloud environments (spec 2026-11-07-environments-design §4.2 items 7, 12, 13; §9): a
60
+ * Personal environments (spec 2026-11-07-environments-design §4.2 items 7, 12, 13; §6; §9):
61
+ * cloud ones on the deployment's sandbox provider and `device` ones on a connected machine; a
45
62
  * person's environments in an org, visible and usable only by their owner (anyone else gets
46
63
  * `environment.not_found`). Deleting one soft-deletes it and destroys its instance and files in a
47
64
  * `sandbox-destroy-for` job after the commit.
@@ -55,7 +72,12 @@ export declare class EnvironmentsService {
55
72
  private readonly sandboxJobs;
56
73
  private readonly access;
57
74
  private readonly log;
58
- constructor(db: KernelDb, txHost: TransactionHost, config: Pick<KernelConfigService, "get">, providers: Pick<SandboxProviders, "current">, sandbox: Pick<SandboxService, "orgLimits">, sandboxJobs: Pick<SandboxJobs, "destroy" | "resetFiles">, access: Pick<EnvironmentAccess, "usable">, log: Pick<KernelLogger, "warn">);
75
+ /** Reads the device relay's lease (`online`). */
76
+ private readonly locks;
77
+ private readonly machines;
78
+ constructor(db: KernelDb, txHost: TransactionHost, config: Pick<KernelConfigService, "get">, providers: Pick<SandboxProviders, "cloud" | "forName">, sandbox: Pick<SandboxService, "orgLimits">, sandboxJobs: Pick<SandboxJobs, "destroy" | "resetFiles">, access: Pick<EnvironmentAccess, "usable">, log: Pick<KernelLogger, "warn">,
79
+ /** Reads the device relay's lease (`online`). */
80
+ locks: Pick<LockService, "holderOf">, machines: Pick<MachinesService, "revoke" | "revokeActive">);
59
81
  /**
60
82
  * The person's live environments in the org, the default first, then by creation. With a
61
83
  * current sandbox provider the default environment is created first if missing (its first need,
@@ -74,6 +96,20 @@ export declare class EnvironmentsService {
74
96
  name: string;
75
97
  }, actorTokenId?: string, auditDetail?: Record<string, unknown>): Promise<EnvironmentView>;
76
98
  private insert;
99
+ /**
100
+ * A new personal `device` environment for a machine being connected (provider `@device`; the
101
+ * machine row is the caller's). Joins the caller's transaction (the device authorization's).
102
+ * Counted against `environments.maxPersonal` (`environment.limit_reached`); a live name of the
103
+ * person's → `environment.name_taken`. Needs no cloud provider.
104
+ */
105
+ createDevice(orgId: string, accountId: string, name: string): Promise<{
106
+ id: string;
107
+ }>;
108
+ /**
109
+ * Inserts a named personal environment under the person's membership lock, after the limit
110
+ * and name checks, and audits `environment.created`. Runs in the caller's transaction.
111
+ */
112
+ private insertNamed;
77
113
  /**
78
114
  * The refusals {@link create} would raise, read without locks or writes (a precheck before a
79
115
  * confirmation card): no sandbox provider, the person's limit, a name already taken. Throws the
@@ -82,7 +118,10 @@ export declare class EnvironmentsService {
82
118
  checkCreate(orgId: string, accountId: string, name: string): Promise<void>;
83
119
  /** How many of the person's conversations work in the environment (read only). */
84
120
  boundConversations(orgId: string, accountId: string, id: string): Promise<number>;
85
- /** The refusals {@link resetFiles} would raise (not found, its provider not current), read only. */
121
+ /**
122
+ * The refusals {@link resetFiles} would raise (not found, its provider not current, a connected
123
+ * environment), read only.
124
+ */
86
125
  checkReset(orgId: string, accountId: string, id: string): Promise<void>;
87
126
  /** The refusals {@link rename} would raise, read without locks (see {@link checkCreate}). */
88
127
  checkRename(orgId: string, accountId: string, id: string, name: string): Promise<void>;
@@ -93,15 +132,23 @@ export declare class EnvironmentsService {
93
132
  * Deletes one of the person's live environments: soft delete, its IM default row cleared, and
94
133
  * after the commit a `sandbox-destroy-for` job (the worker waits for running commands, then
95
134
  * destroys the instance and the files). Conversations bound to it keep pointing at it; their
96
- * sandbox tools then fail with `environment.not_found`.
135
+ * sandbox tools then fail with `environment.not_found`. A device environment's machine is
136
+ * revoked in the same transaction; after the commit it is told `stop { reason: "deleted" }`
137
+ * (while connected) and its connection is closed.
97
138
  */
98
139
  remove(orgId: string, accountId: string, id: string, actorTokenId?: string, auditDetail?: Record<string, unknown>): Promise<void>;
140
+ /**
141
+ * Disconnects the machine of one of the person's own device environments (it must sign in
142
+ * again); see {@link MachinesService.revoke}.
143
+ */
144
+ revokeMachine(orgId: string, accountId: string, id: string, actorTokenId?: string): Promise<void>;
99
145
  /**
100
146
  * Starts one of the person's environments over with empty files (after
101
147
  * `environment.files_lost`): records the request (audit), then, after the commit, enqueues the
102
148
  * `sandbox-reset-files` job (deterministic id per environment), whose worker waits for running
103
149
  * commands and destroys any instance and the file system; the next use creates new ones. Its
104
- * provider must be the current one (`environment.unavailable`). An enqueue failure is thrown
150
+ * provider must be the current one (`environment.unavailable`); a connected environment's files
151
+ * are its machine owner's (`environment.files_on_device`). An enqueue failure is thrown
105
152
  * (the request can be repeated: the reset is idempotent).
106
153
  */
107
154
  resetFiles(orgId: string, accountId: string, id: string, actorTokenId?: string, auditDetail?: Record<string, unknown>): Promise<void>;
@@ -10,6 +10,8 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { Inject, Injectable } from "@nestjs/common";
12
12
  import { sql } from "kysely";
13
+ import { deviceLeaseKey } from "../devices/keys.js";
14
+ import { MachinesService } from "../devices/machines.js";
13
15
  import { ApiError } from "../errors.js";
14
16
  import { audit } from "../identity/audit.js";
15
17
  import { KernelConfigService } from "../infra/config/kernel-config.service.js";
@@ -17,9 +19,10 @@ import { afterCommit } from "../infra/database/after-commit.js";
17
19
  import { KernelDb } from "../infra/database/kernel-db.js";
18
20
  import { TransactionHost } from "../infra/database/transaction-host.js";
19
21
  import { Transactional } from "../infra/database/transactional.decorator.js";
22
+ import { LockService } from "../infra/lock/lock.service.js";
20
23
  import { KERNEL_LOGGER } from "../infra/tokens.js";
21
24
  import { SandboxJobs } from "../sandbox/jobs.js";
22
- import { SandboxProviders } from "../sandbox/providers.js";
25
+ import { DEVICE_PROVIDER, isInternalProvider, SandboxProviders } from "../sandbox/providers.js";
23
26
  import { environmentNotFound, SandboxService } from "../sandbox/sandbox.service.js";
24
27
  import { EnvironmentAccess, environmentProvider, environmentUnavailable, } from "./access.js";
25
28
  const isUniqueViolation = (e, constraint) => typeof e === "object" &&
@@ -28,7 +31,8 @@ const isUniqueViolation = (e, constraint) => typeof e === "object" &&
28
31
  e.constraint === constraint;
29
32
  const nameTaken = () => new ApiError("environment.name_taken", "You already have an environment with this name");
30
33
  /**
31
- * Personal cloud environments (spec 2026-11-07-environments-design §4.2 items 7, 12, 13; §9): a
34
+ * Personal environments (spec 2026-11-07-environments-design §4.2 items 7, 12, 13; §6; §9):
35
+ * cloud ones on the deployment's sandbox provider and `device` ones on a connected machine; a
32
36
  * person's environments in an org, visible and usable only by their owner (anyone else gets
33
37
  * `environment.not_found`). Deleting one soft-deletes it and destroys its instance and files in a
34
38
  * `sandbox-destroy-for` job after the commit.
@@ -42,7 +46,11 @@ let EnvironmentsService = class EnvironmentsService {
42
46
  sandboxJobs;
43
47
  access;
44
48
  log;
45
- constructor(db, txHost, config, providers, sandbox, sandboxJobs, access, log) {
49
+ locks;
50
+ machines;
51
+ constructor(db, txHost, config, providers, sandbox, sandboxJobs, access, log,
52
+ /** Reads the device relay's lease (`online`). */
53
+ locks, machines) {
46
54
  this.db = db;
47
55
  this.txHost = txHost;
48
56
  this.config = config;
@@ -51,6 +59,8 @@ let EnvironmentsService = class EnvironmentsService {
51
59
  this.sandboxJobs = sandboxJobs;
52
60
  this.access = access;
53
61
  this.log = log;
62
+ this.locks = locks;
63
+ this.machines = machines;
54
64
  }
55
65
  /**
56
66
  * The person's live environments in the org, the default first, then by creation. With a
@@ -58,7 +68,7 @@ let EnvironmentsService = class EnvironmentsService {
58
68
  * spec §4.2 item 7; an existing one is only read).
59
69
  */
60
70
  async list(orgId, accountId) {
61
- const cloudAvailable = this.providers.current() !== undefined;
71
+ const cloudAvailable = this.providers.cloud() !== undefined;
62
72
  const defaultId = cloudAvailable ? await this.ensureDefault(orgId, accountId) : null;
63
73
  const items = await this.views(orgId, accountId);
64
74
  const last = await this.db
@@ -99,9 +109,28 @@ let EnvironmentsService = class EnvironmentsService {
99
109
  return this.get(orgId, accountId, id);
100
110
  }
101
111
  async insert(orgId, accountId, name, actorTokenId, auditDetail) {
102
- const current = this.providers.current();
112
+ const current = this.providers.cloud();
103
113
  if (!current)
104
114
  throw environmentUnavailable(null);
115
+ return this.insertNamed(orgId, accountId, name, "cloud", current.plugin, actorTokenId, auditDetail ?? {});
116
+ }
117
+ /**
118
+ * A new personal `device` environment for a machine being connected (provider `@device`; the
119
+ * machine row is the caller's). Joins the caller's transaction (the device authorization's).
120
+ * Counted against `environments.maxPersonal` (`environment.limit_reached`); a live name of the
121
+ * person's → `environment.name_taken`. Needs no cloud provider.
122
+ */
123
+ async createDevice(orgId, accountId, name) {
124
+ const id = await this.insertNamed(orgId, accountId, name, "device", DEVICE_PROVIDER, undefined, {
125
+ source: "device",
126
+ });
127
+ return { id };
128
+ }
129
+ /**
130
+ * Inserts a named personal environment under the person's membership lock, after the limit
131
+ * and name checks, and audits `environment.created`. Runs in the caller's transaction.
132
+ */
133
+ async insertNamed(orgId, accountId, name, source, provider, actorTokenId, auditDetail) {
105
134
  await this.lockMember(orgId, accountId);
106
135
  const { n } = await this.db
107
136
  .selectFrom("environments")
@@ -126,8 +155,8 @@ let EnvironmentsService = class EnvironmentsService {
126
155
  owner_kind: "personal",
127
156
  owner_account_id: accountId,
128
157
  name,
129
- source: "cloud",
130
- provider: current.plugin,
158
+ source,
159
+ provider,
131
160
  })
132
161
  .execute();
133
162
  }
@@ -152,7 +181,7 @@ let EnvironmentsService = class EnvironmentsService {
152
181
  * same {@link ApiError}s; a race still ends in `create`'s own refusal.
153
182
  */
154
183
  async checkCreate(orgId, accountId, name) {
155
- if (!this.providers.current())
184
+ if (!this.providers.cloud())
156
185
  throw environmentUnavailable(null);
157
186
  const { n } = await this.db
158
187
  .selectFrom("environments")
@@ -179,9 +208,12 @@ let EnvironmentsService = class EnvironmentsService {
179
208
  .executeTakeFirstOrThrow();
180
209
  return Number(n);
181
210
  }
182
- /** The refusals {@link resetFiles} would raise (not found, its provider not current), read only. */
211
+ /**
212
+ * The refusals {@link resetFiles} would raise (not found, its provider not current, a connected
213
+ * environment), read only.
214
+ */
183
215
  async checkReset(orgId, accountId, id) {
184
- await this.access.usable(orgId, accountId, id);
216
+ refuseDeviceReset(await this.access.usable(orgId, accountId, id));
185
217
  }
186
218
  /** The refusals {@link rename} would raise, read without locks (see {@link checkCreate}). */
187
219
  async checkRename(orgId, accountId, id, name) {
@@ -228,7 +260,9 @@ let EnvironmentsService = class EnvironmentsService {
228
260
  * Deletes one of the person's live environments: soft delete, its IM default row cleared, and
229
261
  * after the commit a `sandbox-destroy-for` job (the worker waits for running commands, then
230
262
  * destroys the instance and the files). Conversations bound to it keep pointing at it; their
231
- * sandbox tools then fail with `environment.not_found`.
263
+ * sandbox tools then fail with `environment.not_found`. A device environment's machine is
264
+ * revoked in the same transaction; after the commit it is told `stop { reason: "deleted" }`
265
+ * (while connected) and its connection is closed.
232
266
  */
233
267
  async remove(orgId, accountId, id, actorTokenId, auditDetail) {
234
268
  const before = await this.lockOwn(orgId, accountId, id);
@@ -243,6 +277,7 @@ let EnvironmentsService = class EnvironmentsService {
243
277
  .where("account_id", "=", accountId)
244
278
  .where("environment_id", "=", id)
245
279
  .execute();
280
+ await this.machines.revokeActive(id, "deleted");
246
281
  await audit(this.db.db, {
247
282
  actorAccountId: accountId,
248
283
  ...(actorTokenId !== undefined && { actorTokenId }),
@@ -261,12 +296,20 @@ let EnvironmentsService = class EnvironmentsService {
261
296
  }
262
297
  });
263
298
  }
299
+ /**
300
+ * Disconnects the machine of one of the person's own device environments (it must sign in
301
+ * again); see {@link MachinesService.revoke}.
302
+ */
303
+ async revokeMachine(orgId, accountId, id, actorTokenId) {
304
+ await this.machines.revoke(orgId, accountId, id, actorTokenId);
305
+ }
264
306
  /**
265
307
  * Starts one of the person's environments over with empty files (after
266
308
  * `environment.files_lost`): records the request (audit), then, after the commit, enqueues the
267
309
  * `sandbox-reset-files` job (deterministic id per environment), whose worker waits for running
268
310
  * commands and destroys any instance and the file system; the next use creates new ones. Its
269
- * provider must be the current one (`environment.unavailable`). An enqueue failure is thrown
311
+ * provider must be the current one (`environment.unavailable`); a connected environment's files
312
+ * are its machine owner's (`environment.files_on_device`). An enqueue failure is thrown
270
313
  * (the request can be repeated: the reset is idempotent).
271
314
  */
272
315
  async resetFiles(orgId, accountId, id, actorTokenId, auditDetail) {
@@ -275,7 +318,7 @@ let EnvironmentsService = class EnvironmentsService {
275
318
  }
276
319
  /** The check and audit row of {@link resetFiles}, committed before the job is enqueued. */
277
320
  async recordReset(orgId, accountId, id, actorTokenId, auditDetail) {
278
- await this.access.usable(orgId, accountId, id);
321
+ refuseDeviceReset(await this.access.usable(orgId, accountId, id));
279
322
  await audit(this.db.db, {
280
323
  actorAccountId: accountId,
281
324
  ...(actorTokenId !== undefined && { actorTokenId }),
@@ -307,7 +350,7 @@ let EnvironmentsService = class EnvironmentsService {
307
350
  return this.insertDefault(orgId, accountId);
308
351
  }
309
352
  async insertDefault(orgId, accountId) {
310
- const current = this.providers.current();
353
+ const current = this.providers.cloud();
311
354
  if (!current)
312
355
  throw environmentUnavailable(null);
313
356
  await this.lockMember(orgId, accountId, "share");
@@ -439,7 +482,7 @@ let EnvironmentsService = class EnvironmentsService {
439
482
  }
440
483
  /** Its files or instance are kept by a provider that is not the current one. */
441
484
  stranded(f) {
442
- const current = this.providers.current()?.plugin;
485
+ const current = this.providers.cloud()?.plugin;
443
486
  return current !== undefined && environmentProvider(f, current) !== current;
444
487
  }
445
488
  /** The person's live environments (or the one `id`) as views. */
@@ -447,12 +490,21 @@ let EnvironmentsService = class EnvironmentsService {
447
490
  const rows = await this.db
448
491
  .selectFrom("environments as e")
449
492
  .leftJoin("environment_instances as i", "i.environment_id", "e.id")
493
+ .leftJoin("environment_machines as m", (j) => j.onRef("m.environment_id", "=", "e.id").on("m.revoked_at", "is", null))
450
494
  .select([
451
495
  "e.id",
452
496
  "e.name",
453
497
  "e.is_default",
498
+ "e.source",
454
499
  "e.provider",
455
500
  "e.created_at",
501
+ "m.id as machine_id",
502
+ "m.name as machine_name",
503
+ "m.os as machine_os",
504
+ "m.mode as machine_mode",
505
+ "m.program_version as machine_program_version",
506
+ "m.last_seen_at as machine_last_seen_at",
507
+ "m.session_isolation as machine_session_isolation",
456
508
  "i.provider as instance_provider",
457
509
  "i.fs_status",
458
510
  "i.handle",
@@ -471,7 +523,11 @@ let EnvironmentsService = class EnvironmentsService {
471
523
  if (rows.length === 0)
472
524
  return [];
473
525
  const limits = await this.sandbox.orgLimits(orgId);
474
- const current = this.providers.current()?.plugin;
526
+ const current = this.providers.cloud()?.plugin;
527
+ // a device environment is online while a replica holds its machine's connection lease
528
+ const online = new Map(await Promise.all(rows
529
+ .filter((r) => r.source === "device")
530
+ .map(async (r) => [r.id, (await this.locks.holderOf(deviceLeaseKey(r.id))) !== null])));
475
531
  return rows.map((r) => {
476
532
  const provider = environmentProvider({
477
533
  provider: r.provider,
@@ -485,10 +541,24 @@ let EnvironmentsService = class EnvironmentsService {
485
541
  name: r.name,
486
542
  isDefault: r.is_default,
487
543
  ownerKind: "personal",
488
- source: "cloud",
544
+ source: r.source,
489
545
  provider,
490
- available: current !== undefined && provider === current,
491
- sessionIsolation: false,
546
+ available: provider !== null && isInternalProvider(provider)
547
+ ? this.providers.forName(provider) !== undefined
548
+ : current !== undefined && provider === current,
549
+ online: r.source === "device" ? (online.get(r.id) ?? false) : null,
550
+ machine: r.machine_id !== null && r.machine_name !== null && r.machine_os !== null
551
+ ? {
552
+ id: r.machine_id,
553
+ name: r.machine_name,
554
+ os: r.machine_os,
555
+ mode: r.machine_mode,
556
+ programVersion: r.machine_program_version,
557
+ lastSeenAt: r.machine_last_seen_at?.toISOString() ?? null,
558
+ }
559
+ : null,
560
+ // a device environment's machine decides it; cloud environments have none in phase 2
561
+ sessionIsolation: r.machine_session_isolation ?? false,
492
562
  filesLost: r.fs_status === "lost",
493
563
  instance: live
494
564
  ? {
@@ -560,6 +630,9 @@ let EnvironmentsService = class EnvironmentsService {
560
630
  __decorate([
561
631
  Transactional()
562
632
  ], EnvironmentsService.prototype, "insert", null);
633
+ __decorate([
634
+ Transactional()
635
+ ], EnvironmentsService.prototype, "createDevice", null);
563
636
  __decorate([
564
637
  Transactional()
565
638
  ], EnvironmentsService.prototype, "renameIn", null);
@@ -581,6 +654,17 @@ EnvironmentsService = __decorate([
581
654
  __param(4, Inject(SandboxService)),
582
655
  __param(5, Inject(SandboxJobs)),
583
656
  __param(6, Inject(EnvironmentAccess)),
584
- __param(7, Inject(KERNEL_LOGGER))
657
+ __param(7, Inject(KERNEL_LOGGER)),
658
+ __param(8, Inject(LockService)),
659
+ __param(9, Inject(MachinesService))
585
660
  ], EnvironmentsService);
586
661
  export { EnvironmentsService };
662
+ /**
663
+ * Ruling R24: a connected environment's files are in a directory on its machine, which the
664
+ * platform never wipes; a lost directory comes back with `capybara-device logout` and a new `login` on the machine.
665
+ */
666
+ function refuseDeviceReset(env) {
667
+ if (env.provider === DEVICE_PROVIDER) {
668
+ throw new ApiError("environment.files_on_device", "This environment's files are in a directory on its connected machine and cannot be reset from here. To start over, run capybara-device logout, then capybara-device login into this environment, on that machine.");
669
+ }
670
+ }
@@ -27,7 +27,7 @@ export declare class LegacySandboxCleanupJob {
27
27
  readonly config: KernelConfigService;
28
28
  readonly lockService: LockService;
29
29
  readonly log: KernelLogger;
30
- constructor(db: KernelDb, providers: Pick<SandboxProviders, "lookup">, config: KernelConfigService, lockService: LockService, log: KernelLogger);
30
+ constructor(db: KernelDb, providers: Pick<SandboxProviders, "forName">, config: KernelConfigService, lockService: LockService, log: KernelLogger);
31
31
  tick(signal?: AbortSignal): Promise<void>;
32
32
  /** One run (see the class); `signal` (the stop, or a lost run lock) is read between rows. */
33
33
  runOnce(now?: Date, signal?: AbortSignal): Promise<LegacyCleanupResult>;
@@ -65,8 +65,8 @@ let LegacySandboxCleanupJob = class LegacySandboxCleanupJob {
65
65
  .select(["provider", (eb) => eb.fn.countAll().as("n")])
66
66
  .groupBy("provider")
67
67
  .execute();
68
- const loaded = byProvider.filter((p) => this.providers.lookup(p.provider) !== undefined);
69
- const unloaded = byProvider.filter((p) => this.providers.lookup(p.provider) === undefined);
68
+ const loaded = byProvider.filter((p) => this.providers.forName(p.provider) !== undefined);
69
+ const unloaded = byProvider.filter((p) => this.providers.forName(p.provider) === undefined);
70
70
  const kept = unloaded.reduce((sum, p) => sum + Number(p.n), 0);
71
71
  if (unloaded.length > 0 && now.getTime() % 3_600_000 < PERIOD_MS) {
72
72
  this.log.warn("legacy sandboxes kept: their provider is not loaded", {
@@ -89,7 +89,7 @@ let LegacySandboxCleanupJob = class LegacySandboxCleanupJob {
89
89
  if (signal?.aborted)
90
90
  break;
91
91
  const name = `cap-${row.conversation_id}`;
92
- const impl = this.providers.lookup(row.provider);
92
+ const impl = this.providers.forName(row.provider)?.impl;
93
93
  if (impl === undefined)
94
94
  continue; // unloaded since the query: kept for a later tick
95
95
  const callSignal = () => signal
@@ -99,7 +99,7 @@ let LegacySandboxCleanupJob = class LegacySandboxCleanupJob {
99
99
  await impl.destroyByName(name, callSignal());
100
100
  await impl.destroyFileSystemByName(name, callSignal());
101
101
  if (row.prev_provider !== null && row.prev_provider !== row.provider) {
102
- const prev = this.providers.lookup(row.prev_provider);
102
+ const prev = this.providers.forName(row.prev_provider)?.impl;
103
103
  if (prev !== undefined)
104
104
  await prev.destroyByName(name, callSignal());
105
105
  else {