@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.
- package/CHANGELOG.md +64 -12
- package/README.md +6 -3
- package/dist/node/index.cjs +72 -70
- package/dist/node/index.d.cts +16 -8
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +16 -8
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +51 -49
- package/dist/node/index.js.map +1 -1
- package/package.json +15 -13
- package/src/agent/__tests__/agent-command.test.ts +5 -5
- package/src/agent/agent-command-module.ts +1 -1
- package/src/background/__tests__/background-command-module.test.ts +6 -6
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +15 -4
- package/src/default/__tests__/default-command-modules.test.ts +2 -2
- package/src/default/__tests__/model-exposure.test.ts +18 -5
- package/src/default/__tests__/org-policy-forwarding.test.ts +1 -1
- package/src/default/default-command-modules.ts +2 -2
- package/src/devices/devices-command-module.ts +1 -1
- package/src/doctor/__tests__/doctor-command-module.test.ts +10 -10
- package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +1 -1
- package/src/doctor/__tests__/doctor-fixture.ts +14 -14
- package/src/doctor/__tests__/doctor-runner.test.ts +57 -57
- package/src/doctor/doctor-storage-probe.ts +1 -1
- package/src/doctor/doctor-types.ts +2 -2
- package/src/editor/__tests__/editor-command-functional.test.ts +1 -1
- package/src/fork/__tests__/fork-command.test.ts +1 -1
- package/src/fork/fork-command-module.ts +1 -1
- package/src/git/__tests__/git-command-module.test.ts +1 -1
- package/src/handoff/__tests__/handoff-command-module.test.ts +1 -1
- package/src/handoff/handoff-command-module.ts +1 -1
- package/src/keybindings/keybindings-command-module.ts +1 -1
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +8 -8
- package/src/mcp-activation/__tests__/mcp-model-view.test.ts +43 -12
- package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +27 -2
- package/src/mcp-activation/mcp-activation-command.ts +149 -15
- package/src/mcp-activation/mcp-model-notice.ts +19 -5
- package/src/mcp-activation/mcp-model-status.ts +7 -2
- package/src/memory/__tests__/memory-command-module.test.ts +89 -16
- package/src/memory/memory-command-module.ts +4 -1
- package/src/memory/memory-command.ts +53 -1
- package/src/peers/__tests__/peers-command-module.test.ts +1 -1
- package/src/peers/peers-command-module.ts +1 -1
- package/src/permissions/__tests__/permissions-command-module.test.ts +4 -4
- package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +7 -7
- package/src/plugin/plugin-command-module.ts +4 -0
- package/src/provider/provider-startup.ts +4 -2
- package/src/remote-control/__tests__/remote-control-command.test.ts +2 -2
- package/src/rewind/__tests__/rewind-command-module.test.ts +4 -3
- package/src/rewind/rewind-command.ts +27 -21
- package/src/schedule/schedule-command-module.ts +1 -1
- package/src/session/__tests__/session-command-module.test.ts +2 -2
- package/src/skills/__tests__/skill-read-command.test.ts +64 -0
- package/src/skills/__tests__/skills-command-module.test.ts +2 -1
- package/src/skills/skill-read-command.ts +60 -0
- package/src/skills/skills-command-module.ts +3 -2
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +1 -1
- package/src/theme/__tests__/theme-command.test.ts +1 -1
- package/src/theme/theme-command-module.ts +1 -1
- 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(), '
|
|
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('.
|
|
56
|
-
'session-logs': join('.
|
|
57
|
-
memory: join('.
|
|
58
|
-
checkpoints: join('.
|
|
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 [
|
|
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('.
|
|
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, '.
|
|
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, '.
|
|
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, '.
|
|
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, '.
|
|
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, '.
|
|
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, '.
|
|
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, '.
|
|
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, '.
|
|
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, '.
|
|
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
|
-
|
|
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
|
|
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
|
|
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: '~/.
|
|
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: '.
|
|
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
|
-
' ~/.
|
|
184
|
+
' ~/.fixture-state/settings.json [user]',
|
|
185
185
|
' deny: Bash(rm *)',
|
|
186
186
|
' allow: Read',
|
|
187
|
-
' .
|
|
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: '
|
|
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@
|
|
197
|
-
{ name: 'linter@
|
|
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@
|
|
207
|
-
{ name: 'linter@
|
|
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@
|
|
211
|
-
expect(result.message).toContain('linter@
|
|
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/.
|
|
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/.
|
|
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
|
|
270
|
+
'Edit checkpoints need a trusted workspace: run test-agent trust --yes, then restart test-agent.',
|
|
270
271
|
);
|
|
271
272
|
}
|
|
272
273
|
});
|