@robota-sdk/agent-command 3.0.0-beta.100

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.
Files changed (251) hide show
  1. package/CHANGELOG.md +1351 -0
  2. package/LICENSE +661 -0
  3. package/README.md +100 -0
  4. package/dist/node/index.cjs +121 -0
  5. package/dist/node/index.d.cts +1053 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1053 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +122 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +83 -0
  12. package/src/advisor/advisor-command-module.ts +47 -0
  13. package/src/advisor/advisor-command.test.ts +102 -0
  14. package/src/advisor/advisor-command.ts +55 -0
  15. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  16. package/src/advisor/index.ts +6 -0
  17. package/src/agent/__tests__/agent-command.test.ts +597 -0
  18. package/src/agent/agent-command-module.ts +89 -0
  19. package/src/agent/agent-command-parser.ts +180 -0
  20. package/src/agent/agent-command.ts +255 -0
  21. package/src/agent/index.ts +7 -0
  22. package/src/background/__tests__/background-command-module.test.ts +261 -0
  23. package/src/background/background-command-module.ts +52 -0
  24. package/src/background/background-command.ts +65 -0
  25. package/src/background/index.ts +6 -0
  26. package/src/command-module-utils.test.ts +37 -0
  27. package/src/command-module-utils.ts +35 -0
  28. package/src/compact/__tests__/compact-command-module.test.ts +181 -0
  29. package/src/compact/compact-command-module.ts +59 -0
  30. package/src/compact/compact-command.ts +33 -0
  31. package/src/compact/index.ts +6 -0
  32. package/src/context/__tests__/context-command-module.test.ts +502 -0
  33. package/src/context/auto-compact-format.ts +25 -0
  34. package/src/context/context-breakdown.ts +295 -0
  35. package/src/context/context-command-module.ts +82 -0
  36. package/src/context/context-command.ts +291 -0
  37. package/src/context/index.ts +6 -0
  38. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
  39. package/src/default/__tests__/default-command-modules.test.ts +192 -0
  40. package/src/default/__tests__/model-exposure.test.ts +269 -0
  41. package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
  42. package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
  43. package/src/default/default-command-modules.ts +247 -0
  44. package/src/default/index.ts +5 -0
  45. package/src/devices/__tests__/devices-command-module.test.ts +299 -0
  46. package/src/devices/devices-command-module.ts +308 -0
  47. package/src/devices/devices-command-port.ts +143 -0
  48. package/src/devices/index.ts +14 -0
  49. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  50. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  51. package/src/doctor/__tests__/doctor-fixture.ts +146 -0
  52. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  53. package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
  54. package/src/doctor/doctor-command-module.ts +132 -0
  55. package/src/doctor/doctor-extensions-probe.ts +323 -0
  56. package/src/doctor/doctor-node-deps.ts +88 -0
  57. package/src/doctor/doctor-redaction.ts +99 -0
  58. package/src/doctor/doctor-render.ts +70 -0
  59. package/src/doctor/doctor-repair.ts +136 -0
  60. package/src/doctor/doctor-runner.ts +122 -0
  61. package/src/doctor/doctor-settings-probe.ts +239 -0
  62. package/src/doctor/doctor-storage-probe.ts +187 -0
  63. package/src/doctor/doctor-types.ts +109 -0
  64. package/src/doctor/index.ts +32 -0
  65. package/src/editor/__tests__/editor-command-functional.test.ts +124 -0
  66. package/src/editor/editor-command-module.ts +53 -0
  67. package/src/editor/editor-command.ts +58 -0
  68. package/src/editor/index.ts +7 -0
  69. package/src/editor/resolve-editor.ts +21 -0
  70. package/src/effort/effort-command-module.ts +51 -0
  71. package/src/effort/effort-command.test.ts +203 -0
  72. package/src/effort/effort-command.ts +136 -0
  73. package/src/effort/index.ts +6 -0
  74. package/src/events/__tests__/events-command.test.ts +73 -0
  75. package/src/events/events-command-module.ts +49 -0
  76. package/src/events/events-command.ts +69 -0
  77. package/src/events/index.ts +2 -0
  78. package/src/exit/__tests__/exit-command-module.test.ts +61 -0
  79. package/src/exit/exit-command-module.ts +47 -0
  80. package/src/exit/exit-command.ts +26 -0
  81. package/src/exit/index.ts +6 -0
  82. package/src/fork/__tests__/fork-command.test.ts +184 -0
  83. package/src/fork/fork-command-module.ts +61 -0
  84. package/src/fork/fork-command.ts +162 -0
  85. package/src/fork/index.ts +8 -0
  86. package/src/git/__tests__/fake-git-port.ts +35 -0
  87. package/src/git/__tests__/git-command-module.test.ts +111 -0
  88. package/src/git/__tests__/git-commit.test.ts +185 -0
  89. package/src/git/git-command-module.ts +138 -0
  90. package/src/git/git-commit.ts +254 -0
  91. package/src/git/index.ts +20 -0
  92. package/src/goal/__tests__/goal-command.test.ts +80 -0
  93. package/src/goal/goal-command-module.ts +49 -0
  94. package/src/goal/goal-command.ts +69 -0
  95. package/src/goal/index.ts +6 -0
  96. package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
  97. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  98. package/src/handoff/handoff-command-module.ts +49 -0
  99. package/src/handoff/handoff-command.ts +187 -0
  100. package/src/handoff/index.ts +6 -0
  101. package/src/help/__tests__/help-command-module.test.ts +134 -0
  102. package/src/help/__tests__/help-command.test.ts +130 -0
  103. package/src/help/help-command-module.ts +47 -0
  104. package/src/help/help-command.ts +11 -0
  105. package/src/help/index.ts +6 -0
  106. package/src/index.ts +163 -0
  107. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  108. package/src/keybindings/index.ts +6 -0
  109. package/src/keybindings/keybindings-command-module.ts +88 -0
  110. package/src/language/__tests__/language-command-module.test.ts +126 -0
  111. package/src/language/index.ts +6 -0
  112. package/src/language/language-command-module.ts +55 -0
  113. package/src/language/language-command.ts +52 -0
  114. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
  115. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
  116. package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
  117. package/src/mcp-activation/index.ts +12 -0
  118. package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
  119. package/src/mcp-activation/mcp-activation-command.ts +662 -0
  120. package/src/mcp-activation/mcp-model-notice.ts +99 -0
  121. package/src/mcp-activation/mcp-model-status.ts +104 -0
  122. package/src/memory/__tests__/memory-command-module.test.ts +476 -0
  123. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  124. package/src/memory/index.ts +6 -0
  125. package/src/memory/memory-command-module.ts +77 -0
  126. package/src/memory/memory-command.ts +326 -0
  127. package/src/mode/__tests__/mode-command-module.test.ts +230 -0
  128. package/src/mode/index.ts +6 -0
  129. package/src/mode/mode-command-module.ts +55 -0
  130. package/src/mode/mode-command.ts +67 -0
  131. package/src/model/__tests__/model-command-module.test.ts +80 -0
  132. package/src/model/__tests__/model-command.test.ts +265 -0
  133. package/src/model/index.ts +6 -0
  134. package/src/model/model-command-module.ts +56 -0
  135. package/src/model/model-command.ts +249 -0
  136. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  137. package/src/output-style/index.ts +6 -0
  138. package/src/output-style/output-style-command-module.ts +143 -0
  139. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  140. package/src/peers/__tests__/peers-command.test.ts +399 -0
  141. package/src/peers/index.ts +9 -0
  142. package/src/peers/peers-command-module.ts +49 -0
  143. package/src/peers/peers-command.ts +220 -0
  144. package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
  145. package/src/permissions/index.ts +6 -0
  146. package/src/permissions/permissions-command-module.ts +55 -0
  147. package/src/permissions/permissions-command.ts +89 -0
  148. package/src/plan/__tests__/plan-command.test.ts +129 -0
  149. package/src/plan/index.ts +9 -0
  150. package/src/plan/plan-command-module.ts +49 -0
  151. package/src/plan/plan-command.ts +92 -0
  152. package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
  153. package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
  154. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  155. package/src/plugin/index.ts +7 -0
  156. package/src/plugin/plugin-command-module.ts +85 -0
  157. package/src/plugin/plugin-command.ts +271 -0
  158. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  159. package/src/preset/__tests__/preset-command-module.test.ts +220 -0
  160. package/src/preset/index.ts +6 -0
  161. package/src/preset/preset-command-module.ts +80 -0
  162. package/src/preset/preset-command.ts +148 -0
  163. package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
  164. package/src/provider/__tests__/org-policy.test.ts +174 -0
  165. package/src/provider/__tests__/provider-command-module.test.ts +884 -0
  166. package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
  167. package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
  168. package/src/provider/__tests__/provider-setup-flow.test.ts +244 -0
  169. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  170. package/src/provider/__tests__/scripted-interaction.ts +40 -0
  171. package/src/provider/index.ts +32 -0
  172. package/src/provider/provider-command-execution.ts +235 -0
  173. package/src/provider/provider-command-module.ts +82 -0
  174. package/src/provider/provider-command-profile-lifecycle.ts +239 -0
  175. package/src/provider/provider-command-profile-operations.ts +218 -0
  176. package/src/provider/provider-command-profile.ts +116 -0
  177. package/src/provider/provider-command-setup.ts +79 -0
  178. package/src/provider/provider-connection-setup.ts +287 -0
  179. package/src/provider/provider-connection-validation.ts +38 -0
  180. package/src/provider/provider-onboarding.ts +73 -0
  181. package/src/provider/provider-prompt-interaction.ts +28 -0
  182. package/src/provider/provider-setup-ask.ts +67 -0
  183. package/src/provider/provider-setup-flow.ts +326 -0
  184. package/src/provider/provider-startup.ts +225 -0
  185. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  186. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  187. package/src/remote-control/index.ts +8 -0
  188. package/src/remote-control/remote-control-command-module.ts +61 -0
  189. package/src/remote-control/remote-control-command.ts +138 -0
  190. package/src/reset/__tests__/reset-command-module.test.ts +63 -0
  191. package/src/reset/index.ts +2 -0
  192. package/src/reset/reset-command-module.ts +47 -0
  193. package/src/reset/reset-command.ts +10 -0
  194. package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
  195. package/src/rewind/index.ts +2 -0
  196. package/src/rewind/rewind-command-module.ts +56 -0
  197. package/src/rewind/rewind-command.ts +279 -0
  198. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  199. package/src/sandbox/index.ts +2 -0
  200. package/src/sandbox/sandbox-command-module.ts +56 -0
  201. package/src/sandbox/sandbox-command.ts +94 -0
  202. package/src/schedule/__tests__/loop-command-functional.test.ts +399 -0
  203. package/src/schedule/__tests__/loop-command.test.ts +594 -0
  204. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  205. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  206. package/src/schedule/__tests__/schedule-command.test.ts +247 -0
  207. package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
  208. package/src/schedule/index.ts +12 -0
  209. package/src/schedule/loop-command.ts +427 -0
  210. package/src/schedule/loop-jitter.ts +41 -0
  211. package/src/schedule/schedule-command-module.ts +184 -0
  212. package/src/schedule/schedule-command.ts +161 -0
  213. package/src/schedule/schedule-spec-parser.ts +70 -0
  214. package/src/session/__tests__/model-pricing.test.ts +66 -0
  215. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  216. package/src/session/__tests__/session-command-module.test.ts +589 -0
  217. package/src/session/index.ts +17 -0
  218. package/src/session/model-pricing.ts +26 -0
  219. package/src/session/session-command-module.ts +197 -0
  220. package/src/session/session-command.ts +272 -0
  221. package/src/settings/index.ts +7 -0
  222. package/src/settings/settings-command-module.ts +47 -0
  223. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  224. package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
  225. package/src/shell/index.ts +8 -0
  226. package/src/shell/resolve-shell.ts +25 -0
  227. package/src/shell/shell-command-module.ts +53 -0
  228. package/src/shell/shell-command.ts +48 -0
  229. package/src/shell/spawn-inherited.ts +32 -0
  230. package/src/skills/__tests__/skill-read-command.test.ts +64 -0
  231. package/src/skills/__tests__/skills-command-module.test.ts +200 -0
  232. package/src/skills/index.ts +7 -0
  233. package/src/skills/skill-read-command.ts +60 -0
  234. package/src/skills/skills-command-module.ts +76 -0
  235. package/src/skills/skills-command.ts +108 -0
  236. package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
  237. package/src/statusline/index.ts +6 -0
  238. package/src/statusline/statusline-command-module.ts +55 -0
  239. package/src/statusline/statusline-command.ts +80 -0
  240. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  241. package/src/terminal-client/index.ts +5 -0
  242. package/src/terminal-client/terminal-client-commands.ts +109 -0
  243. package/src/theme/__tests__/theme-command.test.ts +204 -0
  244. package/src/theme/index.ts +12 -0
  245. package/src/theme/theme-command-module.ts +221 -0
  246. package/src/user-local/__tests__/user-local-command.test.ts +180 -0
  247. package/src/user-local/index.ts +13 -0
  248. package/src/user-local/user-local-command-constants.ts +5 -0
  249. package/src/user-local/user-local-command-module.ts +65 -0
  250. package/src/user-local/user-local-command.ts +214 -0
  251. package/src/user-local/user-local-memory-command.ts +160 -0
@@ -0,0 +1,299 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-framework/testing';
4
+
5
+ import { createDevicesCommandModule } from '../devices-command-module.js';
6
+
7
+ import type { IDevicesCommandPort } from '../devices-command-port.js';
8
+ import type { ITerminalHandoff } from '@robota-sdk/agent-interface-session';
9
+
10
+ const DEVICE_A = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
11
+ const DEVICE_B = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
12
+ const USER = 'UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU';
13
+ const SIGNING = 'SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS';
14
+
15
+ function fakePort(): IDevicesCommandPort & { calls: string[] } {
16
+ const calls: string[] = [];
17
+ return {
18
+ calls,
19
+ list: vi.fn(async () => {
20
+ calls.push('list');
21
+ return {
22
+ userId: USER,
23
+ devices: [
24
+ {
25
+ deviceId: DEVICE_A,
26
+ name: 'laptop',
27
+ thisDevice: true,
28
+ holdsSigningKey: true,
29
+ certificateExpiresAt: Date.UTC(2026, 11, 25),
30
+ },
31
+ {
32
+ deviceId: DEVICE_B,
33
+ name: 'desktop',
34
+ thisDevice: false,
35
+ holdsSigningKey: false,
36
+ certificateExpiresAt: Date.UTC(2026, 11, 20),
37
+ },
38
+ ],
39
+ revokedDeviceCount: 1,
40
+ listsExpireAt: Date.UTC(2026, 8, 27),
41
+ };
42
+ }),
43
+ init: vi.fn(async (options) => {
44
+ calls.push(`init:${options.name ?? ''}`);
45
+ return {
46
+ ok: true as const,
47
+ value: {
48
+ userId: USER,
49
+ deviceId: DEVICE_A,
50
+ signingKeyId: SIGNING,
51
+ keyStorage: 'OS keychain',
52
+ },
53
+ };
54
+ }),
55
+ recover: vi.fn(async () => {
56
+ calls.push('recover');
57
+ return {
58
+ ok: true as const,
59
+ value: {
60
+ deviceId: DEVICE_A,
61
+ signingKeyId: SIGNING,
62
+ revokedSigningKeyCount: 1,
63
+ droppedDeviceCount: 1,
64
+ },
65
+ };
66
+ }),
67
+ revoke: vi.fn(async (target) => {
68
+ calls.push(`revoke:${target}`);
69
+ return { ok: true as const, value: { deviceId: DEVICE_B, name: 'desktop' } };
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
+ }),
85
+ };
86
+ }
87
+
88
+ function handoff(enabled: boolean, onRun?: () => void): ITerminalHandoff {
89
+ return {
90
+ canHandoffTerminal: enabled,
91
+ runWithTerminal: async (operation) => {
92
+ onRun?.();
93
+ return operation();
94
+ },
95
+ };
96
+ }
97
+
98
+ let session: ScriptedSessionHarness | undefined;
99
+
100
+ afterEach(async () => {
101
+ await session?.dispose();
102
+ session = undefined;
103
+ });
104
+
105
+ function start(port: IDevicesCommandPort, terminal = handoff(true)): ScriptedSessionHarness {
106
+ session = scriptedSession({
107
+ turns: [{ text: 'unused' }],
108
+ terminalHandoff: terminal,
109
+ commandModules: [createDevicesCommandModule(port)],
110
+ });
111
+ return session;
112
+ }
113
+
114
+ describe('/devices command', () => {
115
+ it('is operator-only: never model-invocable, and palette metadata matches the executable', () => {
116
+ const module = createDevicesCommandModule(fakePort());
117
+ const palette = module.commandSources?.[0]?.getCommands()[0];
118
+ const executable = module.systemCommands?.[0];
119
+ expect(executable?.modelInvocable).toBe(false);
120
+ expect(palette?.modelInvocable).toBe(false);
121
+ expect(executable?.userInvocable).toBe(true);
122
+ expect(palette?.subcommands?.map(({ name }) => name)).toEqual([
123
+ 'list',
124
+ 'init',
125
+ 'revoke',
126
+ 'recover',
127
+ 'add',
128
+ 'join',
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/);
140
+ });
141
+
142
+ it('lists the roster with short ids, this device, the signing-key holder and expiry', async () => {
143
+ const port = fakePort();
144
+ const result = await start(port).command('devices', '');
145
+ expect(result?.success).toBe(true);
146
+ expect(result?.message).toContain(DEVICE_A.slice(0, 10));
147
+ expect(result?.message).not.toContain(DEVICE_A);
148
+ expect(result?.message).toMatch(/laptop.*this device.*signing key/);
149
+ expect(result?.message).toContain('2026-12-25');
150
+ expect(result?.message).toContain('desktop');
151
+ expect(result?.message).toMatch(/1 revoked/);
152
+ });
153
+
154
+ it('says so when this device has no identity yet', async () => {
155
+ const port = fakePort();
156
+ vi.mocked(port.list).mockResolvedValueOnce(undefined);
157
+ const result = await start(port).command('devices', 'list');
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/);
170
+ });
171
+
172
+ it('runs init, recover and revoke on the operator terminal', async () => {
173
+ const port = fakePort();
174
+ const runs = vi.fn();
175
+ const harness = start(port, handoff(true, runs));
176
+ const init = await harness.command('devices', 'init my laptop');
177
+ expect(init?.success).toBe(true);
178
+ expect(init?.message).toContain(DEVICE_A.slice(0, 10));
179
+ expect(init?.message).toContain('OS keychain');
180
+ const recover = await harness.command('devices', 'recover');
181
+ expect(recover?.success).toBe(true);
182
+ expect(recover?.message).toMatch(/signing key/i);
183
+ const revoke = await harness.command('devices', `revoke ${DEVICE_B.slice(0, 8)}`);
184
+ expect(revoke?.success).toBe(true);
185
+ expect(revoke?.message).toContain('desktop');
186
+ expect(port.calls).toEqual(['init:my laptop', 'recover', `revoke:${DEVICE_B.slice(0, 8)}`]);
187
+ expect(runs).toHaveBeenCalledTimes(3);
188
+ });
189
+
190
+ it('reports a refusal by its reason', async () => {
191
+ const port = fakePort();
192
+ vi.mocked(port.recover).mockResolvedValueOnce({ ok: false, reason: 'phrase-mismatch' });
193
+ const result = await start(port).command('devices', 'recover');
194
+ expect(result?.success).toBe(false);
195
+ expect(result?.message).toMatch(/different identity/i);
196
+ });
197
+
198
+ it('refuses without an interactive terminal and never reaches the port (fail closed)', async () => {
199
+ const port = fakePort();
200
+ const harness = start(port, handoff(false));
201
+ for (const args of ['init', 'recover', 'revoke abcdef', 'add', 'join', 'join desktop']) {
202
+ const result = await harness.command('devices', args);
203
+ expect(result?.success).toBe(false);
204
+ expect(result?.message).toMatch(/interactive terminal/i);
205
+ }
206
+ expect(port.calls).toEqual([]);
207
+ });
208
+
209
+ it('refuses a model invocation', async () => {
210
+ const port = fakePort();
211
+ const harness = start(port);
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
+ }
216
+ expect(port.calls).toEqual([]);
217
+ });
218
+
219
+ it('refuses every verb from a remote surface', async () => {
220
+ const port = fakePort();
221
+ const harness = start(port);
222
+ for (const args of ['', 'init', 'recover', 'revoke abcdef', 'add', 'join']) {
223
+ const result = await harness.session.executeCommand('devices', args, 'remote');
224
+ expect(result?.success).toBe(false);
225
+ expect(result?.message).toMatch(/operator/i);
226
+ }
227
+ expect(port.calls).toEqual([]);
228
+ });
229
+
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 () => {
274
+ const port = fakePort();
275
+ const harness = start(port);
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/);
282
+ });
283
+
284
+ it('requires a device id for revoke', async () => {
285
+ const port = fakePort();
286
+ const result = await start(port).command('devices', 'revoke');
287
+ expect(result?.success).toBe(false);
288
+ expect(result?.message).toMatch(/usage/i);
289
+ expect(port.calls).toEqual([]);
290
+ });
291
+
292
+ it('reports a failure without its stack', async () => {
293
+ const port = fakePort();
294
+ vi.mocked(port.init).mockRejectedValueOnce(new Error('credential store unavailable'));
295
+ const result = await start(port).command('devices', 'init');
296
+ expect(result?.success).toBe(false);
297
+ expect(result?.message).toContain('credential store unavailable');
298
+ });
299
+ });
@@ -0,0 +1,308 @@
1
+ /**
2
+ * `/devices` — this device's place in the user's device identity: list the roster, create the
3
+ * identity, enrol another device or join with one, revoke a device, recover from the recovery phrase.
4
+ *
5
+ * Operator-only, twice over: the command is not model-invocable, and it refuses any invocation that
6
+ * is not the operator's own (a remote surface, the model). Every verb that touches a secret or
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 — the enrollment
9
+ * code included, which is why `join` reads it there instead of taking it as an argument. Without an
10
+ * interactive terminal those verbs refuse rather than fall back to anything else.
11
+ */
12
+ import { createSystemCommandFromEntry } from '../command-module-utils.js';
13
+
14
+ import type {
15
+ IDevicesCommandPort,
16
+ IDevicesMeshStatus,
17
+ IDevicesView,
18
+ TDevicesOutcome,
19
+ TDevicesRefusal,
20
+ } from './devices-command-port.js';
21
+ import type {
22
+ ICommandHostContext,
23
+ ICommandModule,
24
+ ISystemCommand,
25
+ } from '@robota-sdk/agent-framework';
26
+ import type { ICommand, ICommandResult, ICommandSource } from '@robota-sdk/agent-interface-command';
27
+
28
+ const SHORT_ID_CHARS = 10;
29
+ const USAGE = 'Usage: /devices [list|init [name]|add|join [name]|revoke <device-id>|recover]';
30
+
31
+ export function createDevicesCommandEntry(): ICommand {
32
+ return {
33
+ name: 'devices',
34
+ displayName: 'Devices',
35
+ description:
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.",
37
+ source: 'devices',
38
+ modelInvocable: false,
39
+ userInvocable: true,
40
+ argumentHint: '[list|init [name]|add|join [name]|revoke <device-id>|recover]',
41
+ subcommands: [
42
+ {
43
+ name: 'list',
44
+ description: 'List your devices and the device mesh status',
45
+ source: 'devices',
46
+ },
47
+ {
48
+ name: 'init',
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)',
51
+ source: 'devices',
52
+ argumentHint: '[name]',
53
+ },
54
+ {
55
+ name: 'revoke',
56
+ description: 'Revoke a device with the signing key',
57
+ source: 'devices',
58
+ argumentHint: '<device-id>',
59
+ },
60
+ {
61
+ name: 'recover',
62
+ description: 'Rotate the signing key from your recovery phrase',
63
+ source: 'devices',
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
+ },
76
+ ],
77
+ };
78
+ }
79
+
80
+ const REFUSALS: Readonly<Record<TDevicesRefusal, string>> = {
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 your other devices are already linked, run `/devices add` there and `/devices join` here, typing the code it shows. Run `/devices init` only when setting up your first device: it creates a separate identity that cannot link to an existing device group.',
85
+ 'already-initialized':
86
+ 'This device already has an identity. Use `/devices recover` to rotate its signing key.',
87
+ cancelled: 'Cancelled. Nothing was changed.',
88
+ 'confirmation-failed': 'The confirmation did not match. Nothing was changed.',
89
+ 'phrase-invalid': 'That is not a valid recovery phrase. Nothing was changed.',
90
+ 'phrase-mismatch':
91
+ 'That phrase (with that passphrase) belongs to a different identity than this device. Nothing was changed.',
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.',
96
+ 'unknown-device': 'No device in the roster has that id.',
97
+ 'ambiguous-device': 'More than one device starts with that id; give more of it.',
98
+ 'self-revocation':
99
+ 'This device cannot revoke itself. Revoke it from another device, or `/devices recover`.',
100
+ 'changed-concurrently':
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.',
116
+ };
117
+
118
+ function short(id: string): string {
119
+ return id.slice(0, SHORT_ID_CHARS);
120
+ }
121
+
122
+ function day(ms: number): string {
123
+ return new Date(ms).toISOString().slice(0, 10);
124
+ }
125
+
126
+ function refused(reason: TDevicesRefusal): ICommandResult {
127
+ return { success: false, message: REFUSALS[reason] };
128
+ }
129
+
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 {
151
+ if (view === undefined) return { success: true, message: REFUSALS['not-initialized'] };
152
+ const lines = view.devices.map((device) => {
153
+ const notes = [
154
+ ...(device.thisDevice ? ['this device'] : []),
155
+ ...(device.holdsSigningKey ? ['holds the signing key'] : []),
156
+ ];
157
+ const suffix = notes.length > 0 ? ` (${notes.join(', ')})` : '';
158
+ return ` ${short(device.deviceId)} ${device.name}${suffix} certificate expires ${day(device.certificateExpiresAt)}`;
159
+ });
160
+ return {
161
+ success: true,
162
+ message: [
163
+ `Devices of user ${short(view.userId)}:`,
164
+ ...lines,
165
+ `${view.revokedDeviceCount} revoked. Roster and revocation list valid until ${new Date(view.listsExpireAt).toISOString()}.`,
166
+ ...(mesh !== undefined ? formatMesh(mesh) : []),
167
+ ].join('\n'),
168
+ };
169
+ }
170
+
171
+ function isOperator(context: ICommandHostContext): boolean {
172
+ return context.getCommandInvocationSource() === 'user';
173
+ }
174
+
175
+ /** Run a terminal-bound port call; a refusal or a thrown error becomes a failed result. */
176
+ async function onTerminal<T>(
177
+ context: ICommandHostContext,
178
+ run: () => Promise<TDevicesOutcome<T>>,
179
+ done: (value: T) => string,
180
+ ): Promise<ICommandResult> {
181
+ if (!context.canHandoffTerminal()) return refused('no-terminal');
182
+ try {
183
+ const outcome = await context.runWithTerminal(run);
184
+ return outcome.ok ? { success: true, message: done(outcome.value) } : refused(outcome.reason);
185
+ } catch (error) {
186
+ return {
187
+ success: false,
188
+ message: `Device identity operation failed: ${error instanceof Error ? error.message : 'unknown error'}`,
189
+ };
190
+ }
191
+ }
192
+
193
+ export async function executeDevicesCommand(
194
+ port: IDevicesCommandPort,
195
+ context: ICommandHostContext,
196
+ args: string,
197
+ ): Promise<ICommandResult> {
198
+ if (!isOperator(context)) {
199
+ return {
200
+ success: false,
201
+ message:
202
+ '`/devices` runs only for the operator at this terminal, never from a remote surface or the model.',
203
+ };
204
+ }
205
+ const trimmed = args.trim();
206
+ const spaceAt = trimmed.indexOf(' ');
207
+ const verb = (spaceAt === -1 ? trimmed : trimmed.slice(0, spaceAt)).toLowerCase();
208
+ // Device ids are case-sensitive base64url, so the remainder keeps its case.
209
+ const rest = spaceAt === -1 ? '' : trimmed.slice(spaceAt + 1).trim();
210
+
211
+ switch (verb) {
212
+ case '':
213
+ case 'list':
214
+ try {
215
+ return formatList(await port.list(), port.meshStatus?.());
216
+ } catch (error) {
217
+ return {
218
+ success: false,
219
+ message: `Device identity could not be read: ${error instanceof Error ? error.message : 'unknown error'}`,
220
+ };
221
+ }
222
+ case 'init':
223
+ return onTerminal(
224
+ context,
225
+ () => port.init(rest.length > 0 ? { name: rest } : {}),
226
+ (value) =>
227
+ [
228
+ `Device identity created. This device: ${short(value.deviceId)}; user ${short(value.userId)}.`,
229
+ `It holds signing key ${short(value.signingKeyId)}.`,
230
+ ...(value.keyStorage !== undefined
231
+ ? [`Private keys are kept in: ${value.keyStorage}.`]
232
+ : []),
233
+ 'Keep the recovery phrase offline; it is the only way to recover this identity.',
234
+ ].join('\n'),
235
+ );
236
+ case 'recover':
237
+ return onTerminal(
238
+ context,
239
+ () => port.recover(),
240
+ (value) =>
241
+ [
242
+ `Recovered. New signing key ${short(value.signingKeyId)}; ${value.revokedSigningKeyCount} old signing key(s) revoked.`,
243
+ `This device (${short(value.deviceId)}) is certified again.`,
244
+ ...(value.droppedDeviceCount > 0
245
+ ? [
246
+ `${value.droppedDeviceCount} other device(s) were certified by a retired signing key and must enrol again.`,
247
+ ]
248
+ : []),
249
+ ].join('\n'),
250
+ );
251
+ case 'revoke':
252
+ if (rest.length === 0)
253
+ return { success: false, message: 'Usage: /devices revoke <device-id>' };
254
+ return onTerminal(
255
+ context,
256
+ () => port.revoke(rest),
257
+ (value) =>
258
+ `Revoked ${value.name} (${short(value.deviceId)}). A new roster and revocation list were issued.`,
259
+ );
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
+ );
272
+ case 'join':
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
+ );
284
+ default:
285
+ return { success: false, message: `Unknown argument "${verb}". ${USAGE}` };
286
+ }
287
+ }
288
+
289
+ export class DevicesCommandSource implements ICommandSource {
290
+ readonly name = 'devices';
291
+
292
+ getCommands(): ICommand[] {
293
+ return [createDevicesCommandEntry()];
294
+ }
295
+ }
296
+
297
+ export function createDevicesCommandModule(port: IDevicesCommandPort): ICommandModule {
298
+ const command: ISystemCommand = createSystemCommandFromEntry(createDevicesCommandEntry(), {
299
+ requiresPermission: false,
300
+ lifecycle: 'inline',
301
+ execute: (context, args) => executeDevicesCommand(port, context, args),
302
+ });
303
+ return {
304
+ name: 'agent-command-devices',
305
+ commandSources: [new DevicesCommandSource()],
306
+ systemCommands: [command],
307
+ };
308
+ }