@robota-sdk/agent-command 3.0.0-beta.82 → 3.0.0-beta.84
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/CHANGELOG.md +1071 -0
- package/README.md +71 -62
- package/dist/node/index.cjs +81 -72
- package/dist/node/index.d.cts +136 -52
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +136 -52
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +79 -70
- package/dist/node/index.js.map +1 -1
- package/package.json +16 -10
- package/src/agent/__tests__/agent-command.test.ts +62 -0
- package/src/agent/agent-command-parser.ts +2 -2
- package/src/agent/agent-command.ts +20 -0
- package/src/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +2 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
- package/src/default/__tests__/default-command-modules.test.ts +54 -14
- package/src/default/__tests__/model-exposure.test.ts +18 -3
- package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
- package/src/default/default-command-modules.ts +15 -8
- package/src/devices/__tests__/devices-command-module.test.ts +99 -12
- package/src/devices/devices-command-module.ts +108 -24
- package/src/devices/devices-command-port.ts +62 -1
- package/src/devices/index.ts +3 -0
- package/src/doctor/doctor-node-deps.ts +6 -7
- package/src/editor/editor-command-module.ts +5 -0
- package/src/effort/effort-command.test.ts +65 -2
- package/src/effort/effort-command.ts +19 -5
- package/src/events/__tests__/events-command.test.ts +73 -0
- package/src/events/events-command-module.ts +49 -0
- package/src/events/events-command.ts +69 -0
- package/src/events/index.ts +2 -0
- package/src/git/__tests__/fake-git-port.ts +9 -2
- package/src/git/__tests__/git-command-module.test.ts +10 -2
- package/src/git/git-command-module.ts +3 -4
- package/src/git/git-commit.ts +6 -3
- package/src/git/index.ts +4 -1
- package/src/goal/goal-command.ts +3 -1
- package/src/handoff/__tests__/handoff-command-module.test.ts +2 -1
- package/src/handoff/handoff-command-module.ts +1 -1
- package/src/handoff/handoff-command.ts +2 -2
- package/src/help/__tests__/help-command-module.test.ts +3 -0
- package/src/help/__tests__/help-command.test.ts +3 -0
- package/src/index.ts +14 -0
- package/src/keybindings/keybindings-command-module.ts +17 -3
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +172 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
- package/src/mcp-activation/mcp-activation-command.ts +62 -4
- package/src/memory/__tests__/memory-command-module.test.ts +40 -2
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/memory-command-module.ts +4 -2
- package/src/memory/memory-command.ts +42 -2
- package/src/mode/__tests__/mode-command-module.test.ts +40 -0
- package/src/mode/mode-command.ts +1 -1
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +240 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +223 -0
- package/src/peers/__tests__/peers-command-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +78 -0
- package/src/peers/peers-command-module.ts +1 -1
- package/src/peers/peers-command.ts +33 -4
- package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
- package/src/plugin/plugin-command.ts +47 -4
- package/src/provider/__tests__/org-policy.test.ts +4 -0
- package/src/provider/__tests__/provider-command-module.test.ts +276 -14
- package/src/provider/__tests__/scripted-interaction.ts +12 -2
- package/src/provider/provider-command-execution.ts +54 -8
- package/src/provider/provider-command-profile-lifecycle.ts +48 -2
- package/src/provider/provider-command-profile-operations.ts +14 -0
- package/src/provider/provider-command-profile.ts +7 -4
- package/src/provider/provider-command-setup.ts +14 -1
- package/src/rewind/__tests__/rewind-command-module.test.ts +32 -1
- package/src/rewind/rewind-command.ts +23 -2
- package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
- package/src/schedule/__tests__/loop-command.test.ts +238 -4
- package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
- package/src/schedule/loop-command.ts +98 -4
- package/src/schedule/schedule-command-module.ts +8 -4
- package/src/settings/settings-command-module.ts +2 -2
- package/src/shell/shell-command-module.ts +5 -0
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
- package/src/terminal-client/index.ts +5 -0
- package/src/terminal-client/terminal-client-commands.ts +109 -0
- package/src/theme/__tests__/theme-command.test.ts +1 -1
- package/src/theme/theme-command-module.ts +12 -2
- package/src/user-local/__tests__/user-local-command.test.ts +2 -2
- package/src/git/__tests__/git-diff.test.ts +0 -125
- package/src/git/__tests__/git-process.test.ts +0 -221
- package/src/git/__tests__/git-status.test.ts +0 -117
- package/src/git/git-diff.ts +0 -129
- package/src/git/git-process.ts +0 -169
- package/src/git/git-status.ts +0 -134
|
@@ -44,7 +44,12 @@ function fakePort(): IDevicesCommandPort & { calls: string[] } {
|
|
|
44
44
|
calls.push(`init:${options.name ?? ''}`);
|
|
45
45
|
return {
|
|
46
46
|
ok: true as const,
|
|
47
|
-
value: {
|
|
47
|
+
value: {
|
|
48
|
+
userId: USER,
|
|
49
|
+
deviceId: DEVICE_A,
|
|
50
|
+
signingKeyId: SIGNING,
|
|
51
|
+
keyStorage: 'OS keychain',
|
|
52
|
+
},
|
|
48
53
|
};
|
|
49
54
|
}),
|
|
50
55
|
recover: vi.fn(async () => {
|
|
@@ -63,6 +68,20 @@ function fakePort(): IDevicesCommandPort & { calls: string[] } {
|
|
|
63
68
|
calls.push(`revoke:${target}`);
|
|
64
69
|
return { ok: true as const, value: { deviceId: DEVICE_B, name: 'desktop' } };
|
|
65
70
|
}),
|
|
71
|
+
add: vi.fn(async () => {
|
|
72
|
+
calls.push('add');
|
|
73
|
+
return {
|
|
74
|
+
ok: true as const,
|
|
75
|
+
value: { deviceId: DEVICE_B, name: 'desktop', confirmed: true },
|
|
76
|
+
};
|
|
77
|
+
}),
|
|
78
|
+
join: vi.fn(async (options) => {
|
|
79
|
+
calls.push(`join:${options.name ?? ''}`);
|
|
80
|
+
return {
|
|
81
|
+
ok: true as const,
|
|
82
|
+
value: { userId: USER, deviceId: DEVICE_B, name: 'desktop', keyStorage: 'OS keychain' },
|
|
83
|
+
};
|
|
84
|
+
}),
|
|
66
85
|
};
|
|
67
86
|
}
|
|
68
87
|
|
|
@@ -105,7 +124,19 @@ describe('/devices command', () => {
|
|
|
105
124
|
'init',
|
|
106
125
|
'revoke',
|
|
107
126
|
'recover',
|
|
127
|
+
'add',
|
|
128
|
+
'join',
|
|
108
129
|
]);
|
|
130
|
+
// What the model reads names the one-time code as terminal-only, so it never asks for it.
|
|
131
|
+
expect(palette?.description).toMatch(/enrol/i);
|
|
132
|
+
expect(palette?.description).toMatch(/terminal/i);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('tells the model it also shows the device mesh status', () => {
|
|
136
|
+
const description =
|
|
137
|
+
createDevicesCommandModule(fakePort()).commandSources?.[0]?.getCommands()[0]?.description;
|
|
138
|
+
expect(description).toMatch(/device mesh/);
|
|
139
|
+
expect(description).toMatch(/Operator-only/);
|
|
109
140
|
});
|
|
110
141
|
|
|
111
142
|
it('lists the roster with short ids, this device, the signing-key holder and expiry', async () => {
|
|
@@ -125,6 +156,17 @@ describe('/devices command', () => {
|
|
|
125
156
|
vi.mocked(port.list).mockResolvedValueOnce(undefined);
|
|
126
157
|
const result = await start(port).command('devices', 'list');
|
|
127
158
|
expect(result?.message).toMatch(/\/devices init/);
|
|
159
|
+
// A device of a user who already has others joins them; `init` would never link to them.
|
|
160
|
+
expect(result?.message).toMatch(/\/devices join/);
|
|
161
|
+
expect(result?.message).toMatch(/separate identity/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('describes init as for the first device, and join as the way to the devices a user has', () => {
|
|
165
|
+
const entry = createDevicesCommandModule(fakePort()).commandSources?.[0]?.getCommands()[0];
|
|
166
|
+
const init = entry?.subcommands?.find((sub) => sub.name === 'init');
|
|
167
|
+
expect(init?.description).toMatch(/first device/);
|
|
168
|
+
expect(init?.description).toMatch(/separate identity/);
|
|
169
|
+
expect(entry?.description).toMatch(/separate identity/);
|
|
128
170
|
});
|
|
129
171
|
|
|
130
172
|
it('runs init, recover and revoke on the operator terminal', async () => {
|
|
@@ -156,7 +198,7 @@ describe('/devices command', () => {
|
|
|
156
198
|
it('refuses without an interactive terminal and never reaches the port (fail closed)', async () => {
|
|
157
199
|
const port = fakePort();
|
|
158
200
|
const harness = start(port, handoff(false));
|
|
159
|
-
for (const args of ['init', 'recover', 'revoke abcdef']) {
|
|
201
|
+
for (const args of ['init', 'recover', 'revoke abcdef', 'add', 'join', 'join desktop']) {
|
|
160
202
|
const result = await harness.command('devices', args);
|
|
161
203
|
expect(result?.success).toBe(false);
|
|
162
204
|
expect(result?.message).toMatch(/interactive terminal/i);
|
|
@@ -167,15 +209,17 @@ describe('/devices command', () => {
|
|
|
167
209
|
it('refuses a model invocation', async () => {
|
|
168
210
|
const port = fakePort();
|
|
169
211
|
const harness = start(port);
|
|
170
|
-
const
|
|
171
|
-
|
|
212
|
+
for (const args of ['init', 'add', 'join']) {
|
|
213
|
+
const result = await harness.session.executeModelCommand('devices', args);
|
|
214
|
+
expect(result?.success ?? false).toBe(false);
|
|
215
|
+
}
|
|
172
216
|
expect(port.calls).toEqual([]);
|
|
173
217
|
});
|
|
174
218
|
|
|
175
219
|
it('refuses every verb from a remote surface', async () => {
|
|
176
220
|
const port = fakePort();
|
|
177
221
|
const harness = start(port);
|
|
178
|
-
for (const args of ['', 'init', 'recover', 'revoke abcdef']) {
|
|
222
|
+
for (const args of ['', 'init', 'recover', 'revoke abcdef', 'add', 'join']) {
|
|
179
223
|
const result = await harness.session.executeCommand('devices', args, 'remote');
|
|
180
224
|
expect(result?.success).toBe(false);
|
|
181
225
|
expect(result?.message).toMatch(/operator/i);
|
|
@@ -183,15 +227,58 @@ describe('/devices command', () => {
|
|
|
183
227
|
expect(port.calls).toEqual([]);
|
|
184
228
|
});
|
|
185
229
|
|
|
186
|
-
it('
|
|
230
|
+
it('shows the device mesh: whether it is on, how it finds devices, and what is linked', async () => {
|
|
231
|
+
const port = {
|
|
232
|
+
...fakePort(),
|
|
233
|
+
meshStatus: () => ({
|
|
234
|
+
state: 'on' as const,
|
|
235
|
+
sources: ['local network (mDNS)', 'Mainline DHT'],
|
|
236
|
+
linked: [{ deviceId: DEVICE_B, name: 'desktop', locality: 'another-host' as const }],
|
|
237
|
+
}),
|
|
238
|
+
};
|
|
239
|
+
const result = await start(port).command('devices', '');
|
|
240
|
+
expect(result?.message).toMatch(/Device mesh: on/);
|
|
241
|
+
expect(result?.message).toContain('local network (mDNS), Mainline DHT');
|
|
242
|
+
expect(result?.message).toMatch(
|
|
243
|
+
new RegExp(`${DEVICE_B.slice(0, 10)}\\s+desktop\\s+on another machine`),
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('says how to turn the device mesh on when it is off', async () => {
|
|
248
|
+
const port = {
|
|
249
|
+
...fakePort(),
|
|
250
|
+
meshStatus: () => ({ state: 'off' as const, sources: [], linked: [] }),
|
|
251
|
+
};
|
|
252
|
+
const result = await start(port).command('devices', 'list');
|
|
253
|
+
expect(result?.message).toMatch(/Device mesh: off/);
|
|
254
|
+
expect(result?.message).toContain('transports.mesh.enabled');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('runs add and join on the operator terminal and reports ids and names only', async () => {
|
|
258
|
+
const port = fakePort();
|
|
259
|
+
const runs = vi.fn();
|
|
260
|
+
const harness = start(port, handoff(true, runs));
|
|
261
|
+
const add = await harness.command('devices', 'add');
|
|
262
|
+
expect(add?.success).toBe(true);
|
|
263
|
+
expect(add?.message).toContain('desktop');
|
|
264
|
+
expect(add?.message).toContain(DEVICE_B.slice(0, 10));
|
|
265
|
+
const join = await harness.command('devices', 'join my desktop');
|
|
266
|
+
expect(join?.success).toBe(true);
|
|
267
|
+
expect(join?.message).toContain(DEVICE_B.slice(0, 10));
|
|
268
|
+
expect(join?.message).toContain('OS keychain');
|
|
269
|
+
expect(port.calls).toEqual(['add', 'join:my desktop']);
|
|
270
|
+
expect(runs).toHaveBeenCalledTimes(2);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('says what to do when an enrollment is refused', async () => {
|
|
187
274
|
const port = fakePort();
|
|
188
275
|
const harness = start(port);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
expect(
|
|
276
|
+
vi.mocked(port.join).mockResolvedValueOnce({ ok: false, reason: 'code-not-accepted' });
|
|
277
|
+
expect((await harness.command('devices', 'join'))?.message).toMatch(/devices add/);
|
|
278
|
+
vi.mocked(port.add).mockResolvedValueOnce({ ok: false, reason: 'no-relay' });
|
|
279
|
+
expect((await harness.command('devices', 'add'))?.message).toMatch(/relayUrl/);
|
|
280
|
+
vi.mocked(port.join).mockResolvedValueOnce({ ok: false, reason: 'code-on-command-line' });
|
|
281
|
+
expect((await harness.command('devices', 'join ABCDE'))?.message).toMatch(/devices add/);
|
|
195
282
|
});
|
|
196
283
|
|
|
197
284
|
it('requires a device id for revoke', async () => {
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `/devices` — this device's place in the user's device identity: list the roster, create the
|
|
3
|
-
* identity, revoke a device, recover from the recovery phrase.
|
|
3
|
+
* identity, enrol another device or join with one, revoke a device, recover from the recovery phrase.
|
|
4
4
|
*
|
|
5
5
|
* Operator-only, twice over: the command is not model-invocable, and it refuses any invocation that
|
|
6
6
|
* is not the operator's own (a remote surface, the model). Every verb that touches a secret or
|
|
7
7
|
* asks for a confirmation runs inside the terminal handoff, so what is typed goes to the host's
|
|
8
|
-
* terminal and never through the session's input, prompt history or conversation
|
|
8
|
+
* terminal and never through the session's input, prompt history or conversation — the enrollment
|
|
9
|
+
* code included, which is why `join` reads it there instead of taking it as an argument. Without an
|
|
9
10
|
* interactive terminal those verbs refuse rather than fall back to anything else.
|
|
10
11
|
*/
|
|
11
12
|
import { createSystemCommandFromEntry } from '../command-module-utils.js';
|
|
12
13
|
|
|
13
14
|
import type {
|
|
14
15
|
IDevicesCommandPort,
|
|
16
|
+
IDevicesMeshStatus,
|
|
15
17
|
IDevicesView,
|
|
16
18
|
TDevicesOutcome,
|
|
17
19
|
TDevicesRefusal,
|
|
@@ -24,23 +26,28 @@ import type {
|
|
|
24
26
|
import type { ICommand, ICommandResult, ICommandSource } from '@robota-sdk/agent-interface-command';
|
|
25
27
|
|
|
26
28
|
const SHORT_ID_CHARS = 10;
|
|
27
|
-
const USAGE = 'Usage: /devices [list|init [name]|revoke <device-id>|recover]';
|
|
29
|
+
const USAGE = 'Usage: /devices [list|init [name]|add|join [name]|revoke <device-id>|recover]';
|
|
28
30
|
|
|
29
31
|
export function createDevicesCommandEntry(): ICommand {
|
|
30
32
|
return {
|
|
31
33
|
name: 'devices',
|
|
32
34
|
displayName: 'Devices',
|
|
33
35
|
description:
|
|
34
|
-
"Manage this device's identity among the user's devices: list the roster, create the identity and its recovery phrase, revoke a device, or rotate the signing key from the phrase. Operator-only; the phrase
|
|
36
|
+
"Manage this device's identity among the user's devices: list the roster with the device mesh status (whether it is on, how this device finds the others, which are linked), create the identity and its recovery phrase (init, for the user's first device only: on a device of a user who already has others it makes a separate identity that can never link to them), enrol another device (add shows a one-time code; join on the new device reads it), revoke a device, or rotate the signing key from the phrase. A new identity or new lists take effect in the running session's device mesh at once. Operator-only; the phrase and the enrollment code are handled on the terminal only, and results carry only device ids and names.",
|
|
35
37
|
source: 'devices',
|
|
36
38
|
modelInvocable: false,
|
|
37
39
|
userInvocable: true,
|
|
38
|
-
argumentHint: '[list|init [name]|revoke <device-id>|recover]',
|
|
40
|
+
argumentHint: '[list|init [name]|add|join [name]|revoke <device-id>|recover]',
|
|
39
41
|
subcommands: [
|
|
40
|
-
{
|
|
42
|
+
{
|
|
43
|
+
name: 'list',
|
|
44
|
+
description: 'List your devices and the device mesh status',
|
|
45
|
+
source: 'devices',
|
|
46
|
+
},
|
|
41
47
|
{
|
|
42
48
|
name: 'init',
|
|
43
|
-
description:
|
|
49
|
+
description:
|
|
50
|
+
'Create your device identity and recovery phrase, on your first device only (on another, a separate identity that can never link to your devices; use add/join there)',
|
|
44
51
|
source: 'devices',
|
|
45
52
|
argumentHint: '[name]',
|
|
46
53
|
},
|
|
@@ -55,26 +62,57 @@ export function createDevicesCommandEntry(): ICommand {
|
|
|
55
62
|
description: 'Rotate the signing key from your recovery phrase',
|
|
56
63
|
source: 'devices',
|
|
57
64
|
},
|
|
65
|
+
{
|
|
66
|
+
name: 'add',
|
|
67
|
+
description: 'Enrol another device: show a one-time code for it',
|
|
68
|
+
source: 'devices',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'join',
|
|
72
|
+
description: 'Join your devices with the code another device shows',
|
|
73
|
+
source: 'devices',
|
|
74
|
+
argumentHint: '[name]',
|
|
75
|
+
},
|
|
58
76
|
],
|
|
59
77
|
};
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
const REFUSALS: Readonly<Record<TDevicesRefusal, string>> = {
|
|
63
|
-
'no-terminal':
|
|
64
|
-
|
|
65
|
-
'
|
|
81
|
+
'no-terminal':
|
|
82
|
+
'This needs an interactive terminal; the recovery phrase is never shown or read anywhere else.',
|
|
83
|
+
'not-initialized':
|
|
84
|
+
'This device has no identity yet. If you already use Robota on another device, run `/devices add` there and `/devices join` here, typing the code it shows. Run `/devices init` only for your first device: it creates a separate identity that can never link to your other devices.',
|
|
85
|
+
'already-initialized':
|
|
86
|
+
'This device already has an identity. Use `/devices recover` to rotate its signing key.',
|
|
66
87
|
cancelled: 'Cancelled. Nothing was changed.',
|
|
67
88
|
'confirmation-failed': 'The confirmation did not match. Nothing was changed.',
|
|
68
89
|
'phrase-invalid': 'That is not a valid recovery phrase. Nothing was changed.',
|
|
69
90
|
'phrase-mismatch':
|
|
70
91
|
'That phrase (with that passphrase) belongs to a different identity than this device. Nothing was changed.',
|
|
71
|
-
'no-signing-key':
|
|
72
|
-
|
|
92
|
+
'no-signing-key':
|
|
93
|
+
'This device does not hold the signing key. Revoke from a device that does, or `/devices recover`.',
|
|
94
|
+
'signing-key-expired':
|
|
95
|
+
'The signing key on this device has expired. Run `/devices recover` to issue a new one.',
|
|
73
96
|
'unknown-device': 'No device in the roster has that id.',
|
|
74
97
|
'ambiguous-device': 'More than one device starts with that id; give more of it.',
|
|
75
|
-
'self-revocation':
|
|
98
|
+
'self-revocation':
|
|
99
|
+
'This device cannot revoke itself. Revoke it from another device, or `/devices recover`.',
|
|
76
100
|
'changed-concurrently':
|
|
77
101
|
'The device identity changed in another session while this was waiting. Nothing was changed; try again.',
|
|
102
|
+
'no-relay':
|
|
103
|
+
'Enrolling a device needs a signaling relay. Set `transports.webrtc.options.relayUrl` in your settings on both devices.',
|
|
104
|
+
'code-invalid': 'That is not an enrollment code. Nothing was changed.',
|
|
105
|
+
'code-on-command-line':
|
|
106
|
+
'Do not put the code on the command line, where history keeps it. Run `/devices add` again on the other device for a new code, then `/devices join` here and type it at the prompt.',
|
|
107
|
+
'code-not-accepted':
|
|
108
|
+
'No device accepted that code: it is wrong, expired or already used, or the connection was tampered with. Run `/devices add` on the other device for a new code.',
|
|
109
|
+
'enrollment-expired': 'The code expired before a device joined. Nothing was changed.',
|
|
110
|
+
'too-many-attempts':
|
|
111
|
+
'Too many attempts failed to prove the code, so it no longer works. Nothing was changed; run `/devices add` again.',
|
|
112
|
+
'enrollment-declined': 'The enrollment was declined. Nothing was changed.',
|
|
113
|
+
'enrollment-timed-out': 'Nobody answered in time. Nothing was changed.',
|
|
114
|
+
'enrollment-failed':
|
|
115
|
+
'The signaling relay or the connection to the other device failed before the enrollment finished. Nothing was changed; try again.',
|
|
78
116
|
};
|
|
79
117
|
|
|
80
118
|
function short(id: string): string {
|
|
@@ -89,7 +127,27 @@ function refused(reason: TDevicesRefusal): ICommandResult {
|
|
|
89
127
|
return { success: false, message: REFUSALS[reason] };
|
|
90
128
|
}
|
|
91
129
|
|
|
92
|
-
function
|
|
130
|
+
function formatMesh(mesh: IDevicesMeshStatus): string[] {
|
|
131
|
+
if (mesh.state === 'off') {
|
|
132
|
+
return [
|
|
133
|
+
'Device mesh: off. Set `transports.mesh.enabled` to true in your user settings to link your devices.',
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
if (mesh.state === 'failed') {
|
|
137
|
+
return [`Device mesh: could not start: ${mesh.reason ?? 'no reason was reported'}.`];
|
|
138
|
+
}
|
|
139
|
+
const found = mesh.sources.length > 0 ? `; finds devices by ${mesh.sources.join(', ')}` : '';
|
|
140
|
+
const linked = mesh.linked.map(
|
|
141
|
+
(device) =>
|
|
142
|
+
` ${short(device.deviceId)} ${device.name ?? ''} ${device.locality === 'same-host' ? 'on this machine' : 'on another machine'}`,
|
|
143
|
+
);
|
|
144
|
+
return [
|
|
145
|
+
`Device mesh: ${mesh.state}${found}.`,
|
|
146
|
+
...(linked.length > 0 ? ['Linked now:', ...linked] : ['No device is linked right now.']),
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function formatList(view: IDevicesView | undefined, mesh?: IDevicesMeshStatus): ICommandResult {
|
|
93
151
|
if (view === undefined) return { success: true, message: REFUSALS['not-initialized'] };
|
|
94
152
|
const lines = view.devices.map((device) => {
|
|
95
153
|
const notes = [
|
|
@@ -105,6 +163,7 @@ function formatList(view: IDevicesView | undefined): ICommandResult {
|
|
|
105
163
|
`Devices of user ${short(view.userId)}:`,
|
|
106
164
|
...lines,
|
|
107
165
|
`${view.revokedDeviceCount} revoked. Roster and revocation list valid until ${new Date(view.listsExpireAt).toISOString()}.`,
|
|
166
|
+
...(mesh !== undefined ? formatMesh(mesh) : []),
|
|
108
167
|
].join('\n'),
|
|
109
168
|
};
|
|
110
169
|
}
|
|
@@ -139,7 +198,8 @@ export async function executeDevicesCommand(
|
|
|
139
198
|
if (!isOperator(context)) {
|
|
140
199
|
return {
|
|
141
200
|
success: false,
|
|
142
|
-
message:
|
|
201
|
+
message:
|
|
202
|
+
'`/devices` runs only for the operator at this terminal, never from a remote surface or the model.',
|
|
143
203
|
};
|
|
144
204
|
}
|
|
145
205
|
const trimmed = args.trim();
|
|
@@ -152,7 +212,7 @@ export async function executeDevicesCommand(
|
|
|
152
212
|
case '':
|
|
153
213
|
case 'list':
|
|
154
214
|
try {
|
|
155
|
-
return formatList(await port.list());
|
|
215
|
+
return formatList(await port.list(), port.meshStatus?.());
|
|
156
216
|
} catch (error) {
|
|
157
217
|
return {
|
|
158
218
|
success: false,
|
|
@@ -167,7 +227,9 @@ export async function executeDevicesCommand(
|
|
|
167
227
|
[
|
|
168
228
|
`Device identity created. This device: ${short(value.deviceId)}; user ${short(value.userId)}.`,
|
|
169
229
|
`It holds signing key ${short(value.signingKeyId)}.`,
|
|
170
|
-
...(value.keyStorage !== undefined
|
|
230
|
+
...(value.keyStorage !== undefined
|
|
231
|
+
? [`Private keys are kept in: ${value.keyStorage}.`]
|
|
232
|
+
: []),
|
|
171
233
|
'Keep the recovery phrase offline; it is the only way to recover this identity.',
|
|
172
234
|
].join('\n'),
|
|
173
235
|
);
|
|
@@ -180,23 +242,45 @@ export async function executeDevicesCommand(
|
|
|
180
242
|
`Recovered. New signing key ${short(value.signingKeyId)}; ${value.revokedSigningKeyCount} old signing key(s) revoked.`,
|
|
181
243
|
`This device (${short(value.deviceId)}) is certified again.`,
|
|
182
244
|
...(value.droppedDeviceCount > 0
|
|
183
|
-
? [
|
|
245
|
+
? [
|
|
246
|
+
`${value.droppedDeviceCount} other device(s) were certified by a retired signing key and must enrol again.`,
|
|
247
|
+
]
|
|
184
248
|
: []),
|
|
185
249
|
].join('\n'),
|
|
186
250
|
);
|
|
187
251
|
case 'revoke':
|
|
188
|
-
if (rest.length === 0)
|
|
252
|
+
if (rest.length === 0)
|
|
253
|
+
return { success: false, message: 'Usage: /devices revoke <device-id>' };
|
|
189
254
|
return onTerminal(
|
|
190
255
|
context,
|
|
191
256
|
() => port.revoke(rest),
|
|
192
|
-
(value) =>
|
|
257
|
+
(value) =>
|
|
258
|
+
`Revoked ${value.name} (${short(value.deviceId)}). A new roster and revocation list were issued.`,
|
|
193
259
|
);
|
|
194
260
|
case 'add':
|
|
261
|
+
return onTerminal(
|
|
262
|
+
context,
|
|
263
|
+
() => port.add(),
|
|
264
|
+
(value) =>
|
|
265
|
+
[
|
|
266
|
+
`Enrolled ${value.name} (${short(value.deviceId)}). A new roster was issued.`,
|
|
267
|
+
...(value.confirmed
|
|
268
|
+
? []
|
|
269
|
+
: ['The new device did not confirm it kept its identity; check `/devices` there.']),
|
|
270
|
+
].join('\n'),
|
|
271
|
+
);
|
|
195
272
|
case 'join':
|
|
196
|
-
return
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
273
|
+
return onTerminal(
|
|
274
|
+
context,
|
|
275
|
+
() => port.join(rest.length > 0 ? { name: rest } : {}),
|
|
276
|
+
(value) =>
|
|
277
|
+
[
|
|
278
|
+
`Joined the devices of user ${short(value.userId)}. This device: ${value.name} (${short(value.deviceId)}).`,
|
|
279
|
+
...(value.keyStorage !== undefined
|
|
280
|
+
? [`Private keys are kept in: ${value.keyStorage}.`]
|
|
281
|
+
: []),
|
|
282
|
+
].join('\n'),
|
|
283
|
+
);
|
|
200
284
|
default:
|
|
201
285
|
return { success: false, message: `Unknown argument "${verb}". ${USAGE}` };
|
|
202
286
|
}
|
|
@@ -43,7 +43,25 @@ export type TDevicesRefusal =
|
|
|
43
43
|
| 'ambiguous-device'
|
|
44
44
|
| 'self-revocation'
|
|
45
45
|
/** Another process changed the identity while this operation waited on the operator. */
|
|
46
|
-
| 'changed-concurrently'
|
|
46
|
+
| 'changed-concurrently'
|
|
47
|
+
/** No signaling relay is configured to meet the other device through. */
|
|
48
|
+
| 'no-relay'
|
|
49
|
+
/** What was typed is not an enrollment code. */
|
|
50
|
+
| 'code-invalid'
|
|
51
|
+
/** The code was typed on the command line, where history keeps it. */
|
|
52
|
+
| 'code-on-command-line'
|
|
53
|
+
/** No device waits for that code or proved it: wrong, expired, already used, or something in between. */
|
|
54
|
+
| 'code-not-accepted'
|
|
55
|
+
/** Nobody joined before the code expired. */
|
|
56
|
+
| 'enrollment-expired'
|
|
57
|
+
/** Too many attempts failed to prove the code; it no longer works. */
|
|
58
|
+
| 'too-many-attempts'
|
|
59
|
+
/** The operator of either device declined. */
|
|
60
|
+
| 'enrollment-declined'
|
|
61
|
+
/** An operator did not answer in time. */
|
|
62
|
+
| 'enrollment-timed-out'
|
|
63
|
+
/** The connection or the exchange with the other device failed partway. */
|
|
64
|
+
| 'enrollment-failed';
|
|
47
65
|
|
|
48
66
|
export type TDevicesOutcome<T> =
|
|
49
67
|
| { readonly ok: true; readonly value: T }
|
|
@@ -70,13 +88,56 @@ export interface IDevicesRevokeResult {
|
|
|
70
88
|
readonly name: string;
|
|
71
89
|
}
|
|
72
90
|
|
|
91
|
+
/** This session's endpoint in the device mesh. */
|
|
92
|
+
export interface IDevicesMeshStatus {
|
|
93
|
+
/** `off`: the setting is off, or this run does not open the mesh. `failed`: it could not open. */
|
|
94
|
+
readonly state: 'off' | 'starting' | 'on' | 'failed';
|
|
95
|
+
/** Why it could not open, for `failed`. */
|
|
96
|
+
readonly reason?: string;
|
|
97
|
+
/** How this device looks for the others, in the operator's words. */
|
|
98
|
+
readonly sources: readonly string[];
|
|
99
|
+
/** The devices with an admitted link now. */
|
|
100
|
+
readonly linked: readonly {
|
|
101
|
+
readonly deviceId: string;
|
|
102
|
+
readonly name?: string;
|
|
103
|
+
readonly locality: 'same-host' | 'another-host';
|
|
104
|
+
}[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface IDevicesAddResult {
|
|
108
|
+
readonly deviceId: string;
|
|
109
|
+
readonly name: string;
|
|
110
|
+
/** Whether the new device said it kept what it was given. */
|
|
111
|
+
readonly confirmed: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface IDevicesJoinResult {
|
|
115
|
+
readonly userId: string;
|
|
116
|
+
readonly deviceId: string;
|
|
117
|
+
readonly name: string;
|
|
118
|
+
/** Where the private keys were kept, for the operator. */
|
|
119
|
+
readonly keyStorage?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
73
122
|
export interface IDevicesCommandPort {
|
|
74
123
|
/** This device's view of the roster, or `undefined` when it has no identity yet. */
|
|
75
124
|
list(): Promise<IDevicesView | undefined>;
|
|
125
|
+
/** The device mesh as this session runs it. Absent on a host with no device mesh. */
|
|
126
|
+
meshStatus?(): IDevicesMeshStatus;
|
|
76
127
|
/** Create the identity. Runs on the operator terminal (it shows the phrase once). */
|
|
77
128
|
init(options: { readonly name?: string }): Promise<TDevicesOutcome<IDevicesInitResult>>;
|
|
78
129
|
/** Rotate the signing key from the phrase. Runs on the operator terminal. */
|
|
79
130
|
recover(): Promise<TDevicesOutcome<IDevicesRecoverResult>>;
|
|
80
131
|
/** Revoke a device by id prefix with the signing key. Confirms on the operator terminal. */
|
|
81
132
|
revoke(deviceIdPrefix: string): Promise<TDevicesOutcome<IDevicesRevokeResult>>;
|
|
133
|
+
/**
|
|
134
|
+
* Enrol another device with the signing key: show a one-time code, and certify the device that
|
|
135
|
+
* proves it once both operators confirm the string both devices show. Runs on the operator terminal.
|
|
136
|
+
*/
|
|
137
|
+
add(): Promise<TDevicesOutcome<IDevicesAddResult>>;
|
|
138
|
+
/**
|
|
139
|
+
* Join the user's devices with a code another device shows, once both operators confirm the string
|
|
140
|
+
* both devices show. Runs on the operator terminal.
|
|
141
|
+
*/
|
|
142
|
+
join(options: { readonly name?: string }): Promise<TDevicesOutcome<IDevicesJoinResult>>;
|
|
82
143
|
}
|
package/src/devices/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Node defaults for {@link IDoctorDeps}: a TCP connect probe and read-only filesystem facts. */
|
|
2
|
-
import { accessSync, constants, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { accessSync, constants, existsSync, statSync, type Stats } from 'node:fs';
|
|
3
3
|
import { createConnection } from 'node:net';
|
|
4
4
|
import { delimiter, isAbsolute, join } from 'node:path';
|
|
5
5
|
|
|
@@ -41,16 +41,15 @@ export function probeEndpointViaSocket(
|
|
|
41
41
|
/** Existence, directory-ness, `W_OK` and mode — probed without writing. */
|
|
42
42
|
export function inspectPathFacts(path: string): IDoctorPathFacts {
|
|
43
43
|
if (!existsSync(path)) return { exists: false, isDirectory: false, writable: false };
|
|
44
|
-
let
|
|
45
|
-
let mode: number | undefined;
|
|
44
|
+
let stat: Stats;
|
|
46
45
|
try {
|
|
47
|
-
|
|
48
|
-
isDirectory = stat.isDirectory();
|
|
49
|
-
mode = stat.mode & MODE_BITS;
|
|
46
|
+
stat = statSync(path);
|
|
50
47
|
} catch {
|
|
51
48
|
// allow-fallback: a path that exists but cannot be stat'ed is reported as not a writable directory
|
|
52
49
|
return { exists: true, isDirectory: false, writable: false };
|
|
53
50
|
}
|
|
51
|
+
const isDirectory = stat.isDirectory();
|
|
52
|
+
const mode = stat.mode & MODE_BITS;
|
|
54
53
|
let writable = false;
|
|
55
54
|
try {
|
|
56
55
|
accessSync(path, constants.W_OK);
|
|
@@ -59,7 +58,7 @@ export function inspectPathFacts(path: string): IDoctorPathFacts {
|
|
|
59
58
|
// allow-fallback: W_OK refused IS the fact being reported
|
|
60
59
|
writable = false;
|
|
61
60
|
}
|
|
62
|
-
return { exists: true, isDirectory, writable,
|
|
61
|
+
return { exists: true, isDirectory, writable, mode };
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
/** A bare command resolves through `PATH`; a path-form command must exist as given. */
|
|
@@ -14,6 +14,9 @@ export function createEditorCommandEntry(): ICommand {
|
|
|
14
14
|
source: 'editor',
|
|
15
15
|
// User-only: opens the user's terminal editor; UI-only.
|
|
16
16
|
modelInvocable: false,
|
|
17
|
+
// It takes over the terminal the user sits at, so that terminal runs it, even when attached.
|
|
18
|
+
runner: 'client',
|
|
19
|
+
surfaces: ['terminal'],
|
|
17
20
|
};
|
|
18
21
|
}
|
|
19
22
|
|
|
@@ -26,6 +29,8 @@ function createEditorSystemCommand(temporaryDirectoryPrefix?: string): ISystemCo
|
|
|
26
29
|
requiresPermission: false,
|
|
27
30
|
userInvocable: true,
|
|
28
31
|
modelInvocable: false,
|
|
32
|
+
runner: entry.runner,
|
|
33
|
+
surfaces: entry.surfaces,
|
|
29
34
|
lifecycle: 'inline',
|
|
30
35
|
execute: (context, args) => executeEditorCommand(context, args, temporaryDirectoryPrefix),
|
|
31
36
|
};
|
|
@@ -5,6 +5,37 @@ import { executeEffortCommand } from './effort-command.js';
|
|
|
5
5
|
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
6
6
|
|
|
7
7
|
describe('/effort', () => {
|
|
8
|
+
it('#3282 §2: reports plainly when the model has no matching effort control at all', async () => {
|
|
9
|
+
const host = createTestCommandHost({
|
|
10
|
+
overrides: {
|
|
11
|
+
getCommandHostAdapters: () => ({
|
|
12
|
+
settings: { read: () => ({}), write: vi.fn() },
|
|
13
|
+
effort: {
|
|
14
|
+
getResolution: () => ({
|
|
15
|
+
requested: 'low',
|
|
16
|
+
effective: 'low',
|
|
17
|
+
source: 'command',
|
|
18
|
+
disposition: 'not-applied',
|
|
19
|
+
modelDefault: 'high',
|
|
20
|
+
}),
|
|
21
|
+
apply: async () => ({
|
|
22
|
+
requested: 'low',
|
|
23
|
+
effective: 'low',
|
|
24
|
+
source: 'command',
|
|
25
|
+
disposition: 'not-applied',
|
|
26
|
+
modelDefault: 'high',
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const result = await executeEffortCommand(host, 'low');
|
|
34
|
+
|
|
35
|
+
expect(result.success).toBe(true);
|
|
36
|
+
expect(result.message).toBe("This model doesn't support effort levels, so it will use its default.");
|
|
37
|
+
});
|
|
38
|
+
|
|
8
39
|
it('applies a requested level and returns the complete resolution', async () => {
|
|
9
40
|
const applyModelOptions = vi.fn();
|
|
10
41
|
const writeSettings = vi.fn();
|
|
@@ -36,6 +67,7 @@ describe('/effort', () => {
|
|
|
36
67
|
const result = await executeEffortCommand(host, 'low');
|
|
37
68
|
|
|
38
69
|
expect(result.success).toBe(true);
|
|
70
|
+
expect(result.message).toBe('Effort: Low');
|
|
39
71
|
expect(result.data).toMatchObject({
|
|
40
72
|
effort: { requested: 'low', effective: 'low', source: 'command', disposition: 'applied' },
|
|
41
73
|
});
|
|
@@ -50,7 +82,7 @@ describe('/effort', () => {
|
|
|
50
82
|
const result = await executeEffortCommand(host, '');
|
|
51
83
|
|
|
52
84
|
expect(result.success).toBe(true);
|
|
53
|
-
expect(result.message).
|
|
85
|
+
expect(result.message).toBe('Effort: High');
|
|
54
86
|
expect(applyModelOptions).not.toHaveBeenCalled();
|
|
55
87
|
});
|
|
56
88
|
|
|
@@ -133,8 +165,39 @@ describe('/effort', () => {
|
|
|
133
165
|
const result = await executeEffortCommand(host, '');
|
|
134
166
|
|
|
135
167
|
expect(result.success).toBe(true);
|
|
136
|
-
expect(result.message).
|
|
168
|
+
expect(result.message).toBe('Effort: Medium');
|
|
137
169
|
expect(applyModelOptions).not.toHaveBeenCalled();
|
|
138
170
|
expect(writeSettings).not.toHaveBeenCalled();
|
|
139
171
|
});
|
|
172
|
+
|
|
173
|
+
it('#3282 §2: uses the full plain word for xhigh, not the internal id', async () => {
|
|
174
|
+
const host = createTestCommandHost({
|
|
175
|
+
overrides: {
|
|
176
|
+
getCommandHostAdapters: () => ({
|
|
177
|
+
settings: { read: () => ({}), write: vi.fn() },
|
|
178
|
+
effort: {
|
|
179
|
+
getResolution: () => ({
|
|
180
|
+
requested: 'auto',
|
|
181
|
+
effective: 'high',
|
|
182
|
+
source: 'model-default',
|
|
183
|
+
disposition: 'model-default',
|
|
184
|
+
modelDefault: 'high',
|
|
185
|
+
}),
|
|
186
|
+
apply: async (selection) => ({
|
|
187
|
+
requested: selection,
|
|
188
|
+
effective: selection === 'auto' ? 'high' : selection,
|
|
189
|
+
source: 'command',
|
|
190
|
+
disposition: 'applied',
|
|
191
|
+
modelDefault: 'high',
|
|
192
|
+
}),
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const result = await executeEffortCommand(host, 'xhigh');
|
|
199
|
+
|
|
200
|
+
expect(result.success).toBe(true);
|
|
201
|
+
expect(result.message).toBe('Effort: Extra high');
|
|
202
|
+
});
|
|
140
203
|
});
|