@robota-sdk/agent-command 3.0.0-beta.81 → 3.0.0-beta.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1044 -0
- package/dist/node/index.cjs +76 -69
- package/dist/node/index.d.cts +100 -8
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +100 -8
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +71 -64
- package/dist/node/index.js.map +1 -1
- package/package.json +19 -9
- package/src/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +2 -0
- package/src/default/__tests__/default-command-modules.test.ts +50 -11
- package/src/default/__tests__/model-exposure.test.ts +16 -3
- package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
- package/src/default/default-command-modules.ts +7 -8
- package/src/devices/__tests__/devices-command-module.test.ts +99 -12
- package/src/devices/devices-command-module.ts +108 -24
- package/src/devices/devices-command-port.ts +62 -1
- package/src/devices/index.ts +3 -0
- package/src/doctor/doctor-node-deps.ts +6 -7
- package/src/editor/editor-command-module.ts +5 -0
- package/src/events/__tests__/events-command.test.ts +73 -0
- package/src/events/events-command-module.ts +49 -0
- package/src/events/events-command.ts +69 -0
- package/src/events/index.ts +2 -0
- package/src/git/__tests__/git-command-module.test.ts +10 -2
- package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
- package/src/handoff/__tests__/handoff-command.test.ts +3 -3
- package/src/handoff/handoff-command-module.ts +6 -5
- package/src/handoff/handoff-command.ts +4 -4
- package/src/help/__tests__/help-command-module.test.ts +3 -0
- package/src/help/__tests__/help-command.test.ts +3 -0
- package/src/index.ts +8 -0
- package/src/keybindings/keybindings-command-module.ts +17 -3
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +41 -0
- package/src/mcp-activation/mcp-activation-command.ts +8 -3
- package/src/peers/__tests__/peers-command-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +158 -0
- package/src/peers/peers-command-module.ts +4 -2
- package/src/peers/peers-command.ts +82 -4
- package/src/rewind/__tests__/rewind-command-module.test.ts +31 -1
- package/src/rewind/rewind-command.ts +23 -2
- package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
- package/src/schedule/loop-command.ts +3 -1
- package/src/shell/shell-command-module.ts +5 -0
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
- package/src/terminal-client/index.ts +5 -0
- package/src/terminal-client/terminal-client-commands.ts +109 -0
- package/src/theme/__tests__/theme-command.test.ts +1 -1
- package/src/theme/theme-command-module.ts +12 -2
- package/src/user-local/__tests__/user-local-command.test.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-command",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.83",
|
|
4
4
|
"description": "Consolidated command module implementations for Robota SDK CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/node/index.js",
|
|
@@ -28,19 +28,24 @@
|
|
|
28
28
|
"default": "./dist/node/index.cjs"
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
}
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22.12.0"
|
|
32
36
|
},
|
|
33
37
|
"files": [
|
|
34
38
|
"dist",
|
|
35
|
-
"src"
|
|
39
|
+
"src",
|
|
40
|
+
"CHANGELOG.md"
|
|
36
41
|
],
|
|
37
42
|
"dependencies": {
|
|
38
|
-
"@robota-sdk/agent-core": "3.0.0-beta.
|
|
39
|
-
"@robota-sdk/agent-framework": "3.0.0-beta.
|
|
40
|
-
"@robota-sdk/agent-interface-
|
|
41
|
-
"@robota-sdk/agent-interface-
|
|
42
|
-
"@robota-sdk/agent-
|
|
43
|
-
"@robota-sdk/agent-
|
|
43
|
+
"@robota-sdk/agent-core": "3.0.0-beta.83",
|
|
44
|
+
"@robota-sdk/agent-framework": "3.0.0-beta.83",
|
|
45
|
+
"@robota-sdk/agent-interface-command": "3.0.0-beta.83",
|
|
46
|
+
"@robota-sdk/agent-interface-execution": "3.0.0-beta.83",
|
|
47
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.83",
|
|
48
|
+
"@robota-sdk/agent-preset": "3.0.0-beta.83"
|
|
44
49
|
},
|
|
45
50
|
"devDependencies": {
|
|
46
51
|
"@types/node": "^22.19.21",
|
|
@@ -50,6 +55,11 @@
|
|
|
50
55
|
"vitest": "^3.2.6"
|
|
51
56
|
},
|
|
52
57
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "https://github.com/woojubb/robota.git",
|
|
61
|
+
"directory": "packages/agent-command"
|
|
62
|
+
},
|
|
53
63
|
"publishConfig": {
|
|
54
64
|
"access": "public"
|
|
55
65
|
},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createSystemCommandFromEntry } from './command-module-utils.js';
|
|
4
|
+
|
|
5
|
+
const behavior = {
|
|
6
|
+
lifecycle: 'inline',
|
|
7
|
+
requiresPermission: false,
|
|
8
|
+
execute: () => ({ success: true, message: 'ran' }),
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
describe('createSystemCommandFromEntry', () => {
|
|
12
|
+
it('#3189: carries where the entry says it runs, so the catalog and the palette agree', () => {
|
|
13
|
+
const command = createSystemCommandFromEntry(
|
|
14
|
+
{
|
|
15
|
+
name: 'local',
|
|
16
|
+
description: 'Runs on the terminal',
|
|
17
|
+
source: 'test',
|
|
18
|
+
runner: 'client',
|
|
19
|
+
surfaces: ['terminal'],
|
|
20
|
+
},
|
|
21
|
+
behavior,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(command.runner).toBe('client');
|
|
25
|
+
expect(command.surfaces).toEqual(['terminal']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('leaves an unstated placement unstated rather than guessing one', () => {
|
|
29
|
+
const command = createSystemCommandFromEntry(
|
|
30
|
+
{ name: 'plain', description: 'Runs on the runtime', source: 'test' },
|
|
31
|
+
behavior,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect('runner' in command).toBe(false);
|
|
35
|
+
expect('surfaces' in command).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -25,6 +25,8 @@ export function createSystemCommandFromEntry(
|
|
|
25
25
|
...(entry.argumentHint !== undefined ? { argumentHint: entry.argumentHint } : {}),
|
|
26
26
|
...(entry.safety !== undefined ? { safety: entry.safety } : {}),
|
|
27
27
|
...(entry.subcommands !== undefined ? { subcommands: entry.subcommands } : {}),
|
|
28
|
+
...(entry.runner !== undefined ? { runner: entry.runner } : {}),
|
|
29
|
+
...(entry.surfaces !== undefined ? { surfaces: entry.surfaces } : {}),
|
|
28
30
|
...(behavior.semanticRole !== undefined ? { semanticRole: behavior.semanticRole } : {}),
|
|
29
31
|
lifecycle: behavior.lifecycle,
|
|
30
32
|
requiresPermission: behavior.requiresPermission,
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterAll, describe, expect, it } from 'vitest';
|
|
2
6
|
|
|
3
7
|
import type { IProviderDefinition } from '@robota-sdk/agent-core';
|
|
4
8
|
import type {
|
|
@@ -8,6 +12,10 @@ import type {
|
|
|
8
12
|
|
|
9
13
|
import { createDefaultCommandModules } from '../default-command-modules.js';
|
|
10
14
|
|
|
15
|
+
/** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
|
|
16
|
+
const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
|
|
17
|
+
afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
|
|
18
|
+
|
|
11
19
|
const providerDefinitions: readonly IProviderDefinition[] = [
|
|
12
20
|
{
|
|
13
21
|
type: 'anthropic',
|
|
@@ -26,7 +34,7 @@ const providerSettingsAdapter: IProviderCommandSettingsAdapter = {
|
|
|
26
34
|
writeTargetSettings: () => undefined,
|
|
27
35
|
};
|
|
28
36
|
|
|
29
|
-
const baseOptions = { cwd: '/tmp', userLocalStorageRoot:
|
|
37
|
+
const baseOptions = { cwd: '/tmp', userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT, providerDefinitions, providerSettingsAdapter } as const;
|
|
30
38
|
|
|
31
39
|
function moduleNames(opts: Parameters<typeof createDefaultCommandModules>[0]): string[] {
|
|
32
40
|
return createDefaultCommandModules(opts).modules.map((module) => module.name);
|
|
@@ -45,7 +53,7 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
|
|
|
45
53
|
// `/output-style`; FLOW-008 added `/effort`; `/advisor` joined beside it). The list below is the
|
|
46
54
|
// assertion that matters — a length on its own can be restored by any substitution, and the count
|
|
47
55
|
// exists only to catch an addition that also removed something.
|
|
48
|
-
expect(names).toHaveLength(
|
|
56
|
+
expect(names).toHaveLength(38);
|
|
49
57
|
expect(names).toEqual([
|
|
50
58
|
'agent-command-skills',
|
|
51
59
|
'agent-command-help',
|
|
@@ -66,6 +74,8 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
|
|
|
66
74
|
'agent-command-shell',
|
|
67
75
|
'agent-command-editor',
|
|
68
76
|
'agent-command-git',
|
|
77
|
+
'agent-command-keybindings',
|
|
78
|
+
'agent-command-theme',
|
|
69
79
|
'agent-command-memory',
|
|
70
80
|
'agent-command-mcp-activation',
|
|
71
81
|
'agent-command-user-local',
|
|
@@ -80,6 +90,7 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
|
|
|
80
90
|
'agent-command-plugin',
|
|
81
91
|
'agent-command-settings',
|
|
82
92
|
'agent-command-peers',
|
|
93
|
+
'agent-command-events',
|
|
83
94
|
'agent-command-handoff',
|
|
84
95
|
'agent-command-remote-control',
|
|
85
96
|
'agent-command-provider',
|
|
@@ -92,14 +103,42 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
|
|
|
92
103
|
expect(names).toHaveLength(2);
|
|
93
104
|
});
|
|
94
105
|
|
|
95
|
-
it('
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
})
|
|
102
|
-
|
|
106
|
+
it('#3186: /keybindings and /theme exist on a host without a terminal, and say where they work', async () => {
|
|
107
|
+
const { modules } = createDefaultCommandModules(baseOptions);
|
|
108
|
+
const commands = modules.flatMap((module) => module.systemCommands ?? []);
|
|
109
|
+
for (const name of ['keybindings', 'theme']) {
|
|
110
|
+
const command = commands.find((candidate) => candidate.name === name);
|
|
111
|
+
expect(command, name).toBeDefined();
|
|
112
|
+
const result = await command!.execute({} as never, '');
|
|
113
|
+
expect(result).toMatchObject({ success: false });
|
|
114
|
+
expect(result.message).toMatch(/robota terminal/);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('#3189: exactly /editor, /keybindings, /shell and /theme run on the terminal client', () => {
|
|
119
|
+
const { modules } = createDefaultCommandModules(baseOptions);
|
|
120
|
+
const TERMINAL_CLIENT = ['editor', 'keybindings', 'shell', 'theme'];
|
|
121
|
+
|
|
122
|
+
// The executable commands: what the runtime keeps in its catalog and what the listing reads.
|
|
123
|
+
const clientSystemCommands = modules
|
|
124
|
+
.flatMap((module) => module.systemCommands ?? [])
|
|
125
|
+
.filter((command) => command.runner === 'client');
|
|
126
|
+
expect(clientSystemCommands.map((command) => command.name).sort()).toEqual(TERMINAL_CLIENT);
|
|
127
|
+
for (const command of clientSystemCommands) {
|
|
128
|
+
expect(command.surfaces, command.name).toEqual(['terminal']);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The palette entries, which must say the same — the two views cannot disagree.
|
|
132
|
+
const clientEntries = modules
|
|
133
|
+
.flatMap((module) => module.commandSources ?? [])
|
|
134
|
+
.flatMap((source) => source.getCommands())
|
|
135
|
+
.filter((entry) => entry.runner === 'client');
|
|
136
|
+
expect(clientEntries.map((entry) => entry.name).sort()).toEqual(TERMINAL_CLIENT);
|
|
137
|
+
for (const entry of clientEntries) {
|
|
138
|
+
expect(entry.surfaces, entry.name).toEqual(['terminal']);
|
|
139
|
+
// Running on the client does not change who may run it: all four stay user-only.
|
|
140
|
+
expect(entry.modelInvocable, entry.name).toBe(false);
|
|
141
|
+
}
|
|
103
142
|
});
|
|
104
143
|
|
|
105
144
|
it('TC-02: disabledCommandModules blacklist removes the named module', () => {
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* each command's entry.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
|
11
15
|
|
|
12
16
|
import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
|
|
13
17
|
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
@@ -41,6 +45,10 @@ const themeCataloguePort: IThemeCataloguePort = {
|
|
|
41
45
|
}),
|
|
42
46
|
};
|
|
43
47
|
|
|
48
|
+
/** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
|
|
49
|
+
const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
|
|
50
|
+
afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
|
|
51
|
+
|
|
44
52
|
const fixtures: IDoctorFixture[] = [];
|
|
45
53
|
afterEach(() => {
|
|
46
54
|
for (const fixture of fixtures.splice(0)) fixture.cleanup();
|
|
@@ -52,12 +60,16 @@ function allBuiltInCommands(): ISystemCommand[] {
|
|
|
52
60
|
fixtures.push(fixture);
|
|
53
61
|
const { modules } = createDefaultCommandModules({
|
|
54
62
|
cwd: '/tmp',
|
|
55
|
-
userLocalStorageRoot:
|
|
63
|
+
userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
|
|
56
64
|
providerDefinitions,
|
|
57
65
|
providerSettingsAdapter,
|
|
58
66
|
});
|
|
67
|
+
// /keybindings and /theme are in the default set; built here with their capabilities instead.
|
|
68
|
+
const withoutPortless = modules.filter(
|
|
69
|
+
(module) => module.name !== 'agent-command-keybindings' && module.name !== 'agent-command-theme',
|
|
70
|
+
);
|
|
59
71
|
return [
|
|
60
|
-
...
|
|
72
|
+
...withoutPortless,
|
|
61
73
|
createKeybindingsCommandModule({ ensureFile: async () => '/tmp/keybindings.json' }),
|
|
62
74
|
createThemeCommandModule(themeCataloguePort),
|
|
63
75
|
createDoctorCommandModule(fixture.inputs, fixture.deps),
|
|
@@ -94,6 +106,7 @@ const USER_ONLY_COMMANDS = [
|
|
|
94
106
|
'plugin',
|
|
95
107
|
'reload-plugins',
|
|
96
108
|
'remote-control',
|
|
109
|
+
'events',
|
|
97
110
|
// Account and credential actions.
|
|
98
111
|
'provider',
|
|
99
112
|
// Permission widening or permission-mode changes.
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
* type checker's job.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { afterAll, describe, expect, it } from 'vitest';
|
|
21
25
|
import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
|
|
22
26
|
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
23
27
|
|
|
@@ -55,13 +59,17 @@ const adapter: IProviderCommandSettingsAdapter = {
|
|
|
55
59
|
writeTargetSettings: () => undefined,
|
|
56
60
|
};
|
|
57
61
|
|
|
62
|
+
/** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
|
|
63
|
+
const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
|
|
64
|
+
afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
|
|
65
|
+
|
|
58
66
|
const POLICY: IOrgPolicy = { allowedProviders: ['anthropic'], adminContact: 'ops@example.com' };
|
|
59
67
|
|
|
60
68
|
/** Build the provider executor the way the PRODUCT does — through the default-modules factory. */
|
|
61
69
|
function executorThroughFactory(orgPolicy?: IOrgPolicy): SystemCommandExecutor {
|
|
62
70
|
const { modules } = createDefaultCommandModules({
|
|
63
71
|
cwd: '/work',
|
|
64
|
-
userLocalStorageRoot:
|
|
72
|
+
userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
|
|
65
73
|
providerDefinitions,
|
|
66
74
|
providerSettingsAdapter: adapter,
|
|
67
75
|
...(orgPolicy === undefined ? {} : { orgPolicy }),
|
|
@@ -22,6 +22,7 @@ import { createMemoryCommandModule } from '../memory/index.js';
|
|
|
22
22
|
import { createModeCommandModule } from '../mode/index.js';
|
|
23
23
|
import { createSandboxCommandModule } from '../sandbox/index.js';
|
|
24
24
|
import { createOutputStyleCommandModule } from '../output-style/index.js';
|
|
25
|
+
import { createEventsCommandModule } from '../events/index.js';
|
|
25
26
|
import { createPeersCommandModule } from '../peers/index.js';
|
|
26
27
|
import { createPermissionsCommandModule } from '../permissions/index.js';
|
|
27
28
|
import { createPlanCommandModule } from '../plan/index.js';
|
|
@@ -72,12 +73,11 @@ export interface IDefaultCommandModulesOptions {
|
|
|
72
73
|
* red when the producer dropped it.
|
|
73
74
|
*/
|
|
74
75
|
orgPolicy?: IOrgPolicy;
|
|
75
|
-
/** Optional TUI-owned file capability;
|
|
76
|
+
/** Optional TUI-owned file capability; absent, `/keybindings` says it belongs to the robota terminal. */
|
|
76
77
|
keybindingsFilePort?: IKeybindingsFilePort;
|
|
77
78
|
/**
|
|
78
|
-
* SCREEN-2002: the surface's theme catalogue.
|
|
79
|
-
*
|
|
80
|
-
* missing than present-and-failing.
|
|
79
|
+
* SCREEN-2002: the surface's theme catalogue. Absent (print mode, `--serve`), `/theme` is still
|
|
80
|
+
* listed and says it belongs to the robota terminal, which runs it itself when attached.
|
|
81
81
|
*/
|
|
82
82
|
themeCataloguePort?: IThemeCataloguePort;
|
|
83
83
|
/**
|
|
@@ -173,10 +173,8 @@ export function createDefaultCommandModules({
|
|
|
173
173
|
createShellCommandModule(),
|
|
174
174
|
createEditorCommandModule(editorTemporaryDirectoryPrefix),
|
|
175
175
|
createGitCommandModule(),
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
: [createKeybindingsCommandModule(keybindingsFilePort)]),
|
|
179
|
-
...(themeCataloguePort === undefined ? [] : [createThemeCommandModule(themeCataloguePort)]),
|
|
176
|
+
createKeybindingsCommandModule(keybindingsFilePort),
|
|
177
|
+
createThemeCommandModule(themeCataloguePort),
|
|
180
178
|
...(doctorInputs === undefined
|
|
181
179
|
? []
|
|
182
180
|
: [createDoctorCommandModule(doctorInputs, undefined, doctorDisplay)]),
|
|
@@ -194,6 +192,7 @@ export function createDefaultCommandModules({
|
|
|
194
192
|
createPluginCommandModule(),
|
|
195
193
|
createSettingsCommandModule(),
|
|
196
194
|
createPeersCommandModule(),
|
|
195
|
+
createEventsCommandModule(),
|
|
197
196
|
// HANDOFF-001 (issue #1864). Registered even though no product wires the carrier adapter yet:
|
|
198
197
|
// the command's own answer to a host without one is to say so, which is a better state than a
|
|
199
198
|
// capability nobody can see. It is also what makes the carrier's arrival observable.
|
|
@@ -44,7 +44,12 @@ function fakePort(): IDevicesCommandPort & { calls: string[] } {
|
|
|
44
44
|
calls.push(`init:${options.name ?? ''}`);
|
|
45
45
|
return {
|
|
46
46
|
ok: true as const,
|
|
47
|
-
value: {
|
|
47
|
+
value: {
|
|
48
|
+
userId: USER,
|
|
49
|
+
deviceId: DEVICE_A,
|
|
50
|
+
signingKeyId: SIGNING,
|
|
51
|
+
keyStorage: 'OS keychain',
|
|
52
|
+
},
|
|
48
53
|
};
|
|
49
54
|
}),
|
|
50
55
|
recover: vi.fn(async () => {
|
|
@@ -63,6 +68,20 @@ function fakePort(): IDevicesCommandPort & { calls: string[] } {
|
|
|
63
68
|
calls.push(`revoke:${target}`);
|
|
64
69
|
return { ok: true as const, value: { deviceId: DEVICE_B, name: 'desktop' } };
|
|
65
70
|
}),
|
|
71
|
+
add: vi.fn(async () => {
|
|
72
|
+
calls.push('add');
|
|
73
|
+
return {
|
|
74
|
+
ok: true as const,
|
|
75
|
+
value: { deviceId: DEVICE_B, name: 'desktop', confirmed: true },
|
|
76
|
+
};
|
|
77
|
+
}),
|
|
78
|
+
join: vi.fn(async (options) => {
|
|
79
|
+
calls.push(`join:${options.name ?? ''}`);
|
|
80
|
+
return {
|
|
81
|
+
ok: true as const,
|
|
82
|
+
value: { userId: USER, deviceId: DEVICE_B, name: 'desktop', keyStorage: 'OS keychain' },
|
|
83
|
+
};
|
|
84
|
+
}),
|
|
66
85
|
};
|
|
67
86
|
}
|
|
68
87
|
|
|
@@ -105,7 +124,19 @@ describe('/devices command', () => {
|
|
|
105
124
|
'init',
|
|
106
125
|
'revoke',
|
|
107
126
|
'recover',
|
|
127
|
+
'add',
|
|
128
|
+
'join',
|
|
108
129
|
]);
|
|
130
|
+
// What the model reads names the one-time code as terminal-only, so it never asks for it.
|
|
131
|
+
expect(palette?.description).toMatch(/enrol/i);
|
|
132
|
+
expect(palette?.description).toMatch(/terminal/i);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('tells the model it also shows the device mesh status', () => {
|
|
136
|
+
const description =
|
|
137
|
+
createDevicesCommandModule(fakePort()).commandSources?.[0]?.getCommands()[0]?.description;
|
|
138
|
+
expect(description).toMatch(/device mesh/);
|
|
139
|
+
expect(description).toMatch(/Operator-only/);
|
|
109
140
|
});
|
|
110
141
|
|
|
111
142
|
it('lists the roster with short ids, this device, the signing-key holder and expiry', async () => {
|
|
@@ -125,6 +156,17 @@ describe('/devices command', () => {
|
|
|
125
156
|
vi.mocked(port.list).mockResolvedValueOnce(undefined);
|
|
126
157
|
const result = await start(port).command('devices', 'list');
|
|
127
158
|
expect(result?.message).toMatch(/\/devices init/);
|
|
159
|
+
// A device of a user who already has others joins them; `init` would never link to them.
|
|
160
|
+
expect(result?.message).toMatch(/\/devices join/);
|
|
161
|
+
expect(result?.message).toMatch(/separate identity/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('describes init as for the first device, and join as the way to the devices a user has', () => {
|
|
165
|
+
const entry = createDevicesCommandModule(fakePort()).commandSources?.[0]?.getCommands()[0];
|
|
166
|
+
const init = entry?.subcommands?.find((sub) => sub.name === 'init');
|
|
167
|
+
expect(init?.description).toMatch(/first device/);
|
|
168
|
+
expect(init?.description).toMatch(/separate identity/);
|
|
169
|
+
expect(entry?.description).toMatch(/separate identity/);
|
|
128
170
|
});
|
|
129
171
|
|
|
130
172
|
it('runs init, recover and revoke on the operator terminal', async () => {
|
|
@@ -156,7 +198,7 @@ describe('/devices command', () => {
|
|
|
156
198
|
it('refuses without an interactive terminal and never reaches the port (fail closed)', async () => {
|
|
157
199
|
const port = fakePort();
|
|
158
200
|
const harness = start(port, handoff(false));
|
|
159
|
-
for (const args of ['init', 'recover', 'revoke abcdef']) {
|
|
201
|
+
for (const args of ['init', 'recover', 'revoke abcdef', 'add', 'join', 'join desktop']) {
|
|
160
202
|
const result = await harness.command('devices', args);
|
|
161
203
|
expect(result?.success).toBe(false);
|
|
162
204
|
expect(result?.message).toMatch(/interactive terminal/i);
|
|
@@ -167,15 +209,17 @@ describe('/devices command', () => {
|
|
|
167
209
|
it('refuses a model invocation', async () => {
|
|
168
210
|
const port = fakePort();
|
|
169
211
|
const harness = start(port);
|
|
170
|
-
const
|
|
171
|
-
|
|
212
|
+
for (const args of ['init', 'add', 'join']) {
|
|
213
|
+
const result = await harness.session.executeModelCommand('devices', args);
|
|
214
|
+
expect(result?.success ?? false).toBe(false);
|
|
215
|
+
}
|
|
172
216
|
expect(port.calls).toEqual([]);
|
|
173
217
|
});
|
|
174
218
|
|
|
175
219
|
it('refuses every verb from a remote surface', async () => {
|
|
176
220
|
const port = fakePort();
|
|
177
221
|
const harness = start(port);
|
|
178
|
-
for (const args of ['', 'init', 'recover', 'revoke abcdef']) {
|
|
222
|
+
for (const args of ['', 'init', 'recover', 'revoke abcdef', 'add', 'join']) {
|
|
179
223
|
const result = await harness.session.executeCommand('devices', args, 'remote');
|
|
180
224
|
expect(result?.success).toBe(false);
|
|
181
225
|
expect(result?.message).toMatch(/operator/i);
|
|
@@ -183,15 +227,58 @@ describe('/devices command', () => {
|
|
|
183
227
|
expect(port.calls).toEqual([]);
|
|
184
228
|
});
|
|
185
229
|
|
|
186
|
-
it('
|
|
230
|
+
it('shows the device mesh: whether it is on, how it finds devices, and what is linked', async () => {
|
|
231
|
+
const port = {
|
|
232
|
+
...fakePort(),
|
|
233
|
+
meshStatus: () => ({
|
|
234
|
+
state: 'on' as const,
|
|
235
|
+
sources: ['local network (mDNS)', 'Mainline DHT'],
|
|
236
|
+
linked: [{ deviceId: DEVICE_B, name: 'desktop', locality: 'another-host' as const }],
|
|
237
|
+
}),
|
|
238
|
+
};
|
|
239
|
+
const result = await start(port).command('devices', '');
|
|
240
|
+
expect(result?.message).toMatch(/Device mesh: on/);
|
|
241
|
+
expect(result?.message).toContain('local network (mDNS), Mainline DHT');
|
|
242
|
+
expect(result?.message).toMatch(
|
|
243
|
+
new RegExp(`${DEVICE_B.slice(0, 10)}\\s+desktop\\s+on another machine`),
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('says how to turn the device mesh on when it is off', async () => {
|
|
248
|
+
const port = {
|
|
249
|
+
...fakePort(),
|
|
250
|
+
meshStatus: () => ({ state: 'off' as const, sources: [], linked: [] }),
|
|
251
|
+
};
|
|
252
|
+
const result = await start(port).command('devices', 'list');
|
|
253
|
+
expect(result?.message).toMatch(/Device mesh: off/);
|
|
254
|
+
expect(result?.message).toContain('transports.mesh.enabled');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('runs add and join on the operator terminal and reports ids and names only', async () => {
|
|
258
|
+
const port = fakePort();
|
|
259
|
+
const runs = vi.fn();
|
|
260
|
+
const harness = start(port, handoff(true, runs));
|
|
261
|
+
const add = await harness.command('devices', 'add');
|
|
262
|
+
expect(add?.success).toBe(true);
|
|
263
|
+
expect(add?.message).toContain('desktop');
|
|
264
|
+
expect(add?.message).toContain(DEVICE_B.slice(0, 10));
|
|
265
|
+
const join = await harness.command('devices', 'join my desktop');
|
|
266
|
+
expect(join?.success).toBe(true);
|
|
267
|
+
expect(join?.message).toContain(DEVICE_B.slice(0, 10));
|
|
268
|
+
expect(join?.message).toContain('OS keychain');
|
|
269
|
+
expect(port.calls).toEqual(['add', 'join:my desktop']);
|
|
270
|
+
expect(runs).toHaveBeenCalledTimes(2);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('says what to do when an enrollment is refused', async () => {
|
|
187
274
|
const port = fakePort();
|
|
188
275
|
const harness = start(port);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
expect(
|
|
276
|
+
vi.mocked(port.join).mockResolvedValueOnce({ ok: false, reason: 'code-not-accepted' });
|
|
277
|
+
expect((await harness.command('devices', 'join'))?.message).toMatch(/devices add/);
|
|
278
|
+
vi.mocked(port.add).mockResolvedValueOnce({ ok: false, reason: 'no-relay' });
|
|
279
|
+
expect((await harness.command('devices', 'add'))?.message).toMatch(/relayUrl/);
|
|
280
|
+
vi.mocked(port.join).mockResolvedValueOnce({ ok: false, reason: 'code-on-command-line' });
|
|
281
|
+
expect((await harness.command('devices', 'join ABCDE'))?.message).toMatch(/devices add/);
|
|
195
282
|
});
|
|
196
283
|
|
|
197
284
|
it('requires a device id for revoke', async () => {
|