@capybara-hq/kernel 0.1.0-rc.25 → 0.1.0-rc.26
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.
- package/dist/admin/outbox-dead.service.d.ts +7 -3
- package/dist/admin/outbox-dead.service.js +17 -6
- package/dist/admin/overview.service.d.ts +6 -6
- package/dist/admin/overview.service.js +7 -7
- package/dist/background/wake-rules.d.ts +5 -0
- package/dist/background/wake-rules.js +16 -5
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/channels/channels.module.d.ts +9 -8
- package/dist/channels/channels.module.js +11 -10
- package/dist/channels/connections.service.d.ts +3 -3
- package/dist/channels/connections.service.js +6 -6
- package/dist/channels/deliver-job.d.ts +10 -3
- package/dist/channels/deliver-job.js +7 -3
- package/dist/channels/jobs.d.ts +11 -4
- package/dist/channels/jobs.js +18 -3
- package/dist/channels/outbound.d.ts +5 -2
- package/dist/channels/outbound.js +9 -2
- package/dist/conversations/conversations.controller.d.ts +4 -2
- package/dist/conversations/conversations.controller.js +4 -2
- package/dist/conversations/conversations.service.d.ts +37 -4
- package/dist/conversations/conversations.service.js +77 -20
- package/dist/conversations/events.d.ts +6 -0
- package/dist/conversations/events.js +6 -0
- package/dist/db/kernel-schema.d.ts +10 -1
- package/dist/devices/devices.service.d.ts +14 -6
- package/dist/devices/devices.service.js +20 -7
- package/dist/evals/evals.module.js +5 -1
- package/dist/evals/jobs.d.ts +14 -6
- package/dist/evals/jobs.js +21 -19
- package/dist/evals/runs.service.d.ts +5 -6
- package/dist/evals/runs.service.js +11 -33
- package/dist/infra/outbox/outbox.service.d.ts +6 -0
- package/dist/infra/outbox/outbox.service.js +51 -17
- package/dist/infra/outbox/registry.d.ts +52 -0
- package/dist/infra/outbox/registry.js +57 -0
- package/dist/local-devices/device-policy.service.d.ts +131 -26
- package/dist/local-devices/device-policy.service.js +341 -145
- package/dist/local-devices/machines.d.ts +17 -2
- package/dist/local-devices/machines.js +66 -5
- package/dist/local-devices/policy-op-jobs.d.ts +15 -0
- package/dist/local-devices/policy-op-jobs.js +12 -0
- package/dist/local-devices/policy-summary.d.ts +36 -3
- package/dist/local-devices/policy-summary.js +47 -1
- package/dist/orgs/orgs.service.js +4 -1
- package/dist/overrides.d.ts +4 -0
- package/dist/overrides.js +2 -0
- package/dist/platform/ops/conversations.ops.js +1 -1
- package/dist/runtime/maintenance.d.ts +6 -3
- package/dist/runtime/maintenance.js +21 -7
- package/dist/runtime/plugin-context.service.d.ts +9 -3
- package/dist/runtime/plugin-context.service.js +17 -5
- package/dist/runtime/run-context.service.d.ts +7 -3
- package/dist/runtime/run-context.service.js +12 -2
- package/dist/runtime/run-finish.d.ts +2 -2
- package/dist/runtime/run-finish.js +11 -6
- package/dist/runtime/run-jobs.d.ts +21 -4
- package/dist/runtime/run-jobs.js +29 -6
- package/dist/runtime/run-orphan-check.job.d.ts +14 -11
- package/dist/runtime/run-orphan-check.job.js +16 -11
- package/dist/runtime/run-queue.d.ts +23 -31
- package/dist/runtime/run-queue.js +62 -64
- package/dist/runtime/run-status.d.ts +12 -0
- package/dist/runtime/run-status.js +21 -0
- package/dist/runtime/run-worker.d.ts +10 -2
- package/dist/runtime/run-worker.js +44 -19
- package/dist/runtime/step-log.d.ts +6 -5
- package/dist/runtime/step-log.js +6 -5
- package/dist/runtime/usage-report.d.ts +3 -1
- package/dist/runtime/usage-report.js +4 -2
- package/dist/runtime/waits.d.ts +16 -6
- package/dist/runtime/waits.js +37 -15
- package/dist/schedules/scan.job.d.ts +6 -6
- package/dist/schedules/scan.job.js +6 -6
- package/dist/skills/catalog.d.ts +7 -3
- package/dist/skills/catalog.js +12 -2
- package/dist/tools/approvals.service.d.ts +96 -19
- package/dist/tools/approvals.service.js +300 -67
- package/dist/tools/builtins/sandbox/task-output.js +21 -6
- package/dist/tools/device-op-resolver.d.ts +40 -0
- package/dist/tools/device-op-resolver.js +121 -0
- package/dist/tools/network-asks.d.ts +1 -1
- package/dist/tools/network-asks.js +4 -3
- package/dist/tools/tools.module.js +4 -0
- package/package.json +2 -2
- package/src/migrations/0125_conversation_tree_stopped_at.sql +5 -0
|
@@ -9,66 +9,111 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
9
9
|
};
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import { ToolError, validateAllowHost, } from "@capybara-hq/plugin-sdk";
|
|
12
|
-
import { Inject, Injectable } from "@nestjs/common";
|
|
12
|
+
import { Inject, Injectable, Optional } from "@nestjs/common";
|
|
13
13
|
import { sql } from "kysely";
|
|
14
14
|
import { ApiError } from "../errors.js";
|
|
15
15
|
import { audit } from "../identity/audit.js";
|
|
16
|
+
import { KernelConfigService } from "../infra/config/kernel-config.service.js";
|
|
16
17
|
import { KernelDb } from "../infra/database/kernel-db.js";
|
|
17
18
|
import { currentStore, TransactionHost } from "../infra/database/transaction-host.js";
|
|
18
19
|
import { Transactional } from "../infra/database/transactional.decorator.js";
|
|
20
|
+
import { OutboxService } from "../infra/outbox/outbox.service.js";
|
|
19
21
|
import { KERNEL_LOGGER } from "../infra/tokens.js";
|
|
22
|
+
import { MAINTENANCE_QUEUE_NAME } from "../runtime/maintenance-queue.js";
|
|
20
23
|
import { deviceNotFound } from "../sandbox/sandbox.service.js";
|
|
21
24
|
import { MachinesService } from "./machines.js";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
25
|
+
import { DEVICE_OP_RESOLVE_JOB, deviceOpResolveJobId } from "./policy-op-jobs.js";
|
|
26
|
+
import { isolationOf, pendingPolicyOpsOf } from "./policy-summary.js";
|
|
27
|
+
import { LIST_TTL_MARGIN_MS, LocalDeviceRelay, ON_MACHINE_PREFIX } from "./relay.js";
|
|
24
28
|
/** How long the machine may take to apply a policy change. */
|
|
25
29
|
export const POLICY_DEADLINE_MS = 30_000;
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const PENDING_OP_MS = POLICY_DEADLINE_MS + 5 * 60_000;
|
|
30
|
+
/** How long the machine may take to answer a `policyStatus` (it only reads its op log). */
|
|
31
|
+
const STATUS_DEADLINE_MS = 10_000;
|
|
32
|
+
/** How long an op left `unknown` waits for the machine's hello to report it (from its intent). */
|
|
33
|
+
export const UNKNOWN_OP_MS = 24 * 3_600_000;
|
|
31
34
|
/** The prefix the relay puts before a machine's `policy`-kind refusal. */
|
|
32
35
|
const REFUSED_PREFIX = "Refused by the machine's local policy: ";
|
|
36
|
+
/**
|
|
37
|
+
* Test seam (`KernelOverride`): called by {@link DevicePolicyService.send} right after the `policy`
|
|
38
|
+
* op's call returned, before its outcome is recorded (by `apply` or a network ask's decision);
|
|
39
|
+
* throwing stands for a crash between the call and the resolve transaction.
|
|
40
|
+
* The kernel never provides it.
|
|
41
|
+
*/
|
|
42
|
+
export const DEVICE_POLICY_AFTER_CALL = Symbol("DEVICE_POLICY_AFTER_CALL");
|
|
33
43
|
const OFFLINE_MESSAGE = "The device's machine is offline, so its policy was not changed. Start capybara-device on it.";
|
|
34
44
|
const UNSUPPORTED_MESSAGE = "The device's capybara-device program cannot change its policy from the platform. Update capybara-device on the machine.";
|
|
45
|
+
const UNCONFIRMED_MESSAGE = "The device's machine did not confirm the policy change; the platform checks it again shortly.";
|
|
46
|
+
const INTERRUPTED_MESSAGE = "capybara-device stopped while applying the policy change, and the change is not in the device's policy now.";
|
|
47
|
+
/** The longest text of the machine's own error the caller is told (as its op log keeps it). */
|
|
48
|
+
const MAX_MACHINE_MESSAGE = 500;
|
|
49
|
+
const NOT_RECEIVED_MESSAGE = "The device's machine never received the policy change.";
|
|
35
50
|
const errMsg = (e) => (e instanceof Error ? e.message : String(e));
|
|
36
51
|
/**
|
|
37
52
|
* Changes a local device's policy on its machine (network ask-to-allow spec §5.2; sandbox dev
|
|
38
53
|
* tiers spec §7.3: its hosts, its `tier`, and `isolation` back to `sandbox`): the kernel→device
|
|
39
|
-
* `policy` op over the {@link LocalDeviceRelay}. The machine is the authority: it
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* audits as `device.policy_changed`.
|
|
54
|
+
* `policy` op over the {@link LocalDeviceRelay}. The machine is the authority: it refuses
|
|
55
|
+
* widening unless its owner allows remote grants, validates every host and answers with the
|
|
56
|
+
* policy as written, which the kernel then keeps on the active machine's row (§5.4).
|
|
43
57
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
58
|
+
* Intent first (transactional outbox spec §7): {@link begin} writes the intent in a transaction
|
|
59
|
+
* before anything is sent (the entry on the machine row's `pending_policy_ops`, the audit
|
|
60
|
+
* `device.policy_requested` and the `device-op-resolve` job owed for when the op can no longer
|
|
61
|
+
* reach the machine); the call sends the op with its id (no transaction open); {@link settleIn}
|
|
62
|
+
* records the outcome: `device.policy_changed` (applied), `device.policy_failed` (refused, never
|
|
63
|
+
* received) or `device.policy_unknown` (no answer: the entry stays, `unknown`, for the machine's
|
|
64
|
+
* next hello or the resolver). A crash between the call and the resolve transaction leaves the
|
|
65
|
+
* intent and its owed job, whose resolver asks the machine what became of the op (never sends it
|
|
66
|
+
* again). Callers decide who may change a device's policy (the device owner, in a signed-in
|
|
67
|
+
* session); this service checks only that the device is a live device of the org.
|
|
48
68
|
*/
|
|
49
69
|
let DevicePolicyService = class DevicePolicyService {
|
|
50
70
|
db;
|
|
51
71
|
txHost;
|
|
52
72
|
relay;
|
|
53
73
|
machines;
|
|
74
|
+
outbox;
|
|
75
|
+
config;
|
|
54
76
|
log;
|
|
55
|
-
|
|
77
|
+
afterCall;
|
|
78
|
+
constructor(db, txHost, relay, machines, outbox, config, log, afterCall = () => { }) {
|
|
56
79
|
this.db = db;
|
|
57
80
|
this.txHost = txHost;
|
|
58
81
|
this.relay = relay;
|
|
59
82
|
this.machines = machines;
|
|
83
|
+
this.outbox = outbox;
|
|
84
|
+
this.config = config;
|
|
60
85
|
this.log = log;
|
|
86
|
+
this.afterCall = afterCall;
|
|
61
87
|
}
|
|
62
88
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* the `
|
|
66
|
-
* is `device.not_found`.
|
|
89
|
+
* Changes the policy: {@link begin}, the call, then the outcome recorded in its own transaction
|
|
90
|
+
* (no transaction may be open). Never throws for an offline machine, a refusal or a program
|
|
91
|
+
* without the `policyOpId` feature (nothing is sent to it); a device that is not a live device
|
|
92
|
+
* of `orgId` is `device.not_found`.
|
|
67
93
|
*/
|
|
68
94
|
async apply(input) {
|
|
95
|
+
const begun = await this.begin(input);
|
|
96
|
+
if ("outcome" in begun)
|
|
97
|
+
return begun.outcome;
|
|
98
|
+
const { intent } = begun;
|
|
99
|
+
const sent = await this.send(intent);
|
|
100
|
+
const out = await this.settle(intent, sent.result);
|
|
101
|
+
if (sent.notFound)
|
|
102
|
+
throw deviceNotFound();
|
|
103
|
+
// unknown: the caller is told what the call saw (offline, or an answer the machine did not
|
|
104
|
+
// confirm), not the recorded state's generic text
|
|
105
|
+
return sent.result.outcome === "unknown" ? sent.outcome : (out ?? sent.outcome);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Checks the device and its machine, then records the intent in its own transaction: the entry
|
|
109
|
+
* (`sent`) on the machine's row, the audit `device.policy_requested` and the `device-op-resolve`
|
|
110
|
+
* job owed for when the op can no longer reach the machine. No transaction may be open (the
|
|
111
|
+
* call follows). Returns the intent, or why nothing will be sent (offline, unsupported).
|
|
112
|
+
*/
|
|
113
|
+
async begin(input) {
|
|
69
114
|
if (currentStore()) {
|
|
70
|
-
throw new Error("DevicePolicyService.
|
|
71
|
-
"first, then
|
|
115
|
+
throw new Error("DevicePolicyService.begin() must not run inside a transaction: the intent is recorded " +
|
|
116
|
+
"first, the policy op sent, then the outcome recorded in a transaction.");
|
|
72
117
|
}
|
|
73
118
|
assertHosts(input.change, input.source.approvalId !== undefined);
|
|
74
119
|
const device = await this.db
|
|
@@ -82,176 +127,327 @@ let DevicePolicyService = class DevicePolicyService {
|
|
|
82
127
|
.executeTakeFirst();
|
|
83
128
|
if (!device)
|
|
84
129
|
throw deviceNotFound();
|
|
85
|
-
if (device.source !== "local")
|
|
86
|
-
return {
|
|
87
|
-
}
|
|
130
|
+
if (device.source !== "local")
|
|
131
|
+
return { outcome: unsupported() };
|
|
88
132
|
const machine = await this.machines.activeFor(input.deviceId);
|
|
89
133
|
if (machine === null)
|
|
90
|
-
return {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
134
|
+
return { outcome: offline() };
|
|
135
|
+
// only a program that logs op outcomes under their ids can tell what became of a lost answer
|
|
136
|
+
if (!machine.features.includes("policyOpId"))
|
|
137
|
+
return { outcome: unsupported() };
|
|
94
138
|
// not connected: nothing is sent (a queued op would wait for a reconnect nobody watches)
|
|
95
|
-
if (!(await this.relay.holderLive(input.deviceId)))
|
|
96
|
-
return {
|
|
97
|
-
}
|
|
98
|
-
const args = { ...input.change, source: input.source };
|
|
99
|
-
// noted before it is sent: the machine may report the change by itself (a `policy` frame)
|
|
100
|
-
// before its answer is recorded, and the device page lists it once, as the platform's
|
|
139
|
+
if (!(await this.relay.holderLive(input.deviceId)))
|
|
140
|
+
return { outcome: offline() };
|
|
101
141
|
const opId = randomUUID();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
142
|
+
const args = { ...input.change, opId, source: input.source };
|
|
143
|
+
const recorded = await this.recordIntent({ ...input, machineId: machine.id, opId });
|
|
144
|
+
if (!recorded)
|
|
145
|
+
return { outcome: offline() };
|
|
146
|
+
return {
|
|
147
|
+
intent: { orgId: input.orgId, deviceId: input.deviceId, machineId: machine.id, opId, args },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Sends the intent's `policy` op (no transaction may be open) and classifies the answer:
|
|
152
|
+
* applied; refused (the machine's local policy refused it: its message); anything else is
|
|
153
|
+
* `unknown`, left to the resolver's `policyStatus`: no answer, and an answer the kernel cannot
|
|
154
|
+
* trust to mean "not applied" (a result it cannot read, the machine's own error). `notFound`:
|
|
155
|
+
* the device was deleted meanwhile (recorded as unknown; the caller throws `device.not_found`).
|
|
156
|
+
*/
|
|
157
|
+
async send(intent) {
|
|
158
|
+
if (currentStore()) {
|
|
159
|
+
throw new Error("DevicePolicyService.send() must not run inside a transaction: the outcome is recorded " +
|
|
160
|
+
"afterwards, in its own transaction.");
|
|
161
|
+
}
|
|
162
|
+
const sent = await this.call(intent);
|
|
163
|
+
this.afterCall(intent.opId);
|
|
164
|
+
return sent;
|
|
165
|
+
}
|
|
166
|
+
/** The `policy` op's call and its classification ({@link send}). */
|
|
167
|
+
async call(intent) {
|
|
111
168
|
try {
|
|
112
|
-
|
|
169
|
+
const { policy } = await this.relay.call(intent.deviceId, "policy", intent.args, {
|
|
113
170
|
deadlineMs: POLICY_DEADLINE_MS,
|
|
114
171
|
// the relay gives up by itself (deadline plus the reconnect grace)
|
|
115
172
|
signal: new AbortController().signal,
|
|
116
|
-
})
|
|
173
|
+
});
|
|
174
|
+
return { result: { outcome: "applied", policy }, outcome: { applied: true, policy } };
|
|
117
175
|
}
|
|
118
176
|
catch (e) {
|
|
119
|
-
if (e instanceof ApiError
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
177
|
+
if (e instanceof ApiError) {
|
|
178
|
+
// device.offline (the wait ran out: the machine may still apply it), device.forbidden
|
|
179
|
+
// (the machine was disconnected meanwhile), device.not_found (the device was deleted)
|
|
180
|
+
return {
|
|
181
|
+
result: { outcome: "unknown" },
|
|
182
|
+
outcome: offline(),
|
|
183
|
+
...(e.code === "device.not_found" && { notFound: true }),
|
|
184
|
+
};
|
|
123
185
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
186
|
+
if (e instanceof ToolError && e.message.startsWith(REFUSED_PREFIX)) {
|
|
187
|
+
const message = e.message.slice(REFUSED_PREFIX.length);
|
|
188
|
+
return {
|
|
189
|
+
result: { outcome: "refused", message },
|
|
190
|
+
outcome: { applied: false, reason: "refused", message },
|
|
191
|
+
};
|
|
127
192
|
}
|
|
128
|
-
|
|
193
|
+
// an unreadable result (the machine may have applied it) or the machine's own error (it may
|
|
194
|
+
// have failed after the write): what the machine logged under the op id tells
|
|
195
|
+
this.log.warn("a device policy change was not confirmed by the machine", {
|
|
196
|
+
deviceId: intent.deviceId,
|
|
197
|
+
opId: intent.opId,
|
|
198
|
+
error: errMsg(e),
|
|
199
|
+
});
|
|
200
|
+
const raw = errMsg(e);
|
|
201
|
+
const message = (raw.startsWith(ON_MACHINE_PREFIX) ? raw.slice(ON_MACHINE_PREFIX.length) : raw).slice(0, MAX_MACHINE_MESSAGE);
|
|
202
|
+
return {
|
|
203
|
+
result: { outcome: "unknown" },
|
|
204
|
+
outcome: {
|
|
205
|
+
applied: false,
|
|
206
|
+
reason: "offline",
|
|
207
|
+
message: `${UNCONFIRMED_MESSAGE} ${message}`,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
129
210
|
}
|
|
130
|
-
|
|
131
|
-
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* What the machine says became of the op `opId` (a read-only `policyStatus`), or `offline` when
|
|
214
|
+
* no replica holds its connection or it does not answer. Never sends the change.
|
|
215
|
+
*/
|
|
216
|
+
async outcomeOf(deviceId, opId) {
|
|
217
|
+
if (!(await this.relay.holderLive(deviceId)))
|
|
218
|
+
return { outcome: "offline" };
|
|
219
|
+
try {
|
|
220
|
+
return await this.relay.call(deviceId, "policyStatus", { opId }, { deadlineMs: STATUS_DEADLINE_MS, signal: new AbortController().signal });
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
this.log.warn("could not ask a machine what became of a policy op", {
|
|
224
|
+
deviceId,
|
|
225
|
+
opId,
|
|
226
|
+
error: errMsg(e),
|
|
227
|
+
});
|
|
228
|
+
return { outcome: "offline" };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Records what became of the op `opId` of machine `machineId`, in the caller's transaction (the
|
|
233
|
+
* machine's row locked): `applied` → the policy the machine wrote (when given) kept on the row,
|
|
234
|
+
* the entry removed, `device.policy_changed` (`late` when the op was left `unknown`); `refused`,
|
|
235
|
+
* `not_received` or `interrupted` → the entry removed, `device.policy_failed` (`reason` the
|
|
236
|
+
* outcome); `unknown` → the entry kept, `unknown` until a day after its intent,
|
|
237
|
+
* `device.policy_unknown` (once; ignored once a hello reported the op). Returns the outcome for
|
|
238
|
+
* the caller, or null when the op has no live entry (resolved already, or expired: a no-op).
|
|
239
|
+
*/
|
|
240
|
+
async settleIn(ref, result) {
|
|
241
|
+
const row = await this.db
|
|
242
|
+
.selectFrom("device_machines")
|
|
243
|
+
.select(["org_id", "device_id", "local_policy", "pending_policy_ops", "revoked_at"])
|
|
244
|
+
.where("id", "=", ref.machineId)
|
|
245
|
+
.forUpdate()
|
|
246
|
+
.executeTakeFirst();
|
|
247
|
+
const op = pendingPolicyOpsOf(row?.pending_policy_ops).find((o) => o.id === ref.opId);
|
|
248
|
+
// an expired entry no longer explains the machine's own reports (they are audited as the
|
|
249
|
+
// owner's): recording the op too would list the change twice
|
|
250
|
+
if (row === undefined || op === undefined || op.until <= Date.now())
|
|
251
|
+
return null;
|
|
252
|
+
const auditOp = (action, extra) => audit(this.db.db, {
|
|
253
|
+
actorAccountId: op.source.accountId,
|
|
254
|
+
...(op.actorTokenId !== undefined && { actorTokenId: op.actorTokenId }),
|
|
255
|
+
orgId: row.org_id,
|
|
256
|
+
action,
|
|
257
|
+
target: row.device_id,
|
|
258
|
+
detail: { ...changeDetail(op), ...extra },
|
|
259
|
+
});
|
|
260
|
+
if (result.outcome === "unknown") {
|
|
261
|
+
// a hello reported the op meanwhile (its job records it), or it is unknown already
|
|
262
|
+
if (op.reported !== undefined || op.status === "unknown")
|
|
263
|
+
return { applied: false, reason: "offline", message: OFFLINE_MESSAGE };
|
|
264
|
+
await this.db
|
|
265
|
+
.updateTable("device_machines")
|
|
266
|
+
.set({
|
|
267
|
+
pending_policy_ops: withOp(op.id, {
|
|
268
|
+
status: "unknown",
|
|
269
|
+
until: op.sentAt + UNKNOWN_OP_MS,
|
|
270
|
+
}),
|
|
271
|
+
})
|
|
272
|
+
.where("id", "=", ref.machineId)
|
|
273
|
+
.execute();
|
|
274
|
+
await auditOp("device.policy_unknown", {});
|
|
275
|
+
return offline();
|
|
276
|
+
}
|
|
277
|
+
const live = row.revoked_at === null;
|
|
278
|
+
let isolationNow;
|
|
279
|
+
const policy = result.outcome === "applied" ? result.policy : undefined;
|
|
280
|
+
if (policy !== undefined && live) {
|
|
281
|
+
// the isolation the machine reports now, when it differs from the stored one: a tier change
|
|
282
|
+
// turns an unsandboxed device's sandbox back on without asking for it (spec §5.4, §10.4)
|
|
283
|
+
const before = isolationOf(row.local_policy) ?? "sandbox";
|
|
284
|
+
if (policy.isolation !== before)
|
|
285
|
+
isolationNow = policy.isolation;
|
|
286
|
+
}
|
|
287
|
+
await this.db
|
|
288
|
+
.updateTable("device_machines")
|
|
289
|
+
.set({
|
|
290
|
+
// resolved: the op no longer explains a summary the machine reports
|
|
291
|
+
pending_policy_ops: withoutOp(op.id),
|
|
292
|
+
...(policy !== undefined && live && { local_policy: JSON.stringify(policy) }),
|
|
293
|
+
})
|
|
294
|
+
.where("id", "=", ref.machineId)
|
|
295
|
+
.execute();
|
|
296
|
+
if (result.outcome === "applied") {
|
|
297
|
+
await auditOp("device.policy_changed", {
|
|
298
|
+
...(isolationNow !== undefined && { isolation: isolationNow }),
|
|
299
|
+
...(op.status === "unknown" && { late: true }),
|
|
300
|
+
});
|
|
301
|
+
const stored = policy ?? (await this.machines.activeFor(row.device_id))?.localPolicy;
|
|
302
|
+
return stored
|
|
303
|
+
? { applied: true, policy: stored }
|
|
304
|
+
: { applied: false, reason: "offline", message: OFFLINE_MESSAGE };
|
|
305
|
+
}
|
|
306
|
+
const message = result.outcome === "refused"
|
|
307
|
+
? result.message
|
|
308
|
+
: result.outcome === "interrupted"
|
|
309
|
+
? INTERRUPTED_MESSAGE
|
|
310
|
+
: NOT_RECEIVED_MESSAGE;
|
|
311
|
+
await auditOp("device.policy_failed", { reason: result.outcome, message });
|
|
312
|
+
return result.outcome === "refused"
|
|
313
|
+
? { applied: false, reason: "refused", message }
|
|
314
|
+
: offline();
|
|
132
315
|
}
|
|
133
316
|
/** The active machine's features and policy summary (null when no machine is connected). */
|
|
134
317
|
async summary(deviceId) {
|
|
135
318
|
const m = await this.machines.activeFor(deviceId);
|
|
136
319
|
return m === null ? null : { features: m.features, policy: m.localPolicy };
|
|
137
320
|
}
|
|
138
|
-
/**
|
|
139
|
-
|
|
321
|
+
/**
|
|
322
|
+
* How long after its intent an op can no longer reach the machine: the call's deadline, the
|
|
323
|
+
* reconnect grace and the request list's margin (the relay's list TTL), plus 5 s. A machine
|
|
324
|
+
* that does not know the op id by then never will.
|
|
325
|
+
*/
|
|
326
|
+
resolveDelayMs() {
|
|
327
|
+
const graceMs = this.config.get().devices.local.reconnectGraceSeconds * 1000;
|
|
328
|
+
return POLICY_DEADLINE_MS + graceMs + LIST_TTL_MARGIN_MS + 5_000;
|
|
329
|
+
}
|
|
330
|
+
/** {@link settleIn} in its own transaction (the {@link apply} path). */
|
|
331
|
+
settle(intent, result) {
|
|
332
|
+
return this.settleIn(intent, result);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The intent transaction: locks the machine's row (still the device's active machine), appends
|
|
336
|
+
* the entry (dropping the expired ones), audits `device.policy_requested` and owes the
|
|
337
|
+
* `device-op-resolve` job. False when the machine was revoked meanwhile (nothing written).
|
|
338
|
+
*/
|
|
339
|
+
async recordIntent(input) {
|
|
340
|
+
const locked = await this.db
|
|
341
|
+
.selectFrom("device_machines")
|
|
342
|
+
.select("id")
|
|
343
|
+
.where("id", "=", input.machineId)
|
|
344
|
+
.where("revoked_at", "is", null)
|
|
345
|
+
.forUpdate()
|
|
346
|
+
.executeTakeFirst();
|
|
347
|
+
if (locked === undefined)
|
|
348
|
+
return false;
|
|
349
|
+
const now = Date.now();
|
|
350
|
+
const resolveAt = now + this.resolveDelayMs();
|
|
351
|
+
const { change, source } = input;
|
|
352
|
+
const op = {
|
|
353
|
+
id: input.opId,
|
|
354
|
+
add: change.add ?? [],
|
|
355
|
+
remove: change.remove ?? [],
|
|
356
|
+
...(change.tier !== undefined && { tier: change.tier }),
|
|
357
|
+
...(change.isolation !== undefined && { isolation: change.isolation }),
|
|
358
|
+
source: {
|
|
359
|
+
accountId: source.accountId,
|
|
360
|
+
via: source.via,
|
|
361
|
+
approvalId: source.approvalId ?? null,
|
|
362
|
+
},
|
|
363
|
+
...(input.actorTokenId !== undefined && { actorTokenId: input.actorTokenId }),
|
|
364
|
+
status: "sent",
|
|
365
|
+
sentAt: now,
|
|
366
|
+
resolveAt,
|
|
367
|
+
// a summary the machine reports by itself may be this op's until it is resolved
|
|
368
|
+
until: now + UNKNOWN_OP_MS,
|
|
369
|
+
};
|
|
140
370
|
await this.db
|
|
141
371
|
.updateTable("device_machines")
|
|
142
372
|
.set({
|
|
143
373
|
pending_policy_ops: sql `(
|
|
144
374
|
SELECT coalesce(jsonb_agg(e), '[]'::jsonb) FROM jsonb_array_elements(pending_policy_ops) e
|
|
145
|
-
WHERE (e->>'until')::bigint > ${
|
|
375
|
+
WHERE (e->>'until')::bigint > ${now}
|
|
146
376
|
) || jsonb_build_array(${JSON.stringify(op)}::jsonb)`,
|
|
147
377
|
})
|
|
148
|
-
.where("id", "=", machineId)
|
|
378
|
+
.where("id", "=", input.machineId)
|
|
149
379
|
.execute();
|
|
150
|
-
}
|
|
151
|
-
/** Removes the pending op `opId` from the machine's row. */
|
|
152
|
-
async dropPending(machineId, opId) {
|
|
153
|
-
await this.db
|
|
154
|
-
.updateTable("device_machines")
|
|
155
|
-
.set({ pending_policy_ops: withoutOp(opId) })
|
|
156
|
-
.where("id", "=", machineId)
|
|
157
|
-
.execute();
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Keeps the policy the machine wrote on its row and audits the change (`confirmed`); with no
|
|
161
|
-
* policy (the answer never came) only audits the attempt, unconfirmed. The detail's `isolation`
|
|
162
|
-
* is the one asked for, or the one the machine now reports when it changed (a tier change turns
|
|
163
|
-
* the sandbox back on).
|
|
164
|
-
*/
|
|
165
|
-
async record(input) {
|
|
166
|
-
// the isolation the machine reports now, when it differs from the stored one: a tier change
|
|
167
|
-
// turns an unsandboxed device's sandbox back on without asking for it (spec §5.4, §10.4)
|
|
168
|
-
let isolationNow;
|
|
169
|
-
if (input.policy !== null) {
|
|
170
|
-
const prev = await this.db
|
|
171
|
-
.selectFrom("device_machines")
|
|
172
|
-
.select("local_policy")
|
|
173
|
-
.where("id", "=", input.machineId)
|
|
174
|
-
.where("revoked_at", "is", null)
|
|
175
|
-
.forUpdate()
|
|
176
|
-
.executeTakeFirst();
|
|
177
|
-
const before = isolationOf(prev?.local_policy) ?? "sandbox";
|
|
178
|
-
if (input.policy.isolation !== before)
|
|
179
|
-
isolationNow = input.policy.isolation;
|
|
180
|
-
await this.db
|
|
181
|
-
.updateTable("device_machines")
|
|
182
|
-
// answered: the op no longer explains a summary the machine reports
|
|
183
|
-
.set({
|
|
184
|
-
local_policy: JSON.stringify(input.policy),
|
|
185
|
-
pending_policy_ops: withoutOp(input.opId),
|
|
186
|
-
})
|
|
187
|
-
.where("id", "=", input.machineId)
|
|
188
|
-
.where("revoked_at", "is", null)
|
|
189
|
-
.execute();
|
|
190
|
-
}
|
|
191
|
-
const { change, source } = input;
|
|
192
380
|
await audit(this.db.db, {
|
|
193
381
|
actorAccountId: source.accountId,
|
|
194
382
|
...(input.actorTokenId !== undefined && { actorTokenId: input.actorTokenId }),
|
|
195
383
|
orgId: input.orgId,
|
|
196
|
-
action: "device.
|
|
384
|
+
action: "device.policy_requested",
|
|
197
385
|
target: input.deviceId,
|
|
198
|
-
detail:
|
|
199
|
-
add: change.add ?? [],
|
|
200
|
-
remove: change.remove ?? [],
|
|
201
|
-
// only when set (null: follow the org's default); the device page lists the change
|
|
202
|
-
...(change.tier !== undefined && { tier: change.tier }),
|
|
203
|
-
...((isolationNow ?? change.isolation) !== undefined && {
|
|
204
|
-
isolation: isolationNow ?? change.isolation,
|
|
205
|
-
}),
|
|
206
|
-
by: source.accountId,
|
|
207
|
-
via: source.via,
|
|
208
|
-
approvalId: source.approvalId ?? null,
|
|
209
|
-
confirmed: input.policy !== null,
|
|
210
|
-
},
|
|
386
|
+
detail: changeDetail(op),
|
|
211
387
|
});
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (e instanceof ToolError && e.message.startsWith(REFUSED_PREFIX)) {
|
|
222
|
-
return { applied: false, reason: "refused", message: e.message.slice(REFUSED_PREFIX.length) };
|
|
223
|
-
}
|
|
224
|
-
// anything else the machine answered (its own error, an invalid result): not applied
|
|
225
|
-
this.log.warn("a device policy change failed on the machine", { deviceId, error: errMsg(e) });
|
|
226
|
-
const message = errMsg(e);
|
|
227
|
-
return {
|
|
228
|
-
applied: false,
|
|
229
|
-
reason: "refused",
|
|
230
|
-
message: message.startsWith(ON_MACHINE_PREFIX)
|
|
231
|
-
? message.slice(ON_MACHINE_PREFIX.length)
|
|
232
|
-
: message,
|
|
233
|
-
};
|
|
388
|
+
await this.outbox.add({
|
|
389
|
+
queue: MAINTENANCE_QUEUE_NAME,
|
|
390
|
+
name: DEVICE_OP_RESOLVE_JOB,
|
|
391
|
+
jobId: deviceOpResolveJobId(input.opId),
|
|
392
|
+
data: { machineId: input.machineId, opId: input.opId },
|
|
393
|
+
runAt: new Date(resolveAt),
|
|
394
|
+
orgId: input.orgId,
|
|
395
|
+
});
|
|
396
|
+
return true;
|
|
234
397
|
}
|
|
235
398
|
};
|
|
236
399
|
__decorate([
|
|
237
|
-
Transactional()
|
|
238
|
-
], DevicePolicyService.prototype, "
|
|
400
|
+
Transactional({ propagation: "mandatory" })
|
|
401
|
+
], DevicePolicyService.prototype, "settleIn", null);
|
|
239
402
|
__decorate([
|
|
240
403
|
Transactional()
|
|
241
|
-
], DevicePolicyService.prototype, "
|
|
404
|
+
], DevicePolicyService.prototype, "settle", null);
|
|
242
405
|
__decorate([
|
|
243
406
|
Transactional()
|
|
244
|
-
], DevicePolicyService.prototype, "
|
|
407
|
+
], DevicePolicyService.prototype, "recordIntent", null);
|
|
245
408
|
DevicePolicyService = __decorate([
|
|
246
409
|
Injectable(),
|
|
247
410
|
__param(0, Inject(KernelDb)),
|
|
248
411
|
__param(1, Inject(TransactionHost)),
|
|
249
412
|
__param(2, Inject(LocalDeviceRelay)),
|
|
250
413
|
__param(3, Inject(MachinesService)),
|
|
251
|
-
__param(4, Inject(
|
|
414
|
+
__param(4, Inject(OutboxService)),
|
|
415
|
+
__param(5, Inject(KernelConfigService)),
|
|
416
|
+
__param(6, Inject(KERNEL_LOGGER)),
|
|
417
|
+
__param(7, Optional()),
|
|
418
|
+
__param(7, Inject(DEVICE_POLICY_AFTER_CALL))
|
|
252
419
|
], DevicePolicyService);
|
|
253
420
|
export { DevicePolicyService };
|
|
254
|
-
|
|
421
|
+
const offline = () => ({
|
|
422
|
+
applied: false,
|
|
423
|
+
reason: "offline",
|
|
424
|
+
message: OFFLINE_MESSAGE,
|
|
425
|
+
});
|
|
426
|
+
const unsupported = () => ({
|
|
427
|
+
applied: false,
|
|
428
|
+
reason: "unsupported",
|
|
429
|
+
message: UNSUPPORTED_MESSAGE,
|
|
430
|
+
});
|
|
431
|
+
/** The audit detail of an op's change and source (every `device.policy_*` row of an op). */
|
|
432
|
+
function changeDetail(op) {
|
|
433
|
+
return {
|
|
434
|
+
opId: op.id,
|
|
435
|
+
add: op.add,
|
|
436
|
+
remove: op.remove,
|
|
437
|
+
// only when set (null: follow the org's default); the device page lists the change
|
|
438
|
+
...(op.tier !== undefined && { tier: op.tier }),
|
|
439
|
+
...(op.isolation !== undefined && { isolation: op.isolation }),
|
|
440
|
+
by: op.source.accountId,
|
|
441
|
+
via: op.source.via,
|
|
442
|
+
approvalId: op.source.approvalId,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/** The machine's pending ops with the fields `patch` merged into the op `opId`. */
|
|
446
|
+
const withOp = (opId, patch) => sql `(
|
|
447
|
+
SELECT coalesce(jsonb_agg(CASE WHEN e->>'id' = ${opId} THEN e || ${JSON.stringify(patch)}::jsonb ELSE e END), '[]'::jsonb)
|
|
448
|
+
FROM jsonb_array_elements(pending_policy_ops) e
|
|
449
|
+
)`;
|
|
450
|
+
/** The machine's pending ops without `opId` (an expired one left for the next intent to drop). */
|
|
255
451
|
const withoutOp = (opId) => sql `(
|
|
256
452
|
SELECT coalesce(jsonb_agg(e), '[]'::jsonb) FROM jsonb_array_elements(pending_policy_ops) e
|
|
257
453
|
WHERE e->>'id' <> ${opId}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DeviceFeature, DeviceHello, DeviceLocalPolicySummary, DeviceStopArgs, ExtensionContext } from "@capybara-hq/plugin-sdk";
|
|
2
2
|
import { KernelDb } from "../infra/database/kernel-db.js";
|
|
3
3
|
import { TransactionHost } from "../infra/database/transaction-host.js";
|
|
4
|
+
import { OutboxService } from "../infra/outbox/outbox.service.js";
|
|
4
5
|
import type { KernelLogger } from "../log.js";
|
|
5
6
|
import { LocalDeviceRelay } from "./relay.js";
|
|
6
7
|
/** The machine credential's prefix (`cap_dev_` plus 32 random bytes, base64url). */
|
|
@@ -43,8 +44,9 @@ export declare class MachinesService {
|
|
|
43
44
|
readonly txHost: TransactionHost;
|
|
44
45
|
private readonly relay;
|
|
45
46
|
private readonly bus;
|
|
47
|
+
private readonly outbox;
|
|
46
48
|
private readonly log;
|
|
47
|
-
constructor(db: KernelDb, txHost: TransactionHost, relay: Pick<LocalDeviceRelay, "holderLive" | "send">, bus: ExtensionContext["bus"], log: Pick<KernelLogger, "warn">);
|
|
49
|
+
constructor(db: KernelDb, txHost: TransactionHost, relay: Pick<LocalDeviceRelay, "holderLive" | "send">, bus: ExtensionContext["bus"], outbox: Pick<OutboxService, "add">, log: Pick<KernelLogger, "warn">);
|
|
48
50
|
/**
|
|
49
51
|
* A new machine for the device and its credential (returned once). Revokes the
|
|
50
52
|
* device's active machine first. Joins the caller's transaction (the device confirmation),
|
|
@@ -96,9 +98,22 @@ export declare class MachinesService {
|
|
|
96
98
|
* isolation that differs from the stored one (none stored counts as `sandbox`) is audited
|
|
97
99
|
* `device.isolation_reported` (sandbox dev tiers spec §10.4), once per change; a change of what
|
|
98
100
|
* the owner sets, made on the machine while it was away, is audited `device.policy_changed`
|
|
99
|
-
* ({@link reportedPolicy}).
|
|
101
|
+
* ({@link reportedPolicy}). The outcomes of its recent policy ops (`recentOps`) backfill the
|
|
102
|
+
* platform's ops still pending on the row ({@link backfillOps}).
|
|
100
103
|
*/
|
|
101
104
|
recordHello(machineId: string, hello: DeviceHello, rawPolicy?: unknown): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* Hello backfill (transactional outbox spec §7): a pending op of the platform's whose outcome
|
|
107
|
+
* the hello reports (`applied`, `refused`), or that it does not list although the op can no
|
|
108
|
+
* longer reach the machine (`absent`: past its `resolveAt`), is marked `reported` and owes a
|
|
109
|
+
* `device-op-resolve` job (`device-op-reported-<opId>`) that records the outcome. An op the
|
|
110
|
+
* machine logged only as `started` (it stopped between the log and the answer) is judged once
|
|
111
|
+
* past its `resolveAt` by the policy the hello reports: its change there → `applied`, else
|
|
112
|
+
* `interrupted` (within the window, or with no readable policy, it is left). An expired entry
|
|
113
|
+
* (past `until`) is skipped: the machine's reports are no longer the op's. Runs in the caller's
|
|
114
|
+
* transaction, with the machine's row locked; an op already reported is not owed again.
|
|
115
|
+
*/
|
|
116
|
+
private backfillOps;
|
|
102
117
|
/**
|
|
103
118
|
* Stores the local policy summary a connected program pushed after its hello (a `policy` frame:
|
|
104
119
|
* its policy file changed on the machine, its localhost proxy guard flipped, a new tier
|