@robota-sdk/agent-command 3.0.0-beta.88 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-command",
3
- "version": "3.0.0-beta.88",
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.88",
44
- "@robota-sdk/agent-framework": "3.0.0-beta.88",
45
- "@robota-sdk/agent-interface-command": "3.0.0-beta.88",
46
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.88",
47
- "@robota-sdk/agent-interface-session": "3.0.0-beta.88",
48
- "@robota-sdk/agent-preset": "3.0.0-beta.88"
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, { providerDefinitions: options.providerDefinitions });
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
  }