@robota-sdk/agent-command 3.0.0-beta.87 → 3.0.0-beta.90
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 +67 -0
- package/dist/node/index.cjs +4 -4
- package/dist/node/index.d.cts +6 -1
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +6 -1
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +4 -4
- package/dist/node/index.js.map +1 -1
- package/package.json +7 -7
- package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
- package/src/default/default-command-modules.ts +8 -0
- package/src/model/__tests__/model-command.test.ts +25 -0
- package/src/model/model-command.ts +1 -0
- package/src/provider/__tests__/provider-command-module.test.ts +77 -0
- package/src/provider/provider-command-profile-operations.ts +5 -1
- package/src/provider/provider-command-setup.ts +1 -0
- package/src/provider/provider-startup.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.90",
|
|
4
4
|
"description": "Slash-command modules for Robota SDK sessions (/help, /permissions, /mcp, /rewind, …)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/node/index.js",
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"CHANGELOG.md"
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@robota-sdk/agent-core": "3.0.0-beta.
|
|
44
|
-
"@robota-sdk/agent-framework": "3.0.0-beta.
|
|
45
|
-
"@robota-sdk/agent-interface-command": "3.0.0-beta.
|
|
46
|
-
"@robota-sdk/agent-interface-execution": "3.0.0-beta.
|
|
47
|
-
"@robota-sdk/agent-interface-session": "3.0.0-beta.
|
|
48
|
-
"@robota-sdk/agent-preset": "3.0.0-beta.
|
|
43
|
+
"@robota-sdk/agent-core": "3.0.0-beta.90",
|
|
44
|
+
"@robota-sdk/agent-framework": "3.0.0-beta.90",
|
|
45
|
+
"@robota-sdk/agent-interface-command": "3.0.0-beta.90",
|
|
46
|
+
"@robota-sdk/agent-interface-execution": "3.0.0-beta.90",
|
|
47
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.90",
|
|
48
|
+
"@robota-sdk/agent-preset": "3.0.0-beta.90"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^22.19.21",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3459 — the default command modules hand `/provider` and `/model` the host's environment snapshot.
|
|
3
|
+
*
|
|
4
|
+
* A host that withholds its own credentials from `process.env` passes the snapshot it took first as
|
|
5
|
+
* `providerEnvironment`. This drives both commands through the factory, with the key gone from the
|
|
6
|
+
* live env, so it fails exactly when the factory stops forwarding the snapshot.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { afterAll, describe, expect, it } from 'vitest';
|
|
14
|
+
import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
|
|
15
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
16
|
+
|
|
17
|
+
import { createDefaultCommandModules } from '../default-command-modules.js';
|
|
18
|
+
|
|
19
|
+
import type { IProviderDefinition } from '@robota-sdk/agent-core';
|
|
20
|
+
import type { IProviderCommandSettingsAdapter, TProviderSettingsDocument } from '@robota-sdk/agent-framework';
|
|
21
|
+
|
|
22
|
+
const KEY = 'OPENAI_API_KEY';
|
|
23
|
+
const providerDefinitions: readonly IProviderDefinition[] = [
|
|
24
|
+
{
|
|
25
|
+
type: 'anthropic',
|
|
26
|
+
displayName: 'anthropic',
|
|
27
|
+
defaults: { model: 'm' },
|
|
28
|
+
modelCatalog: { status: 'live', entries: [{ id: 'm', displayName: 'm' }] },
|
|
29
|
+
createProvider: () => {
|
|
30
|
+
throw new Error('not used');
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
type: 'openai',
|
|
35
|
+
displayName: 'openai',
|
|
36
|
+
defaults: { apiKey: `$ENV:${KEY}` },
|
|
37
|
+
modelCatalog: { status: 'unavailable' },
|
|
38
|
+
requiresApiKey: true,
|
|
39
|
+
createProvider: () => {
|
|
40
|
+
throw new Error('not used');
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const SETTINGS: TProviderSettingsDocument = {
|
|
46
|
+
currentProvider: 'anthropic',
|
|
47
|
+
providers: { anthropic: { type: 'anthropic', model: 'm' }, openai: { type: 'openai', model: 'gpt-x' } },
|
|
48
|
+
};
|
|
49
|
+
const adapter: IProviderCommandSettingsAdapter = {
|
|
50
|
+
readMergedSettings: () => SETTINGS,
|
|
51
|
+
readTargetSettings: () => ({}),
|
|
52
|
+
writeTargetSettings: () => undefined,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'agent-test-provider-env-'));
|
|
56
|
+
afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
|
|
57
|
+
|
|
58
|
+
function executorThroughFactory(moduleName: string): SystemCommandExecutor {
|
|
59
|
+
const { modules } = createDefaultCommandModules({
|
|
60
|
+
cwd: '/work',
|
|
61
|
+
userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
|
|
62
|
+
providerDefinitions,
|
|
63
|
+
providerSettingsAdapter: adapter,
|
|
64
|
+
providerEnvironment: { [KEY]: 'snapshot-key' },
|
|
65
|
+
});
|
|
66
|
+
const found = modules.find((module) => module.name === moduleName);
|
|
67
|
+
if (found === undefined) throw new Error(`${moduleName} was not built`);
|
|
68
|
+
return new SystemCommandExecutor([...(found.systemCommands ?? [])]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function withoutLiveKey<T>(run: () => Promise<T>): Promise<T> {
|
|
72
|
+
const live = process.env[KEY];
|
|
73
|
+
delete process.env[KEY];
|
|
74
|
+
try {
|
|
75
|
+
return await run();
|
|
76
|
+
} finally {
|
|
77
|
+
if (live !== undefined) process.env[KEY] = live;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('#3459: the default command modules forward the provider environment', () => {
|
|
82
|
+
it('/provider switch checks the key against the snapshot', async () => {
|
|
83
|
+
const result = await withoutLiveKey(() =>
|
|
84
|
+
executorThroughFactory('agent-command-provider').execute('provider', createTestCommandHost(), 'switch openai'),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(result?.success).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('a cross-profile /model checks the key against the snapshot', async () => {
|
|
91
|
+
const host = createTestCommandHost({ session: { getModelId: () => 'm' } });
|
|
92
|
+
const result = await withoutLiveKey(() =>
|
|
93
|
+
executorThroughFactory('agent-command-model').execute('model', host, 'gpt-x'),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(result?.success).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -74,6 +74,11 @@ export interface IDefaultCommandModulesOptions {
|
|
|
74
74
|
* red when the producer dropped it.
|
|
75
75
|
*/
|
|
76
76
|
orgPolicy?: IOrgPolicy;
|
|
77
|
+
/**
|
|
78
|
+
* Where `/provider` and `/model` check a `$ENV:` or default credential variable: the host's startup
|
|
79
|
+
* snapshot when it withholds its own credentials from `process.env`. Absent, `process.env` is read.
|
|
80
|
+
*/
|
|
81
|
+
providerEnvironment?: Readonly<Record<string, string | undefined>>;
|
|
77
82
|
/** Optional TUI-owned file capability; absent, `/keybindings` says it belongs to an interactive terminal. */
|
|
78
83
|
keybindingsFilePort?: IKeybindingsFilePort;
|
|
79
84
|
/**
|
|
@@ -152,6 +157,7 @@ export function createDefaultCommandModules({
|
|
|
152
157
|
loopOptions,
|
|
153
158
|
enabledCommandModules,
|
|
154
159
|
disabledCommandModules,
|
|
160
|
+
providerEnvironment,
|
|
155
161
|
}: IDefaultCommandModulesOptions): IDefaultCommandModulesResult {
|
|
156
162
|
const modules: readonly ICommandModule[] = [
|
|
157
163
|
createSkillsCommandModule({ contributionSources: contributionSources ?? [], skillRoots }),
|
|
@@ -204,6 +210,7 @@ export function createDefaultCommandModules({
|
|
|
204
210
|
providerDefinitions,
|
|
205
211
|
settings: providerSettingsAdapter,
|
|
206
212
|
...(orgPolicy === undefined ? {} : { orgPolicy }),
|
|
213
|
+
...(providerEnvironment === undefined ? {} : { env: providerEnvironment }),
|
|
207
214
|
}),
|
|
208
215
|
// #3282 §2: shares the provider command's settings adapter and definitions — one settings write
|
|
209
216
|
// owner, never a second copy that could drift from what `/provider switch` reads and writes.
|
|
@@ -211,6 +218,7 @@ export function createDefaultCommandModules({
|
|
|
211
218
|
providerDefinitions,
|
|
212
219
|
settings: providerSettingsAdapter,
|
|
213
220
|
...(orgPolicy === undefined ? {} : { orgPolicy }),
|
|
221
|
+
...(providerEnvironment === undefined ? {} : { env: providerEnvironment }),
|
|
214
222
|
}),
|
|
215
223
|
];
|
|
216
224
|
const builtModuleNames = modules.map((module) => module.name);
|
|
@@ -237,4 +237,29 @@ describe('/model', () => {
|
|
|
237
237
|
expect(result).toEqual({ message: 'Model selection cancelled.', success: true });
|
|
238
238
|
expect(writeTargetSettings).not.toHaveBeenCalled();
|
|
239
239
|
});
|
|
240
|
+
|
|
241
|
+
// #3459: the host withholds its own credentials from `process.env`; the profile's `$ENV:` key is
|
|
242
|
+
// checked against the snapshot it hands the command instead.
|
|
243
|
+
it('switches to a profile whose key is only in the host snapshot', async () => {
|
|
244
|
+
const writeTargetSettings = vi.fn();
|
|
245
|
+
const host = createTestCommandHost({ session: { getModelId: () => 'claude-sonnet-4-6' } });
|
|
246
|
+
const keyed = DEFINITIONS.map((definition) =>
|
|
247
|
+
definition.type === 'openai' ? { ...definition, requiresApiKey: true } : definition,
|
|
248
|
+
);
|
|
249
|
+
const options = {
|
|
250
|
+
...buildOptions({ writeTargetSettings }),
|
|
251
|
+
providerDefinitions: keyed,
|
|
252
|
+
env: { OPENAI_API_KEY: 'snapshot-key' },
|
|
253
|
+
};
|
|
254
|
+
const live = process.env['OPENAI_API_KEY'];
|
|
255
|
+
delete process.env['OPENAI_API_KEY'];
|
|
256
|
+
try {
|
|
257
|
+
const result = await executeModelCommand(host, 'gpt-5.1', options);
|
|
258
|
+
|
|
259
|
+
expect(result.success).toBe(true);
|
|
260
|
+
expect(result.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'my-openai' }]);
|
|
261
|
+
} finally {
|
|
262
|
+
if (live !== undefined) process.env['OPENAI_API_KEY'] = live;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
240
265
|
});
|
|
@@ -199,6 +199,7 @@ function switchModelAcrossProfiles(
|
|
|
199
199
|
try {
|
|
200
200
|
validateProviderProfile(selection.profileName, updatedProfile, {
|
|
201
201
|
providerDefinitions: options.providerDefinitions,
|
|
202
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
202
203
|
});
|
|
203
204
|
} catch (error) {
|
|
204
205
|
return {
|
|
@@ -804,4 +804,81 @@ describe('createProviderCommandModule', () => {
|
|
|
804
804
|
expect(result?.message).toBe('Provider profile "ghost" was not found.');
|
|
805
805
|
});
|
|
806
806
|
});
|
|
807
|
+
|
|
808
|
+
// #3459: a host that withholds its own credentials from `process.env` checks `$ENV:` credentials
|
|
809
|
+
// against the snapshot it took first, which it hands the command as `env`.
|
|
810
|
+
describe('credentials the host withheld from process.env', () => {
|
|
811
|
+
const settings: TProviderSettingsDocument = {
|
|
812
|
+
currentProvider: 'openai',
|
|
813
|
+
providers: {
|
|
814
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
815
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
const snapshot = { ANTHROPIC_API_KEY: 'snapshot-key' };
|
|
819
|
+
|
|
820
|
+
function withoutLiveKey<T>(run: () => Promise<T>): Promise<T> {
|
|
821
|
+
const live = process.env['ANTHROPIC_API_KEY'];
|
|
822
|
+
delete process.env['ANTHROPIC_API_KEY'];
|
|
823
|
+
return run().finally(() => {
|
|
824
|
+
if (live !== undefined) process.env['ANTHROPIC_API_KEY'] = live;
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function executorWithSnapshot(adapter: IProviderCommandSettingsAdapter): SystemCommandExecutor {
|
|
829
|
+
const module = createProviderCommandModule({ providerDefinitions, settings: adapter, env: snapshot });
|
|
830
|
+
return new SystemCommandExecutor([...(module.systemCommands ?? [])]);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
it('switches to a profile whose key is only in the snapshot', async () => {
|
|
834
|
+
const { adapter, readTarget } = createSettingsAdapter(settings);
|
|
835
|
+
const result = await withoutLiveKey(() =>
|
|
836
|
+
executorWithSnapshot(adapter).execute('provider', headlessContext, 'switch anthropic'),
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
expect(result?.success).toBe(true);
|
|
840
|
+
expect(readTarget().currentProvider).toBe('anthropic');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('adds a profile whose default key is only in the snapshot', async () => {
|
|
844
|
+
const { adapter, readTarget } = createSettingsAdapter(settings);
|
|
845
|
+
const { context } = scriptedContext([
|
|
846
|
+
{ type: 'answer', values: [], text: '' },
|
|
847
|
+
{ type: 'answer', values: [], text: '' },
|
|
848
|
+
]);
|
|
849
|
+
const module = createProviderCommandModule({ providerDefinitions, settings: adapter, env: snapshot });
|
|
850
|
+
const result = await withoutLiveKey(() =>
|
|
851
|
+
new SystemCommandExecutor([...(module.systemCommands ?? [])]).execute('provider', context, 'add anthropic'),
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
expect(result?.success).not.toBe(false);
|
|
855
|
+
expect(readTarget().providers?.['anthropic-2']).toMatchObject({ type: 'anthropic' });
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('edits a profile whose default key is only in the snapshot', async () => {
|
|
859
|
+
const { adapter, readTarget } = createSettingsAdapter(settings);
|
|
860
|
+
const { context } = scriptedContext([
|
|
861
|
+
{ type: 'answer', values: [], text: '' },
|
|
862
|
+
{ type: 'answer', values: [], text: 'claude-opus-4-5' },
|
|
863
|
+
]);
|
|
864
|
+
const module = createProviderCommandModule({ providerDefinitions, settings: adapter, env: snapshot });
|
|
865
|
+
const result = await withoutLiveKey(() =>
|
|
866
|
+
new SystemCommandExecutor([...(module.systemCommands ?? [])]).execute('provider', context, 'edit anthropic'),
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
expect(result?.message).toBe('Provider anthropic updated.');
|
|
870
|
+
expect(readTarget().providers?.['anthropic']).toMatchObject({ model: 'claude-opus-4-5' });
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('tests a profile whose key is only in the snapshot', async () => {
|
|
874
|
+
const { adapter } = createSettingsAdapter(settings);
|
|
875
|
+
const result = await withoutLiveKey(() =>
|
|
876
|
+
executorWithSnapshot(adapter).execute('provider', headlessContext, 'test anthropic'),
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
expect(result?.message).toBe(
|
|
880
|
+
'Provider "anthropic" test passed: Profile fields are valid; no endpoint probe configured.',
|
|
881
|
+
);
|
|
882
|
+
});
|
|
883
|
+
});
|
|
807
884
|
});
|
|
@@ -58,7 +58,10 @@ export function buildProviderSwitch(
|
|
|
58
58
|
// previous behavior wrote `currentProvider` unconditionally and let a downstream hot-swap failure
|
|
59
59
|
// (e.g. "Unknown provider: anthropic. Currently supported: ", an empty list) stand uncorrected.
|
|
60
60
|
try {
|
|
61
|
-
validateProviderProfile(profileName, profile, {
|
|
61
|
+
validateProviderProfile(profileName, profile, {
|
|
62
|
+
providerDefinitions: options.providerDefinitions,
|
|
63
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
64
|
+
});
|
|
62
65
|
} catch (error) {
|
|
63
66
|
return {
|
|
64
67
|
message: `Failed to switch to "${profileName}": ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -146,6 +149,7 @@ function completeProviderEdit(
|
|
|
146
149
|
const target = options.settings.readTargetSettings();
|
|
147
150
|
const patch = buildProviderSetupPatch(input, {
|
|
148
151
|
providerDefinitions: options.providerDefinitions,
|
|
152
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
149
153
|
});
|
|
150
154
|
const updatedProfile = patch.providers[profileName];
|
|
151
155
|
if (!updatedProfile) {
|
|
@@ -105,6 +105,7 @@ function completeProviderSetup(
|
|
|
105
105
|
const target = options.settings.readTargetSettings();
|
|
106
106
|
const patch = buildProviderSetupPatch(input, {
|
|
107
107
|
providerDefinitions: options.providerDefinitions,
|
|
108
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
108
109
|
});
|
|
109
110
|
options.settings.writeTargetSettings(mergeProviderPatch(target, patch));
|
|
110
111
|
// #3282 §3: the session's own live setup-mode state (never settings — an ordinary env-default
|
|
@@ -89,7 +89,7 @@ export async function ensureProviderConfig(
|
|
|
89
89
|
const merged = readMergedProviderSettings(access.sources);
|
|
90
90
|
const selectedSettings =
|
|
91
91
|
ctx.provider !== undefined ? { ...merged, currentProvider: ctx.provider } : merged;
|
|
92
|
-
if (checkSettingsDocument(selectedSettings, providerDefinitions) === 'valid') {
|
|
92
|
+
if (checkSettingsDocument(selectedSettings, providerDefinitions, ctx.env ?? options.env) === 'valid') {
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
// Zero-config startup: a recognized provider env key with complete definition defaults
|
|
@@ -114,7 +114,7 @@ export async function ensureProviderConfig(
|
|
|
114
114
|
const updated = readMergedProviderSettings(access.sources);
|
|
115
115
|
const updatedSettings =
|
|
116
116
|
ctx.provider !== undefined ? { ...updated, currentProvider: ctx.provider } : updated;
|
|
117
|
-
if (checkSettingsDocument(updatedSettings, providerDefinitions) !== 'valid') {
|
|
117
|
+
if (checkSettingsDocument(updatedSettings, providerDefinitions, ctx.env ?? options.env) !== 'valid') {
|
|
118
118
|
throw new ProviderConfigError(options.formatError(providerDefinitions));
|
|
119
119
|
}
|
|
120
120
|
}
|