@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,180 @@
|
|
|
1
|
+
/// <reference path="../env.d.ts" />
|
|
2
|
+
|
|
3
|
+
// The Computer settings section: the user's own machines, live.
|
|
4
|
+
//
|
|
5
|
+
// Everything here goes through `ctx.transport.hostedRequest`, which is the
|
|
6
|
+
// session, and through `@frockbot/machine-protocol`'s decoders, which are the
|
|
7
|
+
// seam. The one thing that does not is the desktop bridge — `window
|
|
8
|
+
// .frockbotMachineAgent`, exposed by the Electron preload — and its answers
|
|
9
|
+
// are decoded too, because a different runtime is a seam whoever owns it.
|
|
10
|
+
//
|
|
11
|
+
// The pairing UX is two-sided on purpose:
|
|
12
|
+
//
|
|
13
|
+
// * in the **desktop shell**, "Pair this computer" mints a code and hands it
|
|
14
|
+
// straight to the agent in the main process; the code never leaves the
|
|
15
|
+
// app. There is also a field to paste a code minted elsewhere, which is
|
|
16
|
+
// what "enter pairing code" means when the browser and the laptop are two
|
|
17
|
+
// different devices.
|
|
18
|
+
// * in a **browser**, there is no agent to hand a code to, so the section
|
|
19
|
+
// shows the code and its expiry and says where to type it.
|
|
20
|
+
//
|
|
21
|
+
// Revoking is the browser's, always: a machine cannot un-revoke itself, and
|
|
22
|
+
// the token dies on the next verify wherever the laptop is.
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
clientSurfaceRegistryKey,
|
|
26
|
+
type ClientPlugin,
|
|
27
|
+
} from "@frockbot/client-core";
|
|
28
|
+
import {
|
|
29
|
+
decodeMachineListViewV1,
|
|
30
|
+
decodeMachinePairingOfferV1,
|
|
31
|
+
machineRoutePathV1,
|
|
32
|
+
} from "@frockbot/machine-protocol";
|
|
33
|
+
import { ref } from "vue";
|
|
34
|
+
import { decodeMachineDeviceAgentStatusV1 } from "../device.js";
|
|
35
|
+
import MachineSection from "./MachineSection.vue";
|
|
36
|
+
import MachineSurface from "./MachineSurface.vue";
|
|
37
|
+
import {
|
|
38
|
+
machinesStateKey,
|
|
39
|
+
type MachineAgentBridgeV1,
|
|
40
|
+
type MachinesClientState,
|
|
41
|
+
} from "./state.js";
|
|
42
|
+
|
|
43
|
+
/** The surface the section opens. Not a settings anchor: a registered surface. */
|
|
44
|
+
export const MACHINE_SURFACE_ID_V1 = "user-machines";
|
|
45
|
+
|
|
46
|
+
function message(error: unknown, fallback: string): string {
|
|
47
|
+
return error instanceof Error ? error.message : fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The desktop bridge, if this client is running inside the Electron shell. */
|
|
51
|
+
export function machineAgentBridgeV1(): MachineAgentBridgeV1 | undefined {
|
|
52
|
+
const bridge = (globalThis as { frockbotMachineAgent?: MachineAgentBridgeV1 })
|
|
53
|
+
.frockbotMachineAgent;
|
|
54
|
+
return typeof bridge?.pair === "function" ? bridge : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const userMachineClientPlugin: ClientPlugin = (ctx) => {
|
|
58
|
+
const surfaces = ctx.inject(clientSurfaceRegistryKey);
|
|
59
|
+
const bridge = machineAgentBridgeV1();
|
|
60
|
+
const request = (
|
|
61
|
+
path: string,
|
|
62
|
+
method?: "GET" | "POST",
|
|
63
|
+
body?: string,
|
|
64
|
+
): Promise<unknown> => {
|
|
65
|
+
if (!ctx.transport.hostedRequest) {
|
|
66
|
+
throw new Error("Registered machines are unavailable on this client");
|
|
67
|
+
}
|
|
68
|
+
return ctx.transport.hostedRequest(path, method, body);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const mintOffer = async (label?: string): Promise<void> => {
|
|
72
|
+
state.value.offer = decodeMachinePairingOfferV1(
|
|
73
|
+
await request(
|
|
74
|
+
machineRoutePathV1("pair"),
|
|
75
|
+
"POST",
|
|
76
|
+
JSON.stringify(label === undefined ? {} : { label }),
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const readAgent = async (): Promise<void> => {
|
|
82
|
+
if (!bridge) return;
|
|
83
|
+
state.value.agent = decodeMachineDeviceAgentStatusV1(await bridge.status());
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const guarded = async (work: () => Promise<void>): Promise<void> => {
|
|
87
|
+
state.value.busy = true;
|
|
88
|
+
try {
|
|
89
|
+
await work();
|
|
90
|
+
state.value.error = undefined;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
state.value.error = message(error, "The machine registry refused");
|
|
93
|
+
} finally {
|
|
94
|
+
state.value.busy = false;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const state = ref<MachinesClientState>({
|
|
99
|
+
busy: false,
|
|
100
|
+
desktop: bridge !== undefined,
|
|
101
|
+
async load() {
|
|
102
|
+
await guarded(async () => {
|
|
103
|
+
state.value.view = decodeMachineListViewV1(
|
|
104
|
+
await request(machineRoutePathV1("list")),
|
|
105
|
+
);
|
|
106
|
+
await readAgent();
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
async requestCode(label?: string) {
|
|
110
|
+
await guarded(() => mintOffer(label));
|
|
111
|
+
},
|
|
112
|
+
async pairThisComputer() {
|
|
113
|
+
await guarded(async () => {
|
|
114
|
+
if (!bridge) throw new Error("This client is not the desktop app");
|
|
115
|
+
await mintOffer();
|
|
116
|
+
const offer = state.value.offer;
|
|
117
|
+
if (!offer) throw new Error("No pairing code was issued");
|
|
118
|
+
state.value.agent = decodeMachineDeviceAgentStatusV1(
|
|
119
|
+
await bridge.pair(offer.code),
|
|
120
|
+
);
|
|
121
|
+
// The code is one-time: once it has been spent it is not a secret to
|
|
122
|
+
// keep showing, and showing it invites a second, failing, attempt.
|
|
123
|
+
state.value.offer = undefined;
|
|
124
|
+
state.value.view = decodeMachineListViewV1(
|
|
125
|
+
await request(machineRoutePathV1("list")),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
async enterCode(code: string) {
|
|
130
|
+
await guarded(async () => {
|
|
131
|
+
if (!bridge) throw new Error("This client is not the desktop app");
|
|
132
|
+
state.value.agent = decodeMachineDeviceAgentStatusV1(
|
|
133
|
+
await bridge.pair(code.trim()),
|
|
134
|
+
);
|
|
135
|
+
state.value.offer = undefined;
|
|
136
|
+
state.value.view = decodeMachineListViewV1(
|
|
137
|
+
await request(machineRoutePathV1("list")),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
async revoke(machineId: string) {
|
|
142
|
+
await guarded(async () => {
|
|
143
|
+
// Revocation answers with the whole registry, so the row's new state
|
|
144
|
+
// comes from the authority rather than from a local edit.
|
|
145
|
+
state.value.view = decodeMachineListViewV1(
|
|
146
|
+
await request(
|
|
147
|
+
machineRoutePathV1("revoke", { machineId }),
|
|
148
|
+
"POST",
|
|
149
|
+
JSON.stringify({}),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
if (state.value.agent?.machineId === machineId) await readAgent();
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
async forgetThisComputer() {
|
|
156
|
+
await guarded(async () => {
|
|
157
|
+
if (!bridge) throw new Error("This client is not the desktop app");
|
|
158
|
+
state.value.agent = decodeMachineDeviceAgentStatusV1(
|
|
159
|
+
await bridge.unpair(),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return [
|
|
166
|
+
ctx.provide(machinesStateKey, state),
|
|
167
|
+
surfaces.register({
|
|
168
|
+
id: MACHINE_SURFACE_ID_V1,
|
|
169
|
+
title: "Registered machines",
|
|
170
|
+
component: MachineSurface,
|
|
171
|
+
}),
|
|
172
|
+
ctx.slot({
|
|
173
|
+
slot: "frockbot.user-settings-sections",
|
|
174
|
+
order: 30,
|
|
175
|
+
component: MachineSection,
|
|
176
|
+
}),
|
|
177
|
+
];
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export default userMachineClientPlugin;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MachineListViewV1,
|
|
3
|
+
MachinePairingOfferV1,
|
|
4
|
+
} from "@frockbot/machine-protocol";
|
|
5
|
+
import type { InjectionKey, Ref } from "vue";
|
|
6
|
+
import type { MachineDeviceAgentStatusV1 } from "../device.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The Electron preload bridge, as the renderer sees it.
|
|
10
|
+
*
|
|
11
|
+
* Present only inside the desktop shell. In a browser it is absent, which is
|
|
12
|
+
* exactly the difference the section renders: a browser can register *other*
|
|
13
|
+
* machines by handing them a code, but it cannot register itself.
|
|
14
|
+
*/
|
|
15
|
+
export interface MachineAgentBridgeV1 {
|
|
16
|
+
status(): Promise<unknown>;
|
|
17
|
+
pair(code: string): Promise<unknown>;
|
|
18
|
+
unpair(): Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MachinesClientState {
|
|
22
|
+
/** The registry, as the browser reads it. `connected` is the server's word. */
|
|
23
|
+
view?: MachineListViewV1;
|
|
24
|
+
/** The outstanding pairing code, once asked for. One-time, five minutes. */
|
|
25
|
+
offer?: MachinePairingOfferV1;
|
|
26
|
+
/** This laptop's own agent, when the app is the desktop shell. */
|
|
27
|
+
agent?: MachineDeviceAgentStatusV1;
|
|
28
|
+
/** True while a request is in flight; every button reads it. */
|
|
29
|
+
busy: boolean;
|
|
30
|
+
/** The last refusal, verbatim from the backend. Never swallowed. */
|
|
31
|
+
error?: string;
|
|
32
|
+
/** Whether this client can pair itself — i.e. it is the desktop shell. */
|
|
33
|
+
readonly desktop: boolean;
|
|
34
|
+
load(): Promise<void>;
|
|
35
|
+
/** Mint a pairing code for a machine that is not this one. */
|
|
36
|
+
requestCode(label?: string): Promise<void>;
|
|
37
|
+
/** Mint a code and hand it straight to this laptop's agent. */
|
|
38
|
+
pairThisComputer(): Promise<void>;
|
|
39
|
+
/** Hand a code typed by hand to this laptop's agent. */
|
|
40
|
+
enterCode(code: string): Promise<void>;
|
|
41
|
+
/** Kill every token a machine holds. The row stays as evidence. */
|
|
42
|
+
revoke(machineId: string): Promise<void>;
|
|
43
|
+
/** Forget the token on this laptop without touching the registry. */
|
|
44
|
+
forgetThisComputer(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const machinesStateKey: InjectionKey<Ref<MachinesClientState>> = Symbol(
|
|
48
|
+
"user-machines-state",
|
|
49
|
+
);
|
package/src/delivery.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The one message that travels from the User Durable Object back to a Bot.
|
|
2
|
+
//
|
|
3
|
+
// A machine answers the backend, not the Bot: the result lands on the User
|
|
4
|
+
// object, which is the queue's authority. The Bot is owed the fact that it
|
|
5
|
+
// landed, and it is owed it durably — "its outcome is delivered to the Bot's
|
|
6
|
+
// next conversational Turn as durable input" — so the User object hands the Bot
|
|
7
|
+
// this, and the Bot enqueues a `PendingBotInputV1` from it.
|
|
8
|
+
//
|
|
9
|
+
// It carries a *preview*, never the output. A machine command may return a
|
|
10
|
+
// megabyte of stdout, and a preamble line that carried it would push a person's
|
|
11
|
+
// own words out of the model's context to say something the Bot can read in
|
|
12
|
+
// full whenever it wants, with `machine_command_check`.
|
|
13
|
+
import {
|
|
14
|
+
MACHINE_LIMITS_V1,
|
|
15
|
+
MachineDecodeError,
|
|
16
|
+
MACHINE_COMMAND_OUTCOMES_V1,
|
|
17
|
+
type MachineCommandOutcomeV1,
|
|
18
|
+
type MachineCommandResultV1,
|
|
19
|
+
type MachineCommandV1,
|
|
20
|
+
} from "@frockbot/machine-protocol";
|
|
21
|
+
|
|
22
|
+
/** The longest preview a delivery carries. One line, not a transcript. */
|
|
23
|
+
export const MACHINE_RESULT_PREVIEW_MAX = 400;
|
|
24
|
+
|
|
25
|
+
export interface MachineResultDeliveryV1 {
|
|
26
|
+
schemaVersion: 1;
|
|
27
|
+
botId: string;
|
|
28
|
+
runId: string;
|
|
29
|
+
machineId: string;
|
|
30
|
+
commandId: string;
|
|
31
|
+
outcome: MachineCommandOutcomeV1;
|
|
32
|
+
finishedAt: string;
|
|
33
|
+
preview: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A single readable line about what the machine said. Pure. */
|
|
37
|
+
export function machineResultPreviewV1(result: MachineCommandResultV1): string {
|
|
38
|
+
const parts: string[] = [];
|
|
39
|
+
if (result.exitCode !== undefined) parts.push(`exit ${result.exitCode}`);
|
|
40
|
+
const text =
|
|
41
|
+
result.message ??
|
|
42
|
+
(result.stdout && result.stdout.length > 0
|
|
43
|
+
? result.stdout
|
|
44
|
+
: (result.stderr ?? ""));
|
|
45
|
+
const flattened = text.replace(/\s+/g, " ").trim();
|
|
46
|
+
if (flattened.length > 0) parts.push(flattened);
|
|
47
|
+
if (result.truncated) parts.push("(output truncated)");
|
|
48
|
+
if (result.bytesBase64 !== undefined) {
|
|
49
|
+
parts.push(`(${result.bytesBase64.length} base64 characters returned)`);
|
|
50
|
+
}
|
|
51
|
+
const joined = parts.join(" — ");
|
|
52
|
+
return (joined.length === 0 ? result.outcome : joined).slice(
|
|
53
|
+
0,
|
|
54
|
+
MACHINE_RESULT_PREVIEW_MAX,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The delivery one recorded result owes the Bot that asked for it. Pure. */
|
|
59
|
+
export function machineResultDeliveryV1(
|
|
60
|
+
command: MachineCommandV1,
|
|
61
|
+
result: MachineCommandResultV1,
|
|
62
|
+
): MachineResultDeliveryV1 {
|
|
63
|
+
return {
|
|
64
|
+
schemaVersion: 1,
|
|
65
|
+
botId: command.botId,
|
|
66
|
+
runId: command.runId,
|
|
67
|
+
machineId: command.machineId,
|
|
68
|
+
commandId: result.commandId,
|
|
69
|
+
outcome: result.outcome,
|
|
70
|
+
finishedAt: result.finishedAt,
|
|
71
|
+
preview: machineResultPreviewV1(result),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function text(value: unknown, maximum: number, label: string): string {
|
|
76
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
77
|
+
throw new MachineDecodeError(`${label} must be a non-empty string`);
|
|
78
|
+
}
|
|
79
|
+
if (value.length > maximum) {
|
|
80
|
+
throw new MachineDecodeError(`${label} exceeds ${maximum} characters`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function decodeMachineResultDeliveryV1(
|
|
86
|
+
input: unknown,
|
|
87
|
+
label = "machine result delivery",
|
|
88
|
+
): MachineResultDeliveryV1 {
|
|
89
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
90
|
+
throw new MachineDecodeError(`${label} must be an object`);
|
|
91
|
+
}
|
|
92
|
+
const value = input as Record<string, unknown>;
|
|
93
|
+
const allowed = [
|
|
94
|
+
"schemaVersion",
|
|
95
|
+
"botId",
|
|
96
|
+
"runId",
|
|
97
|
+
"machineId",
|
|
98
|
+
"commandId",
|
|
99
|
+
"outcome",
|
|
100
|
+
"finishedAt",
|
|
101
|
+
"preview",
|
|
102
|
+
];
|
|
103
|
+
for (const key of Object.keys(value)) {
|
|
104
|
+
if (!allowed.includes(key)) {
|
|
105
|
+
throw new MachineDecodeError(`${label} has an unexpected key "${key}"`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (value.schemaVersion !== 1) {
|
|
109
|
+
throw new MachineDecodeError(`${label} schemaVersion is unsupported`);
|
|
110
|
+
}
|
|
111
|
+
if (
|
|
112
|
+
typeof value.outcome !== "string" ||
|
|
113
|
+
!MACHINE_COMMAND_OUTCOMES_V1.includes(
|
|
114
|
+
value.outcome as MachineCommandOutcomeV1,
|
|
115
|
+
)
|
|
116
|
+
) {
|
|
117
|
+
throw new MachineDecodeError(`${label} outcome is invalid`);
|
|
118
|
+
}
|
|
119
|
+
const finishedAt = text(value.finishedAt, 64, `${label} finishedAt`);
|
|
120
|
+
if (Number.isNaN(Date.parse(finishedAt))) {
|
|
121
|
+
throw new MachineDecodeError(`${label} finishedAt is not a timestamp`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
botId: text(value.botId, MACHINE_LIMITS_V1.identifier, `${label} botId`),
|
|
126
|
+
runId: text(value.runId, MACHINE_LIMITS_V1.identifier, `${label} runId`),
|
|
127
|
+
machineId: text(
|
|
128
|
+
value.machineId,
|
|
129
|
+
MACHINE_LIMITS_V1.identifier,
|
|
130
|
+
`${label} machineId`,
|
|
131
|
+
),
|
|
132
|
+
commandId: text(
|
|
133
|
+
value.commandId,
|
|
134
|
+
MACHINE_LIMITS_V1.identifier,
|
|
135
|
+
`${label} commandId`,
|
|
136
|
+
),
|
|
137
|
+
outcome: value.outcome as MachineCommandOutcomeV1,
|
|
138
|
+
finishedAt,
|
|
139
|
+
preview: text(
|
|
140
|
+
value.preview,
|
|
141
|
+
MACHINE_RESULT_PREVIEW_MAX,
|
|
142
|
+
`${label} preview`,
|
|
143
|
+
),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// The `trusted-main` Contribution, mounted on fake capabilities.
|
|
2
|
+
//
|
|
3
|
+
// No Electron here — and that is the assertion as much as the convenience: the
|
|
4
|
+
// contribution is wired through cordis services, so the same plugin the
|
|
5
|
+
// desktop shell mounts runs under `bun test` against a fake host and a fake
|
|
6
|
+
// keychain.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
DesktopCommandRegistry,
|
|
11
|
+
DesktopMachineHostCapability,
|
|
12
|
+
DesktopSecretStoreCapability,
|
|
13
|
+
type DesktopMachineExecResult,
|
|
14
|
+
type DesktopMachineFileResult,
|
|
15
|
+
type DesktopMachineIdentity,
|
|
16
|
+
} from "@frockbot/desktop-core";
|
|
17
|
+
import { Context } from "cordis";
|
|
18
|
+
import {
|
|
19
|
+
MACHINE_AGENT_PAIR_COMMAND_V1,
|
|
20
|
+
MACHINE_AGENT_STATUS_COMMAND_V1,
|
|
21
|
+
MACHINE_AGENT_UNPAIR_COMMAND_V1,
|
|
22
|
+
MACHINE_DESKTOP_CAPABILITIES_V1,
|
|
23
|
+
machineDesktopCapabilitiesV1,
|
|
24
|
+
MACHINE_TOKEN_SECRET_KEY_V1,
|
|
25
|
+
decodeMachineEmptyCommandInputV1,
|
|
26
|
+
decodeMachinePairCommandInputV1,
|
|
27
|
+
machineDesktopPlugin,
|
|
28
|
+
machineSecretStoreV1,
|
|
29
|
+
} from "./desktop.js";
|
|
30
|
+
import {
|
|
31
|
+
decodeMachineDeviceAgentStatusV1,
|
|
32
|
+
type MachineDeviceAgentStatusV1,
|
|
33
|
+
} from "./device.js";
|
|
34
|
+
|
|
35
|
+
const ORIGIN = "https://bot.example.com";
|
|
36
|
+
|
|
37
|
+
class FakeMachineHost extends DesktopMachineHostCapability {
|
|
38
|
+
identity(): DesktopMachineIdentity {
|
|
39
|
+
return { label: "Tims-M5-MacBook-Pro.local", platform: "macos" };
|
|
40
|
+
}
|
|
41
|
+
exec(): Promise<DesktopMachineExecResult> {
|
|
42
|
+
return Promise.resolve({
|
|
43
|
+
exitCode: 0,
|
|
44
|
+
stdout: "",
|
|
45
|
+
stderr: "",
|
|
46
|
+
truncated: false,
|
|
47
|
+
timedOut: false,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
readFile(): Promise<DesktopMachineFileResult> {
|
|
51
|
+
return Promise.resolve({ bytesBase64: "", truncated: false });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class MemorySecretStore extends DesktopSecretStoreCapability {
|
|
56
|
+
readonly held = new Map<string, string>();
|
|
57
|
+
read(key: string): Promise<string | undefined> {
|
|
58
|
+
return Promise.resolve(this.held.get(key));
|
|
59
|
+
}
|
|
60
|
+
write(key: string, value: string): Promise<void> {
|
|
61
|
+
this.held.set(key, value);
|
|
62
|
+
return Promise.resolve();
|
|
63
|
+
}
|
|
64
|
+
clear(key: string): Promise<void> {
|
|
65
|
+
this.held.delete(key);
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function mount(
|
|
71
|
+
fetchImpl: (input: string, init?: RequestInit) => Promise<Response>,
|
|
72
|
+
): Promise<{ root: Context; secrets: MemorySecretStore }> {
|
|
73
|
+
const root = new Context();
|
|
74
|
+
await root.plugin(DesktopCommandRegistry);
|
|
75
|
+
await root.plugin(FakeMachineHost);
|
|
76
|
+
await root.plugin(MemorySecretStore);
|
|
77
|
+
await root.plugin(machineDesktopPlugin, {
|
|
78
|
+
origin: ORIGIN,
|
|
79
|
+
agentVersion: "0.0.1",
|
|
80
|
+
fetch: fetchImpl,
|
|
81
|
+
autoStart: false,
|
|
82
|
+
});
|
|
83
|
+
return { root, secrets: root.desktopSecretStore as MemorySecretStore };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("machine desktop contribution inputs", () => {
|
|
87
|
+
test("a pairing code is required, trimmed, and alone", () => {
|
|
88
|
+
expect(decodeMachinePairCommandInputV1({ code: " abc " })).toEqual({
|
|
89
|
+
code: "abc",
|
|
90
|
+
});
|
|
91
|
+
expect(() => decodeMachinePairCommandInputV1({ code: "" })).toThrow();
|
|
92
|
+
expect(() =>
|
|
93
|
+
decodeMachinePairCommandInputV1({ code: "abc", extra: 1 }),
|
|
94
|
+
).toThrow();
|
|
95
|
+
expect(() => decodeMachinePairCommandInputV1("abc")).toThrow();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("the other two commands take nothing at all", () => {
|
|
99
|
+
expect(decodeMachineEmptyCommandInputV1({})).toEqual({});
|
|
100
|
+
expect(() => decodeMachineEmptyCommandInputV1({ code: "x" })).toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the secret store is narrowed to the agent's own key", async () => {
|
|
104
|
+
const keys: string[] = [];
|
|
105
|
+
const narrowed = machineSecretStoreV1({
|
|
106
|
+
read: (key: string) => {
|
|
107
|
+
keys.push(key);
|
|
108
|
+
return Promise.resolve(undefined);
|
|
109
|
+
},
|
|
110
|
+
write: (key: string) => {
|
|
111
|
+
keys.push(key);
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
},
|
|
114
|
+
clear: (key: string) => {
|
|
115
|
+
keys.push(key);
|
|
116
|
+
return Promise.resolve();
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
await narrowed.read();
|
|
120
|
+
await narrowed.write("value");
|
|
121
|
+
await narrowed.clear();
|
|
122
|
+
expect(new Set(keys)).toEqual(new Set([MACHINE_TOKEN_SECRET_KEY_V1]));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("machine desktop contribution", () => {
|
|
127
|
+
test("registers exactly three commands and removes them on disposal", async () => {
|
|
128
|
+
const { root } = await mount(() =>
|
|
129
|
+
Promise.resolve(new Response("{}", { status: 200 })),
|
|
130
|
+
);
|
|
131
|
+
expect(
|
|
132
|
+
root.desktopCommands
|
|
133
|
+
.list()
|
|
134
|
+
.map((entry) => entry.id)
|
|
135
|
+
.sort(),
|
|
136
|
+
).toEqual(
|
|
137
|
+
[
|
|
138
|
+
MACHINE_AGENT_PAIR_COMMAND_V1,
|
|
139
|
+
MACHINE_AGENT_STATUS_COMMAND_V1,
|
|
140
|
+
MACHINE_AGENT_UNPAIR_COMMAND_V1,
|
|
141
|
+
].sort(),
|
|
142
|
+
);
|
|
143
|
+
const commands = root.desktopCommands;
|
|
144
|
+
await root.fiber.dispose();
|
|
145
|
+
await expect(
|
|
146
|
+
commands.invoke(MACHINE_AGENT_STATUS_COMMAND_V1, {}),
|
|
147
|
+
).rejects.toThrow("is unavailable");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("pairing enrols with the host's own identity and keeps the token", async () => {
|
|
151
|
+
const bodies: string[] = [];
|
|
152
|
+
const { root, secrets } = await mount((input, init) => {
|
|
153
|
+
bodies.push(typeof init?.body === "string" ? init.body : "");
|
|
154
|
+
if (input.endsWith("/enroll")) {
|
|
155
|
+
return Promise.resolve(
|
|
156
|
+
Response.json({
|
|
157
|
+
schemaVersion: 1,
|
|
158
|
+
machineId: "m-1",
|
|
159
|
+
token: "machine-token",
|
|
160
|
+
keyVersion: 1,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return Promise.resolve(
|
|
165
|
+
Response.json({
|
|
166
|
+
schemaVersion: 1,
|
|
167
|
+
commands: [],
|
|
168
|
+
serverTime: "2026-09-01T00:00:00.000Z",
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const status = decodeMachineDeviceAgentStatusV1(
|
|
174
|
+
await root.desktopCommands.invoke<MachineDeviceAgentStatusV1>(
|
|
175
|
+
MACHINE_AGENT_PAIR_COMMAND_V1,
|
|
176
|
+
{ code: "pairing-code" },
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(status.enrolled).toBe(true);
|
|
181
|
+
expect(status.label).toBe("Tims-M5-MacBook-Pro.local");
|
|
182
|
+
expect(JSON.parse(bodies[0] ?? "{}")).toMatchObject({
|
|
183
|
+
label: "Tims-M5-MacBook-Pro.local",
|
|
184
|
+
platform: "macos",
|
|
185
|
+
// Row 57g's second gate: no agent reports `messages` until the macOS
|
|
186
|
+
// handlers exist.
|
|
187
|
+
capabilities: [...MACHINE_DESKTOP_CAPABILITIES_V1],
|
|
188
|
+
});
|
|
189
|
+
expect(secrets.held.get(MACHINE_TOKEN_SECRET_KEY_V1)).toContain(
|
|
190
|
+
"machine-token",
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
await root.desktopCommands.invoke(MACHINE_AGENT_UNPAIR_COMMAND_V1, {});
|
|
194
|
+
expect(secrets.held.has(MACHINE_TOKEN_SECRET_KEY_V1)).toBe(false);
|
|
195
|
+
await root.fiber.dispose();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("status is readable before anything has been paired", async () => {
|
|
199
|
+
const { root } = await mount(() =>
|
|
200
|
+
Promise.reject(new Error("nothing should be dialled")),
|
|
201
|
+
);
|
|
202
|
+
expect(
|
|
203
|
+
decodeMachineDeviceAgentStatusV1(
|
|
204
|
+
await root.desktopCommands.invoke(MACHINE_AGENT_STATUS_COMMAND_V1, {}),
|
|
205
|
+
),
|
|
206
|
+
).toMatchObject({ enrolled: false, running: false, failures: 0 });
|
|
207
|
+
await root.fiber.dispose();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("what this agent claims it can do (register row 57g)", () => {
|
|
212
|
+
test("messages is claimed only by a Mac with handlers behind it", () => {
|
|
213
|
+
expect(machineDesktopCapabilitiesV1("macos", true)).toEqual([
|
|
214
|
+
"exec",
|
|
215
|
+
"files",
|
|
216
|
+
"messages",
|
|
217
|
+
]);
|
|
218
|
+
// A Mac whose shell wired none: claiming it would mean every Messages
|
|
219
|
+
// command reaching an agent that can only refuse.
|
|
220
|
+
expect(machineDesktopCapabilitiesV1("macos", false)).toEqual([
|
|
221
|
+
"exec",
|
|
222
|
+
"files",
|
|
223
|
+
]);
|
|
224
|
+
// Not a Mac. The enrollment decoder refuses the claim anyway; this is the
|
|
225
|
+
// same fact on the agent's own side of the wire.
|
|
226
|
+
for (const platform of ["windows", "linux"] as const) {
|
|
227
|
+
expect(machineDesktopCapabilitiesV1(platform, true)).toEqual([
|
|
228
|
+
"exec",
|
|
229
|
+
"files",
|
|
230
|
+
]);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|