@capybara-hq/kernel 0.1.0-rc.20 → 0.1.0-rc.22

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 (247) hide show
  1. package/bootstrap.json +10 -1
  2. package/dist/agents/admin-agents.d.ts +16 -3
  3. package/dist/agents/admin-agents.js +65 -7
  4. package/dist/agents/agents.schemas.d.ts +24 -6
  5. package/dist/agents/agents.service.d.ts +4 -3
  6. package/dist/agents/agents.service.js +39 -26
  7. package/dist/agents/definition.d.ts +44 -6
  8. package/dist/agents/definition.js +86 -18
  9. package/dist/agents/from-template.d.ts +6 -3
  10. package/dist/agents/from-template.js +57 -44
  11. package/dist/agents/org-templates.js +97 -41
  12. package/dist/agents/templates.d.ts +6 -0
  13. package/dist/agents/templates.js +15 -1
  14. package/dist/catalog/admin-catalog.controller.d.ts +2 -4
  15. package/dist/catalog/admin-catalog.controller.js +2 -6
  16. package/dist/catalog/catalog.module.js +3 -0
  17. package/dist/catalog/claude/report.d.ts +9 -2
  18. package/dist/catalog/claude/report.js +1 -0
  19. package/dist/catalog/claude/translate.js +1 -1
  20. package/dist/catalog/component-promotion.js +1 -2
  21. package/dist/catalog/component-state.d.ts +13 -1
  22. package/dist/catalog/component-state.js +14 -5
  23. package/dist/catalog/marketplaces.service.d.ts +1 -1
  24. package/dist/catalog/marketplaces.service.js +2 -1
  25. package/dist/catalog/org-catalog.controller.d.ts +13 -3
  26. package/dist/catalog/org-catalog.controller.js +26 -4
  27. package/dist/catalog/org-catalog.schemas.d.ts +13 -0
  28. package/dist/catalog/org-catalog.schemas.js +34 -0
  29. package/dist/catalog/org-plugin-uploads.controller.js +0 -1
  30. package/dist/catalog/org-plugin-uploads.d.ts +0 -1
  31. package/dist/catalog/org-plugin-uploads.js +0 -3
  32. package/dist/catalog/org-plugin-uploads.schemas.d.ts +0 -1
  33. package/dist/catalog/org-plugin-uploads.schemas.js +0 -2
  34. package/dist/catalog/platform-components.d.ts +0 -1
  35. package/dist/catalog/platform-components.js +3 -8
  36. package/dist/catalog/plugin-import.d.ts +0 -8
  37. package/dist/catalog/plugin-import.js +4 -35
  38. package/dist/catalog/plugin-install.d.ts +2 -2
  39. package/dist/catalog/plugin-install.js +2 -2
  40. package/dist/catalog/plugin-package.d.ts +7 -0
  41. package/dist/catalog/plugin-package.js +27 -1
  42. package/dist/catalog/plugin-upgrade.d.ts +5 -6
  43. package/dist/catalog/plugin-upgrade.js +5 -23
  44. package/dist/catalog/requests.service.d.ts +66 -10
  45. package/dist/catalog/requests.service.js +196 -15
  46. package/dist/channels/actions.d.ts +2 -0
  47. package/dist/channels/actions.js +19 -4
  48. package/dist/channels/cards.d.ts +16 -2
  49. package/dist/channels/cards.js +40 -7
  50. package/dist/channels/channels.module.js +3 -0
  51. package/dist/channels/outbound.d.ts +22 -1
  52. package/dist/channels/outbound.js +116 -7
  53. package/dist/channels/phrases.d.ts +25 -0
  54. package/dist/channels/phrases.js +19 -0
  55. package/dist/channels/threads.d.ts +12 -0
  56. package/dist/channels/threads.js +34 -0
  57. package/dist/config/schema.d.ts +23 -1
  58. package/dist/config/schema.js +105 -5
  59. package/dist/conversations/conversations.service.d.ts +42 -9
  60. package/dist/conversations/conversations.service.js +57 -25
  61. package/dist/conversations/events.d.ts +106 -11
  62. package/dist/conversations/events.js +121 -18
  63. package/dist/conversations/message-context.d.ts +6 -3
  64. package/dist/conversations/message-context.js +14 -10
  65. package/dist/conversations/queue.d.ts +15 -2
  66. package/dist/conversations/queue.js +22 -3
  67. package/dist/conversations/queue.service.d.ts +12 -6
  68. package/dist/conversations/queue.service.js +36 -13
  69. package/dist/db/kernel-schema.d.ts +105 -3
  70. package/dist/devices/conversation-devices.d.ts +8 -1
  71. package/dist/devices/conversation-devices.js +8 -0
  72. package/dist/devices/devices.controller.d.ts +21 -4
  73. package/dist/devices/devices.controller.js +46 -8
  74. package/dist/devices/devices.schemas.d.ts +9 -1
  75. package/dist/devices/devices.schemas.js +11 -3
  76. package/dist/devices/devices.service.d.ts +176 -4
  77. package/dist/devices/devices.service.js +341 -6
  78. package/dist/devices/dto.d.ts +56 -3
  79. package/dist/devices/dto.js +51 -1
  80. package/dist/devices/public-suffix.d.ts +6 -0
  81. package/dist/devices/public-suffix.js +79 -0
  82. package/dist/errors.d.ts +46 -5
  83. package/dist/errors.js +46 -5
  84. package/dist/evals/reconstruct.d.ts +18 -13
  85. package/dist/evals/reconstruct.js +50 -36
  86. package/dist/evals/recorded-tools.d.ts +7 -7
  87. package/dist/evals/recorded-tools.js +4 -3
  88. package/dist/evals/trajectory.d.ts +6 -1
  89. package/dist/evals/trajectory.js +7 -0
  90. package/dist/extensions/bootstrap.d.ts +49 -10
  91. package/dist/extensions/bootstrap.js +104 -27
  92. package/dist/extensions/package.js +2 -2
  93. package/dist/http/api.module.js +5 -0
  94. package/dist/inbox/inbox.module.js +2 -1
  95. package/dist/inbox/inbox.service.d.ts +19 -4
  96. package/dist/inbox/inbox.service.js +14 -3
  97. package/dist/infra/config/kernel-config.service.js +11 -0
  98. package/dist/infra/events/kernel-events.d.ts +2 -1
  99. package/dist/lifecycle/kernel-lifecycle.d.ts +5 -1
  100. package/dist/lifecycle/kernel-lifecycle.js +16 -4
  101. package/dist/local-devices/device-policy.service.d.ts +65 -0
  102. package/dist/local-devices/device-policy.service.js +217 -0
  103. package/dist/local-devices/local-device-gateway.js +4 -1
  104. package/dist/local-devices/local-device-provider.d.ts +40 -3
  105. package/dist/local-devices/local-device-provider.js +109 -7
  106. package/dist/local-devices/local-devices.module.d.ts +2 -1
  107. package/dist/local-devices/local-devices.module.js +19 -3
  108. package/dist/local-devices/machines.d.ts +14 -3
  109. package/dist/local-devices/machines.js +41 -3
  110. package/dist/local-devices/network-grants.d.ts +104 -0
  111. package/dist/local-devices/network-grants.js +288 -0
  112. package/dist/local-devices/policy-summary.d.ts +20 -0
  113. package/dist/local-devices/policy-summary.js +63 -0
  114. package/dist/orgs/orgs.module.js +4 -10
  115. package/dist/orgs/roles.d.ts +1 -1
  116. package/dist/orgs/roles.js +4 -0
  117. package/dist/orgs/settings.controller.js +2 -1
  118. package/dist/orgs/settings.service.d.ts +164 -12
  119. package/dist/orgs/settings.service.js +212 -24
  120. package/dist/platform/exclusions.js +11 -1
  121. package/dist/platform/operation.js +4 -0
  122. package/dist/platform/ops/admin-items.ops.js +1 -1
  123. package/dist/platform/ops/agent-skills.ops.js +2 -2
  124. package/dist/platform/ops/agent-templates.ops.js +2 -2
  125. package/dist/platform/ops/agents.ops.js +11 -15
  126. package/dist/platform/ops/devices.ops.js +3 -1
  127. package/dist/platform/ops/inbox.ops.js +19 -0
  128. package/dist/platform/ops/org-settings.ops.js +14 -5
  129. package/dist/platform/ops/platform.ops.d.ts +9 -1
  130. package/dist/platform/ops/platform.ops.js +34 -12
  131. package/dist/platform/ops/requests.ops.js +3 -2
  132. package/dist/platform/ops/schedules.ops.d.ts +21 -0
  133. package/dist/platform/ops/schedules.ops.js +470 -0
  134. package/dist/platform/ops/skills.ops.js +3 -3
  135. package/dist/platform/platform-ops.module.d.ts +2 -1
  136. package/dist/platform/platform-ops.module.js +5 -0
  137. package/dist/platform/route-schemas.js +3 -0
  138. package/dist/roles/worker.module.js +3 -0
  139. package/dist/runtime/context/build.js +13 -4
  140. package/dist/runtime/context/compaction.js +6 -3
  141. package/dist/runtime/context/device-paths.d.ts +43 -21
  142. package/dist/runtime/context/device-paths.js +104 -21
  143. package/dist/runtime/context/run-context.d.ts +9 -0
  144. package/dist/runtime/context/run-context.js +8 -0
  145. package/dist/runtime/context/skills.d.ts +14 -1
  146. package/dist/runtime/context/skills.js +36 -5
  147. package/dist/runtime/maintenance.d.ts +4 -2
  148. package/dist/runtime/maintenance.js +16 -5
  149. package/dist/runtime/run-finish.d.ts +6 -1
  150. package/dist/runtime/run-finish.js +16 -2
  151. package/dist/runtime/run-queue.js +21 -2
  152. package/dist/runtime/run-worker.js +14 -33
  153. package/dist/runtime/runtime.module.js +3 -0
  154. package/dist/runtime/subagents/definition.d.ts +32 -14
  155. package/dist/runtime/subagents/definition.js +106 -109
  156. package/dist/runtime/subagents/dispatch.js +4 -3
  157. package/dist/runtime/subagents/preamble.d.ts +2 -1
  158. package/dist/runtime/subagents/preamble.js +2 -1
  159. package/dist/runtime/waits.js +14 -4
  160. package/dist/sandbox/sandbox.service.d.ts +120 -10
  161. package/dist/sandbox/sandbox.service.js +335 -28
  162. package/dist/sandbox/tiers.d.ts +86 -0
  163. package/dist/sandbox/tiers.js +96 -0
  164. package/dist/schedules/effective-settings.d.ts +16 -0
  165. package/dist/schedules/effective-settings.js +17 -0
  166. package/dist/schedules/errors.d.ts +8 -0
  167. package/dist/schedules/errors.js +8 -0
  168. package/dist/schedules/fire.d.ts +118 -0
  169. package/dist/schedules/fire.js +625 -0
  170. package/dist/schedules/grant-rules.d.ts +94 -0
  171. package/dist/schedules/grant-rules.js +126 -0
  172. package/dist/schedules/job-ids.d.ts +32 -0
  173. package/dist/schedules/job-ids.js +27 -0
  174. package/dist/schedules/pause.d.ts +9 -0
  175. package/dist/schedules/pause.js +33 -0
  176. package/dist/schedules/run-tracker.d.ts +81 -0
  177. package/dist/schedules/run-tracker.js +333 -0
  178. package/dist/schedules/scan.job.d.ts +64 -0
  179. package/dist/schedules/scan.job.js +303 -0
  180. package/dist/schedules/schedule-math.d.ts +31 -0
  181. package/dist/schedules/schedule-math.js +223 -0
  182. package/dist/schedules/scheduled-tasks.service.d.ts +351 -0
  183. package/dist/schedules/scheduled-tasks.service.js +1193 -0
  184. package/dist/schedules/schedules.controller.d.ts +68 -0
  185. package/dist/schedules/schedules.controller.js +242 -0
  186. package/dist/schedules/schedules.module.d.ts +16 -0
  187. package/dist/schedules/schedules.module.js +65 -0
  188. package/dist/schedules/schedules.schemas.d.ts +316 -0
  189. package/dist/schedules/schedules.schemas.js +186 -0
  190. package/dist/schedules/store.d.ts +81 -0
  191. package/dist/schedules/store.js +390 -0
  192. package/dist/schedules/store.module.d.ts +6 -0
  193. package/dist/schedules/store.module.js +21 -0
  194. package/dist/schedules/types.d.ts +108 -0
  195. package/dist/schedules/types.js +1 -0
  196. package/dist/schedules/unattended.d.ts +117 -0
  197. package/dist/schedules/unattended.js +228 -0
  198. package/dist/setup/oauth-token-probe.js +8 -4
  199. package/dist/skills/agent-skills.service.d.ts +4 -8
  200. package/dist/skills/agent-skills.service.js +18 -53
  201. package/dist/skills/catalog.d.ts +65 -62
  202. package/dist/skills/catalog.js +109 -206
  203. package/dist/skills/conversation-catalog.d.ts +3 -3
  204. package/dist/skills/conversation-catalog.js +5 -12
  205. package/dist/skills/invoke.d.ts +2 -2
  206. package/dist/skills/skills.controller.d.ts +3 -1
  207. package/dist/skills/skills.service.d.ts +38 -37
  208. package/dist/skills/skills.service.js +168 -107
  209. package/dist/skills/usable.d.ts +57 -8
  210. package/dist/skills/usable.js +172 -38
  211. package/dist/tools/approvals.controller.d.ts +4 -2
  212. package/dist/tools/approvals.controller.js +17 -2
  213. package/dist/tools/approvals.schemas.d.ts +1 -0
  214. package/dist/tools/approvals.schemas.js +3 -1
  215. package/dist/tools/approvals.service.d.ts +182 -7
  216. package/dist/tools/approvals.service.js +597 -13
  217. package/dist/tools/builtin-names.d.ts +25 -4
  218. package/dist/tools/builtin-names.js +45 -4
  219. package/dist/tools/builtins/builtin-tools.d.ts +8 -0
  220. package/dist/tools/builtins/builtin-tools.js +23 -2
  221. package/dist/tools/builtins/sandbox/bash.js +16 -7
  222. package/dist/tools/builtins/sandbox/common.d.ts +1 -1
  223. package/dist/tools/builtins/sandbox/common.js +33 -10
  224. package/dist/tools/builtins/sandbox/edit.js +7 -3
  225. package/dist/tools/builtins/sandbox/file-state.d.ts +31 -1
  226. package/dist/tools/builtins/sandbox/file-state.js +33 -3
  227. package/dist/tools/builtins/sandbox/write.js +8 -4
  228. package/dist/tools/builtins/schedules.d.ts +107 -0
  229. package/dist/tools/builtins/schedules.js +789 -0
  230. package/dist/tools/elicitation.d.ts +2 -0
  231. package/dist/tools/elicitation.js +1 -1
  232. package/dist/tools/fixed-ask.d.ts +7 -0
  233. package/dist/tools/fixed-ask.js +9 -0
  234. package/dist/tools/network-asks.d.ts +109 -0
  235. package/dist/tools/network-asks.js +221 -0
  236. package/dist/tools/pipeline.d.ts +53 -6
  237. package/dist/tools/pipeline.js +202 -13
  238. package/dist/tools/policy.d.ts +21 -8
  239. package/dist/tools/policy.js +63 -9
  240. package/dist/tools/registry.d.ts +10 -1
  241. package/dist/tools/tools.module.js +12 -0
  242. package/package.json +2 -2
  243. package/src/migrations/0113_scheduled_tasks.sql +108 -0
  244. package/src/migrations/0114_network_asks.sql +23 -0
  245. package/src/migrations/0115_network_requests.sql +28 -0
  246. package/src/migrations/0116_skills_default_on.sql +37 -0
  247. package/src/migrations/0117_sandbox_tiers.sql +11 -0
@@ -120,6 +120,8 @@ let KernelLifecycle = class KernelLifecycle {
120
120
  unwatchConfig;
121
121
  /** What the first bootstrap pass did, carried into the second one. */
122
122
  bootstrapCarry;
123
+ /** The packages the first bootstrap pass obtained, so the second downloads nothing again. */
124
+ bootstrapPackages;
123
125
  constructor(accounts, configService, log, options,
124
126
  // biome-ignore lint/suspicious/noExplicitAny: the pool carries no schema types
125
127
  pool, redis, kdb, shutdown, host, registry, installs, blobs, extensionLifecycle, replicaStatus, bootstrap, marketplaces, pluginImport, bus,
@@ -303,7 +305,9 @@ let KernelLifecycle = class KernelLifecycle {
303
305
  * kernel never serves without a required plugin. A failed optional entry is logged and returns
304
306
  * true: the start runs the step once more after the extensions loaded (a declarative entry's
305
307
  * catalog files need the object store, which on a fresh deployment is itself a bootstrap plugin),
306
- * and the next start tries again while it stays incomplete.
308
+ * and the next start tries again while it stays incomplete. The first pass skips the `builtin`
309
+ * entries (they may ship skills and templates, which need the object store) and returns true for
310
+ * them too; the final pass imports them, and one that fails is logged like an optional entry.
307
311
  */
308
312
  async runBootstrap(dir, final = false) {
309
313
  const manifest = this.options.bootstrapManifest ?? (await readBootstrapManifest());
@@ -318,17 +322,25 @@ let KernelLifecycle = class KernelLifecycle {
318
322
  ...(dir !== undefined && { dir }),
319
323
  signal: this.shutdown.signal,
320
324
  ...(this.bootstrapCarry !== undefined && { carry: this.bootstrapCarry }),
325
+ ...(this.bootstrapPackages !== undefined && { packages: this.bootstrapPackages }),
326
+ deferBuiltin: !final,
321
327
  });
322
328
  return false;
323
329
  }
324
330
  catch (e) {
325
331
  if (e instanceof BootstrapFailedError && !e.requiredFailed) {
326
332
  this.bootstrapCarry = e.results;
327
- if (final) {
328
- this.log.error("bootstrap: optional plugins could not be installed; retried on the next start", { failures: e.failures });
333
+ this.bootstrapPackages = e.packages;
334
+ if (e.failures.length === 0) {
335
+ this.log.info("bootstrap: builtin plugins are installed once the extensions have loaded", {
336
+ deferred: e.deferred,
337
+ });
338
+ }
339
+ else if (final) {
340
+ this.log.error("bootstrap: optional or builtin plugins could not be installed; retried on the next start", { failures: e.failures });
329
341
  }
330
342
  else {
331
- this.log.warn("bootstrap: optional plugins could not be installed yet; retried once the extensions have loaded", { failures: e.failures });
343
+ this.log.warn("bootstrap: optional or builtin plugins could not be installed yet; retried once the extensions have loaded", { failures: e.failures });
332
344
  }
333
345
  return true;
334
346
  }
@@ -0,0 +1,65 @@
1
+ import { type DeviceFeature, type DeviceLocalPolicySummary, type DevicePolicyArgs } from "@capybara-hq/plugin-sdk";
2
+ import { KernelDb } from "../infra/database/kernel-db.js";
3
+ import { TransactionHost } from "../infra/database/transaction-host.js";
4
+ import type { KernelLogger } from "../log.js";
5
+ import { MachinesService } from "./machines.js";
6
+ import { LocalDeviceRelay } from "./relay.js";
7
+ /** How long the machine may take to apply a policy change. */
8
+ export declare const POLICY_DEADLINE_MS = 30000;
9
+ /** What became of a policy change. */
10
+ export type PolicyChangeOutcome = {
11
+ applied: true;
12
+ policy: DeviceLocalPolicySummary;
13
+ } | {
14
+ applied: false;
15
+ reason: "offline" | "refused" | "unsupported";
16
+ message: string;
17
+ };
18
+ /**
19
+ * Changes a local device's policy on its machine (network ask-to-allow spec §5.2; sandbox dev
20
+ * tiers spec §7.3: its hosts, its `tier`, and `isolation` back to `sandbox`): the kernel→device
21
+ * `policy` op over the {@link LocalDeviceRelay}. The machine is the authority: it
22
+ * refuses widening unless its owner allows remote grants, validates every host and answers with
23
+ * the policy as written, which the kernel then keeps on the active machine's row (§5.4) and
24
+ * audits as `device.policy_changed`.
25
+ *
26
+ * The op is sent outside any transaction (a decision records its outcome afterwards, in its own
27
+ * transaction); only the summary write and the audit row are transactional. Callers decide who
28
+ * may change a device's policy (the device owner, in a signed-in session); this service checks
29
+ * only that the device is a live device of the org.
30
+ */
31
+ export declare class DevicePolicyService {
32
+ private readonly db;
33
+ readonly txHost: TransactionHost;
34
+ private readonly relay;
35
+ private readonly machines;
36
+ private readonly log;
37
+ constructor(db: KernelDb, txHost: TransactionHost, relay: Pick<LocalDeviceRelay, "call" | "holderLive">, machines: Pick<MachinesService, "activeFor">, log: Pick<KernelLogger, "warn">);
38
+ /**
39
+ * Sends the `policy` op (no transaction may be open), stores the returned summary and audits
40
+ * `device.policy_changed`. Never throws for an offline machine, a refusal or a program without
41
+ * the `policyOp` feature (nothing is sent to it); a device that is not a live device of `orgId`
42
+ * is `device.not_found`.
43
+ */
44
+ apply(input: {
45
+ orgId: string;
46
+ deviceId: string;
47
+ change: Omit<DevicePolicyArgs, "source">;
48
+ source: DevicePolicyArgs["source"];
49
+ actorTokenId?: string;
50
+ }): Promise<PolicyChangeOutcome>;
51
+ /** The active machine's features and policy summary (null when no machine is connected). */
52
+ summary(deviceId: string): Promise<{
53
+ features: DeviceFeature[];
54
+ policy: DeviceLocalPolicySummary | null;
55
+ } | null>;
56
+ /**
57
+ * Keeps the policy the machine wrote on its row and audits the change (`confirmed`); with no
58
+ * policy (the answer never came) only audits the attempt, unconfirmed. The detail's `isolation`
59
+ * is the one asked for, or the one the machine now reports when it changed (a tier change turns
60
+ * the sandbox back on).
61
+ */
62
+ private record;
63
+ /** The outcome of a failed op: offline, or the machine's refusal; a deleted device is thrown. */
64
+ private notApplied;
65
+ }
@@ -0,0 +1,217 @@
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 { ToolError, validateAllowHost, } from "@capybara-hq/plugin-sdk";
11
+ import { Inject, Injectable } from "@nestjs/common";
12
+ import { ApiError } from "../errors.js";
13
+ import { audit } from "../identity/audit.js";
14
+ import { KernelDb } from "../infra/database/kernel-db.js";
15
+ import { currentStore, TransactionHost } from "../infra/database/transaction-host.js";
16
+ import { Transactional } from "../infra/database/transactional.decorator.js";
17
+ import { KERNEL_LOGGER } from "../infra/tokens.js";
18
+ import { deviceNotFound } from "../sandbox/sandbox.service.js";
19
+ import { MachinesService } from "./machines.js";
20
+ import { isolationOf } from "./policy-summary.js";
21
+ import { LocalDeviceRelay, ON_MACHINE_PREFIX } from "./relay.js";
22
+ /** How long the machine may take to apply a policy change. */
23
+ export const POLICY_DEADLINE_MS = 30_000;
24
+ /** The prefix the relay puts before a machine's `policy`-kind refusal. */
25
+ const REFUSED_PREFIX = "Refused by the machine's local policy: ";
26
+ const OFFLINE_MESSAGE = "The device's machine is offline, so its policy was not changed. Start capybara-device on it.";
27
+ const UNSUPPORTED_MESSAGE = "The device's capybara-device program cannot change its policy from the platform. Update capybara-device on the machine.";
28
+ const errMsg = (e) => (e instanceof Error ? e.message : String(e));
29
+ /**
30
+ * Changes a local device's policy on its machine (network ask-to-allow spec §5.2; sandbox dev
31
+ * tiers spec §7.3: its hosts, its `tier`, and `isolation` back to `sandbox`): the kernel→device
32
+ * `policy` op over the {@link LocalDeviceRelay}. The machine is the authority: it
33
+ * refuses widening unless its owner allows remote grants, validates every host and answers with
34
+ * the policy as written, which the kernel then keeps on the active machine's row (§5.4) and
35
+ * audits as `device.policy_changed`.
36
+ *
37
+ * The op is sent outside any transaction (a decision records its outcome afterwards, in its own
38
+ * transaction); only the summary write and the audit row are transactional. Callers decide who
39
+ * may change a device's policy (the device owner, in a signed-in session); this service checks
40
+ * only that the device is a live device of the org.
41
+ */
42
+ let DevicePolicyService = class DevicePolicyService {
43
+ db;
44
+ txHost;
45
+ relay;
46
+ machines;
47
+ log;
48
+ constructor(db, txHost, relay, machines, log) {
49
+ this.db = db;
50
+ this.txHost = txHost;
51
+ this.relay = relay;
52
+ this.machines = machines;
53
+ this.log = log;
54
+ }
55
+ /**
56
+ * Sends the `policy` op (no transaction may be open), stores the returned summary and audits
57
+ * `device.policy_changed`. Never throws for an offline machine, a refusal or a program without
58
+ * the `policyOp` feature (nothing is sent to it); a device that is not a live device of `orgId`
59
+ * is `device.not_found`.
60
+ */
61
+ async apply(input) {
62
+ if (currentStore()) {
63
+ throw new Error("DevicePolicyService.apply() must not run inside a transaction: send the policy op " +
64
+ "first, then record the decision in a transaction.");
65
+ }
66
+ assertHosts(input.change, input.source.approvalId !== undefined);
67
+ const device = await this.db
68
+ .selectFrom("devices as e")
69
+ .innerJoin("orgs as o", "o.id", "e.org_id")
70
+ .select("e.source")
71
+ .where("e.id", "=", input.deviceId)
72
+ .where("e.org_id", "=", input.orgId)
73
+ .where("e.deleted_at", "is", null)
74
+ .where("o.deleted_at", "is", null)
75
+ .executeTakeFirst();
76
+ if (!device)
77
+ throw deviceNotFound();
78
+ if (device.source !== "local") {
79
+ return { applied: false, reason: "unsupported", message: UNSUPPORTED_MESSAGE };
80
+ }
81
+ const machine = await this.machines.activeFor(input.deviceId);
82
+ if (machine === null)
83
+ return { applied: false, reason: "offline", message: OFFLINE_MESSAGE };
84
+ if (!machine.features.includes("policyOp")) {
85
+ return { applied: false, reason: "unsupported", message: UNSUPPORTED_MESSAGE };
86
+ }
87
+ // not connected: nothing is sent (a queued op would wait for a reconnect nobody watches)
88
+ if (!(await this.relay.holderLive(input.deviceId))) {
89
+ return { applied: false, reason: "offline", message: OFFLINE_MESSAGE };
90
+ }
91
+ const args = { ...input.change, source: input.source };
92
+ let policy;
93
+ try {
94
+ ({ policy } = await this.relay.call(input.deviceId, "policy", args, {
95
+ deadlineMs: POLICY_DEADLINE_MS,
96
+ // the relay gives up by itself (deadline plus the reconnect grace)
97
+ signal: new AbortController().signal,
98
+ }));
99
+ }
100
+ catch (e) {
101
+ if (e instanceof ApiError && e.code === "device.offline") {
102
+ // the op was sent and the wait ran out: the machine may still apply it, so the attempt is
103
+ // audited unconfirmed; its next hello reports the policy as it is
104
+ await this.record({ ...input, machineId: machine.id, policy: null });
105
+ }
106
+ return this.notApplied(input.deviceId, e);
107
+ }
108
+ await this.record({ ...input, machineId: machine.id, policy });
109
+ return { applied: true, policy };
110
+ }
111
+ /** The active machine's features and policy summary (null when no machine is connected). */
112
+ async summary(deviceId) {
113
+ const m = await this.machines.activeFor(deviceId);
114
+ return m === null ? null : { features: m.features, policy: m.localPolicy };
115
+ }
116
+ /**
117
+ * Keeps the policy the machine wrote on its row and audits the change (`confirmed`); with no
118
+ * policy (the answer never came) only audits the attempt, unconfirmed. The detail's `isolation`
119
+ * is the one asked for, or the one the machine now reports when it changed (a tier change turns
120
+ * the sandbox back on).
121
+ */
122
+ async record(input) {
123
+ // the isolation the machine reports now, when it differs from the stored one: a tier change
124
+ // turns an unsandboxed device's sandbox back on without asking for it (spec §5.4, §10.4)
125
+ let isolationNow;
126
+ if (input.policy !== null) {
127
+ const prev = await this.db
128
+ .selectFrom("device_machines")
129
+ .select("local_policy")
130
+ .where("id", "=", input.machineId)
131
+ .where("revoked_at", "is", null)
132
+ .forUpdate()
133
+ .executeTakeFirst();
134
+ const before = isolationOf(prev?.local_policy) ?? "sandbox";
135
+ if (input.policy.isolation !== before)
136
+ isolationNow = input.policy.isolation;
137
+ await this.db
138
+ .updateTable("device_machines")
139
+ .set({ local_policy: JSON.stringify(input.policy) })
140
+ .where("id", "=", input.machineId)
141
+ .where("revoked_at", "is", null)
142
+ .execute();
143
+ }
144
+ const { change, source } = input;
145
+ await audit(this.db.db, {
146
+ actorAccountId: source.accountId,
147
+ ...(input.actorTokenId !== undefined && { actorTokenId: input.actorTokenId }),
148
+ orgId: input.orgId,
149
+ action: "device.policy_changed",
150
+ target: input.deviceId,
151
+ detail: {
152
+ add: change.add ?? [],
153
+ remove: change.remove ?? [],
154
+ // only when set (null: follow the org's default); the device page lists the change
155
+ ...(change.tier !== undefined && { tier: change.tier }),
156
+ ...((isolationNow ?? change.isolation) !== undefined && {
157
+ isolation: isolationNow ?? change.isolation,
158
+ }),
159
+ by: source.accountId,
160
+ via: source.via,
161
+ approvalId: source.approvalId ?? null,
162
+ confirmed: input.policy !== null,
163
+ },
164
+ });
165
+ }
166
+ /** The outcome of a failed op: offline, or the machine's refusal; a deleted device is thrown. */
167
+ notApplied(deviceId, e) {
168
+ if (e instanceof ApiError) {
169
+ if (e.code === "device.not_found")
170
+ throw e;
171
+ // device.offline, or device.forbidden (the machine was disconnected meanwhile)
172
+ return { applied: false, reason: "offline", message: OFFLINE_MESSAGE };
173
+ }
174
+ if (e instanceof ToolError && e.message.startsWith(REFUSED_PREFIX)) {
175
+ return { applied: false, reason: "refused", message: e.message.slice(REFUSED_PREFIX.length) };
176
+ }
177
+ // anything else the machine answered (its own error, an invalid result): not applied
178
+ this.log.warn("a device policy change failed on the machine", { deviceId, error: errMsg(e) });
179
+ const message = errMsg(e);
180
+ return {
181
+ applied: false,
182
+ reason: "refused",
183
+ message: message.startsWith(ON_MACHINE_PREFIX)
184
+ ? message.slice(ON_MACHINE_PREFIX.length)
185
+ : message,
186
+ };
187
+ }
188
+ };
189
+ __decorate([
190
+ Transactional()
191
+ ], DevicePolicyService.prototype, "record", null);
192
+ DevicePolicyService = __decorate([
193
+ Injectable(),
194
+ __param(0, Inject(KernelDb)),
195
+ __param(1, Inject(TransactionHost)),
196
+ __param(2, Inject(LocalDeviceRelay)),
197
+ __param(3, Inject(MachinesService)),
198
+ __param(4, Inject(KERNEL_LOGGER))
199
+ ], DevicePolicyService);
200
+ export { DevicePolicyService };
201
+ /**
202
+ * Hosts the platform may send: an approval's `add` takes exact hosts only (a card never grants a
203
+ * wildcard); the owner's own change on the device page (no approval) may add a wildcard (spec §7);
204
+ * `remove` takes any valid entry (narrowing, a wildcard too).
205
+ */
206
+ function assertHosts(change, fromApproval) {
207
+ const bad = (h) => typeof h !== "string" || validateAllowHost(h).length > 0;
208
+ for (const h of change.add ?? []) {
209
+ if (bad(h) || (fromApproval && h.startsWith("*."))) {
210
+ throw new Error(`not an exact host to add to a device policy: ${JSON.stringify(h)}`);
211
+ }
212
+ }
213
+ for (const h of change.remove ?? []) {
214
+ if (bad(h))
215
+ throw new Error(`not a host to remove from a device policy: ${JSON.stringify(h)}`);
216
+ }
217
+ }
@@ -177,7 +177,10 @@ class DeviceConnection {
177
177
  if (!(await this.claim()))
178
178
  return;
179
179
  try {
180
- await this.gw.machines.recordHello(this.identity.machineId, frame);
180
+ // the raw summary too: the frame parser drops one it cannot read whole, and its
181
+ // isolation must still reach the kernel
182
+ const rawPolicy = JSON.parse(text).policy;
183
+ await this.gw.machines.recordHello(this.identity.machineId, frame, rawPolicy);
181
184
  }
182
185
  catch (e) {
183
186
  this.gw.log.warn("could not record a device hello", {
@@ -1,6 +1,8 @@
1
- import type { ExecResult, FileSystemHandle, SandboxHandle, SandboxLimits, SandboxProvider, SandboxRoots } from "@capybara-hq/plugin-sdk";
1
+ import { type ExecResult, type FileSystemHandle, type SandboxHandle, type SandboxLimits, type SandboxProvider, type SandboxRoots } from "@capybara-hq/plugin-sdk";
2
2
  import { KernelConfigService } from "../infra/config/kernel-config.service.js";
3
+ import type { KernelLogger } from "../log.js";
3
4
  import { SandboxService } from "../sandbox/sandbox.service.js";
5
+ import { NetworkGrants } from "./network-grants.js";
4
6
  import { LocalDeviceRelay } from "./relay.js";
5
7
  import { DeviceTargets } from "./targets.js";
6
8
  /** Extra time the machine gets beyond a command's own timeout (spawn, output, the trip back). */
@@ -15,6 +17,11 @@ export declare const OP_DEADLINE_MS = 30000;
15
17
  export declare const FRAME_ENVELOPE_BYTES: number;
16
18
  /** The most raw bytes whose base64 plus {@link FRAME_ENVELOPE_BYTES} fit in `maxFrameBytes`. */
17
19
  export declare const maxReadBytes: (maxFrameBytes: number) => number;
20
+ /**
21
+ * The tool error of a device that runs commands without a sandbox the org does not allow (§4.3);
22
+ * also what a machine answers when it refuses such a command itself.
23
+ */
24
+ export declare const UNSANDBOXED_NOT_ALLOWED_ERROR = "This device runs commands without a sandbox, which your organization does not allow. Its owner can turn the sandbox back on with `capybara-device policy set isolation sandbox`.";
18
25
  /**
19
26
  * The handle of an instance on a machine: the device id (ruling R1: the relay is keyed by
20
27
  * device), the roots the machine reported at `create`, and the org's effective limits at
@@ -42,9 +49,11 @@ export declare class LocalDeviceProvider implements SandboxProvider {
42
49
  private readonly targets;
43
50
  private readonly sandbox;
44
51
  private readonly config;
52
+ private readonly grants;
53
+ private readonly log;
45
54
  readonly concurrentExec: true;
46
55
  readonly sessionIsolation = false;
47
- constructor(relay: LocalDeviceRelay, targets: Pick<DeviceTargets, "orgOf" | "machineRoots">, sandbox: Pick<SandboxService, "orgLimits">, config: Pick<KernelConfigService, "get">);
56
+ constructor(relay: LocalDeviceRelay, targets: Pick<DeviceTargets, "orgOf" | "machineRoots">, sandbox: Pick<SandboxService, "devicePolicy" | "deviceLimits">, config: Pick<KernelConfigService, "get">, grants: Pick<NetworkGrants, "forExec" | "unattendedTier" | "unsandboxed" | "recordMissing">, log: Pick<KernelLogger, "warn">);
48
57
  createFileSystem(args: {
49
58
  name: string;
50
59
  limits: SandboxLimits;
@@ -71,6 +80,23 @@ export declare class LocalDeviceProvider implements SandboxProvider {
71
80
  destroyByName(_name: string, signal: AbortSignal): Promise<void>;
72
81
  killAll(h: SandboxHandle, signal: AbortSignal): Promise<void>;
73
82
  killTagged(h: SandboxHandle, tags: string[], signal: AbortSignal): Promise<void>;
83
+ /**
84
+ * Runs the command on the machine with the org's limits now (ruling R27) and, for a command of a
85
+ * run, the hosts the kernel allows that run on this device beyond the local policy (network
86
+ * ask-to-allow spec §6.3; only those the org's limits admit, sent only when there are some). The device's report of refused hosts is re-layered
87
+ * against the limits this exec carried: the kernel never takes the device's word that the org
88
+ * allows a host (spec §4.1).
89
+ *
90
+ * The limits are the device's **ceiling** (sandbox dev tiers spec §4.2): `limits.tier` is the
91
+ * highest tier the org allows, its network that tier's, `defaultTier` the org's default (the
92
+ * device runs at `min(local tier ?? defaultTier, tier)`) and `devHosts` the dev hosts. A run of
93
+ * a scheduled task without a person and without the task's `tier:open` grant is capped at `dev`
94
+ * (spec §11.4). Before relaying, a machine that last reported running commands without a sandbox
95
+ * is refused when the org does not allow it, or for such a run (`device.unsandboxed_not_allowed`,
96
+ * spec §4.3). The kernel knows the local policy only from the last hello or policy op, so the
97
+ * same decision also travels in `limits.allowUnsandboxed` and the machine enforces it itself;
98
+ * its refusal ({@link UNSANDBOXED_NOT_ALLOWED_MESSAGE}) becomes the same error.
99
+ */
74
100
  exec(h: SandboxHandle, args: Parameters<SandboxProvider["exec"]>[1]): Promise<ExecResult>;
75
101
  readFile(h: SandboxHandle, path: string, opts: {
76
102
  maxBytes: number;
@@ -84,6 +110,17 @@ export declare class LocalDeviceProvider implements SandboxProvider {
84
110
  size: number;
85
111
  isDir: boolean;
86
112
  } | null>;
87
- /** The org's effective limits now (ruling R27); a deleted device is not found. */
113
+ /**
114
+ * The device's org and the limits of one exec now (ruling R27): the ceiling, capped at `dev` for
115
+ * an unattended run without the `tier:open` grant (`cap`, set only when the platform and the org
116
+ * would otherwise allow an unsandboxed command); `allowUnsandboxed` whether this command may run
117
+ * without a sandbox. A deleted device is not found. Refuses an unsandboxed machine the org does
118
+ * not allow (or such a run), see {@link exec}.
119
+ */
88
120
  private currentLimits;
121
+ /**
122
+ * The `device.unsandboxed_not_allowed` error (spec §4.3); for an unattended run refused only for
123
+ * its missing `tier:open` grant, the run's text, with the missing grant recorded on the task run.
124
+ */
125
+ private unsandboxedRefusal;
89
126
  }
@@ -7,9 +7,14 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
+ import { minTier, ToolError, UNSANDBOXED_NOT_ALLOWED_MESSAGE, } from "@capybara-hq/plugin-sdk";
10
11
  import { Inject, Injectable } from "@nestjs/common";
12
+ import { ApiError } from "../errors.js";
11
13
  import { KernelConfigService } from "../infra/config/kernel-config.service.js";
14
+ import { KERNEL_LOGGER } from "../infra/tokens.js";
12
15
  import { deviceNotFound, SandboxService } from "../sandbox/sandbox.service.js";
16
+ import { TIER_OPEN_GRANT_KEY, UNATTENDED_UNSANDBOXED_ERROR } from "../schedules/unattended.js";
17
+ import { grantsWithin, NetworkGrants, relayer } from "./network-grants.js";
13
18
  import { deviceOffline, LocalDeviceRelay } from "./relay.js";
14
19
  import { DeviceTargets, sameRoots } from "./targets.js";
15
20
  /** Extra time the machine gets beyond a command's own timeout (spawn, output, the trip back). */
@@ -25,6 +30,16 @@ export const FRAME_ENVELOPE_BYTES = 64 * 1024;
25
30
  /** The most raw bytes whose base64 plus {@link FRAME_ENVELOPE_BYTES} fit in `maxFrameBytes`. */
26
31
  export const maxReadBytes = (maxFrameBytes) => Math.max(0, Math.floor((maxFrameBytes - FRAME_ENVELOPE_BYTES) / 4) * 3);
27
32
  const NAME_PREFIX = "cap-device-";
33
+ /**
34
+ * The tool error of a device that runs commands without a sandbox the org does not allow (§4.3);
35
+ * also what a machine answers when it refuses such a command itself.
36
+ */
37
+ export const UNSANDBOXED_NOT_ALLOWED_ERROR = UNSANDBOXED_NOT_ALLOWED_MESSAGE;
38
+ /**
39
+ * The relay's error for a machine that refused a command with {@link UNSANDBOXED_NOT_ALLOWED_MESSAGE}
40
+ * (a `policy` error: the relay prefixes the machine's message, `relay.ts`).
41
+ */
42
+ const MACHINE_UNSANDBOXED_REFUSAL = `Refused by the machine's local policy: ${UNSANDBOXED_NOT_ALLOWED_MESSAGE}`;
28
43
  /** The device id of a `cap-device-<deviceId>` name; undefined for any other name. */
29
44
  export const deviceIdOfName = (name) => name.startsWith(NAME_PREFIX) && name.length > NAME_PREFIX.length
30
45
  ? name.slice(NAME_PREFIX.length)
@@ -46,13 +61,17 @@ let LocalDeviceProvider = class LocalDeviceProvider {
46
61
  targets;
47
62
  sandbox;
48
63
  config;
64
+ grants;
65
+ log;
49
66
  concurrentExec = true;
50
67
  sessionIsolation = false;
51
- constructor(relay, targets, sandbox, config) {
68
+ constructor(relay, targets, sandbox, config, grants, log) {
52
69
  this.relay = relay;
53
70
  this.targets = targets;
54
71
  this.sandbox = sandbox;
55
72
  this.config = config;
73
+ this.grants = grants;
74
+ this.log = log;
56
75
  }
57
76
  async createFileSystem(args) {
58
77
  args.signal.throwIfAborted();
@@ -103,13 +122,36 @@ let LocalDeviceProvider = class LocalDeviceProvider {
103
122
  async killTagged(h, tags, signal) {
104
123
  await this.relay.call(h.id, "killTagged", { tags }, { deadlineMs: OP_DEADLINE_MS, signal });
105
124
  }
125
+ /**
126
+ * Runs the command on the machine with the org's limits now (ruling R27) and, for a command of a
127
+ * run, the hosts the kernel allows that run on this device beyond the local policy (network
128
+ * ask-to-allow spec §6.3; only those the org's limits admit, sent only when there are some). The device's report of refused hosts is re-layered
129
+ * against the limits this exec carried: the kernel never takes the device's word that the org
130
+ * allows a host (spec §4.1).
131
+ *
132
+ * The limits are the device's **ceiling** (sandbox dev tiers spec §4.2): `limits.tier` is the
133
+ * highest tier the org allows, its network that tier's, `defaultTier` the org's default (the
134
+ * device runs at `min(local tier ?? defaultTier, tier)`) and `devHosts` the dev hosts. A run of
135
+ * a scheduled task without a person and without the task's `tier:open` grant is capped at `dev`
136
+ * (spec §11.4). Before relaying, a machine that last reported running commands without a sandbox
137
+ * is refused when the org does not allow it, or for such a run (`device.unsandboxed_not_allowed`,
138
+ * spec §4.3). The kernel knows the local policy only from the last hello or policy op, so the
139
+ * same decision also travels in `limits.allowUnsandboxed` and the machine enforces it itself;
140
+ * its refusal ({@link UNSANDBOXED_NOT_ALLOWED_MESSAGE}) becomes the same error.
141
+ */
106
142
  async exec(h, args) {
107
143
  const { signal, ...rest } = args;
108
144
  signal.throwIfAborted();
109
- const limits = await this.currentLimits(h.id);
145
+ const runId = rest.actor?.runId;
146
+ const { orgId, limits, cap } = await this.currentLimits(h.id, runId);
147
+ // the run's grants for this device, cut to what the org allows now
148
+ const grants = runId !== undefined
149
+ ? grantsWithin(await this.grants.forExec({ orgId, runId, deviceId: h.id }), limits)
150
+ : undefined;
110
151
  signal.throwIfAborted();
152
+ let result;
111
153
  try {
112
- return await this.relay.call(h.id, "exec", { ...rest, limits }, { deadlineMs: args.timeoutMs + EXEC_DEADLINE_MARGIN_MS, signal });
154
+ result = await this.relay.call(h.id, "exec", { ...rest, limits, ...(grants !== undefined && { grants }) }, { deadlineMs: args.timeoutMs + EXEC_DEADLINE_MARGIN_MS, signal });
113
155
  }
114
156
  catch (e) {
115
157
  // the contract: an exec aborted after it was sent resolves with exitCode null (the relay
@@ -117,8 +159,19 @@ let LocalDeviceProvider = class LocalDeviceProvider {
117
159
  if (signal.aborted && e === signal.reason) {
118
160
  return { exitCode: null, stdout: "", stderr: "", truncated: false, timedOut: false };
119
161
  }
162
+ // the machine refused to run it unsandboxed (its local policy changed since it reported)
163
+ if (e instanceof ToolError && e.message === MACHINE_UNSANDBOXED_REFUSAL) {
164
+ this.log.warn("the machine refused a command without a sandbox", {
165
+ deviceId: h.id,
166
+ reason: cap !== undefined ? "unattended" : "not_allowed",
167
+ });
168
+ throw await this.unsandboxedRefusal(h.id, cap);
169
+ }
120
170
  throw e;
121
171
  }
172
+ if (result.network?.blocked === undefined)
173
+ return result;
174
+ return { ...result, network: { blocked: relayer(result.network.blocked, limits) } };
122
175
  }
123
176
  async readFile(h, path, opts) {
124
177
  // the answer must fit one frame: a larger file comes back truncated (Deliver: too large)
@@ -133,12 +186,59 @@ let LocalDeviceProvider = class LocalDeviceProvider {
133
186
  async stat(h, path, signal) {
134
187
  return this.relay.call(h.id, "stat", { path }, { deadlineMs: OP_DEADLINE_MS, signal });
135
188
  }
136
- /** The org's effective limits now (ruling R27); a deleted device is not found. */
137
- async currentLimits(deviceId) {
189
+ /**
190
+ * The device's org and the limits of one exec now (ruling R27): the ceiling, capped at `dev` for
191
+ * an unattended run without the `tier:open` grant (`cap`, set only when the platform and the org
192
+ * would otherwise allow an unsandboxed command); `allowUnsandboxed` whether this command may run
193
+ * without a sandbox. A deleted device is not found. Refuses an unsandboxed machine the org does
194
+ * not allow (or such a run), see {@link exec}.
195
+ */
196
+ async currentLimits(deviceId, runId) {
138
197
  const orgId = await this.targets.orgOf(deviceId);
139
198
  if (orgId === null)
140
199
  throw deviceNotFound();
141
- return this.sandbox.orgLimits(orgId);
200
+ const [policy, unattended, unsandboxed] = await Promise.all([
201
+ this.sandbox.devicePolicy({ orgId, deviceId }),
202
+ runId === undefined ? null : this.grants.unattendedTier({ runId, deviceId }),
203
+ this.grants.unsandboxed(deviceId),
204
+ ]);
205
+ const capped = unattended !== null && !unattended.openGranted;
206
+ // what an unsandboxed refusal reports: the run's missing grant only when it alone refused
207
+ const cap = capped && policy.allowUnsandboxed ? { taskRunId: unattended.taskRunId } : undefined;
208
+ if (unsandboxed && (!policy.allowUnsandboxed || capped)) {
209
+ this.log.warn("command refused on a device that runs without a sandbox", {
210
+ deviceId,
211
+ reason: policy.allowUnsandboxed ? "unattended" : "not_allowed",
212
+ });
213
+ throw await this.unsandboxedRefusal(deviceId, cap);
214
+ }
215
+ const tier = capped ? minTier(policy.ceiling, "dev") : policy.ceiling;
216
+ const limits = await this.sandbox.deviceLimits({ orgId, deviceId }, { policy, tier });
217
+ return {
218
+ orgId,
219
+ limits: {
220
+ ...limits,
221
+ defaultTier: minTier(policy.defaultTier, tier),
222
+ // the platform and the org allow it (which needs an open ceiling) and no cap applies
223
+ allowUnsandboxed: policy.allowUnsandboxed && !capped,
224
+ },
225
+ cap,
226
+ };
227
+ }
228
+ /**
229
+ * The `device.unsandboxed_not_allowed` error (spec §4.3); for an unattended run refused only for
230
+ * its missing `tier:open` grant, the run's text, with the missing grant recorded on the task run.
231
+ */
232
+ async unsandboxedRefusal(deviceId, cap) {
233
+ if (cap === undefined) {
234
+ return new ApiError("device.unsandboxed_not_allowed", UNSANDBOXED_NOT_ALLOWED_ERROR);
235
+ }
236
+ await this.grants
237
+ .recordMissing(cap.taskRunId, TIER_OPEN_GRANT_KEY)
238
+ .catch((err) => this.log.warn("cannot record a missing task grant", { deviceId, err }));
239
+ return new ApiError("device.unsandboxed_not_allowed", UNATTENDED_UNSANDBOXED_ERROR, [
240
+ { reason: "unattended" },
241
+ ]);
142
242
  }
143
243
  };
144
244
  LocalDeviceProvider = __decorate([
@@ -146,6 +246,8 @@ LocalDeviceProvider = __decorate([
146
246
  __param(0, Inject(LocalDeviceRelay)),
147
247
  __param(1, Inject(DeviceTargets)),
148
248
  __param(2, Inject(SandboxService)),
149
- __param(3, Inject(KernelConfigService))
249
+ __param(3, Inject(KernelConfigService)),
250
+ __param(4, Inject(NetworkGrants)),
251
+ __param(5, Inject(KERNEL_LOGGER))
150
252
  ], LocalDeviceProvider);
151
253
  export { LocalDeviceProvider };
@@ -4,7 +4,8 @@ import { LocalDeviceProvider } from "./local-device-provider.js";
4
4
  /**
5
5
  * Connected machines (environments spec §6, §13): the caller side of the device relay and the
6
6
  * kernel-internal `@local` sandbox provider, registered with {@link SandboxProviders} on init, and
7
- * the machines and their credentials ({@link MachinesService}).
7
+ * the machines and their credentials ({@link MachinesService}), changes to a device's network
8
+ * policy ({@link DevicePolicyService}) and the run grants sent with each exec ({@link NetworkGrants}).
8
9
  * Imported on every role: any replica may run a sandbox operation on a local device
9
10
  * (the socket itself is held by the gateway role).
10
11
  */
@@ -10,14 +10,17 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
10
10
  import { Inject, Module } from "@nestjs/common";
11
11
  import { LOCAL_PROVIDER, SandboxProviders } from "../sandbox/providers.js";
12
12
  import { SandboxModule } from "../sandbox/sandbox.module.js";
13
+ import { DevicePolicyService } from "./device-policy.service.js";
13
14
  import { LocalDeviceProvider } from "./local-device-provider.js";
14
15
  import { MachinesService } from "./machines.js";
16
+ import { NetworkGrants } from "./network-grants.js";
15
17
  import { LocalDeviceRelay } from "./relay.js";
16
18
  import { DeviceTargets } from "./targets.js";
17
19
  /**
18
20
  * Connected machines (environments spec §6, §13): the caller side of the device relay and the
19
21
  * kernel-internal `@local` sandbox provider, registered with {@link SandboxProviders} on init, and
20
- * the machines and their credentials ({@link MachinesService}).
22
+ * the machines and their credentials ({@link MachinesService}), changes to a device's network
23
+ * policy ({@link DevicePolicyService}) and the run grants sent with each exec ({@link NetworkGrants}).
21
24
  * Imported on every role: any replica may run a sandbox operation on a local device
22
25
  * (the socket itself is held by the gateway role).
23
26
  */
@@ -35,8 +38,21 @@ let LocalDevicesModule = class LocalDevicesModule {
35
38
  LocalDevicesModule = __decorate([
36
39
  Module({
37
40
  imports: [SandboxModule],
38
- providers: [DeviceTargets, LocalDeviceRelay, LocalDeviceProvider, MachinesService],
39
- exports: [LocalDeviceRelay, LocalDeviceProvider, MachinesService],
41
+ providers: [
42
+ DeviceTargets,
43
+ LocalDeviceRelay,
44
+ LocalDeviceProvider,
45
+ MachinesService,
46
+ DevicePolicyService,
47
+ NetworkGrants,
48
+ ],
49
+ exports: [
50
+ LocalDeviceRelay,
51
+ LocalDeviceProvider,
52
+ MachinesService,
53
+ DevicePolicyService,
54
+ NetworkGrants,
55
+ ],
40
56
  }),
41
57
  __param(0, Inject(SandboxProviders)),
42
58
  __param(1, Inject(LocalDeviceProvider))