@robota-sdk/agent-command 3.0.0-beta.86 → 3.0.0-beta.88

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 (62) hide show
  1. package/CHANGELOG.md +64 -12
  2. package/README.md +6 -3
  3. package/dist/node/index.cjs +72 -70
  4. package/dist/node/index.d.cts +16 -8
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +16 -8
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +51 -49
  9. package/dist/node/index.js.map +1 -1
  10. package/package.json +15 -13
  11. package/src/agent/__tests__/agent-command.test.ts +5 -5
  12. package/src/agent/agent-command-module.ts +1 -1
  13. package/src/background/__tests__/background-command-module.test.ts +6 -6
  14. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +15 -4
  15. package/src/default/__tests__/default-command-modules.test.ts +2 -2
  16. package/src/default/__tests__/model-exposure.test.ts +18 -5
  17. package/src/default/__tests__/org-policy-forwarding.test.ts +1 -1
  18. package/src/default/default-command-modules.ts +2 -2
  19. package/src/devices/devices-command-module.ts +1 -1
  20. package/src/doctor/__tests__/doctor-command-module.test.ts +10 -10
  21. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +1 -1
  22. package/src/doctor/__tests__/doctor-fixture.ts +14 -14
  23. package/src/doctor/__tests__/doctor-runner.test.ts +57 -57
  24. package/src/doctor/doctor-storage-probe.ts +1 -1
  25. package/src/doctor/doctor-types.ts +2 -2
  26. package/src/editor/__tests__/editor-command-functional.test.ts +1 -1
  27. package/src/fork/__tests__/fork-command.test.ts +1 -1
  28. package/src/fork/fork-command-module.ts +1 -1
  29. package/src/git/__tests__/git-command-module.test.ts +1 -1
  30. package/src/handoff/__tests__/handoff-command-module.test.ts +1 -1
  31. package/src/handoff/handoff-command-module.ts +1 -1
  32. package/src/keybindings/keybindings-command-module.ts +1 -1
  33. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +8 -8
  34. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +43 -12
  35. package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
  36. package/src/mcp-activation/mcp-activation-command-module.ts +27 -2
  37. package/src/mcp-activation/mcp-activation-command.ts +149 -15
  38. package/src/mcp-activation/mcp-model-notice.ts +19 -5
  39. package/src/mcp-activation/mcp-model-status.ts +7 -2
  40. package/src/memory/__tests__/memory-command-module.test.ts +89 -16
  41. package/src/memory/memory-command-module.ts +4 -1
  42. package/src/memory/memory-command.ts +53 -1
  43. package/src/peers/__tests__/peers-command-module.test.ts +1 -1
  44. package/src/peers/peers-command-module.ts +1 -1
  45. package/src/permissions/__tests__/permissions-command-module.test.ts +4 -4
  46. package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
  47. package/src/plugin/__tests__/plugin-command-module.test.ts +7 -7
  48. package/src/plugin/plugin-command-module.ts +4 -0
  49. package/src/provider/provider-startup.ts +4 -2
  50. package/src/remote-control/__tests__/remote-control-command.test.ts +2 -2
  51. package/src/rewind/__tests__/rewind-command-module.test.ts +4 -3
  52. package/src/rewind/rewind-command.ts +27 -21
  53. package/src/schedule/schedule-command-module.ts +1 -1
  54. package/src/session/__tests__/session-command-module.test.ts +2 -2
  55. package/src/skills/__tests__/skill-read-command.test.ts +64 -0
  56. package/src/skills/__tests__/skills-command-module.test.ts +2 -1
  57. package/src/skills/skill-read-command.ts +60 -0
  58. package/src/skills/skills-command-module.ts +3 -2
  59. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +1 -1
  60. package/src/theme/__tests__/theme-command.test.ts +1 -1
  61. package/src/theme/theme-command-module.ts +1 -1
  62. package/src/user-local/__tests__/user-local-command.test.ts +16 -16
@@ -14,6 +14,7 @@ import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
14
14
 
15
15
  import type {
16
16
  IMemoryStore,
17
+ IRemoteCommandPolicy,
17
18
  IWorkspaceIdentity,
18
19
  IWorkspaceTrustStore,
19
20
  IWorkspaceTrustStoreSnapshot,
@@ -21,7 +22,7 @@ import type {
21
22
 
22
23
  // SEC-003 (js/insecure-temporary-file): `mkdtempSync` yields a 0700 directory with an unpredictable
23
24
  // name, so no other user can pre-create or read the paths this suite writes under it.
24
- const TMP_BASE = realpathSync(mkdtempSync(join(tmpdir(), 'robota-command-memory-')));
25
+ const TMP_BASE = realpathSync(mkdtempSync(join(tmpdir(), 'agent-test-command-memory-')));
25
26
  const memoryStores = new Map<string, Promise<IMemoryStore>>();
26
27
 
27
28
  class TrustedStore implements IWorkspaceTrustStore {
@@ -52,10 +53,10 @@ function createMemoryStore(cwd: string): Promise<IMemoryStore> {
52
53
  identityResolver: { resolve: () => identity },
53
54
  store: new TrustedStore(),
54
55
  projectStateDirectories: {
55
- sessions: join('.robota', 'sessions'),
56
- 'session-logs': join('.robota', 'logs'),
57
- memory: join('.robota', 'memory'),
58
- checkpoints: join('.robota', 'checkpoints'),
56
+ sessions: join('.fixture-state', 'sessions'),
57
+ 'session-logs': join('.fixture-state', 'logs'),
58
+ memory: join('.fixture-state', 'memory'),
59
+ checkpoints: join('.fixture-state', 'checkpoints'),
59
60
  },
60
61
  });
61
62
  const access = await service.inspect(root);
@@ -104,6 +105,7 @@ function createMockRuntimeSession() {
104
105
  async function createInteractiveSession(
105
106
  cwd = makeProject(),
106
107
  permissionMode: 'default' | 'plan' = 'default',
108
+ remoteCommandPolicy?: IRemoteCommandPolicy,
107
109
  ): Promise<InteractiveSession> {
108
110
  const runtime = createMockRuntimeSession();
109
111
  runtime.getPermissionMode.mockReturnValue(permissionMode);
@@ -112,6 +114,7 @@ async function createInteractiveSession(
112
114
  session: runtime as never,
113
115
  memoryStore: await createMemoryStore(cwd),
114
116
  commandModules: [createMemoryCommandModule()],
117
+ ...(remoteCommandPolicy ? { remoteCommandPolicy } : {}),
115
118
  });
116
119
  }
117
120
 
@@ -152,6 +155,8 @@ describe('createMemoryCommandModule', () => {
152
155
  'list',
153
156
  'show',
154
157
  'add',
158
+ 'correct',
159
+ 'forget',
155
160
  'pending',
156
161
  'approve',
157
162
  'reject',
@@ -203,7 +208,14 @@ describe('createMemoryCommandModule', () => {
203
208
  const spied = { ...command!, execute };
204
209
  const gated = new SystemCommandExecutor([spied]);
205
210
 
206
- for (const args of ['approve cand-1', 'reject cand-1', 'APPROVE cand-1']) {
211
+ for (const args of [
212
+ 'approve cand-1',
213
+ 'reject cand-1',
214
+ 'APPROVE cand-1',
215
+ 'correct project build Use pnpm.',
216
+ 'forget build',
217
+ 'FORGET build',
218
+ ]) {
207
219
  const result = await gated.executeModelInvocable('memory', host, args);
208
220
  expect(result?.success).toBe(false);
209
221
  expect(result?.message).toContain('only the user can');
@@ -217,6 +229,67 @@ describe('createMemoryCommandModule', () => {
217
229
  });
218
230
 
219
231
  describe('executeMemoryCommand', () => {
232
+ it.runIf(process.platform === 'linux').each(['user', 'remote'] as const)(
233
+ 'lets an authenticated %s command replace and forget a topic through the shared slash handler',
234
+ async (source) => {
235
+ const cwd = makeProject();
236
+ const session = await createInteractiveSession(cwd);
237
+ await session.executeCommand('memory', 'add project build Use npm for builds.');
238
+ const corrected = await session.executeCommand(
239
+ 'memory',
240
+ 'correct project build Use pnpm for builds.',
241
+ source,
242
+ );
243
+ expect(corrected?.success).toBe(true);
244
+ expect((await session.readProjectMemory()).kind).toBe('memory');
245
+ expect((await session.executeCommand('memory', 'show build'))?.message).toContain('Use pnpm');
246
+ expect((await session.executeCommand('memory', 'show build'))?.message).not.toContain(
247
+ 'Use npm',
248
+ );
249
+ const forgotten = await session.executeCommand('memory', 'forget build', source);
250
+ expect(forgotten?.success).toBe(true);
251
+ expect((await session.executeCommand('memory', 'show'))?.message).not.toContain('Use pnpm');
252
+ expect(forgotten?.message).toContain('transcripts');
253
+ },
254
+ );
255
+
256
+ it.runIf(process.platform === 'linux')(
257
+ 'keeps attached memory curation subject to the host remote command policy',
258
+ async () => {
259
+ const cwd = makeProject();
260
+ const session = await createInteractiveSession(cwd, 'default', { isAllowed: () => false });
261
+ const store = session.getMemoryStore();
262
+ await store.append({ type: 'project', topic: 'build', text: 'Use pnpm for builds.' });
263
+ for (const args of ['correct project build Use npm.', 'forget build']) {
264
+ const result = await session.executeCommand('memory', args, 'remote');
265
+ expect(result?.success).toBe(false);
266
+ expect(result?.message).toContain('configured remote-command policy');
267
+ expect(await store.readTopic('build')).toContain('Use pnpm');
268
+ }
269
+ },
270
+ );
271
+
272
+ it.runIf(process.platform === 'linux')(
273
+ 'refuses direct model calls to destructive memory operations',
274
+ async () => {
275
+ const cwd = makeProject();
276
+ const session = await createInteractiveSession(cwd);
277
+ await session.executeCommand('memory', 'add project build Use pnpm for builds.');
278
+ const corrected = await executeMemoryCommand(
279
+ {
280
+ ...createTestCommandHost(),
281
+ getCwd: () => cwd,
282
+ getMemoryStore: () => session.getMemoryStore(),
283
+ getCommandInvocationSource: () => 'model',
284
+ },
285
+ 'correct project build Use npm for builds.',
286
+ );
287
+ expect(corrected.success).toBe(false);
288
+ expect(corrected.message).toContain('only the user');
289
+ expect(await session.getMemoryStore().readTopic('build')).toContain('Use pnpm');
290
+ },
291
+ );
292
+
220
293
  it('lists configured memory paths', async () => {
221
294
  const cwd = makeProject();
222
295
  const session = await createInteractiveSession(cwd);
@@ -224,7 +297,7 @@ describe('executeMemoryCommand', () => {
224
297
  const result = await session.executeCommand('memory', 'list');
225
298
 
226
299
  expect(result?.success).toBe(true);
227
- expect(result?.message).toContain(join('.robota', 'memory', 'MEMORY.md'));
300
+ expect(result?.message).toContain(join('.fixture-state', 'memory', 'MEMORY.md'));
228
301
  });
229
302
 
230
303
  // ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
@@ -240,7 +313,7 @@ describe('executeMemoryCommand', () => {
240
313
  );
241
314
 
242
315
  expect(result?.success).toBe(true);
243
- expect(readFileSync(join(cwd, '.robota', 'memory', 'MEMORY.md'), 'utf8')).toContain(
316
+ expect(readFileSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'), 'utf8')).toContain(
244
317
  '(project/build) Use pnpm for scripts.',
245
318
  );
246
319
  },
@@ -257,7 +330,7 @@ describe('executeMemoryCommand', () => {
257
330
  );
258
331
 
259
332
  expect(result?.success).toBe(true);
260
- expect(readFileSync(join(cwd, '.robota', 'memory', 'MEMORY.md'), 'utf8')).toContain(
333
+ expect(readFileSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'), 'utf8')).toContain(
261
334
  '(project/build) Use pnpm for package scripts.',
262
335
  );
263
336
  });
@@ -273,7 +346,7 @@ describe('executeMemoryCommand', () => {
273
346
 
274
347
  expect(result?.success).toBe(false);
275
348
  expect(result?.message).toContain('sensitive');
276
- expect(existsSync(join(cwd, '.robota', 'memory', 'MEMORY.md'))).toBe(false);
349
+ expect(existsSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'))).toBe(false);
277
350
  });
278
351
 
279
352
  // ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
@@ -303,7 +376,7 @@ describe('executeMemoryCommand', () => {
303
376
 
304
377
  expect(result?.success).toBe(true);
305
378
  expect(result?.message).toContain('Saved memory candidate mem_123');
306
- expect(readFileSync(join(cwd, '.robota', 'memory', 'MEMORY.md'), 'utf8')).toContain(
379
+ expect(readFileSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'), 'utf8')).toContain(
307
380
  '(project/build) Use pnpm for package scripts.',
308
381
  );
309
382
  expect((await (await createMemoryStore(cwd)).getPending('mem_123'))?.status).toBe('saved');
@@ -342,7 +415,7 @@ describe('executeMemoryCommand', () => {
342
415
  vi.spyOn(session, 'getUsedMemoryReferences').mockReturnValue([
343
416
  {
344
417
  topic: 'build',
345
- path: join(cwd, '.robota', 'memory', 'topics', 'build.md'),
418
+ path: join(cwd, '.fixture-state', 'memory', 'topics', 'build.md'),
346
419
  score: 5,
347
420
  truncated: false,
348
421
  },
@@ -352,7 +425,7 @@ describe('executeMemoryCommand', () => {
352
425
 
353
426
  expect(result?.success).toBe(true);
354
427
  expect(result?.message).toContain('build');
355
- expect(result?.message).toContain(join(cwd, '.robota', 'memory', 'topics', 'build.md'));
428
+ expect(result?.message).toContain(join(cwd, '.fixture-state', 'memory', 'topics', 'build.md'));
356
429
  });
357
430
 
358
431
  it('returns usage for invalid arguments without mutating state', async () => {
@@ -365,7 +438,7 @@ describe('executeMemoryCommand', () => {
365
438
  expect(result.success).toBe(false);
366
439
  expect(result.message).toContain('Usage: memory');
367
440
  expect(unknownResult.success).toBe(false);
368
- expect(existsSync(join(cwd, '.robota', 'memory', 'MEMORY.md'))).toBe(false);
441
+ expect(existsSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'))).toBe(false);
369
442
  });
370
443
 
371
444
  describe('in plan mode', () => {
@@ -377,7 +450,7 @@ describe('executeMemoryCommand', () => {
377
450
 
378
451
  expect(result?.success).toBe(false);
379
452
  expect(result?.message).toContain('Plan mode saves no memory');
380
- expect(existsSync(join(cwd, '.robota', 'memory', 'MEMORY.md'))).toBe(false);
453
+ expect(existsSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'))).toBe(false);
381
454
  });
382
455
 
383
456
  // ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
@@ -390,7 +463,7 @@ describe('executeMemoryCommand', () => {
390
463
  const result = await session.executeCommand('memory', 'add project build Use pnpm.');
391
464
 
392
465
  expect(result?.success).toBe(true);
393
- expect(existsSync(join(cwd, '.robota', 'memory', 'MEMORY.md'))).toBe(true);
466
+ expect(existsSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'))).toBe(true);
394
467
  },
395
468
  );
396
469
 
@@ -24,7 +24,10 @@ const MEMORY_COMMAND_MODEL_DESCRIPTION =
24
24
  'topics. In plan mode `add` is refused: save after plan mode ends. Returns the requested topics ' +
25
25
  'or entries, or a confirmation of what was saved. Approving ' +
26
26
  'or rejecting a pending candidate is the user’s review: suggest `/memory approve <id>` or ' +
27
- '`/memory reject <id>`.';
27
+ '`/memory reject <id>`. Correcting or forgetting saved knowledge is also user-only: ' +
28
+ 'suggest `/memory correct <type> <topic> <text>` to replace every entry in a topic or ' +
29
+ '`/memory forget <topic>` to remove it from active recall. These operations block automatic ' +
30
+ 'restoration of that topic; historical transcripts and backups remain.';
28
31
 
29
32
  export function createMemoryCommandEntry(): ICommand {
30
33
  return {
@@ -226,12 +226,59 @@ export async function executeMemoryCommand(
226
226
  ): Promise<ICommandResult> {
227
227
  const args = rawArgs.trim().split(/\s+/).filter(Boolean);
228
228
  const subcommand = args[SUBCOMMAND_INDEX] ?? 'list';
229
+ const source = context.getCommandInvocationSource();
230
+ if (
231
+ (subcommand === 'correct' || subcommand === 'forget') &&
232
+ source !== 'user' && source !== 'remote'
233
+ ) {
234
+ return {
235
+ success: false,
236
+ message:
237
+ 'Memory correction and forgetting: only the user can run /memory correct or /memory forget.',
238
+ };
239
+ }
229
240
  // SELFHOST-008 P1R: the single injected durable-memory port (or fs default) — authoritative for all
230
241
  // `/memory` operations, so a surface that swaps the store is honored here too (no split-brain).
231
242
  const resolved = getMemoryStoreOrError(context);
232
243
  if (!resolved.ok) return resolved.result;
233
244
  const store = resolved.store;
234
245
 
246
+ if (subcommand === 'correct' || subcommand === 'forget') {
247
+ try {
248
+ if (subcommand === 'correct') {
249
+ const input = parseAdd(args);
250
+ if (!input) return usage();
251
+ if (hasSensitiveCommandMemoryContent(input.text))
252
+ return { success: false, message: 'Refusing to save sensitive memory content.' };
253
+ if (!store.replaceTopic)
254
+ return {
255
+ success: false,
256
+ message: 'This memory backend does not support topic correction.',
257
+ };
258
+ const result = await store.replaceTopic(input);
259
+ return {
260
+ success: true,
261
+ message: `Replaced all active memory in ${result.topicPath}. Automatic additions to this topic now require user correction. Historical transcripts and backups remain.`,
262
+ data: { ...result },
263
+ };
264
+ }
265
+ if (!args[TYPE_INDEX] || args.length !== 2) return usage();
266
+ if (!store.forgetTopic)
267
+ return {
268
+ success: false,
269
+ message: 'This memory backend does not support forgetting topics.',
270
+ };
271
+ const result = await store.forgetTopic(args[TYPE_INDEX]);
272
+ return {
273
+ success: true,
274
+ message: `Forgot active memory topic ${result.topic}. Source topic removed; automatic restoration is blocked. Historical transcripts and backups remain.`,
275
+ data: { ...result },
276
+ };
277
+ } catch (error) {
278
+ return formatError(error instanceof Error ? error : String(error));
279
+ }
280
+ }
281
+
235
282
  if (subcommand === 'list') return formatList(store);
236
283
  if (subcommand === 'show') return formatShow(store, args[TYPE_INDEX]);
237
284
  if (subcommand === 'pending') return formatPending(store);
@@ -255,7 +302,12 @@ export async function executeMemoryCommand(
255
302
  success: false,
256
303
  };
257
304
  }
258
- const result = await store.append(input);
305
+ let result;
306
+ try {
307
+ result = await store.append(input);
308
+ } catch (error) {
309
+ return formatError(error instanceof Error ? error : String(error));
310
+ }
259
311
  return {
260
312
  message: result.deduplicated
261
313
  ? `${input.type} memory already exists in ${result.topicPath}`
@@ -16,7 +16,7 @@ describe('/peers command module', () => {
16
16
  it('tells the model it covers linked devices, what it returns, and what to suggest', () => {
17
17
  const description =
18
18
  createPeersCommandModule().commandSources?.[0]?.getCommands()[0]?.description;
19
- expect(description).toMatch(/linked over the device mesh/);
19
+ expect(description).toMatch(/linked devices/);
20
20
  expect(description).toMatch(/returns/i);
21
21
  expect(description).toMatch(/user-only/i);
22
22
  expect(description).toContain('`/peers`');
@@ -8,7 +8,7 @@ export function createPeersCommandEntry(): ICommand {
8
8
  name: 'peers',
9
9
  displayName: 'Peers',
10
10
  description:
11
- "List the other live Robota sessions on this host and the user's other devices linked over the device mesh, send one a message, or send one a copy of a file. Returns the sessions and devices with the ids to address them by (linked devices even when local discovery is off and sessions on this host cannot be listed), or how a send ended. User-only: the model cannot run it; it answers a peer's message with its reply tool, and when the user wants to reach another session or device, suggest they run `/peers`.",
11
+ "List the other live sessions for this user on this host and the user's linked devices, send one a message, or send one a copy of a file. Returns the sessions and devices with the ids to address them by (linked devices even when local discovery is off and sessions on this host cannot be listed), or how a send ended. User-only: the model cannot run it; it answers a peer's message with its reply tool, and when the user wants to reach another session or device, suggest they run `/peers`.",
12
12
  source: 'peers',
13
13
  // The model does not enumerate the operator's other sessions. Discovery is an operator-facing
14
14
  // view of who is at the machine, which is a fact about the person and not about the task.
@@ -160,14 +160,14 @@ describe('createPermissionsCommandModule', () => {
160
160
  rules: { allow: ['Read', 'mcp__docs__*'], deny: ['Bash(rm *)'], ask: ['Bash(git push*)'] },
161
161
  layers: [
162
162
  {
163
- source: '~/.robota/settings.json',
163
+ source: '~/.fixture-state/settings.json',
164
164
  scope: 'user',
165
165
  allow: ['Read'],
166
166
  deny: ['Bash(rm *)'],
167
167
  ask: [],
168
168
  },
169
169
  {
170
- source: '.robota/settings.json',
170
+ source: '.fixture-state/settings.json',
171
171
  scope: 'project',
172
172
  allow: ['Read', 'Stale'],
173
173
  deny: [],
@@ -181,10 +181,10 @@ describe('createPermissionsCommandModule', () => {
181
181
  expect(result?.message).toContain(
182
182
  [
183
183
  'Rules (checked deny, then ask, then allow):',
184
- ' ~/.robota/settings.json [user]',
184
+ ' ~/.fixture-state/settings.json [user]',
185
185
  ' deny: Bash(rm *)',
186
186
  ' allow: Read',
187
- ' .robota/settings.json [project]',
187
+ ' .fixture-state/settings.json [project]',
188
188
  ' ask: Bash(git push*)',
189
189
  ' allow: Read',
190
190
  ' this session (CLI flags, preset, commands)',
@@ -0,0 +1,100 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { InteractiveSession } from '@robota-sdk/agent-framework';
6
+ import type { IAIProvider, TUniversalMessage } from '@robota-sdk/agent-core';
7
+ import type { ICommandPluginAdapter } from '@robota-sdk/agent-interface-command';
8
+ import { createPluginCommandModule } from '../plugin-command-module.js';
9
+
10
+ async function heldTurn(denyRemote = false, commandModules = [createPluginCommandModule()]) {
11
+ const root = mkdtempSync(join(tmpdir(), 'plugin-active-turn-'));
12
+ let release!: (value: TUniversalMessage) => void;
13
+ const pending = new Promise<TUniversalMessage>((resolve) => { release = resolve; });
14
+ const run = vi.fn(() => pending);
15
+ const adapter: ICommandPluginAdapter = {
16
+ listInstalled: async () => [], listAvailablePlugins: async () => [],
17
+ install: vi.fn(async () => {}), uninstall: vi.fn(async () => {}),
18
+ enable: vi.fn(async () => {}), disable: vi.fn(async () => {}),
19
+ marketplaceAdd: async () => 'fixture', marketplaceRemove: async () => {},
20
+ marketplaceUpdate: async () => {}, marketplaceList: async () => [],
21
+ reloadPlugins: async () => ({ loadedPluginCount: 0 }),
22
+ };
23
+ const session = new InteractiveSession({
24
+ cwd: root, bare: true, environment: { HOME: root },
25
+ provider: { name: 'held-provider', version: '1', chat: run } as unknown as IAIProvider,
26
+ config: {
27
+ provider: { name: 'held-provider', model: 'fixture-model', apiKey: undefined },
28
+ defaultTrustLevel: 'safe', permissions: { allow: [], deny: [] }, env: {},
29
+ },
30
+ commandModules,
31
+ commandHostAdapters: { plugin: adapter },
32
+ ...(denyRemote ? { remoteCommandPolicy: { isAllowed: () => false } } : {}),
33
+ });
34
+ const submission = session.submit('Hold this fixture turn until its owner releases it.');
35
+ await vi.waitFor(() => expect(run).toHaveBeenCalledOnce());
36
+ return {
37
+ session, adapter,
38
+ close: async () => {
39
+ release({ id: 'held-response', role: 'assistant', state: 'complete', content: 'Finished', timestamp: new Date() });
40
+ try { await (await submission).completed; } finally {
41
+ await session.shutdown();
42
+ rmSync(root, { recursive: true, force: true });
43
+ }
44
+ },
45
+ };
46
+ }
47
+
48
+ describe.each(['user', 'remote'] as const)('plugin withdrawal during a %s command', (source) => {
49
+ it.each(['disable', 'uninstall'] as const)('allows %s while an admitted turn remains active', async (operation) => {
50
+ const owned = await heldTurn();
51
+ try {
52
+ const result = await owned.session.executeCommand('plugin', `${operation} fixture@market`, source);
53
+ expect(result?.success).toBe(true);
54
+ expect(owned.adapter[operation]).toHaveBeenCalledExactlyOnceWith('fixture@market');
55
+ expect(owned.session.isExecuting()).toBe(true);
56
+ } finally { await owned.close(); }
57
+ });
58
+
59
+ it.each(['enable', 'install'] as const)('keeps %s behind the active-turn gate', async (operation) => {
60
+ const owned = await heldTurn();
61
+ try {
62
+ const result = await owned.session.executeCommand('plugin', `${operation} fixture@market`, source);
63
+ expect(result?.success).toBe(false);
64
+ expect(result?.message).toContain('already running');
65
+ expect(owned.adapter[operation]).not.toHaveBeenCalled();
66
+ } finally { await owned.close(); }
67
+ });
68
+ });
69
+
70
+ it('preserves remote policy and model refusal for active-turn withdrawal', async () => {
71
+ const owned = await heldTurn(true);
72
+ try {
73
+ const result = await owned.session.executeCommand('plugin', 'disable fixture@market', 'remote');
74
+ expect(result?.success).toBe(false);
75
+ expect(result?.message).toContain('configured remote-command policy');
76
+ for (const operation of ['disable', 'uninstall'] as const) {
77
+ expect(await owned.session.executeModelCommand('plugin', `${operation} fixture@market`)).toBeNull();
78
+ expect(owned.adapter[operation]).not.toHaveBeenCalled();
79
+ }
80
+ } finally { await owned.close(); }
81
+ });
82
+
83
+ it.each(['inline', 'blocking'] as const)('honors generic host control metadata only for %s lifecycle', async (lifecycle) => {
84
+ const execute = vi.fn(() => ({ success: true, message: 'Control applied' }));
85
+ const owned = await heldTurn(false, [{
86
+ name: 'fixture-control', systemCommands: [{
87
+ name: 'withdraw-fixture', description: 'Owner fixture control', lifecycle,
88
+ canRunDuringTurn: (args) => args === 'stop', modelInvocable: false, execute,
89
+ }],
90
+ }]);
91
+ try {
92
+ const refused = await owned.session.executeCommand('withdraw-fixture', 'start');
93
+ expect(refused?.success).toBe(false);
94
+ expect(execute).not.toHaveBeenCalled();
95
+ const result = await owned.session.executeCommand('withdraw-fixture', 'stop');
96
+ expect(result?.success).toBe(lifecycle === 'inline');
97
+ expect(execute).toHaveBeenCalledTimes(lifecycle === 'inline' ? 1 : 0);
98
+ expect(owned.session.isExecuting()).toBe(true);
99
+ } finally { await owned.close(); }
100
+ });
@@ -101,7 +101,7 @@ describe('createPluginCommandModule', () => {
101
101
  expect(module.commandSources?.[0]?.getCommands()).toEqual([
102
102
  expect.objectContaining({
103
103
  name: 'plugin',
104
- description: 'Manage plugins',
104
+ description: expect.stringContaining('returns operation status'),
105
105
  source: 'plugin-manager',
106
106
  modelInvocable: false,
107
107
  argumentHint: expect.stringContaining('install'),
@@ -193,8 +193,8 @@ describe('executePluginCommand', () => {
193
193
  it('lists installed plugins through the adapter, for the Settings screen (#3282 §4 part b-2)', async () => {
194
194
  const adapter = createPluginAdapter({
195
195
  listInstalled: vi.fn().mockResolvedValue([
196
- { name: 'formatter@robota', description: 'Formats code.', enabled: true },
197
- { name: 'linter@robota', description: 'Lints code.', enabled: false },
196
+ { name: 'formatter@fixture-agent', description: 'Formats code.', enabled: true },
197
+ { name: 'linter@fixture-agent', description: 'Lints code.', enabled: false },
198
198
  ]),
199
199
  });
200
200
 
@@ -203,12 +203,12 @@ describe('executePluginCommand', () => {
203
203
  expect(result.success).toBe(true);
204
204
  expect(result.data).toEqual({
205
205
  plugins: [
206
- { name: 'formatter@robota', description: 'Formats code.', enabled: true },
207
- { name: 'linter@robota', description: 'Lints code.', enabled: false },
206
+ { name: 'formatter@fixture-agent', description: 'Formats code.', enabled: true },
207
+ { name: 'linter@fixture-agent', description: 'Lints code.', enabled: false },
208
208
  ],
209
209
  });
210
- expect(result.message).toContain('formatter@robota');
211
- expect(result.message).toContain('linter@robota (disabled)');
210
+ expect(result.message).toContain('formatter@fixture-agent');
211
+ expect(result.message).toContain('linter@fixture-agent (disabled)');
212
212
  });
213
213
 
214
214
  it('no plugins installed reports a plain empty list', async () => {
@@ -45,6 +45,10 @@ function createPluginSystemCommand(): ISystemCommand {
45
45
  modelInvocable: false,
46
46
  argumentHint: entry.argumentHint,
47
47
  lifecycle: 'inline',
48
+ canRunDuringTurn: (args) => {
49
+ const parts = args.trim().split(/\s+/);
50
+ return parts.length === 2 && ['disable', 'uninstall'].includes(parts[0]!);
51
+ },
48
52
  subcommands: entry.subcommands,
49
53
  execute: executePluginCommand,
50
54
  };
@@ -29,6 +29,8 @@ export interface IProviderStartupContext {
29
29
  settingsScope?: TSettingsScope;
30
30
  settingsSources: readonly TSettingsSource[];
31
31
  settingsStores: readonly ISettingsDocumentStore[];
32
+ /** Selected host snapshot for configure-time credential reference checks. */
33
+ env?: Record<string, string | undefined>;
32
34
  }
33
35
 
34
36
  export interface IEnsureProviderConfigOptions {
@@ -65,7 +67,7 @@ export async function runProviderStartupSetup(
65
67
  const input = await runProviderSetupPromptFlow(type, promptInput, providerDefinitions, {
66
68
  existingProfileNames,
67
69
  });
68
- applyProviderConfiguration(settingsStore, input, { providerDefinitions });
70
+ applyProviderConfiguration(settingsStore, input, { providerDefinitions, env: ctx.env });
69
71
  const language = await promptInput(' Response language (ko/en/ja/zh, default: en): ');
70
72
  if (language) {
71
73
  const settings = settingsStore.read();
@@ -104,7 +106,7 @@ export async function ensureProviderConfig(
104
106
  }
105
107
  await runProviderStartupSetup(
106
108
  cwd,
107
- selectStartupContext(ctx, access.stores),
109
+ selectStartupContext({ ...ctx, env: ctx.env ?? options.env }, access.stores),
108
110
  promptInput,
109
111
  terminal,
110
112
  providerDefinitions,
@@ -138,12 +138,12 @@ describe('executeRemoteControlCommand (REMOTE-008)', () => {
138
138
  const result = executeRemoteControlCommand(
139
139
  e3ctx({
140
140
  describeKeyStorage: () =>
141
- 'owner-only file in /h/.robota/credentials (OS keychain unavailable: absent)',
141
+ 'owner-only file in /h/.fixture-state/credentials (OS keychain unavailable: absent)',
142
142
  }),
143
143
  'status',
144
144
  );
145
145
  expect(result.message).toContain(
146
- 'Host key storage: owner-only file in /h/.robota/credentials (OS keychain unavailable: absent)',
146
+ 'Host key storage: owner-only file in /h/.fixture-state/credentials (OS keychain unavailable: absent)',
147
147
  );
148
148
  });
149
149
 
@@ -70,10 +70,11 @@ function createMockRuntimeSession() {
70
70
  };
71
71
  }
72
72
 
73
- function createInteractiveSession(): InteractiveSession {
73
+ function createInteractiveSession(commandProductVocabulary?: { cliName: string; displayName: string }): InteractiveSession {
74
74
  return new InteractiveSession({
75
75
  session: createMockRuntimeSession() as never,
76
76
  commandModules: [createRewindCommandModule()],
77
+ commandProductVocabulary,
77
78
  });
78
79
  }
79
80
 
@@ -260,13 +261,13 @@ describe('executeRewindCommand', () => {
260
261
 
261
262
  it('tells a restricted workspace to trust it, for every subcommand', async () => {
262
263
  vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
263
- const session = createInteractiveSession();
264
+ const session = createInteractiveSession({ cliName: 'test-agent', displayName: 'Test Agent' });
264
265
 
265
266
  for (const args of ['', 'list', 'inspect turn-0001', 'restore turn-0001', 'branches']) {
266
267
  const result = await session.executeCommand('rewind', args);
267
268
  expect(result?.success).toBe(false);
268
269
  expect(result?.message).toBe(
269
- 'Edit checkpoints need a trusted workspace: run robota trust --yes, then restart robota.',
270
+ 'Edit checkpoints need a trusted workspace: run test-agent trust --yes, then restart test-agent.',
270
271
  );
271
272
  }
272
273
  });