@robota-sdk/agent-command 3.0.0-beta.82 → 3.0.0-beta.83
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 +1044 -0
- package/dist/node/index.cjs +76 -70
- package/dist/node/index.d.cts +100 -8
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +100 -8
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +76 -70
- package/dist/node/index.js.map +1 -1
- package/package.json +14 -9
- package/src/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +2 -0
- package/src/default/__tests__/default-command-modules.test.ts +50 -11
- package/src/default/__tests__/model-exposure.test.ts +16 -3
- package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
- package/src/default/default-command-modules.ts +7 -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/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__/git-command-module.test.ts +10 -2
- 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 +8 -0
- package/src/keybindings/keybindings-command-module.ts +17 -3
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +41 -0
- package/src/mcp-activation/mcp-activation-command.ts +8 -3
- 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/rewind/__tests__/rewind-command-module.test.ts +31 -1
- package/src/rewind/rewind-command.ts +23 -2
- package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
- package/src/schedule/loop-command.ts +3 -1
- 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
|
@@ -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
|
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { executeEventsCommand } from '../events-command.js';
|
|
4
|
+
import { createEventsCommandModule } from '../events-command-module.js';
|
|
5
|
+
|
|
6
|
+
import type { ICommandExternalEventsAdapter } from '@robota-sdk/agent-framework';
|
|
7
|
+
|
|
8
|
+
function adapter(): ICommandExternalEventsAdapter & { revoke: ReturnType<typeof vi.fn> } {
|
|
9
|
+
return {
|
|
10
|
+
list: () => [
|
|
11
|
+
{
|
|
12
|
+
grantId: 'ci',
|
|
13
|
+
principal: 'client',
|
|
14
|
+
state: 'open',
|
|
15
|
+
counters: { accepted: 2, refused: { expired: 1 }, settled: { completed: 2 } },
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
revoke: vi.fn((grantId: string) => (grantId === 'ci' ? 'revoked' : 'unknown-grant')),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const context = (externalEvents?: ICommandExternalEventsAdapter) => ({
|
|
23
|
+
getCommandHostAdapters: () => (externalEvents ? { externalEvents } : {}),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('/events command module', () => {
|
|
27
|
+
it('is user-only: never model-invocable, and palette metadata matches the executable', () => {
|
|
28
|
+
const module = createEventsCommandModule();
|
|
29
|
+
const palette = module.commandSources?.[0]?.getCommands()[0];
|
|
30
|
+
const executable = module.systemCommands?.[0];
|
|
31
|
+
expect(palette?.name).toBe('events');
|
|
32
|
+
expect(executable?.modelInvocable).toBe(false);
|
|
33
|
+
expect(palette?.modelInvocable).toBe(false);
|
|
34
|
+
expect(executable?.userInvocable).toBe(true);
|
|
35
|
+
expect(executable?.description).toBe(palette?.description);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('tells the model what it does, what it returns, that it is user-only, and what to suggest', () => {
|
|
39
|
+
const description =
|
|
40
|
+
createEventsCommandModule().commandSources?.[0]?.getCommands()[0]?.description;
|
|
41
|
+
expect(description).toMatch(/external event grants/i);
|
|
42
|
+
expect(description).toMatch(/returns/i);
|
|
43
|
+
expect(description).toMatch(/user-only/i);
|
|
44
|
+
expect(description).toContain('`/events revoke <grant-id>`');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('/events', () => {
|
|
49
|
+
it('lists grants with their principal kind and counts', async () => {
|
|
50
|
+
const result = await executeEventsCommand(context(adapter()), '');
|
|
51
|
+
expect(result.success).toBe(true);
|
|
52
|
+
expect(result.message).toContain(
|
|
53
|
+
'ci client open accepted 2; refused: expired 1; settled: completed 2',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('revokes one grant by label and says when the label is unknown', async () => {
|
|
58
|
+
const events = adapter();
|
|
59
|
+
expect((await executeEventsCommand(context(events), 'revoke ci')).message).toMatch(
|
|
60
|
+
/Revoked external event grant ci/,
|
|
61
|
+
);
|
|
62
|
+
expect(events.revoke).toHaveBeenCalledWith('ci');
|
|
63
|
+
const unknown = await executeEventsCommand(context(events), 'revoke nope');
|
|
64
|
+
expect(unknown.success).toBe(false);
|
|
65
|
+
expect(unknown.message).toMatch(/no external event grant nope/);
|
|
66
|
+
expect((await executeEventsCommand(context(events), 'revoke')).success).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('says how grants are given when the session holds none', async () => {
|
|
70
|
+
const result = await executeEventsCommand(context(), '');
|
|
71
|
+
expect(result.message).toMatch(/only when a session starts/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { executeEventsCommand } from './events-command.js';
|
|
2
|
+
|
|
3
|
+
import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
|
|
4
|
+
import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
|
|
5
|
+
|
|
6
|
+
export function createEventsCommandEntry(): ICommand {
|
|
7
|
+
return {
|
|
8
|
+
name: 'events',
|
|
9
|
+
displayName: 'Events',
|
|
10
|
+
description:
|
|
11
|
+
"List this session's external event grants (each grant's label, whether it pins a subject or a client, whether it is open or revoked, and its counts of accepted, refused and settled events), or revoke one with `/events revoke <grant-id>`. Returns the grants, or whether the revocation took effect. User-only: the model cannot run it; when the user wants to stop events from outside, suggest `/events revoke <grant-id>`.",
|
|
12
|
+
source: 'events',
|
|
13
|
+
argumentHint: '[revoke <grant-id>]',
|
|
14
|
+
// User-only: a grant is the owner's standing decision that someone outside may put turns into
|
|
15
|
+
// this session. Withdrawing it is the owner's call too, and listing it lives on the same command.
|
|
16
|
+
modelInvocable: false,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createEventsSystemCommand(): ISystemCommand {
|
|
21
|
+
const entry = createEventsCommandEntry();
|
|
22
|
+
return {
|
|
23
|
+
name: entry.name,
|
|
24
|
+
displayName: entry.displayName,
|
|
25
|
+
description: entry.description,
|
|
26
|
+
requiresPermission: false,
|
|
27
|
+
userInvocable: true,
|
|
28
|
+
modelInvocable: false,
|
|
29
|
+
...(entry.argumentHint !== undefined ? { argumentHint: entry.argumentHint } : {}),
|
|
30
|
+
lifecycle: 'inline',
|
|
31
|
+
execute: (context, args) => executeEventsCommand(context, args),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class EventsCommandSource implements ICommandSource {
|
|
36
|
+
readonly name = 'events';
|
|
37
|
+
|
|
38
|
+
getCommands(): ICommand[] {
|
|
39
|
+
return [createEventsCommandEntry()];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createEventsCommandModule(): ICommandModule {
|
|
44
|
+
return {
|
|
45
|
+
name: 'agent-command-events',
|
|
46
|
+
commandSources: [new EventsCommandSource()],
|
|
47
|
+
systemCommands: [createEventsSystemCommand()],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ICommandExternalEventGrant,
|
|
3
|
+
ICommandHostAdapterAccess,
|
|
4
|
+
} from '@robota-sdk/agent-framework';
|
|
5
|
+
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
6
|
+
|
|
7
|
+
const GRANT_ID = /^[a-zA-Z0-9_-]{1,64}$/u;
|
|
8
|
+
|
|
9
|
+
function counts(values: Readonly<Partial<Record<string, number>>>): string {
|
|
10
|
+
const entries = Object.entries(values).filter(([, count]) => (count ?? 0) > 0);
|
|
11
|
+
return entries.length === 0
|
|
12
|
+
? 'none'
|
|
13
|
+
: entries.map(([key, count]) => `${key} ${count}`).join(', ');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Label, principal kind, state and counts. The principal itself is never shown. */
|
|
17
|
+
function describe(grant: ICommandExternalEventGrant): string {
|
|
18
|
+
const { counters } = grant;
|
|
19
|
+
return (
|
|
20
|
+
` ${grant.grantId} ${grant.principal} ${grant.state} accepted ${counters.accepted}; ` +
|
|
21
|
+
`refused: ${counts(counters.refused)}; settled: ${counts(counters.settled)}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `/events` — this session's external-event grants, and `/events revoke <grant-id>` to withdraw one.
|
|
27
|
+
* Grants are given only when the session starts; nothing here creates one.
|
|
28
|
+
*/
|
|
29
|
+
export async function executeEventsCommand(
|
|
30
|
+
context: ICommandHostAdapterAccess,
|
|
31
|
+
args = '',
|
|
32
|
+
): Promise<ICommandResult> {
|
|
33
|
+
const adapter = context.getCommandHostAdapters?.().externalEvents;
|
|
34
|
+
if (!adapter) {
|
|
35
|
+
return {
|
|
36
|
+
message:
|
|
37
|
+
'This session holds no external event grant. Grants are given only when a session starts.',
|
|
38
|
+
success: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const [verb, grantId, extra] = args
|
|
42
|
+
.trim()
|
|
43
|
+
.split(/\s+/u)
|
|
44
|
+
.filter((word) => word !== '');
|
|
45
|
+
if (verb === undefined) {
|
|
46
|
+
const grants = adapter.list();
|
|
47
|
+
return {
|
|
48
|
+
message:
|
|
49
|
+
grants.length === 0
|
|
50
|
+
? 'This session holds no external event grant.'
|
|
51
|
+
: `External event grants:\n${grants.map(describe).join('\n')}\n\nWithdraw one: /events revoke <grant-id>`,
|
|
52
|
+
success: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
verb !== 'revoke' ||
|
|
57
|
+
grantId === undefined ||
|
|
58
|
+
extra !== undefined ||
|
|
59
|
+
!GRANT_ID.test(grantId)
|
|
60
|
+
) {
|
|
61
|
+
return { message: 'Usage: /events | /events revoke <grant-id>', success: false };
|
|
62
|
+
}
|
|
63
|
+
return adapter.revoke(grantId) === 'revoked'
|
|
64
|
+
? {
|
|
65
|
+
message: `Revoked external event grant ${grantId}. Later events for it are refused.`,
|
|
66
|
+
success: true,
|
|
67
|
+
}
|
|
68
|
+
: { message: `This session holds no external event grant ${grantId}.`, success: false };
|
|
69
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** BEHAVIOR-2437 TC-05 — registration, the descriptor, and the verb parse through the real session. */
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
3
7
|
|
|
4
8
|
import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-framework/testing';
|
|
5
9
|
|
|
@@ -35,7 +39,11 @@ const providerSettingsAdapter: IProviderCommandSettingsAdapter = {
|
|
|
35
39
|
readTargetSettings: () => ({}) as TProviderSettingsDocument,
|
|
36
40
|
writeTargetSettings: () => undefined,
|
|
37
41
|
};
|
|
38
|
-
|
|
42
|
+
/** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
|
|
43
|
+
const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
|
|
44
|
+
afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
|
|
45
|
+
|
|
46
|
+
const BASE_OPTIONS = { cwd: '/tmp', userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT, providerDefinitions, providerSettingsAdapter } as const;
|
|
39
47
|
|
|
40
48
|
const STATUS_FIXTURE = '# branch.head main\0? scratch.log\0';
|
|
41
49
|
|
|
@@ -17,7 +17,8 @@ describe('/handoff command module', () => {
|
|
|
17
17
|
it('tells the model what it does and which command to suggest, since it cannot run it', () => {
|
|
18
18
|
const description =
|
|
19
19
|
createHandoffCommandModule().commandSources?.[0]?.getCommands()[0]?.description;
|
|
20
|
-
expect(description).toContain('/handoff <session-id>');
|
|
20
|
+
expect(description).toContain('/handoff <session-or-device-id>');
|
|
21
|
+
expect(description).toMatch(/linked over the device mesh/);
|
|
21
22
|
expect(description).toMatch(/user-only/i);
|
|
22
23
|
expect(description).toContain('saved, not started');
|
|
23
24
|
});
|
|
@@ -8,7 +8,7 @@ export function createHandoffCommandEntry(): ICommand {
|
|
|
8
8
|
name: 'handoff',
|
|
9
9
|
displayName: 'Hand off',
|
|
10
10
|
description:
|
|
11
|
-
|
|
11
|
+
"Push this conversation to another running Robota session of the same user on this machine, or to another of the user's devices linked over the device mesh, after the operator confirms what stays behind (uncommitted changes, running processes; credentials never travel). The receiving operator must also accept; the session arrives saved, not started, and this one ends once it is saved there. With no argument it lists the sessions and devices it could go to. User-only: the model cannot run it; when the user wants to continue this work in another session or on another device, suggest they run `/handoff <session-or-device-id>`.",
|
|
12
12
|
source: 'handoff',
|
|
13
13
|
// User-only: a hand-off moves authority over the operator's work to another place, a decision
|
|
14
14
|
// about where the person is, not about the task.
|
|
@@ -47,8 +47,8 @@ function usage(): ICommandResult {
|
|
|
47
47
|
success: true,
|
|
48
48
|
message: [
|
|
49
49
|
'Usage:',
|
|
50
|
-
' /handoff
|
|
51
|
-
' /handoff <session-id> move it there, after confirming what stays behind',
|
|
50
|
+
' /handoff list where this session could move to',
|
|
51
|
+
' /handoff <session-or-device-id> move it there, after confirming what stays behind',
|
|
52
52
|
].join('\n'),
|
|
53
53
|
};
|
|
54
54
|
}
|