Skip to content

Commit 240f071

Browse files
igorcostaAutohand Evolve
andcommitted
Honor process provider selection without persisting credentials
Apply AUTOHAND_PROVIDER after global and workspace configuration. Preserve saved provider settings during incidental hook and permission saves. Validate JSON, YAML, TOML, feature gates, and real terminal inference. Validation: CI=true bun run proof (8,940 unit tests and 111 Tuistory tests passed). Co-authored-by: Autohand Evolve <code-noreply@autohand.ai>
1 parent 0f511cf commit 240f071

4 files changed

Lines changed: 231 additions & 0 deletions

File tree

docs/config-reference.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export AUTOHAND_HOME=/custom/path # Changes ~/.autohand to /custom/path
7676
| -------------------------------------- | ------------------------------------------------ | -------------------------------- |
7777
| `AUTOHAND_HOME` | Base directory for all Autohand data | `/custom/path` |
7878
| `AUTOHAND_CONFIG` | Custom config file path | `/path/to/config.toml` |
79+
| `AUTOHAND_PROVIDER` | Select provider for this process, overriding global and workspace selection | `autohandai` |
7980
| `AUTOHAND_MODELS_CATALOG` | Custom provider model catalog path | `/path/to/models.json` |
8081
| `AUTOHAND_API_URL` | API endpoint (overrides config) | `https://api.autohand.ai` |
8182
| `AUTOHAND_AUTH_URL` | Sign-in and account-sync website origin (independent of `AUTOHAND_API_URL`) | `https://autohand.ai` |
@@ -95,6 +96,21 @@ export AUTOHAND_HOME=/custom/path # Changes ~/.autohand to /custom/path
9596
| `AUTOHAND_CODE_SIMPLE` | Enable bare mode without passing `--bare` | `1` |
9697
| `AUTOHAND_DISABLE_STATEFUL_READ` | Emergency opt-out for all stateful-read experiments | `1` |
9798

99+
### Process provider selection
100+
101+
Set `AUTOHAND_PROVIDER=autohandai` to select Autohand AI for this process even
102+
when the global configuration or `.autohand/settings.local.json` selects another
103+
provider. Supply inference credentials through `AUTOHAND_AI_API_KEY`,
104+
`AUTOHAND_AI_BASE_URL`, and `AUTOHAND_AI_PLAN`; normal account authentication
105+
still applies. Existing feature gates remain in effect.
106+
107+
The override accepts the normal built-in, `custom:<id>`, and `extension:<id>`
108+
provider names. An empty or unsupported explicit value fails startup. Omitting
109+
the variable preserves normal saved-provider selection. Incidental settings
110+
saves retain the saved provider selection and its configuration section,
111+
including credentials, instead of writing process-only values to disk. For
112+
custom or extension providers, their configuration map is retained from disk.
113+
98114
### Thinking Level
99115

100116
The `AUTOHAND_THINKING_LEVEL` environment variable controls the depth of reasoning the model uses:

src/config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,14 @@ function normalizeSavedApiBaseUrl(baseUrl: string | undefined): string | undefin
641641
* Env vars take precedence over config file values
642642
*/
643643
function mergeEnvVariables(config: AutohandConfig): AutohandConfig {
644+
if (process.env.AUTOHAND_PROVIDER !== undefined) {
645+
const provider = normalizeProviderName(process.env.AUTOHAND_PROVIDER.trim());
646+
if (!provider) {
647+
throw new Error("AUTOHAND_PROVIDER must name a supported provider");
648+
}
649+
config = { ...config, provider };
650+
}
651+
644652
config = {
645653
...config,
646654
api: {
@@ -1367,6 +1375,22 @@ export async function saveConfig(
13671375
const { configPath, ...data } = config;
13681376
delete (data as Partial<LoadedConfig>).isNewConfig;
13691377

1378+
const processProvider = normalizeProviderName(process.env.AUTOHAND_PROVIDER?.trim());
1379+
if (processProvider) {
1380+
// Hook and permission saves must not persist process-only provider credentials.
1381+
const persisted = await fs.pathExists(configPath) ? await parseConfigFile(configPath) : {};
1382+
const settingsKey = isCustomProviderName(processProvider) ? "customProviders"
1383+
: processProvider.startsWith("extension:") ? "extensionProviders"
1384+
: processProvider === "blueprint-local" ? "blueprintLocal" : processProvider;
1385+
for (const key of ["provider", settingsKey]) {
1386+
if (Object.hasOwn(persisted, key)) {
1387+
Object.assign(data, { [key]: Reflect.get(persisted, key) });
1388+
} else {
1389+
Reflect.deleteProperty(data, key);
1390+
}
1391+
}
1392+
}
1393+
13701394
if (!options.writeAuth) {
13711395
const persisted = await readPersistedAuth(configPath);
13721396
if (persisted.present) {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import fs from 'fs-extra';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { getProviderConfig, loadConfig, saveConfig } from '../src/config.js';
6+
7+
describe('process provider selection', () => {
8+
let directory: string;
9+
10+
beforeEach(async () => {
11+
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'autohand-provider-env-'));
12+
vi.stubEnv('AUTOHAND_PROVIDER', undefined);
13+
vi.stubEnv('AUTOHAND_AI_API_KEY', 'fixture-inference-key');
14+
vi.stubEnv('AUTOHAND_AI_BASE_URL', 'http://127.0.0.1:12345/v1');
15+
vi.stubEnv('AUTOHAND_AI_PLAN', 'cloud');
16+
});
17+
18+
afterEach(async () => {
19+
vi.unstubAllEnvs();
20+
await fs.remove(directory);
21+
});
22+
23+
const formats = [
24+
['json', '{"provider":"openrouter","auth":{"token":"fixture-account-token"}}'],
25+
['yaml', 'provider: openrouter\nauth:\n token: fixture-account-token\n'],
26+
['toml', 'provider = "openrouter"\n[auth]\ntoken = "fixture-account-token"\n'],
27+
] as const;
28+
29+
it.each(formats)('selects Autohand AI over global and workspace %s settings without rewriting them', async (format, content) => {
30+
const configPath = path.join(directory, `config.${format}`);
31+
const workspace = path.join(directory, 'project');
32+
const localPath = path.join(workspace, '.autohand', 'settings.local.json');
33+
const localContent = '{"provider":"openai","permissions":{"mode":"restricted"}}';
34+
await fs.writeFile(configPath, content);
35+
await fs.outputFile(localPath, localContent);
36+
vi.stubEnv('AUTOHAND_PROVIDER', 'autohandai');
37+
38+
const config = await loadConfig(configPath, workspace, { initializeTheme: false });
39+
40+
expect(config.provider).toBe('autohandai');
41+
expect(config.auth?.token).toBe('fixture-account-token');
42+
expect(config.permissions?.mode).toBe('restricted');
43+
expect(getProviderConfig(config)).toMatchObject({
44+
apiKey: 'fixture-inference-key', baseUrl: 'http://127.0.0.1:12345/v1', model: 'fantail',
45+
});
46+
expect(await fs.readFile(configPath, 'utf8')).toBe(content);
47+
expect(await fs.readFile(localPath, 'utf8')).toBe(localContent);
48+
});
49+
50+
it.each(['anthropic', 'custom:acme', 'extension:company-provider', 'vertex'])('accepts the existing provider contract for %s', async provider => {
51+
const configPath = path.join(directory, 'config.json');
52+
await fs.writeJson(configPath, { provider: 'openrouter' });
53+
vi.stubEnv('AUTOHAND_PROVIDER', provider);
54+
55+
const config = await loadConfig(configPath, undefined, { initializeTheme: false });
56+
57+
expect(config.provider).toBe(provider === 'vertex' ? 'vertexai' : provider);
58+
});
59+
60+
it.each(['', ' ', 'unknown-provider', 'extension:'])('rejects invalid explicit selection %j instead of silently using a saved provider', async provider => {
61+
const configPath = path.join(directory, 'config.json');
62+
await fs.writeJson(configPath, { provider: 'openrouter' });
63+
vi.stubEnv('AUTOHAND_PROVIDER', provider);
64+
65+
await expect(loadConfig(configPath, undefined, { initializeTheme: false }))
66+
.rejects.toThrow('AUTOHAND_PROVIDER');
67+
});
68+
69+
it('keeps the saved provider when only inference credentials are supplied', async () => {
70+
const configPath = path.join(directory, 'config.json');
71+
await fs.writeJson(configPath, { provider: 'openrouter' });
72+
73+
const config = await loadConfig(configPath, undefined, { initializeTheme: false });
74+
75+
expect(config.provider).toBe('openrouter');
76+
});
77+
78+
it('preserves an explicitly disabled inference feature', async () => {
79+
const configPath = path.join(directory, 'config.json');
80+
await fs.writeJson(configPath, { provider: 'openrouter', features: { autohand_inference: false } });
81+
vi.stubEnv('AUTOHAND_PROVIDER', 'autohandai');
82+
83+
const config = await loadConfig(configPath, undefined, { initializeTheme: false });
84+
85+
expect(config.provider).toBe('autohandai');
86+
expect(config.features?.autohand_inference).toBe(false);
87+
expect(getProviderConfig(config)).toBeNull();
88+
});
89+
90+
it.each(formats)('does not persist process provider settings during an unrelated %s save', async (format, content) => {
91+
const configPath = path.join(directory, `config.${format}`);
92+
await fs.writeFile(configPath, content);
93+
vi.stubEnv('AUTOHAND_PROVIDER', 'autohandai');
94+
const config = await loadConfig(configPath, undefined, { initializeTheme: false });
95+
config.ui = { completionReportEnabled: false };
96+
97+
await saveConfig(config);
98+
vi.stubEnv('AUTOHAND_PROVIDER', undefined);
99+
vi.stubEnv('AUTOHAND_AI_API_KEY', undefined);
100+
vi.stubEnv('AUTOHAND_AI_BASE_URL', undefined);
101+
vi.stubEnv('AUTOHAND_AI_PLAN', undefined);
102+
const saved = await loadConfig(configPath, undefined, { initializeTheme: false });
103+
104+
expect(saved.provider).toBe('openrouter');
105+
expect(saved.autohandai).toBeUndefined();
106+
expect(saved.ui?.completionReportEnabled).toBe(false);
107+
expect(saved.auth?.token).toBe('fixture-account-token');
108+
});
109+
110+
it('keeps the latest saved provider credentials when another process updates them', async () => {
111+
const configPath = path.join(directory, 'config.json');
112+
const original = {
113+
provider: 'openrouter',
114+
autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'saved-key', model: 'moa' },
115+
};
116+
await fs.writeJson(configPath, original);
117+
vi.stubEnv('AUTOHAND_PROVIDER', 'autohandai');
118+
const config = await loadConfig(configPath, undefined, { initializeTheme: false });
119+
const newer = { ...original, provider: 'ollama', autohandai: { ...original.autohandai, apiKey: 'newer-key' } };
120+
await fs.writeJson(configPath, newer);
121+
122+
await saveConfig(config);
123+
124+
const saved = await fs.readJson(configPath);
125+
expect(saved.provider).toBe('ollama');
126+
expect(saved.autohandai).toEqual(newer.autohandai);
127+
expect(config.autohandai?.apiKey).toBe('fixture-inference-key');
128+
});
129+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from 'vitest';
2+
import fs from 'fs-extra';
3+
import path from 'node:path';
4+
import type { Session } from 'tuistory';
5+
import {
6+
createMockAuthServer,
7+
createMockAutohandAINativeSequenceServer,
8+
createTempAutohandHome,
9+
expectCleanExit,
10+
exitInteractive,
11+
launchBuiltAutohand,
12+
waitForExit,
13+
} from './helpers/autohandTuistory.js';
14+
15+
describe('provider environment startup', () => {
16+
it('uses Autohand AI for an actual prompt while preserving saved provider selections', async () => {
17+
const auth = await createMockAuthServer();
18+
const provider = await createMockAutohandAINativeSequenceServer([
19+
{ content: 'PROCESS_PROVIDER_OVERRIDE_OK' },
20+
]);
21+
const state = await createTempAutohandHome({ config: {
22+
provider: 'openrouter',
23+
openrouter: { baseUrl: auth.baseUrl },
24+
features: { autohand_inference: true },
25+
agent: { maxIterations: 2, sessionRetryLimit: 0, autoMemory: false },
26+
network: { maxRetries: 0, retryDelay: 0 },
27+
} });
28+
const localPath = path.join(state.workspaceRoot, '.autohand', 'settings.local.json');
29+
await fs.outputJson(localPath, { provider: 'openrouter' });
30+
const originalConfig = await fs.readFile(state.configPath, 'utf8');
31+
const originalLocal = await fs.readFile(localPath, 'utf8');
32+
let session: Session | undefined;
33+
try {
34+
session = await launchBuiltAutohand([
35+
'--path', state.workspaceRoot, '--config', state.configPath,
36+
'--prompt', 'Reply with the provider fixture marker.', '--y',
37+
], {
38+
autohandHome: state.autohandHome,
39+
cwd: state.workspaceRoot,
40+
env: {
41+
AUTOHAND_PROVIDER: 'autohandai',
42+
AUTOHAND_AI_API_KEY: 'fixture-inference-key',
43+
AUTOHAND_AI_BASE_URL: provider.baseUrl,
44+
AUTOHAND_AI_PLAN: 'cloud',
45+
AUTOHAND_AUTH_API_URL: `${auth.baseUrl}/api/auth`,
46+
},
47+
waitForDataTimeout: 15_000,
48+
});
49+
await waitForExit(session, 30_000);
50+
expect(session.readAll()).toContain('PROCESS_PROVIDER_OVERRIDE_OK');
51+
expect(provider.requests).toHaveLength(1);
52+
expectCleanExit(session);
53+
const saved = await fs.readJson(state.configPath);
54+
expect(saved.provider).toBe(JSON.parse(originalConfig).provider);
55+
expect(saved.autohandai).toBeUndefined();
56+
expect(await fs.readFile(localPath, 'utf8')).toBe(originalLocal);
57+
} finally {
58+
if (session && !session.exitInfo) await exitInteractive(session);
59+
await Promise.all([provider.close(), auth.close(), state.cleanup()]);
60+
}
61+
}, 45_000);
62+
});

0 commit comments

Comments
 (0)