@frockbot/plugin-user-machine 0.0.0 → 0.1.0
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/frockbot.json +66 -0
- package/package.json +54 -6
- package/src/agent.test.ts +509 -0
- package/src/agent.ts +733 -0
- package/src/approval.test.ts +323 -0
- package/src/approval.ts +148 -0
- package/src/backend.test.ts +370 -0
- package/src/backend.ts +370 -0
- package/src/client/MachineSection.vue +97 -0
- package/src/client/MachineSurface.vue +308 -0
- package/src/client/index.test.ts +244 -0
- package/src/client/index.ts +180 -0
- package/src/client/state.ts +49 -0
- package/src/delivery.ts +145 -0
- package/src/desktop.test.ts +233 -0
- package/src/desktop.ts +238 -0
- package/src/device-runner.test.ts +326 -0
- package/src/device-runner.ts +205 -0
- package/src/device.test.ts +418 -0
- package/src/device.ts +707 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +13 -0
- package/src/intent.ts +325 -0
- package/src/manifest.ts +3 -0
- package/src/pairing.test.ts +85 -0
- package/src/pairing.ts +182 -0
- package/src/storage-keys.ts +102 -0
- package/src/store.test.ts +507 -0
- package/src/store.ts +638 -0
- package/src/target.ts +86 -0
- package/src/testing.ts +352 -0
- package/src/user.test.ts +182 -0
- package/src/user.ts +442 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The Computer surface: the user's own machines, and how one is registered.
|
|
3
|
+
//
|
|
4
|
+
// Three states are rendered rather than reasoned about:
|
|
5
|
+
//
|
|
6
|
+
// * **connected** — the backend says this machine polled inside the presence
|
|
7
|
+
// TTL. Nothing here computes it.
|
|
8
|
+
// * **revoked** — the row stays, as evidence. A revoked machine is not
|
|
9
|
+
// deleted, because "failures observable in durable state" applies to a
|
|
10
|
+
// decision the user made as much as to one a program made.
|
|
11
|
+
// * **this computer** — only inside the desktop shell, where an agent exists
|
|
12
|
+
// to hand a code to. In a browser the code is shown instead, because a
|
|
13
|
+
// browser cannot register itself.
|
|
14
|
+
import { UiButton, UiField } from "@frockbot/client-ui";
|
|
15
|
+
import { computed, inject, onMounted, ref } from "vue";
|
|
16
|
+
import { machinesStateKey } from "./state.js";
|
|
17
|
+
|
|
18
|
+
const providedState = inject(machinesStateKey);
|
|
19
|
+
if (!providedState) {
|
|
20
|
+
throw new Error("Registered machine client services were not provided");
|
|
21
|
+
}
|
|
22
|
+
const machines = providedState;
|
|
23
|
+
const typedCode = ref("");
|
|
24
|
+
|
|
25
|
+
const rows = computed(() => machines.value.view?.machines ?? []);
|
|
26
|
+
const agent = computed(() => machines.value.agent);
|
|
27
|
+
|
|
28
|
+
/** The machine row this app itself is, when it is one. */
|
|
29
|
+
const thisMachineId = computed(() => agent.value?.machineId);
|
|
30
|
+
|
|
31
|
+
function when(value: string): string {
|
|
32
|
+
const parsed = Date.parse(value);
|
|
33
|
+
return Number.isNaN(parsed) ? value : new Date(parsed).toLocaleString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function state(machine: { connected: boolean; revokedAt?: string }): string {
|
|
37
|
+
if (machine.revokedAt) return "Revoked";
|
|
38
|
+
return machine.connected ? "Connected" : "Offline";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function submitCode(): Promise<void> {
|
|
42
|
+
const code = typedCode.value.trim();
|
|
43
|
+
if (!code) return;
|
|
44
|
+
await machines.value.enterCode(code);
|
|
45
|
+
typedCode.value = "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
onMounted(() => machines.value.load());
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<div class="machines-surface">
|
|
53
|
+
<header>
|
|
54
|
+
<h2>Registered machines</h2>
|
|
55
|
+
<p>
|
|
56
|
+
A registered machine runs the FrockBot desktop app and dials out to this
|
|
57
|
+
deployment. A Bot can read files and run commands on one only after you
|
|
58
|
+
approve each action, and only while the app is running.
|
|
59
|
+
</p>
|
|
60
|
+
</header>
|
|
61
|
+
|
|
62
|
+
<section class="machines-pairing">
|
|
63
|
+
<div v-if="machines.desktop" class="machines-pairing__desktop">
|
|
64
|
+
<div class="machines-pairing__row">
|
|
65
|
+
<span class="machines-pairing__text">
|
|
66
|
+
<strong>This computer</strong>
|
|
67
|
+
<small v-if="agent?.enrolled">
|
|
68
|
+
Paired as {{ agent.label }} · agent
|
|
69
|
+
{{ agent.running ? "running" : "stopped" }}
|
|
70
|
+
</small>
|
|
71
|
+
<small v-else>Not paired yet</small>
|
|
72
|
+
</span>
|
|
73
|
+
<UiButton
|
|
74
|
+
v-if="!agent?.enrolled"
|
|
75
|
+
type="button"
|
|
76
|
+
:disabled="machines.busy"
|
|
77
|
+
@click="machines.pairThisComputer()"
|
|
78
|
+
>
|
|
79
|
+
Pair this computer
|
|
80
|
+
</UiButton>
|
|
81
|
+
<UiButton
|
|
82
|
+
v-else
|
|
83
|
+
type="button"
|
|
84
|
+
:disabled="machines.busy"
|
|
85
|
+
@click="machines.forgetThisComputer()"
|
|
86
|
+
>
|
|
87
|
+
Forget on this computer
|
|
88
|
+
</UiButton>
|
|
89
|
+
</div>
|
|
90
|
+
<p v-if="agent?.lastError" class="machines-surface__note" role="status">
|
|
91
|
+
{{ agent.lastError }}
|
|
92
|
+
</p>
|
|
93
|
+
<form class="machines-pairing__code" @submit.prevent="submitCode">
|
|
94
|
+
<UiField label="Pairing code" hint="from another device's settings">
|
|
95
|
+
<input
|
|
96
|
+
v-model="typedCode"
|
|
97
|
+
type="text"
|
|
98
|
+
autocomplete="off"
|
|
99
|
+
spellcheck="false"
|
|
100
|
+
placeholder="Paste a pairing code"
|
|
101
|
+
/>
|
|
102
|
+
</UiField>
|
|
103
|
+
<UiButton
|
|
104
|
+
type="submit"
|
|
105
|
+
:disabled="machines.busy || !typedCode.trim()"
|
|
106
|
+
>
|
|
107
|
+
Pair with this code
|
|
108
|
+
</UiButton>
|
|
109
|
+
</form>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div v-else class="machines-pairing__browser">
|
|
113
|
+
<div class="machines-pairing__row">
|
|
114
|
+
<span class="machines-pairing__text">
|
|
115
|
+
<strong>Register a machine</strong>
|
|
116
|
+
<small>
|
|
117
|
+
Ask for a code here, then paste it into the FrockBot desktop app
|
|
118
|
+
on the machine you want to register.
|
|
119
|
+
</small>
|
|
120
|
+
</span>
|
|
121
|
+
<UiButton
|
|
122
|
+
type="button"
|
|
123
|
+
:disabled="machines.busy"
|
|
124
|
+
@click="machines.requestCode()"
|
|
125
|
+
>
|
|
126
|
+
Get a pairing code
|
|
127
|
+
</UiButton>
|
|
128
|
+
</div>
|
|
129
|
+
<p v-if="machines.offer" class="machines-pairing__offer">
|
|
130
|
+
<code>{{ machines.offer.code }}</code>
|
|
131
|
+
<small>
|
|
132
|
+
One use only, expires {{ when(machines.offer.expiresAt) }}
|
|
133
|
+
</small>
|
|
134
|
+
</p>
|
|
135
|
+
</div>
|
|
136
|
+
</section>
|
|
137
|
+
|
|
138
|
+
<p v-if="machines.error" class="machines-surface__error" role="alert">
|
|
139
|
+
{{ machines.error }}
|
|
140
|
+
</p>
|
|
141
|
+
|
|
142
|
+
<div v-if="rows.length" class="machines-list">
|
|
143
|
+
<article
|
|
144
|
+
v-for="machine in rows"
|
|
145
|
+
:key="machine.machineId"
|
|
146
|
+
class="machine-card"
|
|
147
|
+
>
|
|
148
|
+
<div class="machine-card__text">
|
|
149
|
+
<strong>
|
|
150
|
+
{{ machine.label }}
|
|
151
|
+
<span v-if="machine.machineId === thisMachineId">(this one)</span>
|
|
152
|
+
</strong>
|
|
153
|
+
<small
|
|
154
|
+
>{{ machine.platform }} ·
|
|
155
|
+
{{ machine.capabilities.join(", ") }}</small
|
|
156
|
+
>
|
|
157
|
+
<small>Last seen {{ when(machine.lastSeenAt) }}</small>
|
|
158
|
+
</div>
|
|
159
|
+
<span
|
|
160
|
+
class="machine-card__state"
|
|
161
|
+
:data-state="state(machine).toLowerCase()"
|
|
162
|
+
>
|
|
163
|
+
{{ state(machine) }}
|
|
164
|
+
</span>
|
|
165
|
+
<UiButton
|
|
166
|
+
v-if="!machine.revokedAt"
|
|
167
|
+
type="button"
|
|
168
|
+
:disabled="machines.busy"
|
|
169
|
+
@click="machines.revoke(machine.machineId)"
|
|
170
|
+
>
|
|
171
|
+
Revoke
|
|
172
|
+
</UiButton>
|
|
173
|
+
</article>
|
|
174
|
+
</div>
|
|
175
|
+
<p v-else-if="!machines.error" class="machines-surface__note">
|
|
176
|
+
No machines are registered yet.
|
|
177
|
+
</p>
|
|
178
|
+
</div>
|
|
179
|
+
</template>
|
|
180
|
+
|
|
181
|
+
<style scoped>
|
|
182
|
+
.machines-surface {
|
|
183
|
+
padding: 24px;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.machines-surface h2 {
|
|
187
|
+
margin: 0;
|
|
188
|
+
font-family: var(--frock-font-display);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.machines-surface header p,
|
|
192
|
+
.machines-surface__note {
|
|
193
|
+
color: var(--frock-text-muted);
|
|
194
|
+
font-size: var(--frock-text-base);
|
|
195
|
+
line-height: 1.5;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.machines-pairing {
|
|
199
|
+
display: grid;
|
|
200
|
+
gap: 12px;
|
|
201
|
+
margin-top: 20px;
|
|
202
|
+
padding: 14px;
|
|
203
|
+
border: 1px solid var(--frock-border);
|
|
204
|
+
border-radius: var(--frock-radius-card);
|
|
205
|
+
background: var(--frock-surface-subtle);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.machines-pairing__row {
|
|
209
|
+
display: flex;
|
|
210
|
+
align-items: center;
|
|
211
|
+
justify-content: space-between;
|
|
212
|
+
gap: 16px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.machines-pairing__text {
|
|
216
|
+
display: flex;
|
|
217
|
+
min-width: 0;
|
|
218
|
+
flex-direction: column;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.machines-pairing__text strong {
|
|
222
|
+
color: var(--frock-text);
|
|
223
|
+
font-size: var(--frock-text-md);
|
|
224
|
+
font-weight: 600;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.machines-pairing__text small,
|
|
228
|
+
.machines-pairing__offer small {
|
|
229
|
+
color: var(--frock-text-muted);
|
|
230
|
+
font-size: var(--frock-text-sm);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.machines-pairing__code {
|
|
234
|
+
display: flex;
|
|
235
|
+
align-items: flex-end;
|
|
236
|
+
gap: 12px;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.machines-pairing__code > :first-child {
|
|
240
|
+
flex: 1 1 auto;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.machines-pairing__offer {
|
|
244
|
+
display: flex;
|
|
245
|
+
flex-direction: column;
|
|
246
|
+
gap: 4px;
|
|
247
|
+
margin: 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.machines-pairing__offer code {
|
|
251
|
+
overflow-wrap: anywhere;
|
|
252
|
+
color: var(--frock-text);
|
|
253
|
+
font-size: var(--frock-text-sm);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.machines-list {
|
|
257
|
+
display: grid;
|
|
258
|
+
gap: 12px;
|
|
259
|
+
margin-top: 20px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.machine-card {
|
|
263
|
+
display: flex;
|
|
264
|
+
min-width: 0;
|
|
265
|
+
align-items: center;
|
|
266
|
+
justify-content: space-between;
|
|
267
|
+
gap: 16px;
|
|
268
|
+
padding: 14px;
|
|
269
|
+
border: 1px solid var(--frock-border);
|
|
270
|
+
border-radius: var(--frock-radius-card);
|
|
271
|
+
background: var(--frock-surface-raised);
|
|
272
|
+
box-shadow: var(--frock-shadow-card);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.machine-card__text {
|
|
276
|
+
display: flex;
|
|
277
|
+
min-width: 0;
|
|
278
|
+
flex-direction: column;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.machine-card__text small {
|
|
282
|
+
margin-top: 4px;
|
|
283
|
+
color: var(--frock-text-muted);
|
|
284
|
+
font-size: var(--frock-text-sm);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.machine-card__state {
|
|
288
|
+
font-size: var(--frock-text-sm);
|
|
289
|
+
font-weight: 700;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.machine-card__state[data-state="connected"] {
|
|
293
|
+
color: var(--frock-success);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.machine-card__state[data-state="offline"] {
|
|
297
|
+
color: var(--frock-text-muted);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.machine-card__state[data-state="revoked"] {
|
|
301
|
+
color: var(--frock-danger-text);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.machines-surface__error {
|
|
305
|
+
color: var(--frock-danger-text);
|
|
306
|
+
font-size: var(--frock-text-sm);
|
|
307
|
+
}
|
|
308
|
+
</style>
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The Computer settings section's store, driven without a DOM.
|
|
2
|
+
//
|
|
3
|
+
// The house pattern: build a `ClientPluginContext` by hand, run the plugin,
|
|
4
|
+
// take the state ref it provided and the slots it mounted, and drive the store
|
|
5
|
+
// against a recorded transport. What is proved here is what the section
|
|
6
|
+
// promises — every read decoded at the seam, every refusal surfaced verbatim,
|
|
7
|
+
// and the desktop half absent when there is no desktop.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import type {
|
|
11
|
+
ClientPluginContext,
|
|
12
|
+
ClientSlotRegistration,
|
|
13
|
+
} from "@frockbot/client-core";
|
|
14
|
+
import { clientSurfaceRegistryKey } from "@frockbot/client-core";
|
|
15
|
+
import { createClientSurfaceRegistry } from "@frockbot/client-ui";
|
|
16
|
+
import { MACHINE_SURFACE_ID_V1, userMachineClientPlugin } from "./index.js";
|
|
17
|
+
import { machinesStateKey, type MachinesClientState } from "./state.js";
|
|
18
|
+
|
|
19
|
+
const MACHINE = {
|
|
20
|
+
machineId: "m-1",
|
|
21
|
+
label: "Tims-M5-MacBook-Pro.local",
|
|
22
|
+
platform: "macos",
|
|
23
|
+
capabilities: ["exec", "files"],
|
|
24
|
+
connected: true,
|
|
25
|
+
lastSeenAt: "2026-09-01T00:00:00.000Z",
|
|
26
|
+
registeredAt: "2026-08-31T00:00:00.000Z",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const LIST = {
|
|
30
|
+
schemaVersion: 1,
|
|
31
|
+
machines: [MACHINE],
|
|
32
|
+
serverTime: "2026-09-01T00:00:10.000Z",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const OFFER = {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
code: "pairing-code",
|
|
38
|
+
machineId: "m-2",
|
|
39
|
+
expiresAt: "2026-09-01T00:05:00.000Z",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const AGENT_STATUS = {
|
|
43
|
+
schemaVersion: 1,
|
|
44
|
+
enrolled: true,
|
|
45
|
+
running: true,
|
|
46
|
+
machineId: "m-1",
|
|
47
|
+
label: "Tims-M5-MacBook-Pro.local",
|
|
48
|
+
origin: "https://bot.example.com",
|
|
49
|
+
failures: 0,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
interface Bridge {
|
|
53
|
+
status(): Promise<unknown>;
|
|
54
|
+
pair(code: string): Promise<unknown>;
|
|
55
|
+
unpair(): Promise<unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mount(
|
|
59
|
+
overrides: {
|
|
60
|
+
hostedRequest?: ClientPluginContext["transport"]["hostedRequest"];
|
|
61
|
+
bridge?: Bridge;
|
|
62
|
+
} = {},
|
|
63
|
+
): {
|
|
64
|
+
state: { value: MachinesClientState };
|
|
65
|
+
slots: ClientSlotRegistration[];
|
|
66
|
+
calls: Array<[string, string | undefined, string | undefined]>;
|
|
67
|
+
surfaces: ReturnType<typeof createClientSurfaceRegistry>;
|
|
68
|
+
dispose(): void;
|
|
69
|
+
} {
|
|
70
|
+
const slots: ClientSlotRegistration[] = [];
|
|
71
|
+
const calls: Array<[string, string | undefined, string | undefined]> = [];
|
|
72
|
+
const surfaces = createClientSurfaceRegistry();
|
|
73
|
+
let state: unknown;
|
|
74
|
+
const globals = globalThis as { frockbotMachineAgent?: Bridge };
|
|
75
|
+
if (overrides.bridge) globals.frockbotMachineAgent = overrides.bridge;
|
|
76
|
+
else delete globals.frockbotMachineAgent;
|
|
77
|
+
const context: ClientPluginContext = {
|
|
78
|
+
transport: {
|
|
79
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
80
|
+
hostedRequest:
|
|
81
|
+
overrides.hostedRequest ??
|
|
82
|
+
((path, method, body) => {
|
|
83
|
+
calls.push([path, method, body]);
|
|
84
|
+
if (path === "/api/machines/pair") return Promise.resolve(OFFER);
|
|
85
|
+
if (path.endsWith("/revoke")) {
|
|
86
|
+
return Promise.resolve({
|
|
87
|
+
...LIST,
|
|
88
|
+
machines: [
|
|
89
|
+
{ ...MACHINE, connected: false, revokedAt: LIST.serverTime },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return Promise.resolve(LIST);
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
inject: (key) => {
|
|
97
|
+
if (key !== clientSurfaceRegistryKey) {
|
|
98
|
+
throw new Error("unexpected client provider");
|
|
99
|
+
}
|
|
100
|
+
return surfaces as never;
|
|
101
|
+
},
|
|
102
|
+
provide: (key, value) => {
|
|
103
|
+
if (key === machinesStateKey) state = value;
|
|
104
|
+
return () => {};
|
|
105
|
+
},
|
|
106
|
+
slot: (registration) => {
|
|
107
|
+
slots.push(registration);
|
|
108
|
+
return () => slots.splice(slots.indexOf(registration), 1);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const disposers = userMachineClientPlugin(context);
|
|
112
|
+
if (!Array.isArray(disposers)) throw new Error("expected registrations");
|
|
113
|
+
return {
|
|
114
|
+
state: state as { value: MachinesClientState },
|
|
115
|
+
slots,
|
|
116
|
+
calls,
|
|
117
|
+
surfaces,
|
|
118
|
+
dispose: () => {
|
|
119
|
+
delete globals.frockbotMachineAgent;
|
|
120
|
+
for (const dispose of disposers.toReversed()) dispose();
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe("registered machines client contribution", () => {
|
|
126
|
+
test("mounts one settings section and one surface", () => {
|
|
127
|
+
const mounted = mount();
|
|
128
|
+
expect(mounted.slots.map((slot) => slot.slot)).toEqual([
|
|
129
|
+
"frockbot.user-settings-sections",
|
|
130
|
+
]);
|
|
131
|
+
expect(mounted.surfaces.has(MACHINE_SURFACE_ID_V1)).toBe(true);
|
|
132
|
+
mounted.dispose();
|
|
133
|
+
expect(mounted.surfaces.has(MACHINE_SURFACE_ID_V1)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("loads the registry and decodes it at the seam", async () => {
|
|
137
|
+
const mounted = mount();
|
|
138
|
+
await mounted.state.value.load();
|
|
139
|
+
expect(mounted.calls[0]).toEqual(["/api/machines", undefined, undefined]);
|
|
140
|
+
expect(mounted.state.value.view?.machines[0]).toMatchObject({
|
|
141
|
+
machineId: "m-1",
|
|
142
|
+
connected: true,
|
|
143
|
+
});
|
|
144
|
+
// Presence came from the backend; nothing here recomputed it.
|
|
145
|
+
expect(mounted.state.value.desktop).toBe(false);
|
|
146
|
+
mounted.dispose();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a browser can mint a code but cannot pair itself", async () => {
|
|
150
|
+
const mounted = mount();
|
|
151
|
+
await mounted.state.value.requestCode("Tims-M5-MacBook-Pro.local");
|
|
152
|
+
expect(mounted.calls[0]).toEqual([
|
|
153
|
+
"/api/machines/pair",
|
|
154
|
+
"POST",
|
|
155
|
+
JSON.stringify({ label: "Tims-M5-MacBook-Pro.local" }),
|
|
156
|
+
]);
|
|
157
|
+
expect(mounted.state.value.offer?.code).toBe("pairing-code");
|
|
158
|
+
|
|
159
|
+
await mounted.state.value.pairThisComputer();
|
|
160
|
+
expect(mounted.state.value.error).toBe(
|
|
161
|
+
"This client is not the desktop app",
|
|
162
|
+
);
|
|
163
|
+
mounted.dispose();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("the desktop shell mints a code and hands it to its own agent", async () => {
|
|
167
|
+
const handed: string[] = [];
|
|
168
|
+
const mounted = mount({
|
|
169
|
+
bridge: {
|
|
170
|
+
status: () => Promise.resolve(AGENT_STATUS),
|
|
171
|
+
pair: (code) => {
|
|
172
|
+
handed.push(code);
|
|
173
|
+
return Promise.resolve(AGENT_STATUS);
|
|
174
|
+
},
|
|
175
|
+
unpair: () =>
|
|
176
|
+
Promise.resolve({
|
|
177
|
+
schemaVersion: 1,
|
|
178
|
+
enrolled: false,
|
|
179
|
+
running: false,
|
|
180
|
+
failures: 0,
|
|
181
|
+
}),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(mounted.state.value.desktop).toBe(true);
|
|
186
|
+
await mounted.state.value.pairThisComputer();
|
|
187
|
+
|
|
188
|
+
expect(handed).toEqual(["pairing-code"]);
|
|
189
|
+
expect(mounted.state.value.agent?.enrolled).toBe(true);
|
|
190
|
+
// The code is one-time: once spent it is not left on screen.
|
|
191
|
+
expect(mounted.state.value.offer).toBeUndefined();
|
|
192
|
+
expect(mounted.calls.map(([path]) => path)).toEqual([
|
|
193
|
+
"/api/machines/pair",
|
|
194
|
+
"/api/machines",
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
await mounted.state.value.forgetThisComputer();
|
|
198
|
+
expect(mounted.state.value.agent?.enrolled).toBe(false);
|
|
199
|
+
mounted.dispose();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a code typed by hand reaches the agent as it was typed", async () => {
|
|
203
|
+
const handed: string[] = [];
|
|
204
|
+
const mounted = mount({
|
|
205
|
+
bridge: {
|
|
206
|
+
status: () => Promise.resolve(AGENT_STATUS),
|
|
207
|
+
pair: (code) => {
|
|
208
|
+
handed.push(code);
|
|
209
|
+
return Promise.resolve(AGENT_STATUS);
|
|
210
|
+
},
|
|
211
|
+
unpair: () => Promise.resolve(AGENT_STATUS),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
await mounted.state.value.enterCode(" typed-code ");
|
|
215
|
+
expect(handed).toEqual(["typed-code"]);
|
|
216
|
+
mounted.dispose();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("revoking takes the registry back from the authority", async () => {
|
|
220
|
+
const mounted = mount();
|
|
221
|
+
await mounted.state.value.revoke("m-1");
|
|
222
|
+
expect(mounted.calls[0]).toEqual([
|
|
223
|
+
"/api/machines/m-1/revoke",
|
|
224
|
+
"POST",
|
|
225
|
+
"{}",
|
|
226
|
+
]);
|
|
227
|
+
expect(mounted.state.value.view?.machines[0]?.revokedAt).toBeDefined();
|
|
228
|
+
expect(mounted.state.value.view?.machines[0]?.connected).toBe(false);
|
|
229
|
+
mounted.dispose();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("a refusal is surfaced rather than swallowed", async () => {
|
|
233
|
+
const mounted = mount({
|
|
234
|
+
hostedRequest: () =>
|
|
235
|
+
Promise.reject(new Error("machine registration is not configured")),
|
|
236
|
+
});
|
|
237
|
+
await mounted.state.value.load();
|
|
238
|
+
expect(mounted.state.value.error).toBe(
|
|
239
|
+
"machine registration is not configured",
|
|
240
|
+
);
|
|
241
|
+
expect(mounted.state.value.busy).toBe(false);
|
|
242
|
+
mounted.dispose();
|
|
243
|
+
});
|
|
244
|
+
});
|