From 38d032a27202b22472a8a79eedbb0344932594df Mon Sep 17 00:00:00 2001 From: Cestercian Date: Thu, 15 Oct 2026 10:58:15 +0530 Subject: [PATCH 01/38] Let a custom provider save when /models does not list its model The New provider flow refused to save an OpenAI-compatible provider whose model was missing from /models. Routers such as Routes accept policy names like router-balanced that they don't list there, so the provider could only be added by editing config.json by hand. A missing model is now a warning: the CLI shows the listed models and asks whether to save anyway (default no). A non-OK /models response or a network error still blocks the save. Fixes #659 --- docs/providers.md | 2 +- src/core/agent/ProviderConfigManager.ts | 22 ++++++- src/i18n/locales/en.json | 5 +- .../ProviderConfigManager.openai.test.ts | 62 ++++++++++++++++++- 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index ecf4079c5..02fc520ef 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -784,7 +784,7 @@ From the TUI, run `/model`, choose **New provider...**, then enter: - model id - optional context window and reasoning effort -Autohand verifies the base URL, API key, and selected model through the OpenAI-compatible `/models` endpoint before saving the provider. If `/models` returns model IDs, the selected model must be present in that list. +Autohand verifies the base URL, API key, and selected model through the OpenAI-compatible `/models` endpoint before saving the provider. If `/models` returns model IDs and the selected model isn't one of them, Autohand warns, shows some of the listed models, and asks whether to save anyway, since routers and gateways often accept model names they don't list. A failed `/models` request still blocks the save. The saved config uses `provider: "custom:"` and stores provider details under `customProviders`: diff --git a/src/core/agent/ProviderConfigManager.ts b/src/core/agent/ProviderConfigManager.ts index ee918dec2..18a0537f0 100644 --- a/src/core/agent/ProviderConfigManager.ts +++ b/src/core/agent/ProviderConfigManager.ts @@ -2385,6 +2385,22 @@ export class ProviderConfigManager { } return; } + if (verification.warning) { + // Some OpenAI-compatible endpoints (routers, gateways) accept model names + // they don't list in /models, so a missing model is a warning, not a stop. + console.log(chalk.yellow(`\n⚠ ${verification.warning}`)); + if (verification.hint) { + console.log(chalk.gray(verification.hint)); + } + const saveAnyway = await showConfirm({ + title: t("providers.custom.saveAnyway", { model: customProvider.model }), + defaultValue: false, + }); + if (!saveAnyway) { + console.log(chalk.gray("\n" + t("providers.config.cancelled"))); + return; + } + } this.runtime.config.customProviders = { ...this.runtime.config.customProviders, @@ -2410,7 +2426,7 @@ export class ProviderConfigManager { private async verifyCustomProvider( provider: CustomProviderSettings, - ): Promise<{ valid: boolean; error?: string; hint?: string }> { + ): Promise<{ valid: boolean; error?: string; warning?: string; hint?: string }> { const headers: Record = { "Content-Type": "application/json", }; @@ -2434,8 +2450,8 @@ export class ProviderConfigManager { const modelIds = this.extractOpenAIModelIds(body); if (modelIds.length > 0 && !modelIds.includes(provider.model)) { return { - valid: false, - error: t("providers.custom.modelNotFound", { + valid: true, + warning: t("providers.custom.modelNotFound", { model: provider.model, }), hint: t("providers.custom.modelNotFoundHint", { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 50c88ed14..bcbaa1b9f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -947,8 +947,9 @@ "verificationFailedStatus": "Provider verification failed with HTTP {{status}}.", "verificationFailedHint": "Check the base URL, API key, and that the endpoint implements the OpenAI-compatible /models API.", "verificationNetworkError": "Provider verification failed because Autohand could not reach the endpoint.", - "modelNotFound": "Provider verification failed because model {{model}} was not returned by /models.", - "modelNotFoundHint": "Available models include: {{models}}" + "modelNotFound": "Model {{model}} was not returned by /models. Some providers, such as routers and gateways, accept model names they don't list.", + "modelNotFoundHint": "Available models include: {{models}}", + "saveAnyway": "Save this provider with model {{model}} anyway?" }, "wizard": { "openrouter": { diff --git a/tests/core/agent/ProviderConfigManager.openai.test.ts b/tests/core/agent/ProviderConfigManager.openai.test.ts index 532c8d239..fd037a76f 100644 --- a/tests/core/agent/ProviderConfigManager.openai.test.ts +++ b/tests/core/agent/ProviderConfigManager.openai.test.ts @@ -970,7 +970,7 @@ describe("ProviderConfigManager openai auth mode", () => { expect(mockSaveConfig).toHaveBeenCalledOnce(); }); - it("does not save a custom provider when verification rejects the model", async () => { + it("does not save a custom provider when the model is not listed and the user declines", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ @@ -986,17 +986,75 @@ describe("ProviderConfigManager openai auth mode", () => { .mockResolvedValueOnce("256000"); mockShowConfirm .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); mockShowPassword.mockResolvedValueOnce("acme-key-long-enough"); mockShowModal.mockResolvedValueOnce({ value: "high" }); await (manager as any).configureCustomProvider(); + expect(mockShowConfirm).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "providers.custom.saveAnyway", defaultValue: false }), + ); expect(runtime.config.customProviders).toBeUndefined(); expect(runtime.config.provider).toBe("openrouter"); expect(mockSaveConfig).not.toHaveBeenCalled(); }); + it("saves a custom provider whose model is not listed by /models when the user confirms", async () => { + // Routers such as Routes accept policy names like router-balanced that + // their /models endpoint does not list. + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "qwen3-coder" }, { id: "gpt-5-mini" }], + }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + mockShowInput + .mockResolvedValueOnce("Routes") + .mockResolvedValueOnce("http://127.0.0.1:8080/v1") + .mockResolvedValueOnce("router-balanced") + .mockResolvedValueOnce(""); + mockShowConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + mockShowPassword.mockResolvedValueOnce("routes-key-long-enough"); + + await (manager as any).configureCustomProvider(); + + expect(mockShowConfirm).toHaveBeenCalledTimes(3); + expect(runtime.config.provider).toBe("custom:routes"); + expect(runtime.config.customProviders.routes).toEqual( + expect.objectContaining({ + baseUrl: "http://127.0.0.1:8080/v1", + model: "router-balanced", + }), + ); + expect(mockSaveConfig).toHaveBeenCalledOnce(); + }); + + it("still refuses a custom provider whose /models request fails", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401 }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + mockShowInput + .mockResolvedValueOnce("Acme AI") + .mockResolvedValueOnce("https://api.acme.example/v1") + .mockResolvedValueOnce("acme-code-1") + .mockResolvedValueOnce(""); + mockShowConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + mockShowPassword.mockResolvedValueOnce("acme-key-long-enough"); + + await (manager as any).configureCustomProvider(); + + expect(mockShowConfirm).toHaveBeenCalledTimes(2); + expect(runtime.config.customProviders).toBeUndefined(); + expect(mockSaveConfig).not.toHaveBeenCalled(); + }); + it("updates reasoning effort from a configured custom provider menu", async () => { runtime.config.provider = "custom:acme"; runtime.config.customProviders = { From eb98157acb95fff3603acdb7b60825cdb95de8a2 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Sun, 18 Oct 2026 09:01:10 +1300 Subject: [PATCH 02/38] Describe Gemini 4 Argon as available on Team Team gained Argon on 2026-10-18 (Q-0008); the /model description now names every plan that can call it. Co-authored-by: Autohand Evolve --- src/providers/models.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/models.json b/src/providers/models.json index 3f4100a36..8b10cde03 100644 --- a/src/providers/models.json +++ b/src/providers/models.json @@ -39,7 +39,7 @@ {"id": "qwen3.8-27b", "displayName": "qwen3.8-27b", "description": "Pro+, Max, Team and Enterprise", "contextWindow": 262144, "maxTokens": 16000, "toolCalls": true}, {"id": "muse-wonder-1.0", "displayName": "Muse Wonder 1.0", "description": "Lite and above", "contextWindow": 262144, "maxTokens": 16000, "toolCalls": true, "reasoningEffort": "high", "reasoningEfforts": ["low", "medium", "high", "xhigh"]}, {"id": "qwen3.8-max", "displayName": "Qwen 3.8 Max", "description": "Lite and above", "contextWindow": 262144, "maxTokens": 16000, "toolCalls": true}, - {"id": "gemini-4-argon", "displayName": "Gemini 4 Argon", "description": "Max and Enterprise", "contextWindow": 1048576, "maxTokens": 16000, "toolCalls": true, "reasoningEffort": "high", "reasoningEfforts": ["low", "medium", "high"]}, + {"id": "gemini-4-argon", "displayName": "Gemini 4 Argon", "description": "Max, Team and Enterprise", "contextWindow": 1048576, "maxTokens": 16000, "toolCalls": true, "reasoningEffort": "high", "reasoningEfforts": ["low", "medium", "high"]}, { "id": "weka", "displayName": "Weka (API + Console only)", From 52d13181a78eb8de38c5086c8111818ceea893d9 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Sat, 17 Oct 2026 16:28:45 +1300 Subject: [PATCH 03/38] Parse --sync-settings as a boolean Commander delivers --sync-settings false as the string "false", which is truthy, so the flag could never turn sync off. --- src/index.ts | 4 ++++ src/startup/cliOptions.ts | 21 +++++++++++++++++++++ tests/startup/cliOptions.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/src/index.ts b/src/index.ts index 827e6ddad..67f36d6bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -523,6 +523,10 @@ program } const normalization = normalizeInitialCliOptions(opts); + if (normalization.invalidOption) { + console.error(chalk.red(normalization.invalidOption)); + process.exit(2); + } if (normalization.deprecatedBrowserOption) { console.warn(chalk.yellow(formatDeprecatedBrowserOptionWarning(normalization.deprecatedBrowserOption))); } diff --git a/src/startup/cliOptions.ts b/src/startup/cliOptions.ts index 61b1f453b..d119a04d4 100644 --- a/src/startup/cliOptions.ts +++ b/src/startup/cliOptions.ts @@ -38,6 +38,20 @@ export interface RootCliOptions extends CLIOptions { export interface InitialCliOptionsNormalization { deprecatedBrowserOption?: DeprecatedBrowserOption; + invalidOption?: string; +} + +const TRUE_WORDS = new Set(['true', '1', 'yes', 'on']); +const FALSE_WORDS = new Set(['false', '0', 'no', 'off']); + +/** Commander hands `--flag [bool]` values through as strings; map the usual spellings. */ +export function parseBooleanOption(value: unknown): boolean | undefined | 'invalid' { + if (value === undefined || typeof value === 'boolean') return value; + if (typeof value !== 'string') return 'invalid'; + const word = value.trim().toLowerCase(); + if (TRUE_WORDS.has(word)) return true; + if (FALSE_WORDS.has(word)) return false; + return 'invalid'; } export function normalizeInitialCliOptions( @@ -74,6 +88,13 @@ export function normalizeInitialCliOptions( if (goal === true) { options.goal = ''; } + const syncSettings = parseBooleanOption(options.syncSettings); + if (syncSettings === 'invalid') { + result.invalidOption = `--sync-settings expects true or false, got ${JSON.stringify(options.syncSettings)}`; + options.syncSettings = undefined; + } else { + options.syncSettings = syncSettings; + } if (options.systemPrompt) { options.sysPrompt = options.systemPrompt; } diff --git a/tests/startup/cliOptions.test.ts b/tests/startup/cliOptions.test.ts index affadbe89..a6ada92f0 100644 --- a/tests/startup/cliOptions.test.ts +++ b/tests/startup/cliOptions.test.ts @@ -162,4 +162,29 @@ describe('CLI option normalization', () => { dryRun: true, }); }); + + it('parses string values of --sync-settings as booleans', () => { + for (const value of ['false', 'FALSE', '0', 'no', 'off']) { + const options = { syncSettings: value } as unknown as RootCliOptions; + normalizeInitialCliOptions(options, {}); + expect(options.syncSettings).toBe(false); + } + for (const value of ['true', 'TRUE', '1', 'yes', 'on']) { + const options = { syncSettings: value } as unknown as RootCliOptions; + normalizeInitialCliOptions(options, {}); + expect(options.syncSettings).toBe(true); + } + const bare = { syncSettings: true } as RootCliOptions; + normalizeInitialCliOptions(bare, {}); + expect(bare.syncSettings).toBe(true); + const unset = {} as RootCliOptions; + normalizeInitialCliOptions(unset, {}); + expect(unset.syncSettings).toBeUndefined(); + }); + + it('rejects an unrecognised --sync-settings value instead of silently enabling sync', () => { + const options = { syncSettings: 'maybe' } as unknown as RootCliOptions; + const result = normalizeInitialCliOptions(options, {}); + expect(result.invalidOption).toBe('--sync-settings expects true or false, got "maybe"'); + }); }); From 0da9b68224f02f2b1301af33cb36cc2c2b636c6b Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Sat, 17 Oct 2026 16:33:54 +1300 Subject: [PATCH 04/38] Gate project meta-tools on workspace trust and stop them shadowing user tools .autohand/tools loaded for any checkout and took precedence over the user's own tools and built-in tool names. Project definitions now load only in a trusted workspace, user tools win on a name clash, and built-in names are reserved; each skipped definition is reported in diagnostics. --- src/core/actionExecutor.ts | 4 +- src/core/agent/AgentDependencyComposer.ts | 4 +- src/core/toolsRegistry.ts | 76 +++++++++++++++++++---- src/extensions/cli.ts | 4 +- src/modes/teammate.ts | 4 +- tests/core/toolsRegistry.trust.test.ts | 73 ++++++++++++++++++++++ tests/toolsRegistry.spec.ts | 20 +++--- 7 files changed, 160 insertions(+), 25 deletions(-) create mode 100644 tests/core/toolsRegistry.trust.test.ts diff --git a/src/core/actionExecutor.ts b/src/core/actionExecutor.ts index 6d5860bcc..8cee973bb 100644 --- a/src/core/actionExecutor.ts +++ b/src/core/actionExecutor.ts @@ -401,7 +401,9 @@ export class ActionExecutor { this.projectManager = deps.projectManager; this.sessionId = deps.sessionId; this.logExploration = deps.onExploration; - this.toolsRegistry = deps.toolsRegistry ?? createToolsRegistry(deps.runtime.workspaceRoot); + this.toolsRegistry = deps.toolsRegistry ?? createToolsRegistry(deps.runtime.workspaceRoot, undefined, { + projectToolsTrusted: deps.runtime.config.workspaceTrust?.trusted === true, + }); this.metaToolService = deps.metaToolService ?? new MetaToolService(this.toolsRegistry); this.getRegisteredTools = deps.getRegisteredTools ?? (() => []); this.permissionManager = deps.permissionManager ?? new PermissionManager(deps.runtime.config.permissions); diff --git a/src/core/agent/AgentDependencyComposer.ts b/src/core/agent/AgentDependencyComposer.ts index a83ad51a3..d2f793187 100644 --- a/src/core/agent/AgentDependencyComposer.ts +++ b/src/core/agent/AgentDependencyComposer.ts @@ -550,7 +550,9 @@ export function initializeAgentDependencies( const agentRegistry = configureAgentRegistry(runtime); const pluginDir = (runtime.config as typeof runtime.config & { pluginDir?: string }).pluginDir; - const toolsRegistry = createToolsRegistry(runtime.workspaceRoot, pluginDir ?? AUTOHAND_PATHS.tools); + const toolsRegistry = createToolsRegistry(runtime.workspaceRoot, pluginDir ?? AUTOHAND_PATHS.tools, { + projectToolsTrusted: runtime.config.workspaceTrust?.trusted === true, + }); host.toolsRegistry = toolsRegistry; host.extensionService = new ExtensionService({ projectRoot: join(runtime.workspaceRoot, PROJECT_DIR_NAME, 'extensions'), diff --git a/src/core/toolsRegistry.ts b/src/core/toolsRegistry.ts index bf357db95..aba6c701c 100644 --- a/src/core/toolsRegistry.ts +++ b/src/core/toolsRegistry.ts @@ -63,14 +63,32 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export function createToolsRegistry(workspaceRoot?: string, userToolsDir = AUTOHAND_PATHS.tools): ToolsRegistry { - const locations: ToolsRegistryLocation[] = workspaceRoot - ? [ - { scope: 'project', dir: path.join(workspaceRoot, PROJECT_DIR_NAME, 'tools') }, - { scope: 'user', dir: userToolsDir }, - ] - : [{ scope: 'user', dir: userToolsDir }]; - return new ToolsRegistry(locations); +export interface ToolsRegistryOptions { + /** Project (.autohand/tools) definitions load only when the workspace is trusted. Defaults to false. */ + projectToolsTrusted?: boolean; +} + +const UNTRUSTED_PROJECT_TOOLS_REASON = 'project tools require a trusted workspace'; +const SHADOW_REASON_PREFIX = 'project tool shadows '; + +/** Active-cache precedence: a user tool always wins over a project tool of the same name. */ +const META_TOOL_PRECEDENCE: readonly MetaToolScope[] = ['user', 'project']; + +export function createToolsRegistry( + workspaceRoot?: string, + userToolsDir = AUTOHAND_PATHS.tools, + options: ToolsRegistryOptions = {}, +): ToolsRegistry { + const registry = new ToolsRegistry([{ scope: 'user', dir: userToolsDir }]); + if (workspaceRoot) { + const projectDir = path.join(workspaceRoot, PROJECT_DIR_NAME, 'tools'); + if (options.projectToolsTrusted) { + registry.addLocation({ scope: 'project', dir: projectDir }); + } else { + registry.noteUntrustedProjectTools(projectDir); + } + } + return registry; } export class ToolsRegistry { @@ -78,12 +96,29 @@ export class ToolsRegistry { private metaToolRecords: Map = new Map(); private diagnostics: MetaToolDiagnostic[] = []; private extensionToolRecords: Map = new Map(); + private reservedBuiltInNames: Set = new Set(); + private untrustedProjectToolsDir?: string; constructor(locations?: string | ToolsRegistryLocation[]) { this.locations = normalizeLocations(locations); } - private readonly locations: ToolsRegistryLocation[]; + private locations: ToolsRegistryLocation[]; + + addLocation(location: ToolsRegistryLocation): void { + this.locations.push(location); + } + + /** Remember a project tools directory that was skipped because the workspace is untrusted. */ + noteUntrustedProjectTools(dir: string): void { + this.untrustedProjectToolsDir = dir; + } + + /** Built-in tool names a project tool may never take; idempotent. */ + reserveBuiltInNames(names: Iterable): void { + for (const name of names) this.reservedBuiltInNames.add(name); + this.rebuildActiveCache(); + } async initialize(): Promise { this.metaToolCache.clear(); @@ -96,6 +131,8 @@ export class ToolsRegistry { } async listTools(builtIns: ToolDefinition[]): Promise { + // Keeps getMetaTool consistent with what the agent advertises as built-in. + this.reserveBuiltInNames(builtIns.map((tool) => tool.name)); const seen = new Set(); const entries: ToolRegistryEntry[] = []; @@ -331,6 +368,9 @@ export class ToolsRegistry { } private async loadMetaToolDefinitions(): Promise { + if (this.untrustedProjectToolsDir && (await fs.pathExists(this.untrustedProjectToolsDir))) { + this.diagnostics.push({ file: this.untrustedProjectToolsDir, reason: UNTRUSTED_PROJECT_TOOLS_REASON }); + } for (const location of this.locations) { try { const exists = await fs.pathExists(location.dir); @@ -451,14 +491,24 @@ export class ToolsRegistry { private rebuildActiveCache(): void { this.metaToolCache.clear(); - for (const location of this.locations) { + this.diagnostics = this.diagnostics.filter((d) => !d.reason.startsWith(SHADOW_REASON_PREFIX)); + for (const scope of META_TOOL_PRECEDENCE) { for (const record of this.metaToolRecords.values()) { - if (record.definition.scope !== location.scope || record.definition.disabled) { + if (record.definition.scope !== scope || record.definition.disabled) { continue; } - if (!this.metaToolCache.has(record.definition.name)) { - this.metaToolCache.set(record.definition.name, record.definition); + const name = record.definition.name; + if (scope === 'project' && this.reservedBuiltInNames.has(name)) { + this.diagnostics.push({ file: record.filePath, reason: `${SHADOW_REASON_PREFIX}built-in tool "${name}"` }); + continue; } + if (this.metaToolCache.has(name)) { + if (scope === 'project') { + this.diagnostics.push({ file: record.filePath, reason: `${SHADOW_REASON_PREFIX}user tool "${name}"` }); + } + continue; + } + this.metaToolCache.set(name, record.definition); } } for (const [name, record] of this.extensionToolRecords) { diff --git a/src/extensions/cli.ts b/src/extensions/cli.ts index 089636c5e..7b0587d7b 100644 --- a/src/extensions/cli.ts +++ b/src/extensions/cli.ts @@ -340,7 +340,9 @@ export async function extensionServiceFor(program: Command): Promise ({ + name, + description: `${scope} ${name}`, + parameters: { type: 'object', properties: {} }, + handler: `echo ${scope}`, +}); + +const SCRATCH_DIR = path.join(os.homedir(), 'Documents/autohand/digital-team/charlie-cox/scratch'); + +let root: string; +let userDir: string; + +beforeEach(async () => { + await fs.ensureDir(SCRATCH_DIR); + root = await fs.mkdtemp(path.join(SCRATCH_DIR, 'tools-trust-')); + userDir = path.join(root, 'user-tools'); + await fs.ensureDir(path.join(root, 'workspace', '.autohand', 'tools')); + await fs.ensureDir(userDir); +}); + +afterEach(async () => { + await fs.remove(root); +}); + +describe('project meta-tools and workspace trust', () => { + it('loads no project meta-tools from an untrusted workspace', async () => { + await fs.writeJson(path.join(root, 'workspace', '.autohand', 'tools', 'deploy.json'), definition('deploy', 'project')); + const registry = createToolsRegistry(path.join(root, 'workspace'), userDir, { projectToolsTrusted: false }); + await registry.initialize(); + await registry.listTools([]); + expect(registry.getMetaTool('deploy')).toBeUndefined(); + expect(registry.getDiagnostics().map((d) => d.reason)).toContain('project tools require a trusted workspace'); + }); + + it('loads project meta-tools from a trusted workspace', async () => { + await fs.writeJson(path.join(root, 'workspace', '.autohand', 'tools', 'deploy.json'), definition('deploy', 'project')); + const registry = createToolsRegistry(path.join(root, 'workspace'), userDir, { projectToolsTrusted: true }); + await registry.initialize(); + await registry.listTools([]); + expect(registry.getMetaTool('deploy')?.scope).toBe('project'); + }); + + it('lets a user tool win over a project tool of the same name', async () => { + await fs.writeJson(path.join(root, 'workspace', '.autohand', 'tools', 'lint.json'), definition('lint', 'project')); + await fs.writeJson(path.join(userDir, 'lint.json'), definition('lint', 'user')); + const registry = createToolsRegistry(path.join(root, 'workspace'), userDir, { projectToolsTrusted: true }); + await registry.initialize(); + await registry.listTools([]); + expect(registry.getMetaTool('lint')?.scope).toBe('user'); + expect(registry.getDiagnostics().map((d) => d.reason)).toContain('project tool shadows user tool "lint"'); + }); + + it('never lets a project tool take a built-in tool name', async () => { + await fs.writeJson(path.join(root, 'workspace', '.autohand', 'tools', 'run_command.json'), definition('run_command', 'project')); + const registry = createToolsRegistry(path.join(root, 'workspace'), userDir, { projectToolsTrusted: true }); + registry.reserveBuiltInNames(['run_command']); + await registry.initialize(); + await registry.listTools([{ name: 'run_command', description: 'built in', parameters: { type: 'object', properties: {} } } as never]); + expect(registry.getMetaTool('run_command')).toBeUndefined(); + expect(registry.getDiagnostics().map((d) => d.reason)).toContain('project tool shadows built-in tool "run_command"'); + }); +}); diff --git a/tests/toolsRegistry.spec.ts b/tests/toolsRegistry.spec.ts index 61709be59..dabd39df3 100644 --- a/tests/toolsRegistry.spec.ts +++ b/tests/toolsRegistry.spec.ts @@ -91,7 +91,7 @@ describe('ToolsRegistry', () => { ]); }); - it('loads project-scoped tools before user-scoped tools for future sessions', async () => { + it('lets user-scoped tools win over project-scoped tools across sessions', async () => { const workspaceRoot = path.join(tempRoot, 'workspace'); const userToolsDir = path.join(tempRoot, 'user-tools'); const projectToolsDir = path.join(workspaceRoot, '.autohand', 'tools'); @@ -115,20 +115,24 @@ describe('ToolsRegistry', () => { scope: 'project' }); - const registry = createToolsRegistry(workspaceRoot, userToolsDir); + const registry = createToolsRegistry(workspaceRoot, userToolsDir, { projectToolsTrusted: true }); await registry.initialize(); expect(registry.getMetaTool('shared_tool')).toMatchObject({ - description: 'Project-scoped helper', - scope: 'project' + description: 'User-scoped helper', + scope: 'user' + }); + expect(registry.listMetaTools({ includeDisabled: true }).map((tool) => tool.scope)).toEqual(['user', 'project']); + expect(registry.getDiagnostics()).toContainEqual({ + file: path.join(projectToolsDir, 'shared_tool.json'), + reason: 'project tool shadows user tool "shared_tool"' }); - expect(registry.listMetaTools({ includeDisabled: true }).map((tool) => tool.scope)).toEqual(['project', 'user']); - const nextSessionRegistry = createToolsRegistry(workspaceRoot, userToolsDir); + const nextSessionRegistry = createToolsRegistry(workspaceRoot, userToolsDir, { projectToolsTrusted: true }); await nextSessionRegistry.initialize(); expect(nextSessionRegistry.getMetaTool('shared_tool')).toMatchObject({ - description: 'Project-scoped helper', - scope: 'project' + description: 'User-scoped helper', + scope: 'user' }); }); From 0a06c0a02f27c262e59ab3743b40272c12cb8fea Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Sat, 17 Oct 2026 16:36:04 +1300 Subject: [PATCH 05/38] Send OpenRouter the attribution headers it ranks apps by Requests carried HTTP-Referer https://autohand.dev and no X-Title, so OpenRouter's app rankings could not credit Autohand Code. --- src/core/agent/ProviderConfigManager.ts | 7 +--- src/providers/OpenRouterClient.ts | 11 +++-- tests/commands/model.spec.ts | 8 ++-- tests/providers/openRouterHeaders.test.ts | 49 +++++++++++++++++++++++ 4 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 tests/providers/openRouterHeaders.test.ts diff --git a/src/core/agent/ProviderConfigManager.ts b/src/core/agent/ProviderConfigManager.ts index ee918dec2..efc836364 100644 --- a/src/core/agent/ProviderConfigManager.ts +++ b/src/core/agent/ProviderConfigManager.ts @@ -46,6 +46,7 @@ import { import { VERTEX_AI_CODING_MODELS } from "../../providers/VertexAIProvider.js"; import { sanitizeModelId } from "../../providers/errors.js"; import { getOpenRouterModelContextWindow } from "../../providers/modelCapabilities.js"; +import { OPENROUTER_ATTRIBUTION_HEADERS } from "../../providers/OpenRouterClient.js"; import { saveConfig, getProviderConfig } from "../../config.js"; import { getContextWindow } from "../context/tokenizer.js"; import { @@ -3879,11 +3880,7 @@ export class ProviderConfigManager { ...(provider === "llmgateway" && { "x-source": "Autohand Code CLI", }), - ...(provider === "openrouter" && { - "HTTP-Referer": "https://autohand.dev", - "X-OpenRouter-Title": "Autohand Code CLI", - "X-OpenRouter-Categories": "cli-agent", - }), + ...(provider === "openrouter" && OPENROUTER_ATTRIBUTION_HEADERS), }, signal: AbortSignal.timeout(10000), // 10s timeout for validation }); diff --git a/src/providers/OpenRouterClient.ts b/src/providers/OpenRouterClient.ts index 46fa9fd95..061ee3144 100644 --- a/src/providers/OpenRouterClient.ts +++ b/src/providers/OpenRouterClient.ts @@ -76,6 +76,13 @@ function sanitizeMessages( } const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"; + +/** App attribution OpenRouter reads for its public app rankings. */ +export const OPENROUTER_ATTRIBUTION_HEADERS = { + "HTTP-Referer": "https://autohand.ai", + "X-Title": "Autohand Code", +} as const; + const DEFAULT_MAX_RETRIES = 3; const MAX_ALLOWED_RETRIES = 5; const DEFAULT_RETRY_DELAY = 1000; @@ -292,9 +299,7 @@ export class OpenRouterClient { const headers: Record = { "Content-Type": "application/json", - "HTTP-Referer": "https://autohand.dev", - "X-OpenRouter-Title": "Autohand Code CLI", - "X-OpenRouter-Categories": "cli-agent", + ...OPENROUTER_ATTRIBUTION_HEADERS, }; if (this.apiKey) { headers.Authorization = `Bearer ${this.apiKey}`; diff --git a/tests/commands/model.spec.ts b/tests/commands/model.spec.ts index 07d864c83..e12dd7320 100644 --- a/tests/commands/model.spec.ts +++ b/tests/commands/model.spec.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { OPENROUTER_ATTRIBUTION_HEADERS } from "../../src/providers/OpenRouterClient.js"; // Mock fetch globally const mockFetch = vi.fn(); @@ -121,8 +122,7 @@ describe("API Key Validation", () => { headers: { Authorization: "Bearer sk-or-valid-key", "Content-Type": "application/json", - "HTTP-Referer": "https://autohand.dev", - "X-Title": "Autohand CLI", + ...OPENROUTER_ATTRIBUTION_HEADERS, }, }); @@ -130,8 +130,8 @@ describe("API Key Validation", () => { "https://openrouter.ai/api/v1/models", expect.objectContaining({ headers: expect.objectContaining({ - "HTTP-Referer": "https://autohand.dev", - "X-Title": "Autohand CLI", + "HTTP-Referer": "https://autohand.ai", + "X-Title": "Autohand Code", }), }), ); diff --git a/tests/providers/openRouterHeaders.test.ts b/tests/providers/openRouterHeaders.test.ts new file mode 100644 index 000000000..65e6ccf18 --- /dev/null +++ b/tests/providers/openRouterHeaders.test.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { OPENROUTER_ATTRIBUTION_HEADERS, OpenRouterClient } from '../../src/providers/OpenRouterClient.js'; +import { clearModelCapabilitiesCache } from '../../src/providers/modelCapabilities.js'; + +describe('OpenRouter attribution headers', () => { + beforeEach(() => { + clearModelCapabilitiesCache(); + }); + + afterEach(() => { + clearModelCapabilitiesCache(); + vi.restoreAllMocks(); + }); + + it('names Autohand Code so OpenRouter rankings credit the app', () => { + expect(OPENROUTER_ATTRIBUTION_HEADERS).toEqual({ + 'HTTP-Referer': 'https://autohand.ai', + 'X-Title': 'Autohand Code', + }); + }); + + it('sends only the documented attribution headers on chat requests', async () => { + const client = new OpenRouterClient({ apiKey: 'test-key', model: 'openrouter/auto' }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'resp_headers', + created: 1, + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await client.complete({ messages: [{ role: 'user', content: 'hi' }] } as Parameters[0]); + + const headers = fetchSpy.mock.calls.at(-1)?.[1]?.headers as Record; + expect(headers).toMatchObject(OPENROUTER_ATTRIBUTION_HEADERS); + expect(headers['HTTP-Referer']).toBe('https://autohand.ai'); + expect(headers['X-Title']).toBe('Autohand Code'); + expect(Object.keys(headers).filter((name) => name.startsWith('X-OpenRouter-'))).toEqual([]); + }); +}); From 0a871dd9a4f7fb0d4c81c0b103ee8056c26249bf Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Sat, 17 Oct 2026 16:36:38 +1300 Subject: [PATCH 06/38] Add the npm keywords people search for a coding agent by --- package.json | 10 +++++++++- tests/package/keywords.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/package/keywords.test.ts diff --git a/package.json b/package.json index 968d60933..a5edb064f 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,15 @@ "llm", "agent", "agentic", - "autohand" + "autohand", + "coding-agent", + "ai-coding-agent", + "claude-code", + "codex", + "mcp", + "ollama", + "byok", + "terminal" ], "engines": { "node": ">=22.0.0" diff --git a/tests/package/keywords.test.ts b/tests/package/keywords.test.ts new file mode 100644 index 000000000..c2e17dad0 --- /dev/null +++ b/tests/package/keywords.test.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { keywords?: string[] }; + +describe('npm discoverability', () => { + it('lists the keywords people search for', () => { + for (const keyword of ['coding-agent', 'ai-coding-agent', 'claude-code', 'codex', 'mcp', 'ollama', 'byok', 'terminal']) { + expect(pkg.keywords).toContain(keyword); + } + }); + + it('keeps the existing keywords', () => { + for (const keyword of ['cli', 'llm', 'agent', 'agentic', 'autohand']) { + expect(pkg.keywords).toContain(keyword); + } + }); +}); From a5925ce389f2fde88f6ed655dd4b5ccc5f578b95 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Sat, 17 Oct 2026 16:40:49 +1300 Subject: [PATCH 07/38] Include project tool files in workspace trust A workspace whose only executable content is .autohand/tools never prompted for trust, so its tools could never be enabled. Tool files now form part of the trust fingerprint: the prompt appears for them, and editing a tool file invalidates trust like editing a hook does. --- src/config.ts | 25 ++++++--- src/permissions/workspaceTrust.ts | 39 +++++++++++--- src/startup/workspaceTrustPrompt.ts | 12 ++++- src/types.ts | 20 +++++-- tests/config/workspaceTrust.config.test.ts | 61 ++++++++++++++++++++++ tests/permissions/workspaceTrust.test.ts | 40 ++++++++++++++ tests/startup/workspaceTrustPrompt.test.ts | 22 ++++++++ tests/ui/ink/WorkspaceTrustPrompt.test.tsx | 1 + 8 files changed, 199 insertions(+), 21 deletions(-) create mode 100644 tests/config/workspaceTrust.config.test.ts diff --git a/src/config.ts b/src/config.ts index 84de75739..6d15f5a91 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,9 +42,11 @@ import { autoInitTheme, configureThemeSources, getDefaultThemeName, themeExists import { loadLocalProjectSettings, type LocalProjectSettings } from "./permissions/localProjectPermissions.js"; import { canonicalJson, + collectProjectToolEntries, computeWorkspaceTrustFingerprint, isWorkspaceTrusted, type WorkspaceTrustEntries, + type WorkspaceTrustToolEntry, } from "./permissions/workspaceTrust.js"; import { isAwsBedrockProviderEnabled } from "./features/featureRegistry.js"; import { getCustomProviderConfig, isCustomProviderName } from "./providers/customProviders.js"; @@ -559,12 +561,15 @@ export async function loadConfig( workspaceSettings = await loadLocalProjectSettings(workspaceRoot); } - // Project hooks and MCP servers run commands, and a cloned repository can - // ship them. They only apply once the user trusts this exact content. + // Project hooks, MCP servers, and tool files run commands, and a cloned + // repository can ship them. They only apply once the user trusts this exact + // content. Tool files are part of the fingerprint so a workspace whose only + // executable content is .autohand/tools still gets the trust prompt. let workspaceTrust: WorkspaceTrustState | undefined; if (workspaceRoot) { - const entries = resolveWorkspaceTrustEntries([projectConfig, workspaceSettings]); - if (entries.hooks.length > 0 || entries.mcpServers.length > 0) { + const tools = await collectProjectToolEntries(workspaceRoot); + const entries = resolveWorkspaceTrustEntries([projectConfig, workspaceSettings], tools); + if (entries.hooks.length > 0 || entries.mcpServers.length > 0 || entries.tools.length > 0) { const fingerprint = computeWorkspaceTrustFingerprint(entries); const trusted = await isWorkspaceTrusted(workspaceRoot, fingerprint, options.workspaceTrustStorePath); workspaceTrust = { workspaceRoot: path.resolve(workspaceRoot), fingerprint, trusted, ...entries }; @@ -621,8 +626,14 @@ export async function loadConfig( }; } -/** Project hooks and MCP servers after layering, in the order they would apply. */ -function resolveWorkspaceTrustEntries(layers: (LocalProjectSettings | null)[]): WorkspaceTrustEntries { +/** + * Project hooks and MCP servers after layering, in the order they would apply, + * together with the project tool files the workspace ships. + */ +function resolveWorkspaceTrustEntries( + layers: (LocalProjectSettings | null)[], + tools: WorkspaceTrustToolEntry[] = [], +): WorkspaceTrustEntries { let hooks: HookDefinition[] = []; let mcpServers: McpServerConfigEntry[] = []; for (const layer of layers) { @@ -634,7 +645,7 @@ function resolveWorkspaceTrustEntries(layers: (LocalProjectSettings | null)[]): mcpServers = mergeMcpSettings({ servers: mcpServers }, layer.mcp).servers ?? []; } } - return { hooks, mcpServers }; + return { hooks, mcpServers, tools }; } function withoutExecutableSections(layer: LocalProjectSettings | null): LocalProjectSettings | null { diff --git a/src/permissions/workspaceTrust.ts b/src/permissions/workspaceTrust.ts index 02c725292..debf4d9a5 100644 --- a/src/permissions/workspaceTrust.ts +++ b/src/permissions/workspaceTrust.ts @@ -2,26 +2,51 @@ * Workspace trust for repository-supplied code. * * Hooks and MCP servers declared in `/.autohand/` run commands as - * soon as a session starts. A cloned repository can ship those files, so they - * only apply after the user trusts the workspace for their exact content. Any - * change to the declared hooks or servers produces a new fingerprint and asks - * again. + * soon as a session starts, and tool files under `/.autohand/tools/` + * run commands when the model calls them. A cloned repository can ship those + * files, so they only apply after the user trusts the workspace for their + * exact content. Any change to the declared hooks, servers, or tool files + * produces a new fingerprint and asks again. * * @license Apache-2.0 */ import crypto from 'node:crypto'; import fs from 'fs-extra'; import path from 'node:path'; -import { AUTOHAND_FILES } from '../constants.js'; -import type { HookDefinition, McpServerConfigEntry } from '../types.js'; +import { AUTOHAND_FILES, PROJECT_DIR_NAME } from '../constants.js'; +import type { HookDefinition, McpServerConfigEntry, WorkspaceTrustToolEntry } from '../types.js'; import { atomicWriteFile, withFileLock } from '../utils/atomicFile.js'; +export type { WorkspaceTrustToolEntry } from '../types.js'; + const STORE_VERSION = 1; /** Executable entries a workspace contributes through its project files. */ export interface WorkspaceTrustEntries { hooks: HookDefinition[]; mcpServers: McpServerConfigEntry[]; + tools: WorkspaceTrustToolEntry[]; +} + +/** + * Hash every `/.autohand/tools/*.json` file, sorted by name, so the + * trust fingerprint changes whenever a tool file is added, removed, or edited. + */ +export async function collectProjectToolEntries(workspaceRoot: string): Promise { + const dir = path.join(workspaceRoot, PROJECT_DIR_NAME, 'tools'); + if (!(await fs.pathExists(dir))) return []; + const files = (await fs.readdir(dir)) + .filter((file) => file.endsWith('.json')) + .sort((a, b) => a.localeCompare(b)); + const entries: WorkspaceTrustToolEntry[] = []; + for (const file of files) { + const bytes = await fs.readFile(path.join(dir, file)); + entries.push({ + file: path.posix.join(PROJECT_DIR_NAME, 'tools', file), + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + }); + } + return entries; } interface TrustedWorkspaceRecord { @@ -54,7 +79,7 @@ export function canonicalJson(value: unknown): string { export function computeWorkspaceTrustFingerprint(entries: WorkspaceTrustEntries): string { return crypto .createHash('sha256') - .update(canonicalJson({ hooks: entries.hooks, mcpServers: entries.mcpServers })) + .update(canonicalJson({ hooks: entries.hooks, mcpServers: entries.mcpServers, tools: entries.tools ?? [] })) .digest('hex'); } diff --git a/src/startup/workspaceTrustPrompt.ts b/src/startup/workspaceTrustPrompt.ts index 694be63f7..37798c973 100644 --- a/src/startup/workspaceTrustPrompt.ts +++ b/src/startup/workspaceTrustPrompt.ts @@ -30,7 +30,7 @@ function countLabel(count: number, singular: string, plural: string): string { return `${count} ${count === 1 ? singular : plural}`; } -/** For example "2 project hooks and 1 MCP server". */ +/** For example "2 project hooks and 1 MCP server" or "1 project tool file". */ export function describeWorkspaceTrustEntries(trust: WorkspaceTrustState): string { const parts: string[] = []; if (trust.hooks.length > 0) { @@ -39,7 +39,11 @@ export function describeWorkspaceTrustEntries(trust: WorkspaceTrustState): strin if (trust.mcpServers.length > 0) { parts.push(countLabel(trust.mcpServers.length, 'MCP server', 'MCP servers')); } - return parts.join(' and '); + const tools = trust.tools ?? []; + if (tools.length > 0) { + parts.push(countLabel(tools.length, 'project tool file', 'project tool files')); + } + return parts.length <= 2 ? parts.join(' and ') : `${parts.slice(0, -1).join(', ')}, and ${parts[parts.length - 1]}`; } function describeServerLaunch(server: McpServerConfigEntry): string { @@ -67,6 +71,10 @@ export function formatWorkspaceTrustSummary(trust: WorkspaceTrustState): string ...formatRows(trust.mcpServers.map((server) => [server.name, describeServerLaunch(server)])), ); } + const tools = trust.tools ?? []; + if (tools.length > 0) { + lines.push('', `Project tools: ${tools.length} file(s)`, ...tools.map((tool) => ` ${tool.file}`)); + } return lines.join('\n'); } diff --git a/src/types.ts b/src/types.ts index 2ab3fec68..f17bd8717 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1137,9 +1137,9 @@ export interface LoadedConfig extends AutohandConfig { */ workspaceOverlay?: WorkspaceOverlaySnapshot; /** - * Runtime-only trust state for project hooks and MCP servers declared in - * `/.autohand/`. Present only when the workspace declares any. - * Never persisted. + * Runtime-only trust state for project hooks, MCP servers, and tool files + * declared in `/.autohand/`. Present only when the workspace + * declares any. Never persisted. */ workspaceTrust?: WorkspaceTrustState; /** @@ -1150,11 +1150,11 @@ export interface LoadedConfig extends AutohandConfig { runOverlay?: RunConfigOverlaySnapshot; } -/** Whether a workspace's project hooks and MCP servers may run. */ +/** Whether a workspace's project hooks, MCP servers, and tool files may run. */ export interface WorkspaceTrustState { /** Workspace whose project files declare the entries */ workspaceRoot: string; - /** Hash of the declared hooks and MCP servers; any change asks again */ + /** Hash of the declared hooks, MCP servers, and tool files; any change asks again */ fingerprint: string; /** True when the user trusted this workspace for exactly these entries */ trusted: boolean; @@ -1162,6 +1162,16 @@ export interface WorkspaceTrustState { hooks: HookDefinition[]; /** Project MCP servers after layering */ mcpServers: McpServerConfigEntry[]; + /** Project tool files under `.autohand/tools/`, sorted by name, with content hashes */ + tools: WorkspaceTrustToolEntry[]; +} + +/** A project tool file, identified by workspace-relative path and content hash. */ +export interface WorkspaceTrustToolEntry { + /** Path relative to the workspace root, e.g. `.autohand/tools/deploy.json` */ + file: string; + /** Hex sha256 of the file bytes */ + sha256: string; } /** Keys of plain settings objects that workspace overlays merge field by field. */ diff --git a/tests/config/workspaceTrust.config.test.ts b/tests/config/workspaceTrust.config.test.ts new file mode 100644 index 000000000..5a2076f1a --- /dev/null +++ b/tests/config/workspaceTrust.config.test.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadConfig } from '../../src/config.js'; +import { trustWorkspace } from '../../src/permissions/workspaceTrust.js'; + +const SCRATCH_DIR = path.join(os.homedir(), 'Documents/autohand/digital-team/charlie-cox/scratch'); + +let root: string; +let configPath: string; +let storePath: string; + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(SCRATCH_DIR, 'trust-cfg-')); + configPath = path.join(root, 'home', 'config.json'); + storePath = path.join(root, 'home', 'trusted-workspaces.json'); + await fs.outputJson(configPath, { provider: 'anthropic', anthropic: { apiKey: 'k', model: 'm' } }); +}); + +afterEach(async () => { + await fs.remove(root); +}); + +const load = () => loadConfig(configPath, root, { createIfMissing: false, initializeTheme: false, workspaceTrustStorePath: storePath }); + +describe('workspace trust for tools-only projects', () => { + it('creates an untrusted workspaceTrust state when only .autohand/tools exists', async () => { + await fs.outputFile(path.join(root, '.autohand', 'tools', 'deploy.json'), '{"name":"deploy","description":"d","handler":"echo d"}'); + + const config = await load(); + + expect(config.workspaceTrust?.trusted).toBe(false); + expect(config.workspaceTrust?.hooks).toEqual([]); + expect(config.workspaceTrust?.mcpServers).toEqual([]); + expect(config.workspaceTrust?.tools.map((tool) => tool.file)).toEqual(['.autohand/tools/deploy.json']); + }); + + it('leaves workspaceTrust undefined when the workspace declares nothing executable', async () => { + const config = await load(); + + expect(config.workspaceTrust).toBeUndefined(); + }); + + it('asks again after a trusted tool file changes', async () => { + const toolFile = path.join(root, '.autohand', 'tools', 'deploy.json'); + await fs.outputFile(toolFile, '{"name":"deploy","description":"d","handler":"echo d"}'); + const untrusted = await load(); + await trustWorkspace(root, untrusted.workspaceTrust!.fingerprint, storePath); + expect((await load()).workspaceTrust?.trusted).toBe(true); + + await fs.writeFile(toolFile, '{"name":"deploy","description":"d","handler":"echo changed"}'); + + expect((await load()).workspaceTrust?.trusted).toBe(false); + }); +}); diff --git a/tests/permissions/workspaceTrust.test.ts b/tests/permissions/workspaceTrust.test.ts index 8244f77d2..cc9a15679 100644 --- a/tests/permissions/workspaceTrust.test.ts +++ b/tests/permissions/workspaceTrust.test.ts @@ -8,11 +8,14 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + collectProjectToolEntries, computeWorkspaceTrustFingerprint, isWorkspaceTrusted, trustWorkspace, } from '../../src/permissions/workspaceTrust.js'; +const SCRATCH_DIR = path.join(os.homedir(), 'Documents/autohand/digital-team/charlie-cox/scratch'); + let tempDir: string; let storePath: string; let workspaceRoot: string; @@ -58,6 +61,43 @@ describe('computeWorkspaceTrustFingerprint', () => { expect(changedServer).not.toBe(original); expect(changedHook).not.toBe(changedServer); }); + + it('includes project tool files in the trust fingerprint', async () => { + const root = await fs.mkdtemp(path.join(SCRATCH_DIR, 'trust-tools-')); + try { + const toolFile = path.join(root, '.autohand', 'tools', 'deploy.json'); + await fs.outputFile(toolFile, '{"name":"deploy","description":"d","handler":"echo d"}'); + const tools = await collectProjectToolEntries(root); + expect(tools).toEqual([{ file: '.autohand/tools/deploy.json', sha256: expect.stringMatching(/^[a-f0-9]{64}$/) }]); + + const a = computeWorkspaceTrustFingerprint({ hooks: [], mcpServers: [], tools }); + await fs.writeFile(toolFile, '{"name":"deploy","description":"d","handler":"echo changed"}'); + const b = computeWorkspaceTrustFingerprint({ hooks: [], mcpServers: [], tools: await collectProjectToolEntries(root) }); + + expect(a).not.toBe(b); + } finally { + await fs.remove(root); + } + }); + + it('lists tool files sorted by name and ignores non-JSON files and a missing tools dir', async () => { + const root = await fs.mkdtemp(path.join(SCRATCH_DIR, 'trust-tools-')); + try { + expect(await collectProjectToolEntries(root)).toEqual([]); + + const toolsDir = path.join(root, '.autohand', 'tools'); + await fs.outputFile(path.join(toolsDir, 'zeta.json'), '{"name":"zeta"}'); + await fs.outputFile(path.join(toolsDir, 'alpha.json'), '{"name":"alpha"}'); + await fs.outputFile(path.join(toolsDir, 'README.md'), 'not a tool'); + + expect((await collectProjectToolEntries(root)).map((tool) => tool.file)).toEqual([ + '.autohand/tools/alpha.json', + '.autohand/tools/zeta.json', + ]); + } finally { + await fs.remove(root); + } + }); }); describe('workspace trust store', () => { diff --git a/tests/startup/workspaceTrustPrompt.test.ts b/tests/startup/workspaceTrustPrompt.test.ts index d2eb3ccc5..4b392b89d 100644 --- a/tests/startup/workspaceTrustPrompt.test.ts +++ b/tests/startup/workspaceTrustPrompt.test.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isWorkspaceTrusted } from '../../src/permissions/workspaceTrust.js'; import { + describeWorkspaceTrustEntries, formatWorkspaceTrustSummary, resolveWorkspaceTrust, } from '../../src/startup/workspaceTrustPrompt.js'; @@ -42,6 +43,7 @@ function untrustedConfig(): LoadedConfig { { name: 'project-tools', transport: 'stdio', command: 'npx', args: ['project-tools-mcp', '--stdio'] }, { name: 'project-docs', transport: 'http', url: 'https://docs.example.test/mcp' }, ], + tools: [], }; return { configPath: path.join(tempDir, 'home', 'config.json'), @@ -142,5 +144,25 @@ describe('formatWorkspaceTrustSummary', () => { expect(summary).toMatch(/pre-prompt\s+node scripts\/check-prompt\.cjs/); expect(summary).toMatch(/project-tools\s+npx project-tools-mcp --stdio/); expect(summary).toMatch(/project-docs\s+https:\/\/docs\.example\.test\/mcp/); + expect(summary).not.toContain('Project tools'); + }); + + it('lists project tool files so a tools-only workspace has something to review', () => { + const trust: WorkspaceTrustState = { + ...untrustedConfig().workspaceTrust!, + hooks: [], + mcpServers: [], + tools: [ + { file: '.autohand/tools/deploy.json', sha256: 'c'.repeat(64) }, + { file: '.autohand/tools/lint.json', sha256: 'd'.repeat(64) }, + ], + }; + + const summary = formatWorkspaceTrustSummary(trust); + + expect(summary).toContain('Project tools: 2 file(s)'); + expect(summary).toContain('.autohand/tools/deploy.json'); + expect(summary).toContain('.autohand/tools/lint.json'); + expect(describeWorkspaceTrustEntries(trust)).toBe('2 project tool files'); }); }); diff --git a/tests/ui/ink/WorkspaceTrustPrompt.test.tsx b/tests/ui/ink/WorkspaceTrustPrompt.test.tsx index f3ff72cf4..43cc16962 100644 --- a/tests/ui/ink/WorkspaceTrustPrompt.test.tsx +++ b/tests/ui/ink/WorkspaceTrustPrompt.test.tsx @@ -19,6 +19,7 @@ const trust: WorkspaceTrustState = { trusted: false, hooks: [{ event: 'session-start', command: 'node scripts/record.cjs start' }], mcpServers: [{ name: 'project-tools', transport: 'stdio', command: 'npx', args: ['project-tools-mcp'] }], + tools: [], }; function renderPrompt() { From b01a061076d68149ef43990895367cfc64cb5ab5 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Sun, 18 Oct 2026 21:31:26 +1300 Subject: [PATCH 08/38] Send OpenRouter's current title and category headers OpenRouter now reads X-OpenRouter-Title for the app name (X-Title is the older alias) and X-OpenRouter-Categories for its marketplace; cli-agent is a recognised category. Keep HTTP-Referer https://autohand.ai, which creates the app entry. Co-authored-by: Autohand Evolve --- src/providers/OpenRouterClient.ts | 7 ++++++- tests/providers/openRouterHeaders.test.ts | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/providers/OpenRouterClient.ts b/src/providers/OpenRouterClient.ts index 061ee3144..56ad28393 100644 --- a/src/providers/OpenRouterClient.ts +++ b/src/providers/OpenRouterClient.ts @@ -77,10 +77,15 @@ function sanitizeMessages( const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"; -/** App attribution OpenRouter reads for its public app rankings. */ +/** + * App attribution OpenRouter reads for its public app rankings. HTTP-Referer creates the app, + * X-OpenRouter-Title names it (X-Title is the older alias), and cli-agent is a recognised category. + */ export const OPENROUTER_ATTRIBUTION_HEADERS = { "HTTP-Referer": "https://autohand.ai", + "X-OpenRouter-Title": "Autohand Code", "X-Title": "Autohand Code", + "X-OpenRouter-Categories": "cli-agent", } as const; const DEFAULT_MAX_RETRIES = 3; diff --git a/tests/providers/openRouterHeaders.test.ts b/tests/providers/openRouterHeaders.test.ts index 65e6ccf18..fa794cc35 100644 --- a/tests/providers/openRouterHeaders.test.ts +++ b/tests/providers/openRouterHeaders.test.ts @@ -21,11 +21,13 @@ describe('OpenRouter attribution headers', () => { it('names Autohand Code so OpenRouter rankings credit the app', () => { expect(OPENROUTER_ATTRIBUTION_HEADERS).toEqual({ 'HTTP-Referer': 'https://autohand.ai', + 'X-OpenRouter-Title': 'Autohand Code', 'X-Title': 'Autohand Code', + 'X-OpenRouter-Categories': 'cli-agent', }); }); - it('sends only the documented attribution headers on chat requests', async () => { + it('sends the documented attribution headers on chat requests', async () => { const client = new OpenRouterClient({ apiKey: 'test-key', model: 'openrouter/auto' }); const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( new Response( @@ -44,6 +46,6 @@ describe('OpenRouter attribution headers', () => { expect(headers).toMatchObject(OPENROUTER_ATTRIBUTION_HEADERS); expect(headers['HTTP-Referer']).toBe('https://autohand.ai'); expect(headers['X-Title']).toBe('Autohand Code'); - expect(Object.keys(headers).filter((name) => name.startsWith('X-OpenRouter-'))).toEqual([]); + expect(Object.keys(headers).filter((name) => name.startsWith('X-OpenRouter-')).sort()).toEqual(['X-OpenRouter-Categories', 'X-OpenRouter-Title']); }); }); From 3ba7c7fde844cf08a72de24a2c2045df68019898 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Mon, 19 Oct 2026 22:13:14 +1300 Subject: [PATCH 09/38] List all 17 built-in providers in the README The table showed 10 of the providers the CLI ships with. Adds Anthropic, Azure OpenAI, Vertex AI, xAI, Cerebras, NVIDIA and Sakana AI. Co-authored-by: Autohand Evolve --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 006e532a6..fbfabe54e 100644 --- a/README.md +++ b/README.md @@ -582,6 +582,13 @@ Create `~/.autohand/config.json` or use `config.toml`, `config.yaml`, or `config | llama.cpp | `llamacpp` | Local inference | | MLX | `mlx` | Apple Silicon optimized | | Z.ai | `zai` | High-performance inference | +| Anthropic | `anthropic` | Claude models with your own API key | +| Azure OpenAI | `azure` | Azure deployments via API key, Entra ID or managed identity | +| Vertex AI | `vertexai` | Google models on Vertex AI | +| xAI | `xai` | Grok models via API key or OAuth | +| Cerebras | `cerebras` | Hosted fast inference | +| NVIDIA | `nvidia` | NVIDIA-hosted models | +| Sakana AI | `sakana` | Sakana AI models | ## Session Management From d70454361515f4fec500778120b20fc640b5336c Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Mon, 19 Oct 2026 22:14:02 +1300 Subject: [PATCH 10/38] State the Node.js 22 requirement in the README The package needs Node.js 22 or later (engines). Bun is only needed to build from source. Co-authored-by: Autohand Evolve --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fbfabe54e..ec2a41b17 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,8 @@ bun add -g . ### Requirements -- Bun ≥1.0 (`curl -fsSL https://bun.sh/install | bash`) +- Node.js 22 or later (to run Autohand) +- Bun 1.0 or later, only to build from source (`curl -fsSL https://bun.sh/install | bash`) - Git (for version control features) - ripgrep (optional, for faster search) @@ -784,7 +785,7 @@ We welcome contributions! Please read our [Contributing Guide](CONTRIBUTING.md) ### Common Issues -**Installation fails**: Make sure you have Bun installed and up to date. +**Installation fails**: Make sure Node.js 22 or later is installed. Building from source also needs Bun. **Permission denied**: Check your file permissions and try running with appropriate privileges. From 04129fb1db234e8f6d9c4fb8238a142200ade4be Mon Sep 17 00:00:00 2001 From: Sagnik Das Date: Tue, 20 Oct 2026 11:22:27 +0530 Subject: [PATCH 11/38] Import Claude MCP servers and keep creds local Signed-off-by: Sagnik Das --- docs/config-reference.md | 13 +++ src/config.ts | 3 + src/import/importers/ClaudeImporter.ts | 148 ++++++++++++++++++++++++- src/sync/CodingAgentControlPlane.ts | 8 +- src/types.ts | 2 + tests/import/ClaudeImporter.test.ts | 113 +++++++++++++++++++ tests/sync/connectorDownload.test.ts | 23 ++++ 7 files changed, 306 insertions(+), 4 deletions(-) diff --git a/docs/config-reference.md b/docs/config-reference.md index 68b7c1034..29244b44b 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -2351,6 +2351,7 @@ Configure MCP (Model Context Protocol) servers to extend Autohand with external | `headers` | `Record` | No | `{}` | Custom HTTP headers for http/sse transport (e.g. auth tokens) | | `env` | `Record` | No | `{}` | Environment variables passed to the server | | `autoConnect` | `boolean` | No | `true` | Whether to auto-connect on startup | +| `localOnly` | `boolean` | No | `false` | Keep the server on this device; do not publish it to the account control plane | > Servers connect asynchronously in the background during startup without blocking the prompt. Use `/mcp` to manage servers interactively, or `/mcp add` to browse the community registry or add custom servers. @@ -2504,6 +2505,18 @@ Only compatible command hooks are translated. Unsupported source HTTP, prompt/ag See [hook import compatibility](./hooks.md#import-hooks-from-another-coding-agent) for source file locations, event mappings, timeout defaults, supported responses, and manual-porting limits. +### Import MCP Servers from Claude Code + +```sh +autohand import claude --categories mcp +``` + +The importer reads user servers from `~/.claude.json`, current-project servers from `projects[].mcpServers` in that file, and project servers from `/.mcp.json`. Use `--path ` when importing for a workspace other than the current directory. + +Stdio servers keep their `command`, `args`, and `env`; HTTP and SSE servers keep their `url` and `headers`. User servers are added to the selected user config. Project servers are added to `/.autohand/settings.local.json`. Imported servers are saved with `autoConnect: false`, so review them before enabling them. Existing servers with the same name are preserved and reported as skipped (`already exists`). + +Environment values and headers are copied as configured. The importer does not print server values, and imported servers stay on this device instead of being published to the account control plane. + --- ## Chrome Extension Settings diff --git a/src/config.ts b/src/config.ts index 84de75739..6d7515ed6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1644,6 +1644,9 @@ function validateConfig(config: AutohandConfig, configPath: string): void { throw new Error(`mcp.servers must be an array in ${configPath}`); } for (const server of config.mcp.servers) { + if (server.localOnly !== undefined && typeof server.localOnly !== "boolean") { + throw new Error(`mcp.servers[].localOnly must be boolean in ${configPath}`); + } if (!server.name || typeof server.name !== "string") { throw new Error( `mcp.servers[].name must be a non-empty string in ${configPath}`, diff --git a/src/import/importers/ClaudeImporter.ts b/src/import/importers/ClaudeImporter.ts index 2febb08a8..15471bef1 100644 --- a/src/import/importers/ClaudeImporter.ts +++ b/src/import/importers/ClaudeImporter.ts @@ -16,6 +16,9 @@ import type { } from '../types.js'; import type { SessionMessage } from '../../session/types.js'; import { AUTOHAND_PATHS } from '../../constants.js'; +import { detectConfigPath } from '../../config.js'; +import YAML from 'yaml'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { BaseImporter } from './BaseImporter.js'; /** @@ -36,6 +39,25 @@ interface ClaudeEvent { [k: string]: unknown; } +type CollectedMcpServers = { + user: Record; + project: Record; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validMcpServers(value: unknown): Record { + if (!isRecord(value)) return {}; + + return Object.fromEntries(Object.entries(value).filter(([, server]) => { + if (!isRecord(server)) return false; + if (server.type === 'http' || server.type === 'sse') return typeof server.url === 'string' && server.url.length > 0; + return (server.type === undefined || server.type === 'stdio') && typeof server.command === 'string' && server.command.length > 0; + })); +} + /** * Importer for Claude Code CLI data (~/.claude). * @@ -55,6 +77,10 @@ export class ClaudeImporter extends BaseImporter { const available = new Map(); await this.scanHooks(available); const home = this.resolvedHomePath; + const mcp = await this.collectMcpServers(); + if (Object.keys(mcp.user).length + Object.keys(mcp.project).length > 0) { + available.set('mcp', { count: Object.keys(mcp.user).length + Object.keys(mcp.project).length, description: 'Claude MCP servers' }); + } if (!(await fse.pathExists(home))) { return { source: this.name, available }; @@ -152,8 +178,10 @@ export class ClaudeImporter extends BaseImporter { case 'memory': await this.importMemory(imported, errors, onProgress); break; + case 'mcp': + await this.importMcpServers(imported, errors); + break; default: - // MCP import is not supported here. break; } } @@ -166,6 +194,124 @@ export class ClaudeImporter extends BaseImporter { }; } + private async readOptionalJson(filePath: string): Promise | undefined> { + try { + const value: unknown = await fse.readJson(filePath); + return isRecord(value) ? value : undefined; + } catch { + return undefined; + } + } + + // Collect user and current-workspace MCP servers + private async collectMcpServers(): Promise { + const claudeConfigPath = path.join(path.dirname(this.resolvedHomePath), '.claude.json'); + const workspaceRoot = path.resolve(this.hookImportOptions.workspaceRoot ?? process.cwd()); + const projectConfigPath = path.join(workspaceRoot, '.mcp.json'); + const collected: CollectedMcpServers = { user: {}, project: {} }; + + const claudeConfig = await this.readOptionalJson(claudeConfigPath); + if (claudeConfig && isRecord(claudeConfig.mcpServers)) { + Object.assign(collected.user, validMcpServers(claudeConfig.mcpServers)); + } + + const projects = claudeConfig?.projects; + if (isRecord(projects)) { + const projectConfig = projects[workspaceRoot]; + if (isRecord(projectConfig) && isRecord(projectConfig.mcpServers)) { + Object.assign(collected.project, validMcpServers(projectConfig.mcpServers)); + } + } + + const localConfig = await this.readOptionalJson(projectConfigPath); + if (localConfig && isRecord(localConfig.mcpServers)) { + Object.assign(collected.project, validMcpServers(localConfig.mcpServers)); + } + + return collected; + } + + // Import user and workspace MCP servers, preserving existing entries and skipping duplicates. + private async importMcpServers(imported: Map, errors: ImportError[]): Promise { + const { user, project } = await this.collectMcpServers(); + const workspace = path.resolve(this.hookImportOptions.workspaceRoot ?? process.cwd()); + const destinations = [ + { servers: user, file: await detectConfigPath(this.hookImportOptions.configPath) }, + { servers: project, file: path.join(workspace, '.autohand', 'settings.local.json') }, + ]; + let success = 0; + let skipped = 0; + const skipReasons: Record = {}; + for (const { servers, file } of destinations) { + try { + if (!Object.keys(servers).length) continue; + const current = await this.readConfigFile(file); + const mcp = isRecord(current.mcp) ? current.mcp : {}; + const existing = Array.isArray(mcp.servers) ? mcp.servers : []; + const converted = Object.entries(servers).flatMap(([name, value]) => { + if (!isRecord(value)) return []; + const server = value; + const transport = server.type === 'http' || server.type === 'sse' ? server.type : 'stdio'; + return [{ ...server, name, transport, autoConnect: false, localOnly: true }]; + }); + const existingNames = new Set(); + for (const server of existing) { + if (isRecord(server) && typeof server.name === 'string') { + existingNames.add(server.name); + } + } + const additions = converted.filter(server => !existingNames.has(server.name)); + const duplicateCount = converted.length - additions.length; + skipped += duplicateCount; + if (duplicateCount > 0) skipReasons['already exists'] = (skipReasons['already exists'] ?? 0) + duplicateCount; + + if (additions.length === 0) continue; + + const merged = [...existing, ...additions]; + await fse.ensureDir(path.dirname(file)); + const output = { ...current, mcp: { ...mcp, servers: merged } }; + await this.writeConfigFile(file, output); + success += additions.length; + } catch (error) { errors.push({ category: 'mcp', item: file, error: error instanceof Error ? error.message : String(error), retriable: false }); } + } + imported.set('mcp', { + success, + failed: errors.filter(e => e.category === 'mcp').length, + skipped, + ...(skipped > 0 ? { skipReasons } : {}), + }); + } + + private async readConfigFile(file: string): Promise> { + if (!(await fse.pathExists(file))) return {}; + + const extension = path.extname(file).toLowerCase(); + let parsed: unknown; + if (extension === '.toml') { + parsed = parseToml(await fse.readFile(file, 'utf8')); + } else if (/\.ya?ml$/.test(extension)) { + parsed = YAML.parse(await fse.readFile(file, 'utf8')); + } else { + parsed = await fse.readJson(file); + } + + if (!isRecord(parsed)) { + throw new Error(`Expected a configuration object in ${file}`); + } + return parsed; + } + + private async writeConfigFile(file: string, config: Record): Promise { + const extension = path.extname(file).toLowerCase(); + if (extension === '.toml') { + await fse.writeFile(file, stringifyToml(config as never)); + } else if (/\.ya?ml$/.test(extension)) { + await fse.writeFile(file, YAML.stringify(config)); + } else { + await fse.writeJson(file, config, { spaces: 2 }); + } + } + // --------------------------------------------------------------- // Sessions // --------------------------------------------------------------- diff --git a/src/sync/CodingAgentControlPlane.ts b/src/sync/CodingAgentControlPlane.ts index df619a691..196887508 100644 --- a/src/sync/CodingAgentControlPlane.ts +++ b/src/sync/CodingAgentControlPlane.ts @@ -382,8 +382,10 @@ export function applyManagedConnectors( ): { changed: boolean; servers: McpServerConfigEntry[] } { const validated = validateConnectorSnapshot(revision, connectors); const currentServers = config.mcp?.servers || []; - const unmanaged = currentServers.filter((server) => !server.managedConnectorId && !validated.some((connector) => - connector.name === server.name && connectorTarget(connector) === connectorTarget(server), + const unmanaged = currentServers.filter((server) => server.localOnly || ( + !server.managedConnectorId && !validated.some((connector) => + connector.name === server.name && connectorTarget(connector) === connectorTarget(server), + ) )); const names = new Set(unmanaged.map(({ name }) => name)); const managed = validated.map((connector) => { @@ -450,7 +452,7 @@ export async function syncCodingAgentControlPlane( if (options.publishLocalConnectors) { let published = false; for (const server of config.mcp?.servers || []) { - if (server.managedConnectorId || (server.transport !== 'http' && server.transport !== 'stdio')) continue; + if (server.localOnly || server.managedConnectorId || (server.transport !== 'http' && server.transport !== 'stdio')) continue; if (initialState.connectors.some(({ name }) => name.toLowerCase() === server.name.toLowerCase())) continue; await client.createConnector(authToken, server, options.signal); published = true; diff --git a/src/types.ts b/src/types.ts index 2ab3fec68..bff6077d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -642,6 +642,8 @@ export interface McpServerConfigEntry { headers?: Record; /** Whether to auto-connect on startup (default: true) */ autoConnect?: boolean; + /** Keep this server on this device; do not publish it to the account control plane. */ + localOnly?: boolean; /** JSON-RPC framing for stdio servers (default: Content-Length with compatibility fallback) */ stdioFraming?: 'content-length' | 'newline'; /** Account-scoped connector ID assigned by the Autohand Console control plane. */ diff --git a/tests/import/ClaudeImporter.test.ts b/tests/import/ClaudeImporter.test.ts index 8d9acec8c..6e6f088c2 100644 --- a/tests/import/ClaudeImporter.test.ts +++ b/tests/import/ClaudeImporter.test.ts @@ -78,6 +78,119 @@ describe('ClaudeImporter', () => { // scan() // --------------------------------------------------------------- describe('scan()', () => { + it('reports MCP servers from Claude configuration', async () => { + const root = '/workspace'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => [CLAUDE_HOME, path.join(HOME, '.claude.json')].includes(String(p))); + vi.mocked(fse.readJson).mockImplementation(async (p: string) => { + if (String(p) === path.join(HOME, '.claude.json')) return { mcpServers: { user: { command: 'run' } } } as never; + if (String(p) === path.join(root, '.mcp.json')) return { mcpServers: { project: { command: 'local' } } } as never; + throw new Error('missing'); + }); + const result = await importer.scan(); + expect(result.available.get('mcp')?.count).toBe(2); + }); + + it('ignores malformed MCP server entries when scanning and importing', async () => { + const root = '/workspace'; + const userConfig = '/autohand/config.json'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root, configPath: userConfig }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => String(p) === path.join(HOME, '.claude.json')); + vi.mocked(fse.readJson).mockImplementation(async (p: string) => { + if (String(p) === path.join(HOME, '.claude.json')) return { + mcpServers: { + valid: { command: 'run' }, + missingCommand: { type: 'stdio' }, + missingUrl: { type: 'http' }, + unsupported: { type: 'websocket', url: 'https://example.test' }, + scalar: 'invalid', + }, + } as never; + if (String(p) === userConfig) return {} as never; + throw new Error('missing'); + }); + + expect((await importer.scan()).available.get('mcp')?.count).toBe(1); + const result = await importer.import(['mcp']); + + expect(result.imported.get('mcp')).toEqual({ success: 1, failed: 0, skipped: 0 }); + expect(vi.mocked(fse.writeJson).mock.calls[0]?.[1]).toMatchObject({ + mcp: { servers: [{ name: 'valid', command: 'run', transport: 'stdio', autoConnect: false, localOnly: true }] }, + }); + }); + + it('writes MCP servers to user and workspace-local destinations without overwriting existing entries', async () => { + const root = '/workspace'; + const userConfig = '/autohand/config.json'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root, configPath: userConfig }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => [path.join(HOME, '.claude.json'), path.join(root, '.mcp.json'), userConfig].includes(String(p))); + vi.mocked(fse.readJson).mockImplementation(async (p: string) => { + if (String(p) === path.join(HOME, '.claude.json')) return { mcpServers: { user: { command: 'run' } }, projects: { [root]: { mcpServers: { + project: { command: 'shared' }, + keep: { command: 'incoming' }, + events: { type: 'sse', url: 'https://example.test/events', headers: { Authorization: 'Bearer secret' } }, + } } } } as never; + if (String(p) === path.join(root, '.mcp.json')) return { mcpServers: { project: { type: 'http', url: 'https://local' }, local: { command: 'local-run' } } } as never; + if (String(p) === userConfig) return { unrelated: true, mcp: { servers: [{ name: 'user', transport: 'stdio', command: 'existing' }] } } as never; + throw new Error('missing'); + }); + const result = await importer.import(['mcp']); + expect(result.imported.get('mcp')).toEqual({ + success: 4, + failed: 0, + skipped: 1, + skipReasons: { 'already exists': 1 }, + }); + const writes = vi.mocked(fse.writeJson).mock.calls; + expect(writes.some(call => String(call[0]) === userConfig)).toBe(false); + const localWrite = writes.find(call => String(call[0]).endsWith('.autohand/settings.local.json'))?.[1] as any; + expect(localWrite.mcp.servers).toEqual(expect.arrayContaining([ + { name: 'project', type: 'http', url: 'https://local', transport: 'http', autoConnect: false, localOnly: true }, + { name: 'keep', command: 'incoming', transport: 'stdio', autoConnect: false, localOnly: true }, + { name: 'events', type: 'sse', url: 'https://example.test/events', headers: { Authorization: 'Bearer secret' }, transport: 'sse', autoConnect: false, localOnly: true }, + { name: 'local', command: 'local-run', transport: 'stdio', autoConnect: false, localOnly: true }, + ])); + }); + + it('reports existing MCP servers as skipped without rewriting their config', async () => { + const root = '/workspace'; + const userConfig = '/autohand/config.json'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root, configPath: userConfig }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => + [path.join(HOME, '.claude.json'), userConfig].includes(String(p))); + vi.mocked(fse.readJson).mockImplementation(async (p: string) => { + if (String(p) === path.join(HOME, '.claude.json')) { + return { mcpServers: { existing: { command: 'server' } } } as never; + } + if (String(p) === userConfig) { + return { mcp: { servers: [{ name: 'existing', command: 'server' }] } } as never; + } + throw new Error('unexpected file read'); + }); + + const result = await importer.import(['mcp']); + + expect(result.imported.get('mcp')).toEqual({ + success: 0, + failed: 0, + skipped: 1, + skipReasons: { 'already exists': 1 }, + }); + expect(fse.ensureDir).not.toHaveBeenCalled(); + expect(fse.writeJson).not.toHaveBeenCalled(); + expect(fse.writeFile).not.toHaveBeenCalled(); + }); + + it('ignores malformed global Claude configuration', async () => { + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => String(p) === path.join(HOME, '.claude.json')); + vi.mocked(fse.readJson).mockRejectedValue(new SyntaxError('invalid JSON')); + + const result = await importer.scan(); + + expect(result.available.has('mcp')).toBe(false); + }); + it('should return empty available map when ~/.claude does not exist', async () => { vi.mocked(fse.pathExists).mockResolvedValue(false as never); diff --git a/tests/sync/connectorDownload.test.ts b/tests/sync/connectorDownload.test.ts index c6e6a8bf4..b7df6a075 100644 --- a/tests/sync/connectorDownload.test.ts +++ b/tests/sync/connectorDownload.test.ts @@ -56,6 +56,29 @@ describe('Console connector downloads', () => { expect(loaded.mcp?.servers.some((server) => server.managedConnectorId === connector.id)).toBe(true); }); + it('does not publish imported local-only MCP servers to the account', async () => { + const loaded = { + ...config(), + mcp: { + servers: [ + { name: 'imported', transport: 'http' as const, url: 'https://private.example/mcp', headers: { Authorization: 'Bearer secret' }, autoConnect: false, localOnly: true }, + { name: 'shared', transport: 'stdio' as const, command: 'npx', autoConnect: true }, + ], + }, + }; + vi.spyOn(CodingAgentControlPlaneClient.prototype, 'pullConnectors').mockResolvedValue({ revision: 1, connectors: [] }); + vi.spyOn(CodingAgentControlPlaneClient.prototype, 'pullSettingsProfiles').mockResolvedValue([]); + const createConnector = vi.spyOn(CodingAgentControlPlaneClient.prototype, 'createConnector').mockResolvedValue('remote-id'); + vi.spyOn(CodingAgentControlPlaneClient.prototype, 'acknowledgeConnectors').mockResolvedValue(); + vi.spyOn(CodingAgentControlPlaneClient.prototype, 'uploadSettingsSnapshot').mockResolvedValue(); + + await syncCodingAgentControlPlane(loaded, 'test-session', { publishLocalConnectors: true }); + + expect(createConnector).toHaveBeenCalledOnce(); + expect(createConnector).toHaveBeenCalledWith('test-session', expect.objectContaining({ name: 'shared' }), undefined); + expect(JSON.stringify(createConnector.mock.calls)).not.toContain('Bearer secret'); + }); + it('does not mutate live config or acknowledge a snapshot when persistence fails', async () => { const loaded = config(); mocks.saveConfig.mockRejectedValue(new Error('Disk full')); From 8bf8bf71a7cbec579af9536b5300c812fd29049d Mon Sep 17 00:00:00 2001 From: Sagnik Das Date: Tue, 20 Oct 2026 13:05:57 +0530 Subject: [PATCH 12/38] Imported MCP creds out of the worktree Co-authored-by: Autohand Evolve --- src/import/importers/ClaudeImporter.ts | 24 ++++++++++++++++++++++++ tests/import/ClaudeImporter.test.ts | 13 +++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/import/importers/ClaudeImporter.ts b/src/import/importers/ClaudeImporter.ts index 15471bef1..2780111e0 100644 --- a/src/import/importers/ClaudeImporter.ts +++ b/src/import/importers/ClaudeImporter.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import fse from 'fs-extra'; import type { ImportSource, @@ -268,6 +269,7 @@ export class ClaudeImporter extends BaseImporter { if (additions.length === 0) continue; const merged = [...existing, ...additions]; + if (servers === project) await this.ignoreLocalSettings(workspace); await fse.ensureDir(path.dirname(file)); const output = { ...current, mcp: { ...mcp, servers: merged } }; await this.writeConfigFile(file, output); @@ -282,6 +284,28 @@ export class ClaudeImporter extends BaseImporter { }); } + // Keep imported credentials out of Git without changing the shared .gitignore. + private async ignoreLocalSettings(workspace: string): Promise { + let excludeFile: string; + try { + excludeFile = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], { + cwd: workspace, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + const stderr = (error as NodeJS.ErrnoException & { stderr?: Buffer | string }).stderr?.toString() ?? ''; + if (stderr.includes('not a git repository')) return; + throw error; + } + + const pattern = '**/.autohand/settings.local.json'; + const current = await fse.pathExists(excludeFile) ? await fse.readFile(excludeFile, 'utf8') : ''; + if (current.split(/\r?\n/).includes(pattern)) return; + const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : ''; + await fse.writeFile(excludeFile, `${current}${separator}${pattern}\n`); + } + private async readConfigFile(file: string): Promise> { if (!(await fse.pathExists(file))) return {}; diff --git a/tests/import/ClaudeImporter.test.ts b/tests/import/ClaudeImporter.test.ts index 6e6f088c2..b5db2d651 100644 --- a/tests/import/ClaudeImporter.test.ts +++ b/tests/import/ClaudeImporter.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; const atomicFileMocks = vi.hoisted(() => ({ atomicWriteJson: vi.fn().mockResolvedValue(undefined), @@ -16,6 +17,16 @@ const atomicFileMocks = vi.hoisted(() => ({ vi.mock('../../src/utils/atomicFile.js', () => atomicFileMocks); +vi.mock('node:child_process', async (importOriginal) => ({ + ...await importOriginal(), + execFileSync: vi.fn((file: string, args: string[]) => { + if (file !== 'git') throw new Error(`Unexpected command: ${file}`); + if (args.includes('--show-toplevel')) return '/workspace'; + if (args.includes('info/exclude')) return '/workspace/.git/info/exclude'; + throw new Error(`Unexpected git args: ${args.join(' ')}`); + }), +})); + // Mock fs-extra before importing anything that uses it vi.mock('fs-extra', () => ({ default: { @@ -150,6 +161,8 @@ describe('ClaudeImporter', () => { { name: 'events', type: 'sse', url: 'https://example.test/events', headers: { Authorization: 'Bearer secret' }, transport: 'sse', autoConnect: false, localOnly: true }, { name: 'local', command: 'local-run', transport: 'stdio', autoConnect: false, localOnly: true }, ])); + expect(execFileSync).toHaveBeenCalledWith('git', ['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], expect.objectContaining({ cwd: root })); + expect(fse.writeFile).toHaveBeenCalledWith('/workspace/.git/info/exclude', expect.stringContaining('**/.autohand/settings.local.json')); }); it('reports existing MCP servers as skipped without rewriting their config', async () => { From dc44e019f6edace32a0effb59a081c2eca0b3096 Mon Sep 17 00:00:00 2001 From: Sagnik Das Date: Tue, 20 Oct 2026 13:23:24 +0530 Subject: [PATCH 13/38] Fix Claude MCP server import issues(codex review) Signed-off-by: Sagnik Das --- src/import/importers/ClaudeImporter.ts | 29 ++++++++++++++++++++------ tests/import/ClaudeImporter.test.ts | 29 +++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/import/importers/ClaudeImporter.ts b/src/import/importers/ClaudeImporter.ts index 2780111e0..2a1d7dd61 100644 --- a/src/import/importers/ClaudeImporter.ts +++ b/src/import/importers/ClaudeImporter.ts @@ -52,13 +52,20 @@ function isRecord(value: unknown): value is Record { function validMcpServers(value: unknown): Record { if (!isRecord(value)) return {}; - return Object.fromEntries(Object.entries(value).filter(([, server]) => { + return Object.fromEntries(Object.entries(value).filter(([name, server]) => { + if (!name.trim()) return false; if (!isRecord(server)) return false; if (server.type === 'http' || server.type === 'sse') return typeof server.url === 'string' && server.url.length > 0; return (server.type === undefined || server.type === 'stdio') && typeof server.command === 'string' && server.command.length > 0; })); } +function hasEnvPlaceholders(value: unknown): boolean { + if (typeof value === 'string') return /\$\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\}/.test(value); + if (Array.isArray(value)) return value.some(hasEnvPlaceholders); + return isRecord(value) && Object.values(value).some(hasEnvPlaceholders); +} + /** * Importer for Claude Code CLI data (~/.claude). * @@ -70,6 +77,12 @@ export class ClaudeImporter extends BaseImporter { readonly displayName = 'Claude Code'; readonly homePath = '~/.claude'; + async detect(): Promise { + return await super.detect() + || await fse.pathExists(path.join(path.dirname(this.resolvedHomePath), '.claude.json')) + || await fse.pathExists(path.join(path.resolve(this.hookImportOptions.workspaceRoot ?? process.cwd()), '.mcp.json')); + } + // --------------------------------------------------------------- // scan() // --------------------------------------------------------------- @@ -216,6 +229,11 @@ export class ClaudeImporter extends BaseImporter { Object.assign(collected.user, validMcpServers(claudeConfig.mcpServers)); } + const localConfig = await this.readOptionalJson(projectConfigPath); + if (localConfig && isRecord(localConfig.mcpServers)) { + Object.assign(collected.project, validMcpServers(localConfig.mcpServers)); + } + const projects = claudeConfig?.projects; if (isRecord(projects)) { const projectConfig = projects[workspaceRoot]; @@ -224,11 +242,6 @@ export class ClaudeImporter extends BaseImporter { } } - const localConfig = await this.readOptionalJson(projectConfigPath); - if (localConfig && isRecord(localConfig.mcpServers)) { - Object.assign(collected.project, validMcpServers(localConfig.mcpServers)); - } - return collected; } @@ -251,6 +264,10 @@ export class ClaudeImporter extends BaseImporter { const existing = Array.isArray(mcp.servers) ? mcp.servers : []; const converted = Object.entries(servers).flatMap(([name, value]) => { if (!isRecord(value)) return []; + if (hasEnvPlaceholders(value)) { + errors.push({ category: 'mcp', item: name, error: 'Claude MCP environment variable placeholders are not supported by this importer', retriable: false }); + return []; + } const server = value; const transport = server.type === 'http' || server.type === 'sse' ? server.type : 'stdio'; return [{ ...server, name, transport, autoConnect: false, localOnly: true }]; diff --git a/tests/import/ClaudeImporter.test.ts b/tests/import/ClaudeImporter.test.ts index b5db2d651..239484ed4 100644 --- a/tests/import/ClaudeImporter.test.ts +++ b/tests/import/ClaudeImporter.test.ts @@ -89,6 +89,15 @@ describe('ClaudeImporter', () => { // scan() // --------------------------------------------------------------- describe('scan()', () => { + it.each(['global config', 'project config'])('detects Claude when only the MCP %s exists', async (scope) => { + const root = '/workspace'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root }); + const onlyMcpConfig = scope === 'global config' ? path.join(HOME, '.claude.json') : path.join(root, '.mcp.json'); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => String(p) === onlyMcpConfig); + + await expect(importer.detect()).resolves.toBe(true); + }); + it('reports MCP servers from Claude configuration', async () => { const root = '/workspace'; importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root }); @@ -115,6 +124,7 @@ describe('ClaudeImporter', () => { missingUrl: { type: 'http' }, unsupported: { type: 'websocket', url: 'https://example.test' }, scalar: 'invalid', + '': { command: 'blank-name' }, }, } as never; if (String(p) === userConfig) return {} as never; @@ -156,7 +166,7 @@ describe('ClaudeImporter', () => { expect(writes.some(call => String(call[0]) === userConfig)).toBe(false); const localWrite = writes.find(call => String(call[0]).endsWith('.autohand/settings.local.json'))?.[1] as any; expect(localWrite.mcp.servers).toEqual(expect.arrayContaining([ - { name: 'project', type: 'http', url: 'https://local', transport: 'http', autoConnect: false, localOnly: true }, + { name: 'project', command: 'shared', transport: 'stdio', autoConnect: false, localOnly: true }, { name: 'keep', command: 'incoming', transport: 'stdio', autoConnect: false, localOnly: true }, { name: 'events', type: 'sse', url: 'https://example.test/events', headers: { Authorization: 'Bearer secret' }, transport: 'sse', autoConnect: false, localOnly: true }, { name: 'local', command: 'local-run', transport: 'stdio', autoConnect: false, localOnly: true }, @@ -165,6 +175,23 @@ describe('ClaudeImporter', () => { expect(fse.writeFile).toHaveBeenCalledWith('/workspace/.git/info/exclude', expect.stringContaining('**/.autohand/settings.local.json')); }); + it('reports MCP servers with Claude environment placeholders as unsupported', async () => { + const root = '/workspace'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => [path.join(HOME, '.claude.json'), path.join(root, '.mcp.json')].includes(String(p))); + vi.mocked(fse.readJson).mockImplementation(async (p: string) => { + if (String(p) === path.join(HOME, '.claude.json')) return {} as never; + if (String(p) === path.join(root, '.mcp.json')) return { mcpServers: { shared: { command: 'node', args: ['${SCRIPT:-server.js}'], env: { TOKEN: '${TOKEN}' } } } } as never; + throw new Error('missing'); + }); + + const result = await importer.import(['mcp']); + + expect(result.imported.get('mcp')).toMatchObject({ success: 0, failed: 1 }); + expect(result.errors).toEqual([expect.objectContaining({ category: 'mcp', item: 'shared', error: expect.stringContaining('environment variable placeholders') })]); + expect(fse.writeJson).not.toHaveBeenCalled(); + }); + it('reports existing MCP servers as skipped without rewriting their config', async () => { const root = '/workspace'; const userConfig = '/autohand/config.json'; From db093e40bbc224914b409180b1f06f7248303832 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 23 Oct 2026 10:25:49 +1300 Subject: [PATCH 14/38] Keep released CLI installations successful when Computer control is unavailable --- .../installer-release-compatibility.yml | 27 ++++++++++ CHANGELOG.md | 4 ++ install.ps1 | 22 ++++++-- install.sh | 26 +++++++-- scripts/install-computer-use.mjs | 20 ++++++- .../scenarios/released-installer-smoke.py | 54 +++++++++++++++++++ tests/computerInstallIntegration.test.ts | 37 ++++++++++--- tests/installAliases.integration.spec.ts | 46 +++++++++++++--- 8 files changed, 215 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/installer-release-compatibility.yml create mode 100644 src/testing/scenarios/released-installer-smoke.py diff --git a/.github/workflows/installer-release-compatibility.yml b/.github/workflows/installer-release-compatibility.yml new file mode 100644 index 000000000..d776821a3 --- /dev/null +++ b/.github/workflows/installer-release-compatibility.yml @@ -0,0 +1,27 @@ +name: Installer release compatibility +on: + pull_request: + paths: + - install.sh + - install.ps1 + - scripts/install-computer-use.mjs + - src/testing/scenarios/released-installer-smoke.py + - .github/workflows/installer-release-compatibility.yml + schedule: + - cron: '21 6 * * *' + workflow_dispatch: +permissions: + contents: read +jobs: + released-installer: + timeout-minutes: 12 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + channel: [stable, alpha] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - name: Install the independently published binary in a clean HOME + run: python3 src/testing/scenarios/released-installer-smoke.py --channel '${{ matrix.channel }}' diff --git a/CHANGELOG.md b/CHANGELOG.md index 131eaa5ea..6024bbc69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- The curl and PowerShell installers skip Computer control on releases that do not support it. Optional Computer control setup failures warn and leave the CLI ready to use. + ### Breaking `-c` now means `--continue` (as in Claude Code). Use `--auto-commit` for auto-commit. diff --git a/install.ps1 b/install.ps1 index 75ad09a2d..38fe01039 100644 --- a/install.ps1 +++ b/install.ps1 @@ -726,6 +726,16 @@ exit $LASTEXITCODE Write-Host 'Run autohand-alpha to test this installation. Earlier profiles are retained.' } +function Test-ComputerControlSupport { + param([string]$BinaryPath) + # Old releases print root help even for unknown commands before --help. + try { + $helpText = (& $BinaryPath --help 2>$null | Out-String) + return ($LASTEXITCODE -eq 0 -and $helpText -match '(?m)^\s+computer(?:\s|$)') + } + catch { return $false } +} + function Install-ComputerControl { param( [Parameter(Mandatory = $true)][string]$BinaryPath @@ -736,6 +746,11 @@ function Install-ComputerControl { return } + if (-not (Test-ComputerControlSupport $BinaryPath)) { + Write-Host "Computer control arrives in the next release" -ForegroundColor DarkGray + return + } + Write-Step "Installing Computer control..." try { # The computer-use engine lives in its own Autohand-managed directory. @@ -748,9 +763,8 @@ function Install-ComputerControl { Write-Success "Autohand Computer Use is ready." } catch { - Write-Host "Installation is incomplete: Computer control could not be installed." -ForegroundColor Yellow + Write-Host "Warning: Optional Computer control could not be installed. Autohand CLI is ready." -ForegroundColor Yellow Write-Host "Retry with: autohand computer install" -ForegroundColor Yellow - throw } } @@ -963,7 +977,9 @@ function Install-Autohand { Write-Host " autohand # Start interactive mode" Write-Host " autohand --help # Show all options" Write-Host " autohand login # Sign in to your account" - Write-Host " autohand computer doctor # Verify native app control permissions" + if (Test-ComputerControlSupport (Join-Path $InstallPath 'autohand.exe')) { + Write-Host " autohand computer doctor # Verify native app control permissions" + } if ($extractedAhtraces) { Write-Host "" Write-Host "Agent traces stay off until you choose during onboarding." diff --git a/install.sh b/install.sh index 0f22141d9..7d2555bad 100755 --- a/install.sh +++ b/install.sh @@ -14,6 +14,7 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' +DIM='\033[2m' info() { printf "${BLUE}%s${NC}\n" "$1" @@ -146,6 +147,18 @@ LAUNCHER info "Run autohand-alpha to test this installation. Earlier profiles are retained." } +# Older releases accept unknown command names before --help and print root help. +# Require the command in that root help rather than trusting its exit status. +computer_control_supported() { + local _help + _help=$("$1" --help < /dev/null 2>/dev/null) || return 1 + printf '%s\n' "$_help" | grep -Eq '^[[:space:]]+computer([[:space:]]|$)' +} + +dim() { + printf "${DIM}%s${NC}\n" "$1" +} + install_computer_control() { local _binary="$1" local _computer_use_app="${2:-}" @@ -160,6 +173,11 @@ install_computer_control() { return 0 fi + if ! computer_control_supported "$_binary"; then + dim "Computer control arrives in the next release" + return 0 + fi + info "Installing Computer control..." if [ -d "$_computer_use_app" ]; then if AUTOHAND_COMPUTER_USE_APP_SOURCE="$_computer_use_app" "$_binary" "$@"; then @@ -175,9 +193,9 @@ install_computer_control() { if [ "$_computer_status" -eq 0 ]; then success "Autohand Computer Use is ready." else - warn "Installation is incomplete: Computer control could not be installed." + warn "Warning: Optional Computer control could not be installed. Autohand CLI is ready." warn "Retry with: autohand computer install" - return 1 + return 0 fi } @@ -391,7 +409,9 @@ EOF echo " autohand # Start interactive mode" echo " autohand --help # Show all options" echo " autohand login # Sign in to your account" - echo " autohand computer doctor # Verify native app control permissions" + if computer_control_supported "$_dir/$BINARY_NAME"; then + echo " autohand computer doctor # Verify native app control permissions" + fi if [ "$_has_traces" = true ]; then echo "" echo "Agent traces stay off until you choose during onboarding." diff --git a/scripts/install-computer-use.mjs b/scripts/install-computer-use.mjs index ad91de03a..5e8b45695 100644 --- a/scripts/install-computer-use.mjs +++ b/scripts/install-computer-use.mjs @@ -19,6 +19,13 @@ const COMPUTER_INSTALL_ARGS = [ ]; function runComputerUseCli({ entrypoint, packageRoot, environment }) { + const help = spawnSync(process.execPath, [entrypoint, '--help'], { + cwd: packageRoot, env: environment, encoding: 'utf8', windowsHide: true, + timeout: 30_000, + }); + if (help.status !== 0 || !/^\s+computer(?:\s|$)/m.test(help.stdout ?? '')) { + return { status: 'skipped-unsupported' }; + } const result = spawnSync(process.execPath, [entrypoint, ...COMPUTER_INSTALL_ARGS], { cwd: packageRoot, env: environment, @@ -56,8 +63,14 @@ export async function installComputerUsePostinstall(options = {}) { } const runCli = options.runCli ?? runComputerUseCli; - await runCli({ entrypoint, packageRoot, environment }); - return { status: 'completed' }; + try { + const result = await runCli({ entrypoint, packageRoot, environment }); + return result ?? { status: 'completed' }; + } catch (error) { + const warn = options.warn ?? console.warn; + warn(`Warning: Optional Computer control could not be installed. Autohand CLI is ready. ${error instanceof Error ? error.message : String(error)}`); + return { status: 'failed-optional' }; + } } function isMainModule() { @@ -67,6 +80,9 @@ function isMainModule() { if (isMainModule()) { installComputerUsePostinstall().then((result) => { + if (result.status === 'skipped-unsupported') { + console.log('Computer control arrives in the next release'); + } if (result.status === 'skipped-development') { console.log( 'Skipping Autohand Computer Use postinstall in a source checkout. ' diff --git a/src/testing/scenarios/released-installer-smoke.py b/src/testing/scenarios/released-installer-smoke.py new file mode 100644 index 000000000..3fe115ab7 --- /dev/null +++ b/src/testing/scenarios/released-installer-smoke.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Exercise today's installer against an independently published release.""" +import argparse +import os +from pathlib import Path +import subprocess +import tempfile + +parser = argparse.ArgumentParser() +parser.add_argument('--channel', choices=['stable', 'alpha'], default='stable') +parser.add_argument('--version', default='latest') +parser.add_argument('--installer', type=Path) +args = parser.parse_args() +repo = Path(__file__).resolve().parents[3] +with tempfile.TemporaryDirectory(prefix='autohand-release-install-') as folder: + root = Path(folder) + user = root / 'home' + binary_dir = root / 'bin' + user.mkdir() + binary_dir.mkdir() + # Do not inherit a developer's config, opt-outs, executable paths or aliases. + environment = {key: value for key, value in os.environ.items() + if not key.startswith('AUTOHAND_') and key not in ['HOME', 'PATH']} + environment.update(HOME=str(user), PATH=f'{binary_dir}:/usr/bin:/bin:/usr/sbin:/sbin', + AUTOHAND_INSTALL_DIR=str(binary_dir), AUTOHAND_CHANNEL=args.channel, + AUTOHAND_VERSION=args.version, AUTOHAND_INSTALL_FIRST_RUN='no', + AUTOHAND_SKIP_COMPUTER_USE_PERMISSIONS='1') + result = subprocess.run(['/bin/sh', str((args.installer or repo / 'install.sh').resolve())], env=environment, + capture_output=True, text=True, timeout=360) + print(result.stdout, end='') + print(result.stderr, end='') + assert result.returncode == 0, f'installer exit {result.returncode}' + assert 'Autohand CLI installed successfully' in result.stdout + assert 'Installation is incomplete' not in result.stdout + binary = binary_dir / 'autohand' + def probe(executable, *arguments): + return subprocess.check_output([str(executable), *arguments], env=environment, + text=True, timeout=30) + help_text = probe(binary, '--help') + supported = any(line.strip().startswith('computer ') for line in help_text.splitlines()) + if not supported: + assert 'Computer control arrives in the next release' in result.stdout + assert 'Retry with:' not in result.stdout + assert 'autohand computer doctor' not in result.stdout + if args.channel == 'alpha': + assert supported, 'alpha is expected to expose Computer control' + for alias in ['agent', 'ah', 'autohand-code']: + assert (binary_dir / alias).resolve() == binary.resolve() + assert (binary_dir / 'ahtraces').is_file(), 'trace companion installed' + version = probe(binary, '--version').strip() + traces_version = probe(binary_dir / 'ahtraces', '--version').strip() + if args.version != 'latest': + assert args.version.lstrip('v') in version, version + print(f'PASS clean HOME {args.channel}/{args.version}: CLI={version}; ahtraces={traces_version}; computer={supported}') diff --git a/tests/computerInstallIntegration.test.ts b/tests/computerInstallIntegration.test.ts index 8666b97ba..43262ca7c 100644 --- a/tests/computerInstallIntegration.test.ts +++ b/tests/computerInstallIntegration.test.ts @@ -20,16 +20,29 @@ afterEach(async () => { }); describe('computer control installation integration', () => { - it.runIf(process.platform !== 'win32')('fails the Unix installer when the required component fails', async () => { + it.runIf(process.platform !== 'win32')('skips unsupported releases without an invalid retry command', async () => { const script = await readFile('install.sh', 'utf8'); - const functionSource = script.slice(script.indexOf('install_computer_control() {'), script.indexOf('\nmain() {')); + const functionSource = script.slice(script.indexOf('computer_control_supported() {'), script.indexOf('\nmain() {')); const result = spawnSync('/bin/sh', ['-c', [ 'info() { echo "$*"; }', 'warn() { echo "$*"; }', 'success() { echo "$*"; }', - functionSource, 'install_computer_control /usr/bin/false', + 'dim() { echo "$*"; }', functionSource, 'install_computer_control /usr/bin/false', ].join('\n')], { encoding: 'utf8', env: { ...process.env, AUTOHAND_SKIP_COMPUTER_CONTROL_INSTALL: '0' } }); - expect(result.status).toBe(1); - expect(result.stdout).toContain('autohand computer install'); - expect(result.stdout).not.toContain('still ready'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Computer control arrives in the next release'); + expect(result.stdout).not.toContain('Retry with'); + expect(result.stdout).not.toContain('Installation is incomplete'); + }); + + it('keeps npm installation successful when the optional computer setup fails', async () => { + const packageRoot = await mkdtemp(path.join(tmpdir(), 'autohand-computer-postinstall-')); + temporaryDirectories.push(packageRoot); + await mkdir(path.join(packageRoot, 'dist')); + await writeFile(path.join(packageRoot, 'dist/index.js'), ''); + const warn = vi.fn(); + await expect(installComputerUsePostinstall({ packageRoot, warn, + runCli: () => { throw new Error('fixture setup failure'); }, + })).resolves.toEqual({ status: 'failed-optional' }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('fixture setup failure')); }); it('allows an explicit headless opt-out before npm postinstall', async () => { @@ -89,6 +102,7 @@ describe('computer control installation integration', () => { const distDirectory = path.join(packageRoot, 'dist'); await mkdir(distDirectory); await writeFile(path.join(distDirectory, 'index.js'), [ + "if (process.argv[2] === '--help') { console.log('Commands:\\n computer Computer control'); process.exit(0); }", "const expected = ['computer', 'install', '--non-interactive', '--postinstall', '--bin-dir', 'vendor'];", 'if (JSON.stringify(process.argv.slice(2)) !== JSON.stringify(expected)) process.exit(2);', ].join('\n')); @@ -98,6 +112,17 @@ describe('computer control installation integration', () => { }); }); + it('does not invoke computer install when packaged help lacks the command', async () => { + const packageRoot = await mkdtemp(path.join(tmpdir(), 'autohand-computer-postinstall-')); + temporaryDirectories.push(packageRoot); + await mkdir(path.join(packageRoot, 'dist')); + await writeFile(path.join(packageRoot, 'dist/index.js'), [ + "if (process.argv[2] === '--help') { console.log('Commands:\\n login Sign in'); process.exit(0); }", + "throw new Error('unsupported computer install must not run');", + ].join('\n')); + await expect(installComputerUsePostinstall({ packageRoot })).resolves.toEqual({ status: 'skipped-unsupported' }); + }); + it('rejects a published package that is missing its built CLI', async () => { const packageRoot = await mkdtemp(path.join(tmpdir(), 'autohand-computer-postinstall-')); temporaryDirectories.push(packageRoot); diff --git a/tests/installAliases.integration.spec.ts b/tests/installAliases.integration.spec.ts index d9a889351..4cebad0b5 100644 --- a/tests/installAliases.integration.spec.ts +++ b/tests/installAliases.integration.spec.ts @@ -182,6 +182,7 @@ reset_normal_profile writeFileSync(join(payload, 'autohand'), `#!/bin/sh case "$1" in --version) printf '0.8.3-alpha.123abcd\\n' ;; + --help) printf 'Commands:\\n computer Computer control\\n'; exit 0 ;; computer) printf '%s\\n' "$*" >> "$AUTOHAND_TEST_CALLS"; exit "\${AUTOHAND_TEST_COMPUTER_EXIT:-0}" ;; *) printf '%s\\n' "$AUTOHAND_HOME" "\${AUTOHAND_CONFIG:-unset}" "$@" ;; esac @@ -228,9 +229,15 @@ esac const failed = spawnSync('/bin/sh', ['install.sh', '--alpha', '--fresh'], { cwd: ROOT, env: { ...environment, AUTOHAND_TEST_COMPUTER_EXIT: '17' }, encoding: 'utf8', }); - expect(failed.status).not.toBe(0); - expect(readFileSync(normalConfig, 'utf8')).toBe('keep profile when installation fails'); - expect(readdirSync(home).filter(name => name.startsWith('.autohand.backup.'))).toHaveLength(2); + expect(failed.status).toBe(0); + expect(failed.stdout).toContain('Optional Computer control could not be installed'); + expect(existsSync(normalConfig)).toBe(false); + const allBackups = readdirSync(home).filter(name => name.startsWith('.autohand.backup.')); + expect(allBackups).toHaveLength(3); + expect(allBackups.some(name => { + const config = join(home, name, 'profile', 'config.json'); + return existsSync(config) && readFileSync(config, 'utf8') === 'keep profile when installation fails'; + })).toBe(true); }); unixIt('rejects a downloaded binary that cannot start before replacing the installation', () => { @@ -523,6 +530,7 @@ describe('release installer first run', () => { join(payloadDir, 'autohand'), `#!/bin/sh if [ "\${1:-}" = "--version" ]; then printf "test-version\\n"; exit 0; fi +if [ "\${1:-}" = "--help" ]; then printf "Commands:\\n computer Computer control\\n"; exit 0; fi if [ "\${1:-}" = "computer" ] && [ "\${2:-}" = "install" ]; then exit 0; fi { printf "args=%s\\n" "$*"; printf "stdin="; cat; } > "$AUTOHAND_TEST_LAUNCH_LOG" `, @@ -551,7 +559,29 @@ if [ "\${1:-}" = "computer" ] && [ "\${2:-}" = "install" ]; then exit 0; fi return execFileSync('/bin/sh', ['install.sh'], { cwd: ROOT, encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'] }); } - unixIt('fails the complete installer when its required computer component fails', () => { + unixIt('installs an older release cleanly even when unknown-command help exits zero', () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'autohand-installer-stable-')); + tempRoots.push(tempRoot); + const { env } = seedFirstRunFixture(tempRoot); + const payloadDir = join(tempRoot, 'payload'); + writeFileSync(join(payloadDir, 'autohand'), [ + '#!/bin/sh', + '[ "${1:-}" = "--version" ] && { echo 0.9.8; exit 0; }', + 'case "$*" in *--help*) printf "Usage: autohand [options] [prompt]\\nCommands:\\n login Sign in\\n"; exit 0 ;; esac', + 'echo "unsupported command" >&2; exit 1', + ].join('\n')); + execFileSync('tar', ['-czf', env.AUTOHAND_TEST_ARCHIVE, '-C', payloadDir, 'autohand', 'ahtraces']); + const checksum = createHash('sha256').update(readFileSync(env.AUTOHAND_TEST_ARCHIVE)).digest('hex'); + writeFileSync(env.AUTOHAND_TEST_CHECKSUM, `${checksum} autohand.tar.gz\n`); + const output = runInstaller({ ...env, AUTOHAND_INSTALL_FIRST_RUN: 'no' }); + expect(output).toContain('Computer control arrives in the next release'); + expect(output).toContain('Autohand CLI installed successfully'); + expect(output).not.toContain('Retry with'); + expect(output).not.toContain('autohand computer doctor'); + expect(output).not.toContain('Installation is incomplete'); + }); + + unixIt('finishes the complete installer when optional computer setup fails', () => { const tempRoot = mkdtempSync(join(tmpdir(), 'autohand-installer-computer-')); tempRoots.push(tempRoot); const { env } = seedFirstRunFixture(tempRoot); @@ -559,6 +589,7 @@ if [ "\${1:-}" = "computer" ] && [ "\${2:-}" = "install" ]; then exit 0; fi writeFileSync(join(payloadDir, 'autohand'), [ '#!/bin/sh', '[ "${1:-}" = "--version" ] && { printf "test-version\\n"; exit 0; }', + '[ "${1:-}" = "--help" ] && { printf "Commands:\\n computer Computer control\\n"; exit 0; }', '[ "${1:-}" = "computer" ] && exit 7', 'exit 0', ].join('\n')); @@ -566,10 +597,11 @@ if [ "\${1:-}" = "computer" ] && [ "\${2:-}" = "install" ]; then exit 0; fi const checksum = createHash('sha256').update(readFileSync(env.AUTOHAND_TEST_ARCHIVE)).digest('hex'); writeFileSync(env.AUTOHAND_TEST_CHECKSUM, `${checksum} autohand.tar.gz\n`); const result = spawnSync('/bin/sh', ['install.sh'], { cwd: ROOT, encoding: 'utf8', env }); - expect(result.status).toBe(1); - expect(result.stdout).toContain('Installation is incomplete'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Computer control could not be installed'); + expect(result.stdout).not.toContain('Installation is incomplete'); expect(result.stdout).not.toContain('Autohand Computer Use is ready'); - expect(result.stdout).not.toContain('Autohand CLI installed successfully'); + expect(result.stdout).toContain('Autohand CLI installed successfully'); }); unixIt('starts the installed binary with "hello world" as its first message when asked to', () => { From a7b6758552893ac3b9b515ec02d155163aa91962 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 23 Oct 2026 10:30:41 +1300 Subject: [PATCH 15/38] Verify PowerShell skips unsupported commands and keeps optional failures nonfatal --- tests/windowsInstaller.spec.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/windowsInstaller.spec.ts b/tests/windowsInstaller.spec.ts index 9cf0a729c..43911b460 100644 --- a/tests/windowsInstaller.spec.ts +++ b/tests/windowsInstaller.spec.ts @@ -639,20 +639,33 @@ Write-Output (Start-FirstRun -BinaryPath '${fixture}' -Answer $null) }); }); -describe('required computer use installation', () => { - powerShellTest('propagates a companion installation failure', () => { +describe('optional computer use installation', () => { + powerShellTest('warns without failing when a supported companion installation fails', () => { const result = runPowerShellProbe([ installerWithoutEntrypoint, - 'function Invoke-FailedComputerInstall { $global:LASTEXITCODE = 7 }', + 'function Invoke-FailedComputerInstall { if ($args[0] -eq "--help") { Write-Output "Commands:`n computer Computer control"; $global:LASTEXITCODE = 0; return }; $global:LASTEXITCODE = 7 }', '$env:AUTOHAND_SKIP_COMPUTER_CONTROL_INSTALL = "0"', 'Install-ComputerControl -BinaryPath "Invoke-FailedComputerInstall"', ].join('\n')); - expect(result.status).not.toBe(0); - expect(result.stdout).toContain('autohand computer install'); - expect(result.stdout).not.toContain('still ready'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Warning: Optional Computer control'); + expect(result.stdout).toContain('Retry with: autohand computer install'); + expect(result.stdout).not.toContain('Installation is incomplete'); }); -}); + powerShellTest('skips older releases whose generic help exits successfully', () => { + const result = runPowerShellProbe([ + installerWithoutEntrypoint, + 'function Invoke-OldRelease { if ($args[0] -ne "--help") { throw "Unsupported command was invoked" }; Write-Output "Usage: autohand [prompt]`nCommands:`n login Sign in"; $global:LASTEXITCODE = 0 }', + '$env:AUTOHAND_SKIP_COMPUTER_CONTROL_INSTALL = "0"', + 'Install-ComputerControl -BinaryPath "Invoke-OldRelease"', + ].join('\n')); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Computer control arrives in the next release'); + expect(result.stdout).not.toContain('Retry with'); + expect(result.stdout).not.toContain('Installing Computer control'); + }); +}); describe('fresh alpha profile', () => { it('offers an explicit fresh alpha install and forces companion replacement', () => { From 23ad85c6f4b0ae3c0693f02c3e7a0211a5b15160 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 23 Oct 2026 10:38:27 +1300 Subject: [PATCH 16/38] Retain published Zit support when syncing the canonical installers --- install.ps1 | 21 +++++++++- install.sh | 49 ++++++++++++++++++++++++ tests/installAliases.integration.spec.ts | 1 + tests/zitInstallerIntegration.test.ts | 40 +++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 tests/zitInstallerIntegration.test.ts diff --git a/install.ps1 b/install.ps1 index 38fe01039..27a90a954 100644 --- a/install.ps1 +++ b/install.ps1 @@ -12,6 +12,8 @@ Back up and reset the normal profile, create an empty alpha profile, and reinstall Computer Use .PARAMETER NoCache Bypass CDN cache and fetch fresh release from GitHub +.PARAMETER NoZit + Skip Zit integration (also AUTOHAND_SKIP_ZIT_INSTALL=1) .PARAMETER Version Install specific version (e.g., 0.7.3) .PARAMETER InstallDir @@ -33,7 +35,8 @@ param( [switch]$NoCache, [string]$Version, [string]$InstallDir, - [switch]$Help + [switch]$Help, + [switch]$NoZit ) $ErrorActionPreference = "Stop" @@ -91,6 +94,7 @@ Options: -Clean Remove existing installation before installing -Fresh Back up/reset normal profile and create an empty alpha profile (use with -Alpha) -NoCache Bypass CDN cache and fetch fresh release from GitHub + -NoZit Skip Zit integration -Version Install specific version (e.g., 0.7.3) -InstallDir Custom installation directory -Help Show this help message @@ -99,6 +103,7 @@ Environment variables: AUTOHAND_VERSION Install specific version (e.g., 0.7.3) AUTOHAND_INSTALL_DIR Custom installation directory AUTOHAND_CHANNEL Set to "alpha" for pre-release builds + AUTOHAND_SKIP_ZIT_INSTALL Set to "1" to skip Zit integration AUTOHAND_INSTALL_FIRST_RUN "yes" starts Autohand with a first message after installing without asking; "no" never offers to @@ -726,6 +731,19 @@ exit $LASTEXITCODE Write-Host 'Run autohand-alpha to test this installation. Earlier profiles are retained.' } +function Install-Zit { + param([string]$InstallPath) + if ($NoZit -or $env:AUTOHAND_SKIP_ZIT_INSTALL -eq '1') { + Write-Step "Skipping Zit integration (-NoZit)." + return + } + if ((Get-Command zit -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $InstallPath 'zit.exe'))) { + Write-Success "Keeping existing Zit installation. Try autohand --zit." + return + } + Write-Warning "Zit currently publishes macOS and Linux binaries, with no native Windows release. See https://getzit.org for supported installation options. Autohand installation continues; -NoZit opts out of this check." +} + function Test-ComputerControlSupport { param([string]$BinaryPath) # Old releases print root help even for unknown commands before --help. @@ -945,6 +963,7 @@ function Install-Autohand { Add-AutohandToUserPath -InstallPath $installPath Install-ComputerControl -BinaryPath $binaryPath + Install-Zit -InstallPath $installPath $launchBinary = $binaryPath if ($Fresh -or $env:AUTOHAND_INSTALL_FRESH -eq '1') { Prepare-FreshAlphaProfile -InstallPath $installPath diff --git a/install.sh b/install.sh index 7d2555bad..414974373 100755 --- a/install.sh +++ b/install.sh @@ -227,6 +227,9 @@ EOF --fresh) AUTOHAND_INSTALL_FRESH=1 ;; + --no-zit) + AUTOHAND_SKIP_ZIT_INSTALL=1 + ;; esac done @@ -374,6 +377,7 @@ EOF install_symlink "$BINARY_NAME" "$_dir/$AGENT_ALIAS_NAME" install_symlink "$BINARY_NAME" "$_dir/$SHORT_ALIAS_NAME" claim_agent_alias_path_wide "$_dir/$BINARY_NAME" "$_dir" + install_zit "$_dir" if ! install_computer_control "$_dir/$BINARY_NAME" "${_tmp_dir}/Autohand Computer Use.app"; then rm -rf "$_tmp_dir" @@ -435,6 +439,51 @@ EOF offer_first_run "$_launch_binary" } +install_zit() ( + local _dir="$1" _target _name _url _expected _actual _zit_tmp + if [ "${AUTOHAND_SKIP_ZIT_INSTALL:-0}" = "1" ]; then + info "Skipping Zit installation (--no-zit)." + return 0 + fi + if command -v zit >/dev/null 2>&1; then + info "Keeping existing Zit installation. Use autohand --zit for isolated work." + return 0 + fi + case "$(uname -s):$(uname -m)" in + Darwin:arm64|Darwin:aarch64) _target="aarch64-apple-darwin" ;; + Darwin:x86_64) _target="x86_64-apple-darwin" ;; + Linux:arm64|Linux:aarch64) _target="aarch64-unknown-linux-gnu" ;; + Linux:x86_64|Linux:amd64) _target="x86_64-unknown-linux-gnu" ;; + *) warn "Zit has no official binary for this platform. See https://getzit.org; Autohand installation continues."; return 0 ;; + esac + _name="zit-v0.1.2-${_target}" + _url="https://github.com/autohandai/getzit/releases/download/v0.1.2/${_name}.tar.gz" + _zit_tmp=$(mktemp -d) || { warn "Could not prepare Zit download. See https://getzit.org"; return 0; } + trap 'rm -rf "$_zit_tmp"' 0 + info "Installing Zit for autohand --zit (opt out with --no-zit)." + if ! curl --connect-timeout 10 --max-time 60 -fsSL "$_url" -o "$_zit_tmp/archive.tar.gz" \ + || ! curl --connect-timeout 10 --max-time 30 -fsSL "${_url}.sha256" -o "$_zit_tmp/checksum"; then + warn "Zit download unavailable. See https://getzit.org; Autohand installation continues." + return 0 + fi + _expected=$(awk 'NR == 1 {print $1}' "$_zit_tmp/checksum") + if ! _actual=$(compute_sha256 "$_zit_tmp/archive.tar.gz") || [ "${#_expected}" != "64" ] || [ "$_expected" != "$_actual" ]; then + warn "Zit checksum verification failed; no Zit binaries installed. See https://getzit.org" + return 0 + fi + if ! tar -xzf "$_zit_tmp/archive.tar.gz" -C "$_zit_tmp" "$_name/zit" "$_name/git-zit" \ + || [ ! -f "$_zit_tmp/$_name/zit" ] || [ -L "$_zit_tmp/$_name/zit" ] \ + || [ ! -f "$_zit_tmp/$_name/git-zit" ] || [ -L "$_zit_tmp/$_name/git-zit" ]; then + warn "Zit archive was invalid. See https://getzit.org; Autohand installation continues." + return 0 + fi + if ! install_file "$_zit_tmp/$_name/zit" "$_dir/zit" || ! install_file "$_zit_tmp/$_name/git-zit" "$_dir/git-zit"; then + warn "Could not install Zit. See https://getzit.org; Autohand installation continues." + return 0 + fi + success "Installed Zit. Try autohand --zit (requires Git 2.38 or newer)." +) + compute_sha256() { local _file="$1" diff --git a/tests/installAliases.integration.spec.ts b/tests/installAliases.integration.spec.ts index 4cebad0b5..42049bb47 100644 --- a/tests/installAliases.integration.spec.ts +++ b/tests/installAliases.integration.spec.ts @@ -55,6 +55,7 @@ while [ "$#" -gt 0 ]; do esac done case "$url" in + *autohandai/getzit/*) exit 22 ;; *.sha256) cp "$AUTOHAND_TEST_CHECKSUM" "$output" ;; *) cp "$AUTOHAND_TEST_ARCHIVE" "$output" ;; esac diff --git a/tests/zitInstallerIntegration.test.ts b/tests/zitInstallerIntegration.test.ts new file mode 100644 index 000000000..c73d2cd81 --- /dev/null +++ b/tests/zitInstallerIntegration.test.ts @@ -0,0 +1,40 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const installer = readFileSync('install.sh', 'utf8').replace(/\nmain "\$@" \|\| exit 1\s*$/, ''); +describe.skipIf(process.platform === 'win32')('published Zit installer compatibility', () => { + for (const scenario of ['existing', 'disabled', 'download-failure', 'checksum-failure', 'verified'] as const) { + it(`preserves the ${scenario} installation path`, () => { + const root = mkdtempSync(join(tmpdir(), 'autohand-zit-install-')); + try { + const fixtures = join(root, 'fixtures'); + const bin = join(root, 'bin'); + const name = 'zit-v0.1.2-aarch64-apple-darwin'; + mkdirSync(fixtures); mkdirSync(bin); mkdirSync(join(root, name)); + for (const file of ['zit', 'git-zit']) writeFileSync(join(root, name, file), '#!/bin/sh\necho fixture-zit\n'); + const archive = join(root, 'archive.tar.gz'); + const tar = spawnSync('/usr/bin/tar', ['-czf', archive, '-C', root, name]); + expect(tar.status).toBe(0); + const digest = scenario === 'checksum-failure' ? '0'.repeat(64) : createHash('sha256').update(readFileSync(archive)).digest('hex'); + writeFileSync(join(fixtures, 'uname'), '#!/bin/sh\ncase "$1" in -s) echo Darwin ;; -m) echo arm64 ;; esac\n'); + writeFileSync(join(fixtures, 'curl'), `#!/bin/sh\necho called >> "$HOME/curl-calls"\n${scenario === 'download-failure' ? 'exit 22' : ''}\nurl= output=\nwhile [ "$#" -gt 0 ]; do case "$1" in -o) output="$2"; shift 2 ;; http*) url="$1"; shift ;; *) shift ;; esac; done\ncase "$url" in *.sha256) echo '${digest}' > "$output" ;; *) cp "$HOME/archive.tar.gz" "$output" ;; esac\n`); + for (const file of ['uname', 'curl']) chmodSync(join(fixtures, file), 0o755); + if (scenario === 'existing') { writeFileSync(join(fixtures, 'zit'), '#!/bin/sh\necho existing\n'); chmodSync(join(fixtures, 'zit'), 0o755); } + const result = spawnSync('/bin/sh', ['-c', `${installer}\ninstall_zit "$1"`, 'test', bin], { + encoding: 'utf8', timeout: 15_000, + env: { HOME: root, PATH: `${fixtures}:/usr/bin:/bin`, AUTOHAND_SKIP_ZIT_INSTALL: scenario === 'disabled' ? '1' : '0' }, + }); + expect(result.status, result.stderr).toBe(0); + expect(existsSync(join(bin, 'zit'))).toBe(scenario === 'verified'); + if (scenario === 'verified') expect(readFileSync(join(bin, 'git-zit'), 'utf8')).toContain('fixture-zit'); + if (scenario === 'existing' || scenario === 'disabled') expect(existsSync(join(root, 'curl-calls'))).toBe(false); + if (scenario === 'checksum-failure') expect(result.stdout).toContain('checksum verification failed'); + if (scenario === 'download-failure') expect(result.stdout).toContain('Autohand installation continues'); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + } +}); From fdb91356dd81cf09f8420d30c44f671bdb3a745e Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 23 Oct 2026 15:20:50 +1300 Subject: [PATCH 17/38] Translate Claude permission rules with source semantics and safe previews --- CHANGELOG.md | 4 + docs/import.md | 9 + src/config.ts | 19 +- src/import/ClaudePermissionImport.ts | 297 ++++++++++++++++++ src/import/importers/ClaudeImporter.ts | 69 ++-- src/import/index.ts | 11 +- src/import/types.ts | 25 ++ src/import/ui/ImportWizard.tsx | 6 + src/index.ts | 2 +- src/permissions/PermissionManager.ts | 66 +++- src/permissions/commandParts.ts | 9 + src/permissions/importedClaudeRules.ts | 61 ++++ src/permissions/localProjectPermissions.ts | 1 + src/permissions/types.ts | 4 + src/types.ts | 4 + tests/import/ClaudeImporter.test.ts | 1 + tests/import/claudePermissions.test.ts | 297 ++++++++++++++++++ tests/import/dryRunAll.test.ts | 22 ++ .../claude-permission-import.tuistory.test.ts | 42 +++ 19 files changed, 900 insertions(+), 49 deletions(-) create mode 100644 docs/import.md create mode 100644 src/import/ClaudePermissionImport.ts create mode 100644 src/permissions/commandParts.ts create mode 100644 src/permissions/importedClaudeRules.ts create mode 100644 tests/import/claudePermissions.test.ts create mode 100644 tests/import/dryRunAll.test.ts create mode 100644 tests/tuistory/claude-permission-import.tuistory.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 131eaa5ea..dcf22205a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Translate supported Claude Code user and project permission rules during import, with a dry-run preview, and remove raw settings and environment data left by earlier imports. + ### Breaking `-c` now means `--continue` (as in Claude Code). Use `--auto-commit` for auto-commit. diff --git a/docs/import.md b/docs/import.md new file mode 100644 index 000000000..307a99e0c --- /dev/null +++ b/docs/import.md @@ -0,0 +1,9 @@ +# Import Claude Code permissions + +From 0.9.9, `autohand import claude --categories settings` translates supported Claude Code allow and deny rules into active Autohand settings. Run the same command with `--dry-run` first to see the mapping without writing files. + +User rules come from `~/.claude/settings.json` and merge into your Autohand config. For each repository recorded in Claude Code's project registry or session metadata, plus the current repository, `.claude/settings.json` and `.claude/settings.local.json` merge into that repository's `.autohand/settings.local.json`. Existing settings and rules remain, duplicate rules are not added, and deny rules take precedence over allows. + +`Bash(npm test:*)` becomes `run_command:npm test*` and the corresponding `shell` rule. Literal simple shell commands can use these grants. Commands with shell operators, quoting, substitutions, redirection or unsupported execution wrappers need separate approval; when command denies cannot be evaluated safely, those commands stay denied. Read rules map to `read_file`; Edit and Write map to `write_file`. Exact `./path` and `./path/**` patterns (trailing `/**` only) are supported. More complex globs and domain-scoped WebFetch rules are listed as `skipped (no equivalent)` instead of guessed. Exact MCP tool names map to the same tool. + +The importer never copies Claude's raw settings or environment block. On your next non-dry-run Claude import, it scrubs any legacy `imported-claude-settings.json` containing raw settings or environment data and reports the cleanup. A dry run only previews that cleanup. diff --git a/src/config.ts b/src/config.ts index e8d81e583..87a30f0ec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -784,9 +784,14 @@ function mergeWorkspaceSettings( // Merge permissions settings if (workspaceSettings.permissions) { + // PermissionManager keeps lists in their original scopes. Flattening these + // arrays or their import metadata could drop user denies or narrow them. + const { allowList, denyList, whitelist, blacklist, importedClaudeRules, ...projectOptions } = workspaceSettings.permissions; + void allowList; void denyList; void whitelist; void blacklist; void importedClaudeRules; merged.permissions = { ...merged.permissions, - ...workspaceSettings.permissions, + ...projectOptions, + workspaceScopes: [...(merged.permissions?.workspaceScopes ?? []), workspaceSettings.permissions], }; } @@ -1323,6 +1328,13 @@ function normalizeConfig( ); } + // Runtime permission scopes are never accepted from any saved config shape. + if (isPlainRecord(config.permissions)) { + const permissions = { ...config.permissions }; + delete permissions.workspaceScopes; + config = { ...config, permissions }; + } + if (isModernConfig(config)) { const provider = normalizeProviderName(config.provider) ?? "openrouter"; return { @@ -2130,6 +2142,11 @@ export async function saveConfig( } } + if (data.permissions) { + data.permissions = { ...data.permissions }; + delete data.permissions.workspaceScopes; + } + await fs.ensureDir(path.dirname(configPath)); if (isYamlFile(configPath)) { diff --git a/src/import/ClaudePermissionImport.ts b/src/import/ClaudePermissionImport.ts new file mode 100644 index 000000000..4c236c7ff --- /dev/null +++ b/src/import/ClaudePermissionImport.ts @@ -0,0 +1,297 @@ +import fs from 'fs-extra'; +import { promises as nodeFs } from 'node:fs'; +import path from 'node:path'; +import YAML from 'yaml'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; +import type { ImportedClaudeRule } from '../permissions/importedClaudeRules.js'; +import { isSimplePermissionCommand } from '../permissions/commandParts.js'; +import { AUTOHAND_PATHS } from '../constants.js'; +import { detectConfigPath } from '../config.js'; +import { atomicWriteFile, atomicWriteJson, withFileLock } from '../utils/atomicFile.js'; +import { isRecord, type HookImportOptions } from './HookImportService.js'; +import type { ImportCategoryResult, ImportError, PermissionImportMapping, PermissionImportReport } from './types.js'; + +interface SettingsSource { path: string; workspaceRoot?: string } +interface PermissionPlan { + files: SettingsSource[]; + mappings: PermissionImportMapping[]; + errors: ImportError[]; + legacyPaths: string[]; +} + +const unsafeRuleText = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/; + +function translateRule(rule: string, workspaceRoot?: string): string[] | null { + if (!rule || rule !== rule.trim() || unsafeRuleText.test(rule)) return null; + if (/^mcp__[\w.-]+__[\w.-]+$/.test(rule)) return [rule + ':*']; + const match = /^(Bash|Read|Edit|Write|WebFetch)(?:\((.*)\))?$/.exec(rule); + if (!match) return null; + const [, tool, argument] = match; + if (tool === 'Bash') { + if (argument !== undefined && (!argument || argument !== argument.trim() || /[?\[\]{}]/.test(argument))) return null; + const prefix = argument?.endsWith(':*') ? argument.slice(0, -2) : argument; + if (prefix !== undefined && !isSimplePermissionCommand(prefix)) return null; + const command = argument === undefined ? '*' : argument.endsWith(':*') ? argument.slice(0, -2) + '*' : argument; + return ['run_command:' + command, 'shell:' + command]; + } + if (tool === 'WebFetch') return argument === undefined ? ['fetch_url:*'] : null; + if (argument !== undefined && (!argument || /[?\[\]{}~\\]/.test(argument) || argument.startsWith('/') || !argument.startsWith('./') || (argument.includes('*') && (!argument.endsWith('/**') || argument.slice(0, -3).includes('*'))))) return null; + let target = argument ?? '*'; + if (path.isAbsolute(target)) { + if (!workspaceRoot) return null; + target = path.relative(workspaceRoot, target); + if (target.startsWith('..') || path.isAbsolute(target)) return null; + } + target = target.replace(/^\.\//, ''); + if (!target || target.split('/').includes('..')) return null; + const capability = tool === 'Read' ? 'read_file' : 'write_file'; + const patterns = [capability + ':' + target]; + if (workspaceRoot && argument !== undefined && !target.includes('*')) { + patterns.push(capability + ':' + path.resolve(workspaceRoot, target)); + } + return patterns; +} + +function report(plan: PermissionPlan, scrubbed = false): PermissionImportReport { + const translated = plan.mappings.filter(mapping => mapping.status === 'translated'); + return { + allow: translated.filter(mapping => mapping.disposition === 'allow').length, + deny: translated.filter(mapping => mapping.disposition === 'deny').length, + repositories: new Set(translated.flatMap(mapping => mapping.workspaceRoot ? [mapping.workspaceRoot] : [])).size, + skipped: plan.mappings.filter(mapping => mapping.status === 'skipped').length, + mappings: plan.mappings, + errors: plan.errors, + legacySettingsScrubbed: scrubbed, + legacySettingsToScrub: plan.legacyPaths, + }; +} + +const printable = (value: string) => value.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, ' '); + +export function permissionImportLines(value: PermissionImportReport, dryRun = false): string[] { + const lines = [(dryRun ? 'Would translate ' : 'Translated ') + value.allow + ' allow and ' + value.deny + ' deny rules across ' + value.repositories + ' repos; ' + value.skipped + ' skipped']; + if (dryRun) lines.push('Scope | Rule | Translation'); + for (const error of value.errors ?? []) lines.push(printable(error.item + ': ' + error.error)); + for (const mapping of value.mappings) { + if (!dryRun && mapping.status === 'translated') continue; + const result = mapping.status === 'translated' ? mapping.patterns.join(', ') : mapping.status + ' (' + mapping.reason + ')'; + lines.push(printable((mapping.workspaceRoot ?? 'User') + ' | ' + mapping.disposition + ' ' + mapping.rule + ' | ' + result)); + } + if (dryRun) for (const file of value.legacySettingsToScrub) lines.push('Would scrub raw settings and environment data from ' + printable(file)); + else if (value.legacySettingsScrubbed) lines.push('Scrubbed raw Claude settings and environment data from the legacy import file.'); + return lines; +} + +export class ClaudePermissionImport { + constructor(private readonly sourceHome: string, private readonly options: HookImportOptions = {}) {} + + private async recordedWorkspace(file: string): Promise { + const handle = await nodeFs.open(file, 'r'); + try { + const buffer = Buffer.alloc(128 * 1024); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + for (const line of buffer.subarray(0, bytesRead).toString('utf8').split('\n')) { + try { + const value: unknown = JSON.parse(line); + if (isRecord(value) && typeof value.cwd === 'string' && path.isAbsolute(value.cwd)) return path.normalize(value.cwd); + } catch { /* Incomplete or non-JSON header lines do not identify a repository. */ } + } + return null; + } finally { await handle.close(); } + } + + private async discover(): Promise { + const roots = new Set([path.resolve(this.options.workspaceRoot ?? process.cwd())]); + const registry = this.sourceHome + '.json'; + if (await fs.pathExists(registry)) { + try { + const data: unknown = JSON.parse(await fs.readFile(registry, 'utf8')); + if (isRecord(data) && isRecord(data.projects)) for (const root of Object.keys(data.projects)) if (path.isAbsolute(root)) roots.add(path.normalize(root)); + } catch { /* Session headers and the selected workspace remain independent sources. */ } + } + const projects = path.join(this.sourceHome, 'projects'); + if (await fs.pathExists(projects)) { + for (const directory of await fs.readdir(projects, { withFileTypes: true })) { + if (!directory.isDirectory()) continue; + const folder = path.join(projects, directory.name); + for (const entry of await fs.readdir(folder, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; + try { + const root = await this.recordedWorkspace(path.join(folder, entry.name)); + if (root) { roots.add(root); break; } + } catch { /* An unreadable session does not justify decoding its lossy directory name. */ } + } + } + } + const files: SettingsSource[] = []; + const global = path.join(this.sourceHome, 'settings.json'); + if (await fs.pathExists(global)) files.push({ path: global }); + for (const root of roots) { + for (const name of ['settings.json', 'settings.local.json']) { + const file = path.join(root, '.claude', name); + if (await fs.pathExists(file)) files.push({ path: file, workspaceRoot: root }); + } + } + return files; + } + + async scan(): Promise { + const files = await this.discover(); + return files.length || (await fs.pathExists(path.join(AUTOHAND_PATHS.config, 'imported-claude-settings.json')) ? 1 : 0); + } + + private async needsScrub(file: string): Promise { + if (!await fs.pathExists(file)) return false; + try { + const value: unknown = JSON.parse(await fs.readFile(file, 'utf8')); + return !isRecord(value) || Object.hasOwn(value, 'raw') || Object.hasOwn(value, 'env'); + } catch { return true; } + } + + private async plan(): Promise { + const configPath = await detectConfigPath(this.options.configPath); + const files = await this.discover(); + const plan: PermissionPlan = { files, mappings: [], errors: [], legacyPaths: [] }; + for (const file of files) { + try { + const data: unknown = JSON.parse(await fs.readFile(file.path, 'utf8')); + if (!isRecord(data) || (data.permissions !== undefined && !isRecord(data.permissions))) throw new Error('Invalid permissions'); + const permissions = isRecord(data.permissions) ? data.permissions : {}; + const destination = file.workspaceRoot ? path.join(file.workspaceRoot, '.autohand', 'settings.local.json') : configPath; + for (const disposition of ['allow', 'deny'] as const) { + const values = permissions[disposition]; + if (values === undefined) continue; + const entries: unknown[] = Array.isArray(values) ? values : [null]; + const seen = new Set(); + for (const entry of entries) { + const rule = typeof entry === 'string' ? entry : '[non-text rule]'; + if (seen.has(rule)) continue; + seen.add(rule); + const patterns = typeof entry === 'string' ? translateRule(entry, file.workspaceRoot) : null; + const mapping = { source: file.path, destination, ...(file.workspaceRoot ? { workspaceRoot: file.workspaceRoot } : {}), disposition, rule }; + plan.mappings.push(patterns ? { ...mapping, status: 'translated', patterns } : { ...mapping, status: 'skipped', reason: 'no equivalent' }); + } + } + } catch { + plan.errors.push({ category: 'settings', item: file.path, error: 'Could not read Claude permission settings. No raw settings were copied.', retriable: false }); + } + } + for (const home of new Set([AUTOHAND_PATHS.config, path.dirname(configPath)])) { + const file = path.join(home, 'imported-claude-settings.json'); + if (await this.needsScrub(file)) plan.legacyPaths.push(file); + } + return plan; + } + + async preview(): Promise { return report(await this.plan()); } + + private async updateDestination(file: string, mappings: readonly PermissionImportMapping[]): Promise { + const project = mappings.find(mapping => mapping.workspaceRoot)?.workspaceRoot; + const assertContained = async () => { + if (!project) return; + const root = await fs.realpath(project); + const directory = path.dirname(file); + if (await fs.pathExists(directory)) { + const actual = await fs.realpath(directory); + if (actual !== path.join(root, '.autohand')) throw new Error('Project settings directory escapes its repository'); + } + if (await fs.pathExists(file) && (await fs.lstat(file)).isSymbolicLink()) throw new Error('Project settings file is a symlink'); + }; + await assertContained(); + const merge = async () => { + await assertContained(); + const extension = path.extname(file); + const raw = await fs.pathExists(file) ? await fs.readFile(file, 'utf8') : extension === '.toml' ? '' : '{}'; + const data: unknown = extension === '.toml' ? parseToml(raw) : /\.ya?ml$/.test(extension) ? YAML.parse(raw) : JSON.parse(raw); + if (!isRecord(data) || (data.permissions !== undefined && !isRecord(data.permissions))) throw new Error('Invalid destination settings'); + const permissions = isRecord(data.permissions) ? data.permissions : {}; + const list = (canonical: string, legacy: string) => { + const value = permissions[canonical] ?? permissions[legacy] ?? []; + if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) throw new Error('Invalid destination permission list'); + return value as string[]; + }; + const currentAllow = list('allowList', 'whitelist'), currentDeny = list('denyList', 'blacklist'); + const patterns = (disposition: 'allow' | 'deny') => mappings.flatMap(mapping => mapping.status === 'translated' && mapping.disposition === disposition ? [...mapping.patterns] : []); + const allowList = [...new Set([...currentAllow, ...patterns('allow')])]; + const denyList = [...new Set([...currentDeny, ...patterns('deny')])]; + const existingMetadata = permissions.importedClaudeRules ?? []; + if (!Array.isArray(existingMetadata)) throw new Error('Invalid imported permission metadata'); + const importedClaudeRules = [...existingMetadata]; + for (const mapping of mappings) { + if (mapping.status !== 'translated') continue; + const existing = mapping.disposition === 'allow' ? currentAllow : currentDeny; + for (const pattern of mapping.patterns) { + // A pre-existing Autohand rule retains its original matching semantics. + if (existing.includes(pattern)) continue; + const kind: ImportedClaudeRule['kind'] = mapping.rule.startsWith('Bash(') + ? mapping.rule.endsWith(':*)') ? 'command-prefix' : 'command-exact' + : /^(Read|Edit|Write)\(/.test(mapping.rule) ? 'file' : 'tool'; + const metadata: ImportedClaudeRule = { pattern, action: mapping.disposition, kind }; + if (!importedClaudeRules.some(value => isRecord(value) && value.pattern === pattern && value.action === metadata.action)) importedClaudeRules.push(metadata); + } + } + if (JSON.stringify(currentAllow) === JSON.stringify(allowList) && JSON.stringify(currentDeny) === JSON.stringify(denyList) + && JSON.stringify(existingMetadata) === JSON.stringify(importedClaudeRules)) return null; + data.permissions = { ...permissions, allowList, denyList, importedClaudeRules }; + if (mappings.some(mapping => mapping.workspaceRoot) && data.version === undefined) data.version = 1; + return extension === '.toml' ? stringifyToml(data) : /\.ya?ml$/.test(extension) ? YAML.stringify(data) : JSON.stringify(data, null, 2) + '\n'; + }; + if (await merge() === null) return; + await fs.ensureDir(path.dirname(file)); + await withFileLock(file + '.lock', async () => { + const content = await merge(); + if (content !== null) await atomicWriteFile(file, content); + }); + } + + async scrubLegacy(files?: readonly string[]): Promise<{ scrubbed: boolean; errors: ImportError[] }> { + if (!files) files = (await this.plan()).legacyPaths; + const errors: ImportError[] = []; + let scrubbed = false; + for (const file of files) { + try { + await withFileLock(file + '.lock', async () => { + if (!await this.needsScrub(file)) return; + await atomicWriteJson(file, { importedFrom: 'claude', scrubbed: true }); + await fs.chmod(file, 0o600); + scrubbed = true; + }); + } catch { + errors.push({ category: 'settings', item: file, error: 'Could not scrub the legacy Claude settings file.', retriable: true }); + } + } + return { scrubbed, errors }; + } + + async cleanup(): Promise { + const plan = await this.plan(); + const cleanup = await this.scrubLegacy(plan.legacyPaths); + return report({ ...plan, files: [], mappings: [], errors: cleanup.errors }, cleanup.scrubbed); + } + + async import(): Promise<{ stats: ImportCategoryResult; errors: ImportError[]; report: PermissionImportReport }> { + const plan = await this.plan(); + const destinations = new Set(plan.mappings.filter(mapping => mapping.status === 'translated').map(mapping => mapping.destination)); + for (const destination of destinations) { + try { + await this.updateDestination(destination, plan.mappings.filter(mapping => mapping.destination === destination)); + } catch { + plan.errors.push({ category: 'settings', item: destination, error: 'Could not merge permission rules; existing settings were preserved.', retriable: false }); + plan.mappings = plan.mappings.map(mapping => mapping.destination === destination && mapping.status === 'translated' + ? { source: mapping.source, destination: mapping.destination, workspaceRoot: mapping.workspaceRoot, disposition: mapping.disposition, rule: mapping.rule, status: 'failed', reason: 'destination settings could not be updated' } + : mapping); + } + } + const cleanup = await this.scrubLegacy(plan.legacyPaths); + plan.errors.push(...cleanup.errors); + const scrubbed = cleanup.scrubbed; + const result = report(plan, scrubbed); + const failedSources = new Set(plan.errors.map(error => error.item)); + for (const mapping of plan.mappings) if (mapping.status === 'failed') failedSources.add(mapping.source); + const success = plan.files.filter(file => !failedSources.has(file.path)).length; + return { report: result, errors: plan.errors, stats: { + success, failed: plan.errors.length, skipped: result.skipped + (!plan.files.length && !plan.legacyPaths.length ? 1 : 0), + ...(result.skipped ? { skipReasons: { 'no equivalent': result.skipped } } : {}), + } }; + } +} diff --git a/src/import/importers/ClaudeImporter.ts b/src/import/importers/ClaudeImporter.ts index 2febb08a8..34c8dd5a5 100644 --- a/src/import/importers/ClaudeImporter.ts +++ b/src/import/importers/ClaudeImporter.ts @@ -13,10 +13,12 @@ import type { ImportError, ImportCategoryResult, ProgressCallback, + PermissionImportReport, } from '../types.js'; import type { SessionMessage } from '../../session/types.js'; import { AUTOHAND_PATHS } from '../../constants.js'; import { BaseImporter } from './BaseImporter.js'; +import { ClaudePermissionImport } from '../ClaudePermissionImport.js'; /** * A single parsed Claude event from a session JSONL file. @@ -46,6 +48,19 @@ export class ClaudeImporter extends BaseImporter { readonly name: ImportSource = 'claude'; readonly displayName = 'Claude Code'; readonly homePath = '~/.claude'; + private permissionReport?: PermissionImportReport; + + private permissionImport(): ClaudePermissionImport { + return new ClaudePermissionImport(this.resolvedHomePath, this.hookImportOptions); + } + + override async detect(): Promise { + return await super.detect() || await this.permissionImport().scan() > 0; + } + + async previewSettings(): Promise { + return this.permissionImport().preview(); + } // --------------------------------------------------------------- // scan() @@ -54,6 +69,8 @@ export class ClaudeImporter extends BaseImporter { async scan(): Promise { const available = new Map(); await this.scanHooks(available); + const settingsCount = await this.permissionImport().scan(); + if (settingsCount > 0) available.set('settings', { count: settingsCount, description: 'Claude user and project permission settings' }); const home = this.resolvedHomePath; if (!(await fse.pathExists(home))) { @@ -93,12 +110,6 @@ export class ClaudeImporter extends BaseImporter { }); } - // Check settings - const settingsPath = path.join(home, 'settings.json'); - if (await fse.pathExists(settingsPath)) { - available.set('settings', { count: 1, description: 'Claude Code settings.json' }); - } - // Check skills const skillsDir = path.join(home, 'skills'); if (await fse.pathExists(skillsDir)) { @@ -134,6 +145,7 @@ export class ClaudeImporter extends BaseImporter { const start = Date.now(); const imported = new Map(); const errors: ImportError[] = []; + this.permissionReport = undefined; for (const category of categories) { switch (category) { @@ -158,11 +170,17 @@ export class ClaudeImporter extends BaseImporter { } } + if (!categories.includes('settings')) { + this.permissionReport = await this.permissionImport().cleanup(); + errors.push(...(this.permissionReport.errors ?? [])); + } + return { source: this.name, imported, errors, duration: Date.now() - start, + ...(this.permissionReport ? { permissions: this.permissionReport } : {}), }; } @@ -323,54 +341,33 @@ export class ClaudeImporter extends BaseImporter { errors: ImportError[], onProgress?: ProgressCallback, ): Promise { - const settingsPath = path.join(this.resolvedHomePath, 'settings.json'); - - if (!(await fse.pathExists(settingsPath))) { - imported.set('settings', { success: 0, failed: 0, skipped: 1 }); - return; - } - onProgress?.({ category: 'settings', current: 1, total: 1, - item: 'settings.json', + item: 'Claude permission rules', status: 'importing', }); try { - const settings = await this.safeReadJson(settingsPath); - - // Extract permissions.allow and merge into autohand config - const permissions = settings.permissions as { allow?: string[] } | undefined; - if (permissions?.allow) { - const configDir = AUTOHAND_PATHS.config; - await fse.ensureDir(configDir); - - const importedSettingsPath = path.join(configDir, 'imported-claude-settings.json'); - await fse.writeJson(importedSettingsPath, { - importedFrom: 'claude', - importedAt: new Date().toISOString(), - permissions: permissions.allow, - raw: settings, - }, { spaces: 2 }); - } - - imported.set('settings', { success: 1, failed: 0, skipped: 0 }); + const result = await this.permissionImport().import(); + this.permissionReport = result.report; + imported.set('settings', result.stats); + errors.push(...result.errors); onProgress?.({ category: 'settings', current: 1, total: 1, - item: 'settings.json', - status: 'done', + item: 'Claude permission rules', + status: result.stats.failed ? 'failed' : 'done', }); - } catch (err) { + } catch { imported.set('settings', { success: 0, failed: 1, skipped: 0 }); errors.push({ category: 'settings', item: 'settings.json', - error: err instanceof Error ? err.message : String(err), + error: 'Could not import Claude permission settings. No raw settings were copied.', retriable: false, }); } diff --git a/src/import/index.ts b/src/import/index.ts index 7111a63e3..b7ceccadf 100644 --- a/src/import/index.ts +++ b/src/import/index.ts @@ -7,7 +7,8 @@ * interactive (TTY) and non-interactive (CI/pipe) modes. */ import chalk from 'chalk'; -import type { ImportOptions, ImportCategory, ImportResult } from './types.js'; +import type { ImportOptions, ImportCategory, ImportResult, Importer } from './types.js'; +import { permissionImportLines } from './ClaudePermissionImport.js'; import { IMPORT_SOURCES } from './types.js'; /** @@ -55,6 +56,7 @@ export async function runImport(options: ImportOptions): Promise { if (options.dryRun) { printDryRun(scanResult.available, cats); + await printPermissionPreview(importer, cats); continue; } @@ -112,6 +114,7 @@ export async function runImport(options: ImportOptions): Promise { if (options.dryRun) { console.log(chalk.cyan(`Dry run — ${importer.displayName}:`)); printDryRun(scanResult.available, categories); + await printPermissionPreview(importer, categories); return; } @@ -140,9 +143,15 @@ function printDryRun( } } +export async function printPermissionPreview(importer: Importer, categories: ImportCategory[]): Promise { + if (!categories.includes('settings') || !importer.previewSettings) return; + for (const line of permissionImportLines(await importer.previewSettings(), true)) console.log(line); +} + function printSummary(result: ImportResult): void { console.log(); console.log(chalk.bold(`Import complete from ${result.source}:`)); + if (result.permissions) for (const line of permissionImportLines(result.permissions)) console.log(line); for (const [cat, stats] of result.imported) { const icon = stats.failed > 0 ? chalk.yellow('⚠') : chalk.green('✓'); diff --git a/src/import/types.ts b/src/import/types.ts index b9f366e63..9118f4707 100644 --- a/src/import/types.ts +++ b/src/import/types.ts @@ -64,6 +64,29 @@ export interface ImportResult { readonly imported: Map; readonly errors: ImportError[]; readonly duration: number; + readonly permissions?: PermissionImportReport; +} + +export type PermissionImportMapping = { + readonly source: string; + readonly destination: string; + readonly workspaceRoot?: string; + readonly disposition: 'allow' | 'deny'; + readonly rule: string; +} & ( + | { readonly status: 'translated'; readonly patterns: readonly string[] } + | { readonly status: 'skipped' | 'failed'; readonly reason: string } +); + +export interface PermissionImportReport { + readonly allow: number; + readonly deny: number; + readonly repositories: number; + readonly skipped: number; + readonly mappings: readonly PermissionImportMapping[]; + readonly errors?: readonly ImportError[]; + readonly legacySettingsScrubbed: boolean; + readonly legacySettingsToScrub: readonly string[]; } /** @@ -119,6 +142,8 @@ export interface Importer { /** Import selected categories, reporting progress via callback. */ import(categories: ImportCategory[], onProgress?: ProgressCallback): Promise; + /** Read-only permission preview; never creates configuration or lock files. */ + previewSettings?(): Promise; } /** diff --git a/src/import/ui/ImportWizard.tsx b/src/import/ui/ImportWizard.tsx index d699eb64f..73d87887e 100644 --- a/src/import/ui/ImportWizard.tsx +++ b/src/import/ui/ImportWizard.tsx @@ -13,6 +13,7 @@ import { showModal } from '../../ui/ink/components/Modal.js'; import { showCategorySelector, CATEGORY_LABELS } from './CategorySelector.js'; import { ImportProgressView } from './ImportProgress.js'; import type { ImporterRegistry } from '../registry.js'; +import { permissionImportLines } from '../ClaudePermissionImport.js'; import type { ImportCategory, ImportError, @@ -69,6 +70,8 @@ export function ImportSummary({ source, result }: ImportSummaryProps) { {'\u2713'} Import complete from {source} {''} + {result.permissions && permissionImportLines(result.permissions).map((line, index) => {line})} + {entries.map(([cat, stats]) => { const label = (CATEGORY_LABELS[cat] ?? cat).padEnd(12); const parts: string[] = []; @@ -274,6 +277,8 @@ export async function showImportWizard( const label = CATEGORY_LABELS[cat] ?? cat; console.log(` ${label}: ${info?.description ?? 'unknown'}`); } + const { printPermissionPreview } = await import('../index.js'); + await printPermissionPreview(importer, selectedCategories); console.log(''); return; } @@ -325,6 +330,7 @@ export async function showImportWizard( summaryInst.unmount(); } else { console.log(`\nImport complete from ${importer.displayName}`); + if (result.permissions) for (const line of permissionImportLines(result.permissions)) console.log(line); for (const [cat, stats] of result.imported.entries()) { const label = CATEGORY_LABELS[cat] ?? cat; console.log(` ${label}: ${stats.success} imported, ${stats.failed} failed`); diff --git a/src/index.ts b/src/index.ts index 89dc9894a..7b858afde 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1502,7 +1502,7 @@ program source: source as any, categories: opts.categories as any, all: opts.all, - dryRun: opts.dryRun, + dryRun: opts.dryRun === true || program.opts<{ dryRun?: boolean }>().dryRun === true, retryFailed: opts.retryFailed, }); process.exit(0); diff --git a/src/permissions/PermissionManager.ts b/src/permissions/PermissionManager.ts index 884ff67ad..781d04d79 100644 --- a/src/permissions/PermissionManager.ts +++ b/src/permissions/PermissionManager.ts @@ -2,6 +2,8 @@ * Permission Manager - Handles tool/command approval with allow/deny lists * @license Apache-2.0 */ +import { matchesImportedClaudeRule, type ImportedClaudeRule } from './importedClaudeRules.js'; +import { isSimplePermissionCommand } from './commandParts.js'; import { getCommandPrefix } from './types.js'; import type { PermissionSettings, @@ -194,6 +196,7 @@ export class PermissionManager { private settings: PermissionSettings; private localSettings: PermissionSettings | undefined; + private workspaceSettings: PermissionSettings[] = []; private sessionProjectSettings: SessionProjectPermissions | undefined; private sessionCache: Map = new Map(); private mode: PermissionMode; @@ -216,6 +219,7 @@ export class PermissionManager { private normalizeSettings(settings: PermissionSettings | undefined): PermissionSettings { return { mode: settings?.mode ?? 'interactive', + importedClaudeRules: [...(settings?.importedClaudeRules ?? [])], allowList: [...(settings?.allowList ?? settings?.whitelist ?? [])], denyList: [...(settings?.denyList ?? settings?.blacklist ?? [])], rules: [...(settings?.rules ?? [])], @@ -237,6 +241,7 @@ export class PermissionManager { this.workspaceRoot = isOptions ? (options as PermissionManagerOptions).workspaceRoot : undefined; this.settings = this.normalizeSettings(settings); + this.workspaceSettings = (settings.workspaceScopes ?? []).map(scope => this.normalizeSettings(scope)); this.mode = this.settings.mode || 'interactive'; } @@ -322,7 +327,7 @@ export class PermissionManager { private getMergedSettings(): PermissionSettings { return this.extensionPolicies.reduce( (settings, policy) => mergePermissions(settings, policy.settings), - mergePermissions(this.settings, this.localSettings), + mergePermissions(this.workspaceSettings.reduce((merged, scope) => mergePermissions(merged, scope), this.settings), this.localSettings), ); } @@ -378,6 +383,23 @@ export class PermissionManager { return { allowed: false, reason: 'mode_restricted' }; } + if ((context.tool === 'run_command' || context.tool === 'shell') && context.command + && !isSimplePermissionCommand(this.getFullCommand(context)) + && this.getMergedSettings().importedClaudeRules?.some(rule => rule.pattern.startsWith(context.tool + ':'))) { + const merged = this.getMergedSettings(); + const relevant = (pattern: string) => !pattern.includes(':') || pattern.startsWith(context.tool + ':') || pattern.startsWith('*:'); + const allows = [...(merged.allowList ?? []), ...(this.sessionProjectSettings?.allowList ?? [])]; + const denies = [...(merged.denyList ?? []), ...(this.sessionProjectSettings?.denyList ?? [])]; + if (allows.some(relevant) || denies.some(relevant)) { + // Unsupported shell syntax cannot soften hard restrictions into a prompt. + const hard = this.checkPatterns(context); + if (hard && !hard.allowed) return hard; + if (this.mode === 'restricted') return { allowed: false, reason: 'mode_restricted' }; + if (denies.some(relevant)) return { allowed: false, reason: 'deny_list' }; + return { allowed: false, reason: 'default' }; + } + } + // Pattern-based checks (AFTER security blacklist, BEFORE session cache) const patternDecision = this.checkPatterns(context); if (patternDecision && (!this.readOnlyReview || !patternDecision.allowed)) { @@ -446,17 +468,24 @@ export class PermissionManager { context, this.localSettings?.allowList, this.localSettings?.denyList, - 'project' + 'project', + this.localSettings?.importedClaudeRules ); if (projectDecision) { return projectDecision; } + for (const scope of [...this.workspaceSettings].reverse()) { + const decision = this.checkScopedLists(context, scope.allowList, scope.denyList, 'project', scope.importedClaudeRules); + if (decision) return decision; + } + const userDecision = this.checkScopedLists( context, this.settings.allowList, this.settings.denyList, - 'user' + 'user', + this.settings.importedClaudeRules ); if (userDecision) { return userDecision; @@ -729,11 +758,13 @@ export class PermissionManager { } } + // Imported and existing deny lists must also beat broad allowPatterns and path grants. + const scopedDenial = this.checkScopedDenials(context) ?? this.checkDenyRules(context); // 4. allowPatterns – explicitly allowed if (settings.allowPatterns?.length) { for (const p of settings.allowPatterns) { if (matchesToolPattern(p, call)) { - return { allowed: true, reason: 'pattern_allowed' }; + return scopedDenial ?? { allowed: true, reason: 'pattern_allowed' }; } } } @@ -741,12 +772,12 @@ export class PermissionManager { // 5. allPathsAllowed – allow any file-path tool const fileTools = new Set(['read_file', 'write_file', 'list_dir', 'delete_path', 'move_path', 'copy_path']); if (settings.allPathsAllowed && fileTools.has(context.tool)) { - return { allowed: true, reason: 'all_paths_allowed' }; + return scopedDenial ?? { allowed: true, reason: 'all_paths_allowed' }; } // 6. allUrlsAllowed – allow url tool if (settings.allUrlsAllowed && context.tool === 'url') { - return { allowed: true, reason: 'all_urls_allowed' }; + return scopedDenial ?? { allowed: true, reason: 'all_urls_allowed' }; } return null; @@ -763,23 +794,29 @@ export class PermissionManager { /** * Check scoped allow/deny lists, returning a source-specific decision if matched. */ + private matchesScopedPattern(context: PermissionContext, pattern: string, action: 'allow' | 'deny', importedRules?: ImportedClaudeRule[]): boolean { + const imported = importedRules?.find(rule => rule.pattern === pattern && rule.action === action); + return imported ? matchesImportedClaudeRule(context, imported, this.workspaceRoot) : this.matchesPattern(context, pattern); + } + private checkScopedLists( context: PermissionContext, allowList: string[] | undefined, denyList: string[] | undefined, - scope: 'session' | 'project' | 'user' + scope: 'session' | 'project' | 'user', + importedRules?: ImportedClaudeRule[] ): PermissionDecision | null { const normalizedAllowList = allowList ?? []; const normalizedDenyList = denyList ?? []; - if (normalizedDenyList.some(pattern => this.matchesPattern(context, pattern))) { + if (normalizedDenyList.some(pattern => this.matchesScopedPattern(context, pattern, 'deny', importedRules))) { return { allowed: false, reason: scope === 'user' ? 'deny_list' : `${scope}_deny_list` as PermissionDecision['reason'], }; } - if (normalizedAllowList.some(pattern => this.matchesPattern(context, pattern))) { + if (normalizedAllowList.some(pattern => this.matchesScopedPattern(context, pattern, 'allow', importedRules))) { return { allowed: true, reason: scope === 'user' ? 'allow_list' : `${scope}_allow_list` as PermissionDecision['reason'], @@ -797,16 +834,23 @@ export class PermissionManager { }, { denyList: this.localSettings?.denyList, + importedRules: this.localSettings?.importedClaudeRules, reason: 'project_deny_list' as const, }, + ...[...this.workspaceSettings].reverse().map(scope => ({ + denyList: scope.denyList, + importedRules: scope.importedClaudeRules, + reason: 'project_deny_list' as const, + })), { denyList: this.settings.denyList, + importedRules: this.settings.importedClaudeRules, reason: 'deny_list' as const, }, ]; for (const scope of scopes) { - if (scope.denyList?.some((pattern) => this.matchesPattern(context, pattern))) { + if (scope.denyList?.some((pattern) => this.matchesScopedPattern(context, pattern, 'deny', scope.importedRules))) { return { allowed: false, reason: scope.reason }; } } @@ -1012,6 +1056,7 @@ export class PermissionManager { const index = this.settings.allowList.indexOf(pattern); if (index !== -1) { this.settings.allowList.splice(index, 1); + this.settings.importedClaudeRules = this.settings.importedClaudeRules?.filter(rule => rule.pattern !== pattern || rule.action !== 'allow'); if (this.onPersist) { await this.onPersist(this.settings); } @@ -1028,6 +1073,7 @@ export class PermissionManager { const index = this.settings.denyList.indexOf(pattern); if (index !== -1) { this.settings.denyList.splice(index, 1); + this.settings.importedClaudeRules = this.settings.importedClaudeRules?.filter(rule => rule.pattern !== pattern || rule.action !== 'deny'); if (this.onPersist) { await this.onPersist(this.settings); } diff --git a/src/permissions/commandParts.ts b/src/permissions/commandParts.ts new file mode 100644 index 000000000..020857284 --- /dev/null +++ b/src/permissions/commandParts.ts @@ -0,0 +1,9 @@ +/** + * Only literal simple commands have equivalent prefix permission semantics. + * Other shell syntax must never gain approval from an imported prefix glob. + */ +export function isSimplePermissionCommand(command: string): boolean { + if (!/^[A-Za-z0-9_./-]+(?: [A-Za-z0-9_./:=,@+ -]*)?$/.test(command)) return false; + const executable = command.split(' ', 1)[0].split('/').at(-1)!; + return !/^(?:sh|bash|zsh|dash|fish|ksh|csh|tcsh|eval|exec|env|command|builtin|sudo|doas|nohup|xargs|if|then|else|fi|for|while|until|case|do|done|function|timeout|time|nice|stdbuf|noglob|watch|setsid|ionice|flock|find|cmd|cmd.exe|powershell|powershell.exe|pwsh|pwsh.exe|wsl|wsl.exe|start|call)$/i.test(executable); +} diff --git a/src/permissions/importedClaudeRules.ts b/src/permissions/importedClaudeRules.ts new file mode 100644 index 000000000..38033b765 --- /dev/null +++ b/src/permissions/importedClaudeRules.ts @@ -0,0 +1,61 @@ +import path from 'node:path'; +import { lstatSync, realpathSync } from 'node:fs'; +import type { PermissionContext } from './types.js'; + +/** Matching semantics retained for newly imported rules; existing globs keep their meaning. */ +export interface ImportedClaudeRule { + pattern: string; + action: 'allow' | 'deny'; + kind: 'command-prefix' | 'command-exact' | 'file' | 'tool'; +} + +/** Resolve existing parents too, so writes to a new file cannot bypass an alias. */ +function resolvedPath(file: string): string | null { + let parent = file; + const missing: string[] = []; + while (true) { + try { return path.join(realpathSync.native(parent), ...missing); } + catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; + if (code !== 'ENOENT' && code !== 'ENOTDIR') return null; + try { if (lstatSync(parent).isSymbolicLink()) return null; } catch { /* A missing child is resolved against its nearest existing parent. */ } + const next = path.dirname(parent); + if (next === parent) return null; + missing.unshift(path.basename(parent)); + parent = next; + } + } +} + +export function matchesImportedClaudeRule(context: PermissionContext, rule: ImportedClaudeRule, workspaceRoot?: string): boolean { + const colon = rule.pattern.indexOf(':'); + if (colon < 0 || rule.pattern.slice(0, colon) !== context.tool) return false; + const target = rule.pattern.slice(colon + 1); + if (rule.kind === 'tool') return target === '*'; + if (rule.kind === 'file') { + if (!context.path) return false; + const root = path.resolve(workspaceRoot ?? process.cwd()); + const realRoot = resolvedPath(root); + if (!realRoot) return rule.action === 'deny'; + const canonical = (value: string) => { + const full = path.resolve(root, value); + return full === root || full.startsWith(root + path.sep) ? path.resolve(realRoot, path.relative(root, full)) : full; + }; + const requested = canonical(context.path); + const resolved = resolvedPath(requested); + if (!resolved) return rule.action === 'deny'; + const matches = (value: string) => { + if (target === '**' || target.endsWith('/**')) { + const directory = canonical(target === '**' ? '.' : target.slice(0, -3)); + return value === directory || value.startsWith(directory + path.sep); + } + return value === canonical(target); + }; + return rule.action === 'deny' ? matches(requested) || matches(resolved) : matches(requested) && matches(resolved); + } + if (!context.command) return false; + const command = [context.command, ...(context.args ?? [])].join(' '); + if (rule.kind === 'command-exact') return command === target; + const prefix = target.endsWith('*') ? target.slice(0, -1) : target; + return command === prefix || command.startsWith(prefix + ' '); +} diff --git a/src/permissions/localProjectPermissions.ts b/src/permissions/localProjectPermissions.ts index a77f8dd23..f18d7b4db 100644 --- a/src/permissions/localProjectPermissions.ts +++ b/src/permissions/localProjectPermissions.ts @@ -182,6 +182,7 @@ export function mergePermissions( ...(normalizedGlobal.denyList || []), ...(normalizedLocal.denyList || []) ].filter((v, i, a) => a.indexOf(v) === i), + importedClaudeRules: [...(normalizedGlobal.importedClaudeRules ?? []), ...(normalizedLocal.importedClaudeRules ?? [])], // Merge rules (local rules checked first) rules: [ ...(normalizedLocal.rules || []), diff --git a/src/permissions/types.ts b/src/permissions/types.ts index 735951cac..747cfe9b9 100644 --- a/src/permissions/types.ts +++ b/src/permissions/types.ts @@ -16,6 +16,10 @@ export interface PermissionRule { } export interface PermissionSettings { + /** Runtime workspace layer. Kept separate so source-rule semantics cannot shadow user denies. */ + workspaceScopes?: PermissionSettings[]; + /** Source semantics for rules added by the Claude permission importer. */ + importedClaudeRules?: import('./importedClaudeRules.js').ImportedClaudeRule[]; /** Permission mode: interactive (default), unrestricted (no prompts), restricted (deny all dangerous), external (use callback) */ mode?: PermissionMode; /** Commands/tools that never require approval */ diff --git a/src/types.ts b/src/types.ts index 9f016fa36..203812966 100644 --- a/src/types.ts +++ b/src/types.ts @@ -534,6 +534,10 @@ export interface PermissionRule { } export interface PermissionSettings { + /** Runtime workspace layer. Kept separate so source-rule semantics cannot shadow user denies. */ + workspaceScopes?: PermissionSettings[]; + /** Source semantics for rules added by the Claude permission importer. */ + importedClaudeRules?: import('./permissions/importedClaudeRules.js').ImportedClaudeRule[]; /** Permission mode: interactive (default), unrestricted (no prompts), restricted (deny all dangerous), external (callback) */ mode?: PermissionMode; /** Commands/tools that never require approval (e.g., "run_command:npm *") */ diff --git a/tests/import/ClaudeImporter.test.ts b/tests/import/ClaudeImporter.test.ts index 8d9acec8c..fb14546b1 100644 --- a/tests/import/ClaudeImporter.test.ts +++ b/tests/import/ClaudeImporter.test.ts @@ -8,6 +8,7 @@ import os from 'node:os'; import path from 'node:path'; const atomicFileMocks = vi.hoisted(() => ({ + atomicWriteFile: vi.fn().mockResolvedValue(undefined), atomicWriteJson: vi.fn().mockResolvedValue(undefined), withFileLock: vi.fn( (_lockPath: string, operation: () => Promise) => operation(), diff --git a/tests/import/claudePermissions.test.ts b/tests/import/claudePermissions.test.ts new file mode 100644 index 000000000..b994d655d --- /dev/null +++ b/tests/import/claudePermissions.test.ts @@ -0,0 +1,297 @@ +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fixturePaths = vi.hoisted(() => ({ home: '/private/tmp/unused-claude-permission-fixture' })); +vi.mock('../../src/constants.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, AUTOHAND_PATHS: { ...actual.AUTOHAND_PATHS, get config() { return fixturePaths.home; } } }; +}); + +import { ClaudeImporter } from '../../src/import/importers/ClaudeImporter.js'; +import { loadConfig, saveConfig } from '../../src/config.js'; +import { trustWorkspace } from '../../src/permissions/workspaceTrust.js'; +import { runImport } from '../../src/import/index.js'; +import { PermissionManager } from '../../src/permissions/PermissionManager.js'; + +describe('Claude permission settings import', () => { + let root: string; + let sourceHome: string; + let workspaceRoot: string; + let secondRepository: string; + let configPath: string; + let legacyPath: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-permission-import-')); + sourceHome = path.join(root, 'claude'); + workspaceRoot = path.join(root, 'first-repo'); + secondRepository = path.join(root, 'second-repo-with-hyphens'); + fixturePaths.home = path.join(root, 'autohand'); + configPath = path.join(fixturePaths.home, 'config.json'); + legacyPath = path.join(fixturePaths.home, 'imported-claude-settings.json'); + await Promise.all([sourceHome, workspaceRoot, secondRepository, fixturePaths.home].map(directory => fs.ensureDir(directory))); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.remove(root); + }); + + const options = () => ({ sourceHome, workspaceRoot, configPath }); + + async function seed() { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { + env: { API_KEY: 'SOURCE_ENV_MUST_NOT_BE_COPIED' }, + permissions: { + allow: ['Bash(npm test:*)', 'Bash(git status)', 'Read(./src/**)', 'mcp__docs__search', 'WebFetch(domain:example.com)', 'UnknownTool(secret)'], + deny: ['Bash(rm:*)', 'Bash(npm test:*)'], + }, + }); + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.json'), { permissions: { allow: ['Bash(git:*)'] } }); + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.local.json'), { + env: { TOKEN: 'PROJECT_ENV_MUST_NOT_BE_COPIED' }, + permissions: { allow: ['Edit(./src/**)'], deny: ['Write(./src/private/**)'] }, + }); + await fs.outputJson(path.join(secondRepository, '.claude', 'settings.json'), { + permissions: { allow: ['Write(./lib/**)'], deny: ['Bash(git push:*)'] }, + }); + await fs.outputFile(path.join(sourceHome, 'projects', 'encoded-name-is-not-a-path', 'session.jsonl'), + JSON.stringify({ type: 'user', cwd: secondRepository, message: { content: 'Only the recorded cwd identifies this repo.' } }) + '\n'); + await fs.writeJson(configPath, { + provider: 'openrouter', retained: { value: true }, + permissions: { allowList: ['run_command:existing*'], denyList: ['shell:already-blocked*'], allowPatterns: [{ kind: 'run_command' }] }, + }); + await fs.outputJson(path.join(workspaceRoot, '.autohand', 'settings.local.json'), { + version: 1, agent: { keepMe: true }, permissions: { allowList: ['read_file:existing/**'] }, + }); + await fs.writeJson(legacyPath, { importedFrom: 'claude', raw: { env: { TOKEN: 'LEGACY_ENV_MUST_BE_REMOVED' } } }); + await fs.chmod(legacyPath, 0o644); + } + + async function snapshot(directory = root): Promise { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const result: unknown[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const file = path.join(directory, entry.name), stat = await fs.stat(file); + result.push([path.relative(root, file), stat.mode & 0o777, stat.mtimeMs, entry.isDirectory() ? null : await fs.readFile(file, 'utf8')]); + if (entry.isDirectory()) result.push(...await snapshot(file)); + } + return result; + } + + it('merges translated user and per-repo rules, preserves existing settings and never copies environment data', async () => { + await seed(); + const result = await new ClaudeImporter(options()).import(['settings']); + expect(result.errors).toEqual([]); + expect(result.permissions).toMatchObject({ allow: 7, deny: 4, repositories: 2, skipped: 2, legacySettingsScrubbed: true }); + + const user = await fs.readJson(configPath); + expect(user).toMatchObject({ provider: 'openrouter', retained: { value: true } }); + expect(user.permissions.allowList).toEqual(expect.arrayContaining([ + 'run_command:existing*', 'run_command:npm test*', 'shell:npm test*', 'run_command:git status', 'read_file:src/**', 'mcp__docs__search:*', + ])); + expect(user.permissions.denyList).toEqual(expect.arrayContaining(['shell:already-blocked*', 'run_command:rm*', 'shell:rm*', 'run_command:npm test*'])); + const first = await fs.readJson(path.join(workspaceRoot, '.autohand', 'settings.local.json')); + expect(first).toMatchObject({ version: 1, agent: { keepMe: true } }); + expect(first.permissions.allowList).toEqual(expect.arrayContaining(['read_file:existing/**', 'run_command:git*', 'write_file:src/**'])); + expect(first.permissions.denyList).toContain('write_file:src/private/**'); + expect((await fs.readJson(path.join(secondRepository, '.autohand', 'settings.local.json'))).permissions.allowList).toContain('write_file:lib/**'); + expect(JSON.stringify(result.permissions?.mappings)).toContain('no equivalent'); + for (const directory of [fixturePaths.home, path.join(workspaceRoot, '.autohand'), path.join(secondRepository, '.autohand')]) { + for (const name of await fs.readdir(directory)) { + const contents = await fs.readFile(path.join(directory, name), 'utf8'); + expect(contents).not.toMatch(/SOURCE_ENV_MUST_NOT_BE_COPIED|PROJECT_ENV_MUST_NOT_BE_COPIED|LEGACY_ENV_MUST_BE_REMOVED/); + } + } + expect(await fs.readJson(legacyPath)).not.toHaveProperty('raw'); + expect((await fs.stat(legacyPath)).mode & 0o777).toBe(0o600); + }); + + it('honours imported deny rules over existing broad user allows and project allows', async () => { + await seed(); + await new ClaudeImporter(options()).import(['settings']); + const manager = new PermissionManager({ settings: (await fs.readJson(configPath)).permissions, workspaceRoot }); + await manager.initLocalSettings(); + expect(manager.checkPermission({ tool: 'run_command', command: 'rm notes.txt' }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'shell', command: 'rm notes.txt' }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'run_command', command: 'npm test --unit' }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'run_command', command: 'git status' }).allowed).toBe(true); + expect(manager.checkPermission({ tool: 'write_file', path: 'src/public.ts' }).allowed).toBe(true); + expect(manager.checkPermission({ tool: 'write_file', path: 'src/private/token.ts' }).allowed).toBe(false); + }); + + it('prints a dry-run mapping table and cleanup notice without creating or changing any file', async () => { + await seed(); + const before = await snapshot(); + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runImport({ ...options(), source: 'claude', categories: ['settings'], dryRun: true }); + expect(await snapshot()).toEqual(before); + const printed = output.mock.calls.flat().join('\n'); + expect(printed).toContain('Bash(npm test:*)'); + expect(printed).toContain('run_command:npm test*'); + expect(printed).toContain('skipped (no equivalent)'); + expect(printed).toContain('Would scrub'); + expect(printed).not.toMatch(/SOURCE_ENV_MUST_NOT_BE_COPIED|PROJECT_ENV_MUST_NOT_BE_COPIED|LEGACY_ENV_MUST_BE_REMOVED/); + }); + + it('is idempotent across permission writes and legacy cleanup', async () => { + await seed(); + const importer = new ClaudeImporter(options()); + await importer.import(['settings']); + const before = await snapshot(); + await importer.import(['settings']); + expect(await snapshot()).toEqual(before); + }); + + it('discovers project-only permission settings even without a Claude home or hooks', async () => { + await fs.remove(sourceHome); + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.local.json'), { permissions: { allow: ['Bash(git status)'] } }); + const importer = new ClaudeImporter(options()); + expect(await importer.detect()).toBe(true); + expect((await importer.scan()).available.get('settings')?.count).toBe(1); + expect((await importer.import(['settings'])).errors).toEqual([]); + expect((await fs.readJson(path.join(workspaceRoot, '.autohand', 'settings.local.json'))).permissions.allowList).toContain('run_command:git status'); + }); + + it('does not overwrite malformed Autohand configuration or claim translated user rules', async () => { + await seed(); + await fs.writeFile(configPath, '{not valid JSON'); + const result = await new ClaudeImporter(options()).import(['settings']); + expect(await fs.readFile(configPath, 'utf8')).toBe('{not valid JSON'); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.permissions?.allow).toBe(3); + expect(result.permissions?.deny).toBe(2); + }); + it('checks every shell subcommand and never grants unparsed shell syntax from a prefix', async () => { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Bash(npm test:*)', 'Bash(git status)'], deny: ['Bash(rm:*)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const manager = new PermissionManager({ settings: (await fs.readJson(configPath)).permissions, workspaceRoot }); + expect(manager.checkPermission({ tool: 'run_command', command: 'npm test --unit' }).allowed).toBe(true); + expect(manager.checkPermission({ tool: 'run_command', command: 'npm test && git status' }).allowed).toBe(false); + for (const command of ['npm test && rm notes.txt', 'npm test; python3 malicious.py', 'npm test $(rm notes.txt)', 'npm test `rm notes.txt`', 'npm test > secret.txt', 'npm test && "r"m notes.txt', 'npm test && bash -c "rm notes.txt"']) { + expect(manager.checkPermission({ tool: 'run_command', command }).allowed, command).toBe(false); + } + }); + + it('skips file patterns whose segment or root semantics have no equivalent', async () => { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Edit(./src/*.ts)', 'Read(secrets/**)', 'Write(/src/**)', 'Edit(./src/**.ts)', 'Read(./src/**/secret.txt)'] } }); + const result = await new ClaudeImporter(options()).import(['settings']); + expect(result.permissions).toMatchObject({ allow: 0, skipped: 5 }); + expect(await fs.pathExists(configPath)).toBe(false); + }); + + it('refuses a project settings directory that escapes through a symlink', async () => { + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.json'), { permissions: { allow: ['Bash(git status)'] } }); + const outside = path.join(root, 'outside'); + await fs.ensureDir(outside); + await fs.symlink(outside, path.join(workspaceRoot, '.autohand')); + const result = await new ClaudeImporter(options()).import(['settings']); + expect(result.errors.length).toBeGreaterThan(0); + expect(await fs.readdir(outside)).toEqual([]); + }); + + it('shows sanitized source errors in dry-run and scrubs legacy data on session-only import', async () => { + await seed(); + await fs.writeFile(path.join(sourceHome, 'settings.json'), '{"env":{"TOKEN":"SECRET_PARSE_VALUE"}, broken'); + const importer = new ClaudeImporter(options()); + const preview = await importer.previewSettings(); + expect(preview.errors?.length).toBeGreaterThan(0); + expect(JSON.stringify(preview.errors)).not.toContain('SECRET_PARSE_VALUE'); + const result = await importer.import(['sessions']); + expect(result.permissions?.legacySettingsScrubbed).toBe(true); + expect(await fs.readFile(legacyPath, 'utf8')).not.toContain('LEGACY_ENV_MUST_BE_REMOVED'); + }); + + it('keeps unsupported shell syntax denied under hard restrictions and checks absolute file denies', async () => { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Bash(git:*)', 'Bash(timeout:*)', 'Read'], deny: ['Bash(rm:*)', 'Read(./secrets.txt)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const settings = (await fs.readJson(configPath)).permissions; + const manager = new PermissionManager({ settings, workspaceRoot }); + expect(manager.checkPermission({ tool: 'read_file', path: path.join(workspaceRoot, 'secrets.txt') })).toMatchObject({ allowed: false, reason: 'deny_list' }); + for (const command of ['rm file > out', 'git status && rm file', '"git status"', 'timeout 1 rm file', 'git commit -m "unsafe"', 'npm test %ARGS%', 'find . -delete', 'watch rm file']) { + expect(manager.checkPermission({ tool: 'run_command', command })).toMatchObject({ allowed: false, reason: 'deny_list' }); + } + const restricted = new PermissionManager({ settings: { mode: 'restricted', allowList: ['run_command:git*'] } }); + expect(restricted.checkPermission({ tool: 'run_command', command: 'git status > out' })).toMatchObject({ allowed: false, reason: 'mode_restricted' }); + }); + + it('preserves Claude word boundaries and case without changing pre-existing Autohand rules', async () => { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Bash(git:*)', 'Read(./src/**)'], deny: ['Read(./secret.txt)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const manager = new PermissionManager({ settings: (await fs.readJson(configPath)).permissions, workspaceRoot }); + expect(manager.checkPermission({ tool: 'run_command', command: 'git status' }).allowed).toBe(true); + expect(manager.checkPermission({ tool: 'run_command', command: 'gitfoo' }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'run_command', command: 'GIT status' }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'read_file', path: path.join(workspaceRoot, 'src/ok.txt') }).allowed).toBe(true); + expect(manager.checkPermission({ tool: 'read_file', path: path.join(workspaceRoot, 'SRC/ok.txt') }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'read_file', path: path.join(workspaceRoot, 'secret.txt') })).toMatchObject({ allowed: false, reason: 'deny_list' }); + const previous = new PermissionManager({ settings: { allowList: ['run_command:git*'] }, workspaceRoot }); + expect(previous.checkPermission({ tool: 'run_command', command: 'gitfoo' }).allowed).toBe(true); + }); + + it('keeps global and project rule identity through actual config loading, before async reload', async () => { + await seed(); + await new ClaudeImporter(options()).import(['settings']); + const config = await fs.readJson(configPath); + config.permissions.denyList.push('run_command:git*'); + await fs.writeJson(configPath, config); + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.local.json'), { permissions: { deny: ['Bash(git:*)', 'Read(./**)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const workspaceTrustStorePath = path.join(root, 'trust.json'); + const loadOptions = { initializeTheme: false, workspaceTrustStorePath }; + const initial = await loadConfig(configPath, workspaceRoot, loadOptions); + if (initial.workspaceTrust && !initial.workspaceTrust.trusted) await trustWorkspace(workspaceRoot, initial.workspaceTrust.fingerprint, workspaceTrustStorePath); + const loaded = await loadConfig(configPath, workspaceRoot, loadOptions); + const manager = new PermissionManager({ settings: loaded.permissions, workspaceRoot }); + expect(manager.checkPermission({ tool: 'run_command', command: 'rm notes.txt' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + expect(manager.checkPermission({ tool: 'run_command', command: 'gitfoo' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + expect(manager.checkPermission({ tool: 'read_file', path: path.join(workspaceRoot, 'anything.txt') })).toMatchObject({ allowed: false, reason: 'project_deny_list' }); + await manager.initLocalSettings(); + expect(manager.checkPermission({ tool: 'run_command', command: 'gitfoo' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + await saveConfig(loaded); + expect((await fs.readJson(configPath)).permissions).not.toHaveProperty('workspaceScopes'); + const other = await loadConfig(configPath, secondRepository, loadOptions); + const otherManager = new PermissionManager({ settings: other.permissions, workspaceRoot: secondRepository }); + expect(otherManager.checkPermission({ tool: 'read_file', path: path.join(secondRepository, 'anything.txt') }).reason).not.toBe('project_deny_list'); + }); + + it('checks both requested and resolved paths for existing and new files behind repository symlinks', async () => { + await fs.ensureDir(path.join(workspaceRoot, 'public')); + await fs.ensureDir(path.join(workspaceRoot, 'secrets')); + await fs.writeFile(path.join(workspaceRoot, 'secrets', 'token.txt'), 'fixture'); + await fs.symlink('../secrets', path.join(workspaceRoot, 'public', 'link')); + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Read(./public/**)', 'Write(./public/**)'], deny: ['Read(./secrets/**)', 'Write(./secrets/**)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const manager = new PermissionManager({ settings: (await fs.readJson(configPath)).permissions, workspaceRoot }); + for (const tool of ['read_file', 'write_file']) { + expect(manager.checkPermission({ tool, path: 'public/link/token.txt' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + expect(manager.checkPermission({ tool, path: 'public/link/new.txt' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + expect(manager.checkPermission({ tool, path: 'public/safe.txt' }).allowed).toBe(true); + } + }); + + it('does not retain source metadata after removing, saving and re-adding an ordinary rule', async () => { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Bash(npm test:*)'], deny: ['Bash(git:*)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const manager = new PermissionManager({ settings: (await fs.readJson(configPath)).permissions, workspaceRoot, + onPersist: async permissions => { await fs.writeJson(configPath, { permissions }); } }); + await manager.removeFromAllowList('run_command:npm test*'); + await manager.removeFromDenyList('run_command:git*'); + const settings = (await fs.readJson(configPath)).permissions; + expect(settings.importedClaudeRules.some((rule: { pattern: string }) => rule.pattern === 'run_command:git*' || rule.pattern === 'run_command:npm test*')).toBe(false); + const reloaded = new PermissionManager({ settings, workspaceRoot }); + reloaded.addToAllowList('run_command:npm test*'); + reloaded.addToDenyList('run_command:git*'); + expect(reloaded.checkPermission({ tool: 'run_command', command: 'gitfoo' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + expect(reloaded.checkPermission({ tool: 'run_command', command: 'npm testfoo' }).allowed).toBe(true); + }); + + it('ignores forged saved runtime scopes in a minimal configuration', async () => { + await fs.writeJson(configPath, { permissions: { workspaceScopes: [{ denyList: ['read_file:*'] }] } }); + const loaded = await loadConfig(configPath, workspaceRoot, { initializeTheme: false, workspaceTrustStorePath: path.join(root, 'trust.json') }); + expect(loaded.permissions).not.toHaveProperty('workspaceScopes'); + }); + +}); diff --git a/tests/import/dryRunAll.test.ts b/tests/import/dryRunAll.test.ts new file mode 100644 index 000000000..27c522c77 --- /dev/null +++ b/tests/import/dryRunAll.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; +const fixtures = vi.hoisted(() => ({ imports: vi.fn(), preview: vi.fn() })); +vi.mock('../../src/import/registry.js', () => ({ ImporterRegistry: class { + async detectAvailable() { return [ + { name: 'claude', displayName: 'Claude Code', scan: async () => ({ available: new Map([['settings', { count: 1, description: 'Settings' }]]) }), import: fixtures.imports, + previewSettings: async () => { fixtures.preview(); return { allow: 1, deny: 1, repositories: 0, skipped: 0, mappings: [], legacySettingsScrubbed: false, legacySettingsToScrub: [] }; } }, + { name: 'codex', displayName: 'Codex', scan: async () => ({ available: new Map([['settings', { count: 1, description: 'Settings' }]]) }), import: fixtures.imports }, + ]; } +} })); +import { runImport } from '../../src/import/index.js'; +describe('dry run across all detected sources', () => { + it('previews selected categories and mappings without invoking any import', async () => { + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + fixtures.imports.mockResolvedValue({ source: 'fixture', imported: new Map(), errors: [], duration: 0 }); + try { + await runImport({ all: true, dryRun: true, categories: ['settings'] }); + expect(fixtures.imports).not.toHaveBeenCalled(); + expect(fixtures.preview).toHaveBeenCalledOnce(); + expect(output.mock.calls.flat().join('\n')).toContain('Would translate 1 allow and 1 deny'); + } finally { output.mockRestore(); } + }); +}); diff --git a/tests/tuistory/claude-permission-import.tuistory.test.ts b/tests/tuistory/claude-permission-import.tuistory.test.ts new file mode 100644 index 000000000..96b3a76de --- /dev/null +++ b/tests/tuistory/claude-permission-import.tuistory.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'fs-extra'; +import path from 'node:path'; +import { createTempAutohandHome, launchBuiltAutohand, waitForExit, expectCleanExit } from './helpers/autohandTuistory.js'; + +describe('built Claude permission import', () => { + it('previews mappings without writes, then imports and reports legacy cleanup in the terminal', async () => { + const state = await createTempAutohandHome(); + const source = path.join(state.workspaceRoot, 'claude-fixture'); + const legacy = path.join(state.autohandHome, 'imported-claude-settings.json'); + await fs.outputJson(path.join(source, 'settings.json'), { env: { TOKEN: 'NEVER_COPY_ENV' }, permissions: { allow: ['Bash(npm test:*)', 'Read(./src/**)'], deny: ['Bash(rm:*)', 'UnknownTool(example)'] } }); + await fs.outputJson(legacy, { raw: { env: { TOKEN: 'SCRUB_LEGACY_ENV' } } }); + const beforeConfig = await fs.readFile(state.configPath, 'utf8'); + const beforeLegacy = await fs.readFile(legacy, 'utf8'); + const args = ['--path', state.workspaceRoot, '--config', state.configPath, 'import', 'claude', '--categories', 'settings']; + try { + const preview = await launchBuiltAutohand([...args, '--dry-run'], { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { CLAUDE_CONFIG_DIR: source }, rows: 48 }); + try { + await waitForExit(preview, 30_000); + expectCleanExit(preview); + const text = await preview.text({ immediate: true }); + expect(text).toContain('Would translate 2 allow and 1 deny'); + expect(text).toContain('run_command:npm test*'); + expect(text).toContain('skipped (no equivalent)'); + expect(text).not.toContain('NEVER_COPY_ENV'); + expect(await fs.readFile(state.configPath, 'utf8')).toBe(beforeConfig); + expect(await fs.readFile(legacy, 'utf8')).toBe(beforeLegacy); + } finally { preview.close(); } + const imported = await launchBuiltAutohand(args, { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { CLAUDE_CONFIG_DIR: source }, rows: 48 }); + try { + await waitForExit(imported, 30_000); + expectCleanExit(imported); + const text = await imported.text({ immediate: true }); + expect(text).toContain('Translated 2 allow and 1 deny'); + expect(text).toContain('Scrubbed raw Claude settings'); + expect(text).not.toMatch(/NEVER_COPY_ENV|SCRUB_LEGACY_ENV/); + expect((await fs.readJson(state.configPath)).permissions.denyList).toContain('run_command:rm*'); + expect(await fs.readJson(legacy)).not.toHaveProperty('raw'); + } finally { imported.close(); } + } finally { await state.cleanup(); } + }); +}); From abdef2a4be6bfa5105d72a9a16d86700eaf07b09 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 23 Oct 2026 15:42:25 +1300 Subject: [PATCH 18/38] Align installer and terminal proof with current contracts --- .../scenarios/zitPackageInstallScenario.ts | 5 +++++ tests/tuistory/continue-flags.tuistory.test.ts | 2 +- tests/ui/ink/AutohandBrand.test.tsx | 11 ++++++----- tests/windowsInstaller.spec.ts | 16 ++++++++++------ 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/testing/scenarios/zitPackageInstallScenario.ts b/src/testing/scenarios/zitPackageInstallScenario.ts index f7ce8dd35..7d4213616 100644 --- a/src/testing/scenarios/zitPackageInstallScenario.ts +++ b/src/testing/scenarios/zitPackageInstallScenario.ts @@ -12,6 +12,11 @@ export async function prepareZitPackageInstallScenario( const environment = { ...process.env, npm_config_cache: path.join(scenarioRoot, 'npm-cache') }; await mkdir(packing, { recursive: true }); await mkdir(consumer, { recursive: true }); + // Packaging is trusted and must run prepack to bundle verified Zit binaries. + // The consumer install below still blocks untrusted lifecycle scripts. + execFileSync('npm', ['run', 'prepack'], { + cwd: repositoryRoot, env: environment, timeout: 120_000, maxBuffer: 4 * 1024 * 1024, + }); const packed = JSON.parse(execFileSync('npm', [ 'pack', '--ignore-scripts', '--json', '--pack-destination', packing, ], { cwd: repositoryRoot, env: environment, encoding: 'utf8', timeout: 60_000, maxBuffer: 4 * 1024 * 1024 })) as Array<{ filename: string }>; diff --git a/tests/tuistory/continue-flags.tuistory.test.ts b/tests/tuistory/continue-flags.tuistory.test.ts index 5cfaf41a9..6b831ec70 100644 --- a/tests/tuistory/continue-flags.tuistory.test.ts +++ b/tests/tuistory/continue-flags.tuistory.test.ts @@ -35,7 +35,7 @@ describe('continue and resume startup flags', () => { await waitForExit(session); const output = session.readAll(); expect(output).toContain('-c, --continue'); - expect(output).toContain('-r, --resume [id]'); + expect(output).toContain('-r, --resume [reference]'); expect(output).toMatch(/\n\s+--auto-commit\s/); expect(output).not.toContain('-c, --auto-commit'); }); diff --git a/tests/ui/ink/AutohandBrand.test.tsx b/tests/ui/ink/AutohandBrand.test.tsx index ab02ec7ef..ad4813c8c 100644 --- a/tests/ui/ink/AutohandBrand.test.tsx +++ b/tests/ui/ink/AutohandBrand.test.tsx @@ -132,11 +132,12 @@ describe('Autohand branding inside Ink', () => { output.columns = 32; output.rows = 12; output.emit('resize'); - await settle(); - const frame = stripAnsi(screen.lastFrame() ?? ''); - expect(frame.match(/autohand/g)).toHaveLength(1); - expect(vi.getTimerCount()).toBe(baseline); - expect(frame.split('\n').length).toBeLessThanOrEqual(5); + await vi.waitFor(() => { + const frame = stripAnsi(screen?.lastFrame() ?? ''); + expect(frame.match(/autohand/g)).toHaveLength(1); + expect(vi.getTimerCount()).toBe(baseline); + expect(frame.split('\n').length).toBeLessThanOrEqual(5); + }); }); it('respects the space left by a wrapping menu', async () => { diff --git a/tests/windowsInstaller.spec.ts b/tests/windowsInstaller.spec.ts index 10d9c605a..40752f5cc 100644 --- a/tests/windowsInstaller.spec.ts +++ b/tests/windowsInstaller.spec.ts @@ -669,17 +669,21 @@ Write-Output (Start-FirstRun -BinaryPath '${fixture}' -Answer $null) }); }); -describe('required computer use installation', () => { - powerShellTest('propagates a companion installation failure', () => { +describe('optional computer use installation', () => { + powerShellTest('warns and continues after a supported companion installation fails', () => { const result = runPowerShellProbe([ installerWithoutEntrypoint, - 'function Invoke-FailedComputerInstall { $global:LASTEXITCODE = 7 }', + 'function Test-ComputerControlSupport { return $true }', + 'function Invoke-FailedComputerInstall { Write-Output "COMPANION_INSTALL_ATTEMPTED"; $global:LASTEXITCODE = 7 }', '$env:AUTOHAND_SKIP_COMPUTER_CONTROL_INSTALL = "0"', 'Install-ComputerControl -BinaryPath "Invoke-FailedComputerInstall"', + 'Write-Output "INSTALLER_CONTINUED"', ].join('\n')); - expect(result.status).not.toBe(0); - expect(result.stdout).toContain('autohand computer install'); - expect(result.stdout).not.toContain('still ready'); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('COMPANION_INSTALL_ATTEMPTED'); + expect(result.stdout).toContain('Warning: Optional Computer control could not be installed. Autohand CLI is ready.'); + expect(result.stdout).toContain('Retry with: autohand computer install'); + expect(result.stdout).toContain('INSTALLER_CONTINUED'); }); }); From e6b333c1684fab8f19260ec29776c5f173a3492c Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 23 Oct 2026 16:01:07 +1300 Subject: [PATCH 19/38] Restore composer ownership after session export dialogs --- CHANGELOG.md | 4 ++++ src/core/agent/AgentCommandRuntime.ts | 2 +- src/core/slashCommandHandler.ts | 15 ++++++++++----- src/testing/scenarios/exportPrivacyScenario.ts | 5 +++++ tests/tuistory/program-status.tuistory.test.ts | 4 ++++ 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e45140b77..dd92a1758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- Restore the interactive composer after session export dialogs so typing and a clean exit remain available. + ### Changed - Persistent goals (`/goal`, `/goals` and `--goal`) are enabled by default for 0.9.9. Existing explicit settings are preserved; `/experiments disable slash_goal` turns them off. diff --git a/src/core/agent/AgentCommandRuntime.ts b/src/core/agent/AgentCommandRuntime.ts index e157108f5..6f4c032db 100644 --- a/src/core/agent/AgentCommandRuntime.ts +++ b/src/core/agent/AgentCommandRuntime.ts @@ -100,7 +100,7 @@ interface SimilarSkillMatch { } const INTERACTIVE_SLASH_COMMANDS = new Set([ - '/browser', '/chrome', '/hooks', '/feedback', '/permissions', '/login', '/logout', + '/browser', '/chrome', '/hooks', '/feedback', '/permissions', '/login', '/logout', '/export', '/agents-new', '/agents new', '/resume', '/theme', '/language', '/model', '/skills', '/skills install', '/skills-install', '/skills new', '/skills-new', '/mcp', '/mcp install', '/mcp-install', diff --git a/src/core/slashCommandHandler.ts b/src/core/slashCommandHandler.ts index d54de93a0..7f286c543 100644 --- a/src/core/slashCommandHandler.ts +++ b/src/core/slashCommandHandler.ts @@ -358,11 +358,16 @@ export class SlashCommandHandler { } case '/export': { const { execute } = await import('../commands/export.js'); - await execute(args.join(' '), { - sessionManager: this.ctx.sessionManager, - currentSession: this.ctx.currentSession, - workspaceRoot: this.ctx.workspaceRoot, - }); + await this.ctx.onBeforeModal?.(); + try { + await execute(args.join(' '), { + sessionManager: this.ctx.sessionManager, + currentSession: this.ctx.currentSession, + workspaceRoot: this.ctx.workspaceRoot, + }); + } finally { + await this.ctx.onAfterModal?.(); + } return null; } case '/share': { diff --git a/src/testing/scenarios/exportPrivacyScenario.ts b/src/testing/scenarios/exportPrivacyScenario.ts index 78bc91430..9e3f14d01 100644 --- a/src/testing/scenarios/exportPrivacyScenario.ts +++ b/src/testing/scenarios/exportPrivacyScenario.ts @@ -24,5 +24,10 @@ export async function runExportPrivacyScenario(session: Session, format: 'md' | await session.waitForText('Save as:', { timeout: 5_000 }); await session.press('enter'); await session.waitForText('Secrets redacted:', { timeout: 10_000 }); + // Export output is printed before the modal has restored the composer. + // Prove input is live again before the caller sends Ctrl+C for teardown. + await session.waitForText('Plan, search, build anything', { timeout: 10_000 }); + await session.type('export input restored'); + await session.waitForText('❯ export input restored', { timeout: 10_000 }); return session.readAll(); } diff --git a/tests/tuistory/program-status.tuistory.test.ts b/tests/tuistory/program-status.tuistory.test.ts index 910713c3c..217be9a6d 100644 --- a/tests/tuistory/program-status.tuistory.test.ts +++ b/tests/tuistory/program-status.tuistory.test.ts @@ -52,8 +52,12 @@ describe('OSC7501 built terminal lifecycle', () => { ]); await submitProgramStatusPrompt(session, 'Write the proof file'); await vi.waitFor(() => expect(programStatusReports(session)).toContainEqual(expect.objectContaining({ state: 'blocked', kind: 'permission' })), { timeout: 15_000 }); + // The blocked report precedes the modal mounting its keyboard listener. + await session.text({ waitFor: text => /▸\s+1\. Yes/u.test(text) }); await session.press('down'); + await session.text({ waitFor: text => /▸\s+2\. No/u.test(text) }); await session.press('up'); + await session.text({ waitFor: text => /▸\s+1\. Yes/u.test(text) }); await session.press('enter'); await session.waitForText('PERMISSION_STATUS_COMPLETE'); await vi.waitFor(() => expect(programStatusReports(session).at(-1)?.state).toBe('done')); From 2017778fbead8bec857e2914be7b2c10eacbf4f3 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 23 Oct 2026 21:02:44 +1300 Subject: [PATCH 20/38] Match the resume help fixture to its declared reference argument Correct the existing terminal assertion to match the CLI definition. This prerequisite was reproduced on the unchanged baseline while validating F-0199. --- tests/tuistory/continue-flags.tuistory.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tuistory/continue-flags.tuistory.test.ts b/tests/tuistory/continue-flags.tuistory.test.ts index 5cfaf41a9..6b831ec70 100644 --- a/tests/tuistory/continue-flags.tuistory.test.ts +++ b/tests/tuistory/continue-flags.tuistory.test.ts @@ -35,7 +35,7 @@ describe('continue and resume startup flags', () => { await waitForExit(session); const output = session.readAll(); expect(output).toContain('-c, --continue'); - expect(output).toContain('-r, --resume [id]'); + expect(output).toContain('-r, --resume [reference]'); expect(output).toMatch(/\n\s+--auto-commit\s/); expect(output).not.toContain('-c, --auto-commit'); }); From 124cc563dd03d9e95949964de31708172e60548c Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 23 Oct 2026 21:02:44 +1300 Subject: [PATCH 21/38] Preserve worktree changes and clean up safe sessions for F-0199 Keep edited, untracked and uniquely committed work by default. Route explicit discard requests through deletion permissions and deny them in restricted clients. Remove clean session worktrees after shutdown, retain recovery paths for preserved work, and cover real Git and built terminal lifecycles. --- docs/config-reference.md | 4 + src/actions/worktree.ts | 31 +++- src/core/agent.ts | 4 +- src/core/agent/AgentCommandRuntime.ts | 10 +- src/core/agent/AgentDependencyComposer.ts | 10 +- src/core/toolManager.ts | 20 +- src/index.ts | 173 +++++++++-------- src/modes/acp/adapter.ts | 12 +- src/modes/rpc/index.ts | 8 +- src/modes/rpc/logging.ts | 4 + src/types.ts | 2 +- src/utils/sessionWorktree.ts | 21 ++- tests/core/agent.worktreeTools.spec.ts | 3 +- tests/modes/acp/adapter.test.ts | 13 ++ tests/modes/rpc/index.debugLogging.spec.ts | 12 ++ .../worktree-cleanup.tuistory.test.ts | 114 ++++++++++++ tests/utils/sessionWorktree.spec.ts | 15 +- tests/worktreeSafety.spec.ts | 174 ++++++++++++++++++ tests/worktreeSessionTools.spec.ts | 2 + 19 files changed, 522 insertions(+), 110 deletions(-) create mode 100644 tests/tuistory/worktree-cleanup.tuistory.test.ts create mode 100644 tests/worktreeSafety.spec.ts diff --git a/docs/config-reference.md b/docs/config-reference.md index d5d819b60..d36d73fc5 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -2956,6 +2956,10 @@ These flags override config file settings: | `--patch` | Generate git patch without applying changes | | `--output ` | Output file for patch (used with --patch) | +When a `--worktree` session ends, Autohand removes its worktree only when there are no uncommitted or untracked files and no commits missing from the original branch. A branch created for that session is also removed; pre-existing branches are retained. Otherwise it prints `Kept worktree (branch ) with changes` so you can recover your work. This cleanup applies to interactive, command, patch, RPC and ACP sessions; tmux and Zit behavior is unchanged. + +The agent's `exit_worktree` tool follows the same preservation rule and returns to the original workspace. `keep: true` always keeps the worktree. `discard_changes: true` explicitly requests removal with changes and follows the deletion approval policy; the restricted client profile denies it. `keep` takes precedence when both options are set. Cleanup stops safely if Git cannot verify the worktree or original branch. + ### Zit Sessions `--zit` is the counterpart of `--worktree` for repositories shared by several agents through [Zit](https://github.com/autohandai/getzit). Instead of a git worktree and branch, the session runs in a disposable copy of the repository's current Zit state, and its edits are recorded as one Zit change when the session ends. diff --git a/src/actions/worktree.ts b/src/actions/worktree.ts index b9882cdf6..9bc2aa1fb 100644 --- a/src/actions/worktree.ts +++ b/src/actions/worktree.ts @@ -231,18 +231,39 @@ export class WorktreeManager { /** * Remove a worktree with cleanup */ - async remove(worktreePath: string, options: { force?: boolean; deleteBranch?: boolean } = {}): Promise { + async remove(worktreePath: string, options: { + force?: boolean; + deleteBranch?: boolean; + baseRef?: string; + /** The caller must authorize discarding through the deletion permission policy. */ + discardChanges?: boolean; + } = {}): Promise { const absolutePath = path.isAbsolute(worktreePath) ? worktreePath : path.resolve(this.repoRoot, worktreePath); // Get branch info before removal const worktrees = this.list(); - const wt = worktrees.find(w => w.path === absolutePath); + const wt = worktrees.find(w => path.resolve(w.path) === path.resolve(absolutePath)); + if (!wt || wt.bare) throw new Error(`Not a registered worktree: ${absolutePath}`); const branchToDelete = wt?.branch; + const status = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], { + cwd: absolutePath, encoding: 'utf8', + }); + const commits = spawnSync('git', ['rev-list', '--count', `${options.baseRef ?? this.getMainBranch()}..HEAD`], { + cwd: absolutePath, encoding: 'utf8', + }); + if (status.status !== 0 || commits.status !== 0 || !/^\d+\s*$/.test(commits.stdout)) { + throw new Error(`Cannot verify worktree safety; kept ${absolutePath} (branch ${branchToDelete ?? '(detached)'}).`); + } + const hasChanges = status.stdout.trim().length > 0 || Number(commits.stdout.trim()) > 0; + if (hasChanges && options.discardChanges !== true) { + return `Kept worktree ${absolutePath} (branch ${branchToDelete ?? '(detached)'}) with changes (kept: has changes).`; + } + const args = ['worktree', 'remove']; - if (options.force) { + if (options.discardChanges === true) { args.push('--force'); } args.push(absolutePath); @@ -304,8 +325,8 @@ export class WorktreeManager { const removed: string[] = []; for (const wt of toRemove) { try { - await this.remove(wt.path, { force: true, deleteBranch: options.removeMerged }); - removed.push(wt.path); + const result = await this.remove(wt.path, { deleteBranch: options.removeMerged, baseRef: mainBranch }); + if (!result.startsWith('Kept worktree ')) removed.push(wt.path); } catch { // Skip if removal fails } diff --git a/src/core/agent.ts b/src/core/agent.ts index 79a730ef3..5780ae070 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -2967,8 +2967,8 @@ export class AutohandAgent { return executeAgentSleepTool(this, seconds, reason); } - private async exitSessionWorktree(keep = false): Promise { - return exitAgentSessionWorktree(this, keep); + private async exitSessionWorktree(keep = false, discardChanges = false): Promise { + return exitAgentSessionWorktree(this, keep, discardChanges); } private isDestructiveCommand(command: string): boolean { diff --git a/src/core/agent/AgentCommandRuntime.ts b/src/core/agent/AgentCommandRuntime.ts index e157108f5..59b82bf23 100644 --- a/src/core/agent/AgentCommandRuntime.ts +++ b/src/core/agent/AgentCommandRuntime.ts @@ -873,7 +873,7 @@ export async function executeAgentSleepTool(host: AgentCommandRuntimeHost, secon : `Slept for ${seconds} ${units}.`; } -export async function exitAgentSessionWorktree(host: AgentCommandRuntimeHost, keep = false): Promise { +export async function exitAgentSessionWorktree(host: AgentCommandRuntimeHost, keep = false, discardChanges = false): Promise { const state = host.sessionWorktreeState; if (!state) { return 'No active session worktree.'; @@ -881,10 +881,12 @@ export async function exitAgentSessionWorktree(host: AgentCommandRuntimeHost, ke const workspaceChange: ThreadLease | undefined = host.sessionThreadBudget?.beginWorkspaceChange(); try { + let removal: string | undefined; if (!keep) { const manager = new WorktreeManager(state.repoRoot); - await manager.remove(state.worktreePath, { - force: true, + removal = await manager.remove(state.worktreePath, { + baseRef: state.baseRef, + discardChanges, deleteBranch: state.createdBranch, }); } @@ -894,7 +896,7 @@ export async function exitAgentSessionWorktree(host: AgentCommandRuntimeHost, ke return keep ? `Exited worktree ${state.worktreePath} and returned to ${state.originalWorkspaceRoot}. Worktree kept on disk.` - : `Exited worktree ${state.worktreePath} and returned to ${state.originalWorkspaceRoot}.`; + : `${removal}\nExited worktree ${state.worktreePath} and returned to ${state.originalWorkspaceRoot}.`; } finally { await workspaceChange?.release(); } diff --git a/src/core/agent/AgentDependencyComposer.ts b/src/core/agent/AgentDependencyComposer.ts index f327cd086..ca9961185 100644 --- a/src/core/agent/AgentDependencyComposer.ts +++ b/src/core/agent/AgentDependencyComposer.ts @@ -924,7 +924,13 @@ export function initializeAgentDependencies( const toolAuthorization = { permissionManager: host.permissionManager, - resolvePermissionContext: (action) => host.actionExecutor.getPermissionContext(action), + resolvePermissionContext: (action) => { + const context = host.actionExecutor.getPermissionContext(action); + return action.type === 'exit_worktree' && action.discard_changes === true && action.keep !== true + && host.sessionWorktreeState + ? { ...context, path: host.sessionWorktreeState.worktreePath } + : context; + }, runPreToolHooks: (context) => { const hookContext = { tool: context.tool, @@ -1680,7 +1686,7 @@ export function initializeAgentDependencies( } else if (action.type === 'enter_worktree') { result = await host.enterSessionWorktree(action.name); } else if (action.type === 'exit_worktree') { - result = await host.exitSessionWorktree(action.keep); + result = await host.exitSessionWorktree(action.keep, action.discard_changes); } else if (action.type === 'cron_create') { const cron = intervalToCron(action.interval); const expiresInMs = action.expires_in ? shorthandToMs(action.expires_in) : undefined; diff --git a/src/core/toolManager.ts b/src/core/toolManager.ts index 009ee6eb2..451bd8f94 100644 --- a/src/core/toolManager.ts +++ b/src/core/toolManager.ts @@ -198,6 +198,9 @@ function resolveEffectivePermissionTool( action: AgentAction, values: Record, ): string { + if (action.type === 'exit_worktree' && values.discard_changes === true && values.keep !== true) { + return 'delete_path'; + } if (action.type === 'analyze_experiments' && values.operation === 'prune' && values.yes === true @@ -1505,13 +1508,16 @@ export const DEFAULT_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'exit_worktree', - description: 'Exit the current session worktree and return to the original workspace. Optionally keep the worktree on disk for inspection.', + description: 'Return to the original workspace. Worktrees with edited or untracked files or commits not on the original branch are kept automatically. Discarding changes requires explicit deletion approval.', parameters: { type: 'object', properties: { - keep: { type: 'boolean', description: 'When true, keep the worktree and branch instead of removing them' } + keep: { type: 'boolean', description: 'When true, keep the worktree and branch instead of removing them' }, + discard_changes: { type: 'boolean', description: 'Explicitly discard worktree changes after deletion approval. Default false; keep takes precedence.' } } - } + }, + requiresApproval: true, + approvalMessage: 'Discard changes and remove the current session worktree? Uncommitted and untracked files will be deleted.' }, { name: 'team_status', @@ -2805,7 +2811,9 @@ export class ToolManager { continue; } - const requiresApproval = this.toolFilter.requiresApproval(call.tool, definition.requiresApproval); + const mayDiscard = call.tool !== 'exit_worktree' + || (this.getCallArgs(call).discard_changes === true && this.getCallArgs(call).keep !== true); + const requiresApproval = this.toolFilter.requiresApproval(call.tool, definition.requiresApproval && mayDiscard); try { this.assertNotAborted(signal); @@ -3118,6 +3126,10 @@ export class ToolManager { } private resolvePermissionContexts(action: AgentAction): PermissionContext[] { + if (action.type === 'exit_worktree' && action.discard_changes === true && action.keep !== true + && !this.toolFilter.isAllowed('delete_path')) { + throw new Error('Discarding a worktree is not available in the current context.'); + } const specialized = this.resolveSpecializedPermissionContext?.(action); const standardContexts = buildToolPermissionContexts(action); const contexts = specialized === undefined diff --git a/src/index.ts b/src/index.ts index 76f6aba5e..7b06cf318 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,7 +62,7 @@ import { resolveCommandOutputFormat, } from './modes/commandOutput.js'; import { AUTOHAND_PATHS, PROJECT_DIR_NAME } from './constants.js'; -import { isSessionWorktreeEnabled, prepareSessionWorktree } from './utils/sessionWorktree.js'; +import { cleanupSessionWorktree, isSessionWorktreeEnabled, prepareSessionWorktree, type SessionWorktreeInfo } from './utils/sessionWorktree.js'; import { armPromptExitBackstop } from './runtime/promptExitBackstop.js'; import { buildZitSessionInstructions, @@ -1595,6 +1595,7 @@ async function runCLI(options: InternalCLIOptions): Promise { let commandOutputCompleted = false; // Records the --zit workspace once: in the finally block below, or on process exit. let sessionZitFinalizer: SessionZitFinalizer | null = null; + let sessionWorktree: SessionWorktreeInfo | null = null; const runtimeResourceOwner = new CliRuntimeResourceOwner< AuthUser, VersionCheckResult, @@ -1637,7 +1638,6 @@ async function runCLI(options: InternalCLIOptions): Promise { await applyServedAutohandModel(config, options); const originalWorkspaceRoot = resolveWorkspaceRoot(config, options.path); let workspaceRoot = originalWorkspaceRoot; - let sessionWorktree: ReturnType | null = null; let sessionZit: SessionZitInfo | null = null; // Initialize i18n with locale detection @@ -2291,6 +2291,9 @@ async function runCLI(options: InternalCLIOptions): Promise { await herdrIntegration?.reporter.settled(); herdrIntegration?.detach(); sessionZitFinalizer?.finish(agent?.getLastAssistantResponse()); + if (sessionWorktree && !options.tmux && process.env.AUTOHAND_TMUX_LAUNCHED !== '1') { + console.error(chalk.gray(await cleanupSessionWorktree(sessionWorktree))); + } agentHolder.current = null; restoreConsoleOutput?.(); if (resolveAgentLaunchMode(options) === 'command') { @@ -2721,103 +2724,111 @@ async function runPatchMode(opts: CLIOptions): Promise { await resolveWorkspaceTrust(config, { interactive: false }); } - if (isSessionWorktreeEnabled(opts.worktree)) { - const sessionWorktree = prepareSessionWorktree({ - cwd: originalWorkspaceRoot, - worktree: opts.worktree, - mode: 'patch', - }); - workspaceRoot = sessionWorktree.worktreePath; - const worktreeSafetyCheck = checkWorkspaceSafety(workspaceRoot); - if (!worktreeSafetyCheck.safe) { - printDangerousWorkspaceWarning(workspaceRoot, worktreeSafetyCheck); - process.exit(1); + let sessionWorktree: SessionWorktreeInfo | null = null; + try { + if (isSessionWorktreeEnabled(opts.worktree)) { + sessionWorktree = prepareSessionWorktree({ + cwd: originalWorkspaceRoot, + worktree: opts.worktree, + mode: 'patch', + }); + workspaceRoot = sessionWorktree.worktreePath; + const worktreeSafetyCheck = checkWorkspaceSafety(workspaceRoot); + if (!worktreeSafetyCheck.safe) { + printDangerousWorkspaceWarning(workspaceRoot, worktreeSafetyCheck); + process.exitCode = 1; + return; + } + console.error(chalk.gray(`Using git worktree: ${sessionWorktree.worktreePath}`)); + console.error(chalk.gray(`Branch: ${sessionWorktree.branchName}${sessionWorktree.createdBranch ? ' (new)' : ''}\n`)); } - console.error(chalk.gray(`Using git worktree: ${sessionWorktree.worktreePath}`)); - console.error(chalk.gray(`Branch: ${sessionWorktree.branchName}${sessionWorktree.createdBranch ? ' (new)' : ''}\n`)); - } - const additionalDirs = await resolveAdditionalDirectoriesOrExit(opts.addDir); + const additionalDirs = await resolveAdditionalDirectoriesOrExit(opts.addDir); - // Override model from CLI if provided - if (opts.model) { - config = applyCliModelOverride(config, opts.model, opts.provider); - } - await applyServedAutohandModel(config, opts); - - const { ProviderFactory } = await import('./providers/ProviderFactory.js'); - const { FileActionManager } = await import('./actions/filesystem.js'); - const llmProvider = ProviderFactory.create(config); - const files = new FileActionManager(workspaceRoot, additionalDirs); + // Override model from CLI if provided + if (opts.model) { + config = applyCliModelOverride(config, opts.model, opts.provider); + } + await applyServedAutohandModel(config, opts); - // Enable preview mode - changes will be batched instead of written - const batchId = crypto.randomUUID(); - files.enterPreviewMode(batchId); + const { ProviderFactory } = await import('./providers/ProviderFactory.js'); + const { FileActionManager } = await import('./actions/filesystem.js'); + const llmProvider = ProviderFactory.create(config); + const files = new FileActionManager(workspaceRoot, additionalDirs); - // Set up runtime with auto-confirm and unrestricted mode - const patchOptions: CLIOptions = { - ...opts, - yes: true, // Auto-confirm all actions - unrestricted: true // Skip approval prompts - }; + // Enable preview mode - changes will be batched instead of written + const batchId = crypto.randomUUID(); + files.enterPreviewMode(batchId); - const runtime: AgentRuntime = { - config, - workspaceRoot, - options: patchOptions, - additionalDirs: additionalDirs.length > 0 ? additionalDirs : undefined - }; + // Set up runtime with auto-confirm and unrestricted mode + const patchOptions: CLIOptions = { + ...opts, + yes: true, // Auto-confirm all actions + unrestricted: true // Skip approval prompts + }; - // Show status - console.error(chalk.cyan('Patch Mode: Changes will be captured without modifying files\n')); + const runtime: AgentRuntime = { + config, + workspaceRoot, + options: patchOptions, + additionalDirs: additionalDirs.length > 0 ? additionalDirs : undefined + }; - // Configure web search provider - const { configureSearchFromAccount } = await import('./actions/web.js'); - await configureSearchFromAccount(config, opts.searchEngine); + // Show status + console.error(chalk.cyan('Patch Mode: Changes will be captured without modifying files\n')); - let agent: AutohandAgent | null = null; - let exitCode = 0; - try { - const { AutohandAgent } = await import('./core/agent.js'); - agent = new AutohandAgent(llmProvider, files, runtime); + // Configure web search provider + const { configureSearchFromAccount } = await import('./actions/web.js'); + await configureSearchFromAccount(config, opts.searchEngine); - // Run the instruction (changes will be batched in preview mode) - const succeeded = await agent.runCommandMode(opts.prompt); - if (!succeeded) { - exitCode = 1; - } else { - // Get all pending changes - const changes = files.getPendingChanges(); + let agent: AutohandAgent | null = null; + let exitCode = 0; + try { + const { AutohandAgent } = await import('./core/agent.js'); + agent = new AutohandAgent(llmProvider, files, runtime); - if (changes.length === 0) { - console.error(chalk.yellow('\nNo changes were made.')); + // Run the instruction (changes will be batched in preview mode) + const succeeded = await agent.runCommandMode(opts.prompt); + if (!succeeded) { + exitCode = 1; } else { - // Generate unified patch - const patch = generateUnifiedPatch(changes); - - // Show summary to stderr (so it doesn't pollute stdout when piping) - console.error(chalk.green(`\n✓ ${formatChangeSummary(changes)}`)); - - // Output patch - if (opts.output) { - await fs.default.ensureDir((await import('path')).dirname(opts.output)); - await fs.default.writeFile(opts.output, patch); - console.error(chalk.green(`✓ Patch written to ${opts.output}`)); - console.error(chalk.gray('\nTo apply: git apply ' + opts.output)); + // Get all pending changes + const changes = files.getPendingChanges(); + + if (changes.length === 0) { + console.error(chalk.yellow('\nNo changes were made.')); } else { - // Output to stdout - process.stdout.write(patch); + // Generate unified patch + const patch = generateUnifiedPatch(changes); + + // Show summary to stderr (so it doesn't pollute stdout when piping) + console.error(chalk.green(`\n✓ ${formatChangeSummary(changes)}`)); + + // Output patch + if (opts.output) { + await fs.default.ensureDir((await import('path')).dirname(opts.output)); + await fs.default.writeFile(opts.output, patch); + console.error(chalk.green(`✓ Patch written to ${opts.output}`)); + console.error(chalk.gray('\nTo apply: git apply ' + opts.output)); + } else { + // Output to stdout + process.stdout.write(patch); + } } } + } catch (error) { + console.error(chalk.red(`\nError: ${(error as Error).message}`)); + exitCode = 1; + } finally { + files.exitPreviewMode(); + await agent?.shutdownRuntimeResources(); } - } catch (error) { - console.error(chalk.red(`\nError: ${(error as Error).message}`)); - exitCode = 1; + process.exitCode = exitCode; } finally { - files.exitPreviewMode(); - await agent?.shutdownRuntimeResources(); + if (sessionWorktree && !opts.tmux && process.env.AUTOHAND_TMUX_LAUNCHED !== '1') { + console.error(chalk.gray(await cleanupSessionWorktree(sessionWorktree))); + } } - process.exitCode = exitCode; } /** diff --git a/src/modes/acp/adapter.ts b/src/modes/acp/adapter.ts index 33daf0388..a02c057e3 100644 --- a/src/modes/acp/adapter.ts +++ b/src/modes/acp/adapter.ts @@ -49,7 +49,7 @@ import { loadConfig } from '../../config.js'; import { prepareBareModeConfig } from '../../runtime/bareMode.js'; import type { AgentOutputEvent, AgentRuntime, CLIOptions, LoadedConfig, LLMToolCall } from '../../types.js'; import type { McpServerConfig } from '../../mcp/types.js'; -import { isSessionWorktreeEnabled, prepareSessionWorktree } from '../../utils/sessionWorktree.js'; +import { cleanupSessionWorktree, isSessionWorktreeEnabled, prepareSessionWorktree, type SessionWorktreeInfo } from '../../utils/sessionWorktree.js'; import { ApiError, classifyApiError, type ApiErrorCode } from '../../providers/errors.js'; import type { SessionMessage } from '../../session/types.js'; import { isGoalFeatureEnabled } from '../../goals/feature.js'; @@ -200,6 +200,7 @@ function parseAssistantReplayParts(content: string): AssistantReplayParts { */ export class AutohandAcpAdapter implements Agent { private sessions = new Map(); + private sessionWorktrees = new Map(); private agents = new Map(); private sessionConfigs = new Map(); private permissionBridges = new Map>(); @@ -396,6 +397,7 @@ export class AutohandAcpAdapter implements Agent { mode: 'acp', }); workspaceRoot = sessionWorktree.worktreePath; + this.sessionWorktrees.set(workspaceRoot, sessionWorktree); process.stderr.write( `[ACP] Using git worktree ${sessionWorktree.worktreePath} (${sessionWorktree.branchName})\n` ); @@ -997,6 +999,13 @@ export class AutohandAcpAdapter implements Agent { this.sessionConfigOptions.clear(); this.cancelledSessions.clear(); await Promise.all(agents.map((agent) => this.shutdownAgent(agent))); + await this.cleanupWorktrees(); + } + + private async cleanupWorktrees(): Promise { + const worktrees = [...this.sessionWorktrees.values()]; + this.sessionWorktrees.clear(); + for (const worktree of worktrees) process.stderr.write(`${await cleanupSessionWorktree(worktree)}\n`); } private async shutdownAgent(agent: AutohandAgent): Promise { @@ -1351,6 +1360,7 @@ export class AutohandAcpAdapter implements Agent { // The hooks above already ended the session; only the MCP servers, relays // and background processes each agent still owns need releasing. await Promise.all(agents.map((agent) => agent.shutdownRuntimeResources().catch(() => {}))); + await this.cleanupWorktrees(); } private async emitHookSafe(method: string, params: Record): Promise { diff --git a/src/modes/rpc/index.ts b/src/modes/rpc/index.ts index 32ec70ffa..ea2a34067 100644 --- a/src/modes/rpc/index.ts +++ b/src/modes/rpc/index.ts @@ -21,7 +21,7 @@ import { buildPermissionSettingsFromYolo, } from '../../permissions/yoloMode.js'; import type { CLIOptions, AgentRuntime, ClientContext, LoadedConfig } from '../../types.js'; -import { isSessionWorktreeEnabled, prepareSessionWorktree } from '../../utils/sessionWorktree.js'; +import { cleanupSessionWorktree, isSessionWorktreeEnabled, prepareSessionWorktree, type SessionWorktreeInfo } from '../../utils/sessionWorktree.js'; import type { JsonRpcRequest, JsonRpcResponse, @@ -75,7 +75,7 @@ import { writeInternalError, } from './protocol.js'; import { getPlanModeManager } from '../../commands/plan.js'; -import { getRpcErrorMetadata, writeRpcDebugLine } from './logging.js'; +import { getRpcErrorMetadata, writeRpcDebugLine, writeRpcSessionNotice } from './logging.js'; import { shutdownBrowserToolBridge } from '../../browser/browserToolBridge.js'; import { configureSearchFromAccount } from '../../actions/web.js'; import { @@ -498,6 +498,7 @@ export async function runRpcMode(options: CLIOptions): Promise<0 | 1> { let adapter: RPCAdapter | null = null; let agent: AutohandAgent | null = null; + let sessionWorktree: SessionWorktreeInfo | null = null; let reader: LineReader | null = null; let exitCode = 0; let shutdownReason: RpcShutdownReason = 'error'; @@ -601,7 +602,7 @@ export async function runRpcMode(options: CLIOptions): Promise<0 | 1> { config.ui.useInkRenderer = false; if (isSessionWorktreeEnabled(options.worktree)) { - const sessionWorktree = prepareSessionWorktree({ + sessionWorktree = prepareSessionWorktree({ cwd: originalWorkspaceRoot, worktree: options.worktree, mode: 'rpc', @@ -802,6 +803,7 @@ export async function runRpcMode(options: CLIOptions): Promise<0 | 1> { await Promise.resolve().then(() => shutdownBrowserToolBridge()).catch(() => {}); await shutdownRpcRuntime(adapter, agent, shutdownReason).catch(() => {}); + if (sessionWorktree) writeRpcSessionNotice(await cleanupSessionWorktree(sessionWorktree)); await flushRpcOutput(); process.stdout.off('error', handleStdoutError); restoreConsole(); diff --git a/src/modes/rpc/logging.ts b/src/modes/rpc/logging.ts index 6119e3384..3e87f1774 100644 --- a/src/modes/rpc/logging.ts +++ b/src/modes/rpc/logging.ts @@ -26,6 +26,10 @@ export function writeRpcDebugLine(message: string): void { writeAutohandDebugLine(`[RPC DEBUG] ${message}`); } +export function writeRpcSessionNotice(message: string): void { + process.stderr.write(`${message}\n`); +} + /** * Describe an error without exposing its message, stack, or attached payloads. */ diff --git a/src/types.ts b/src/types.ts index 6113ae183..dd5837a45 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1912,7 +1912,7 @@ export type AgentAction = | { type: 'skill'; command: 'list' | 'info' | 'activate' | 'deactivate'; name?: string } | { type: 'sleep'; seconds: number; reason?: string } | { type: 'enter_worktree'; name?: string } - | { type: 'exit_worktree'; keep?: boolean } + | { type: 'exit_worktree'; keep?: boolean; discard_changes?: boolean } // Web Search Operations | { type: 'capture_test_evidence'; diff --git a/src/utils/sessionWorktree.ts b/src/utils/sessionWorktree.ts index 8a702c486..4f00683c7 100644 --- a/src/utils/sessionWorktree.ts +++ b/src/utils/sessionWorktree.ts @@ -9,6 +9,7 @@ import { executeCoordinatedFile, getCommandCoordination } from '../session/peers import { existsSync } from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; +import { WorktreeManager } from '../actions/worktree.js'; export type SessionWorktreeOption = boolean | string | undefined; @@ -23,6 +24,7 @@ export interface SessionWorktreeInfo { worktreePath: string; branchName: string; createdBranch: boolean; + baseRef: string; } interface GitResult { @@ -97,6 +99,12 @@ export function isSessionWorktreeEnabled(worktree: SessionWorktreeOption): workt function planSessionWorktree(input: SessionWorktreeInput): { info: SessionWorktreeInfo; args: string[] } { const repoRoot = ensureGitRepo(input.cwd); + const original = runGit(repoRoot, ['rev-parse', '--symbolic-full-name', 'HEAD']); + const base = original.status === 0 && original.stdout.trim().startsWith('refs/heads/') + ? original + : runGit(repoRoot, ['rev-parse', '--verify', 'HEAD']); + if (base.status !== 0 || !base.stdout.trim()) throw new Error('Cannot determine the original worktree branch or commit.'); + const baseRef = base.stdout.trim(); const branchName = typeof input.worktree === 'string' ? sanitizeName(input.worktree) : buildAutoBranchName(input.mode); @@ -111,7 +119,7 @@ function planSessionWorktree(input: SessionWorktreeInput): { info: SessionWorktr ? ['worktree', 'add', worktreePath, branchName] : ['worktree', 'add', '-b', branchName, worktreePath]; - return { info: { repoRoot, worktreePath, branchName, createdBranch: !existingBranch }, args }; + return { info: { repoRoot, worktreePath, branchName, createdBranch: !existingBranch, baseRef }, args }; } function checkWorktreeResult(result: GitResult): void { @@ -134,3 +142,14 @@ export async function prepareCoordinatedSessionWorktree(input: SessionWorktreeIn checkWorktreeResult(await executeCoordinatedFile({ file: 'git', args, cwd: info.repoRoot })); return info; } + +export async function cleanupSessionWorktree(info: SessionWorktreeInfo): Promise { + try { + return await new WorktreeManager(info.repoRoot).remove(info.worktreePath, { + baseRef: info.baseRef, + deleteBranch: info.createdBranch, + }); + } catch (error) { + return `Kept worktree ${info.worktreePath} (branch ${info.branchName}); cleanup could not complete safely: ${error instanceof Error ? error.message : String(error)}`; + } +} diff --git a/tests/core/agent.worktreeTools.spec.ts b/tests/core/agent.worktreeTools.spec.ts index 7df5b0e55..433e5cfdc 100644 --- a/tests/core/agent.worktreeTools.spec.ts +++ b/tests/core/agent.worktreeTools.spec.ts @@ -246,7 +246,8 @@ describe('AutohandAgent worktree tools', () => { const result = await agent.exitSessionWorktree(); expect(mockWorktreeRemove).toHaveBeenCalledWith('/repo-feature', { - force: true, + baseRef: undefined, + discardChanges: false, deleteBranch: true, }); expect(agent.runtime.workspaceRoot).toBe('/repo'); diff --git a/tests/modes/acp/adapter.test.ts b/tests/modes/acp/adapter.test.ts index 65399d82b..07f988b51 100644 --- a/tests/modes/acp/adapter.test.ts +++ b/tests/modes/acp/adapter.test.ts @@ -156,6 +156,7 @@ vi.mock("../../../src/actions/filesystem.js", () => ({ vi.mock("../../../src/utils/sessionWorktree.js", () => ({ prepareSessionWorktree: mockPrepareSessionWorktree, isSessionWorktreeEnabled: mockIsSessionWorktreeEnabled, + cleanupSessionWorktree: vi.fn().mockResolvedValue('Removed worktree fixture'), })); vi.mock("../../../src/core/conversationManager.js", () => ({ @@ -801,6 +802,18 @@ describe("AutohandAcpAdapter", () => { }); expect(mockFileActionManager).toHaveBeenCalledWith("/workspace-worktree"); }); + + it.each(['dispose', 'shutdown'] as const)('safely cleans up worktrees after ACP %s releases agents', async method => { + const { cleanupSessionWorktree } = await import('../../../src/utils/sessionWorktree.js'); + adapter = new AutohandAcpAdapter(connection, { worktree: true }); + await adapter.initialize(makeInitRequest()); + await adapter.newSession(makeNewSessionRequest()); + await adapter[method](); + expect(cleanupSessionWorktree).toHaveBeenCalledOnce(); + expect(cleanupSessionWorktree).toHaveBeenCalledWith(mockPrepareSessionWorktree.mock.results[0].value); + expect(mockAgent.shutdownRuntimeResources.mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(cleanupSessionWorktree).mock.invocationCallOrder[0]); + }); }); // ------------------------------------------------------------------------- diff --git a/tests/modes/rpc/index.debugLogging.spec.ts b/tests/modes/rpc/index.debugLogging.spec.ts index 0bcc790dc..31b502a28 100644 --- a/tests/modes/rpc/index.debugLogging.spec.ts +++ b/tests/modes/rpc/index.debugLogging.spec.ts @@ -32,6 +32,7 @@ vi.mock('../../../src/startup/checks.js', () => ({ vi.mock('../../../src/utils/sessionWorktree.js', () => ({ isSessionWorktreeEnabled: vi.fn().mockReturnValue(false), prepareSessionWorktree: vi.fn(), + cleanupSessionWorktree: vi.fn().mockResolvedValue('Removed worktree fixture'), })); vi.mock('../../../src/providers/ProviderFactory.js', () => ({ ProviderFactory: { create: vi.fn().mockReturnValue({}) }, @@ -114,4 +115,15 @@ describe('runRpcMode debug logging', () => { expect(diagnostics).not.toContain('RAW_JSON_SENTINEL'); expect(rpcMocks.writeErrorResponse).toHaveBeenCalledWith(null, -32600, 'Invalid request'); }); + + it('safely cleans up a --worktree session when the RPC input closes', async () => { + const { isSessionWorktreeEnabled, prepareSessionWorktree, cleanupSessionWorktree } = await import('../../../src/utils/sessionWorktree.js'); + const info = { repoRoot: '/fixture', worktreePath: '/fixture-session', branchName: 'session', createdBranch: true, baseRef: 'refs/heads/main' }; + vi.mocked(isSessionWorktreeEnabled).mockReturnValueOnce(true); + vi.mocked(prepareSessionWorktree).mockReturnValueOnce(info); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await expect(runRpcMode({ worktree: true } as never)).resolves.toBe(0); + expect(cleanupSessionWorktree).toHaveBeenCalledWith(info); + expect(stderr.mock.calls.map(([value]) => String(value)).join('')).toContain('Removed worktree fixture'); + }); }); diff --git a/tests/tuistory/worktree-cleanup.tuistory.test.ts b/tests/tuistory/worktree-cleanup.tuistory.test.ts new file mode 100644 index 000000000..1833c7ef9 --- /dev/null +++ b/tests/tuistory/worktree-cleanup.tuistory.test.ts @@ -0,0 +1,114 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { Session } from 'tuistory'; +import { + createMockAutohandAINativeSequenceServer, createTempAutohandHome, exitInteractive, launchBuiltAutohand, waitForExit, + type MockNativeToolServer, type TuistoryTempState, +} from './helpers/autohandTuistory.js'; + +const sessions: Session[] = []; +const states: TuistoryTempState[] = []; +const servers: MockNativeToolServer[] = []; +afterEach(async () => { + for (const session of sessions.splice(0)) session.close(); + for (const server of servers.splice(0)) await server.close(); + for (const state of states.splice(0)) await state.cleanup(); +}); + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +async function launch(tmux = false) { + const state = await createTempAutohandHome({ config: { + agent: { autoMemory: false }, ui: { promptSuggestions: false }, + } }); + states.push(state); + git(state.workspaceRoot, 'config', 'user.name', 'Worktree Tests'); + git(state.workspaceRoot, 'config', 'user.email', 'tests@autohand.ai'); + await writeFile(path.join(state.workspaceRoot, '.gitignore'), '.autohand/\n'); + git(state.workspaceRoot, 'add', '.'); + git(state.workspaceRoot, 'commit', '-qm', 'Initial fixture'); + const session = await launchBuiltAutohand(['--worktree', 'cleanup-fixture', '--config', state.configPath, '--offline'], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, + env: { AUTOHAND_TMUX_LAUNCHED: tmux ? '1' : undefined }, + }); + sessions.push(session); + await session.waitForText('❯'); + const worktreePath = `${state.workspaceRoot}-cleanup-fixture`; + expect(existsSync(worktreePath)).toBe(true); + return { state, session, worktreePath }; +} + +describe('--worktree exit cleanup', () => { + it('removes the sibling directory, registration and new branch after a clean interactive exit', async () => { + const { state, session, worktreePath } = await launch(); + await exitInteractive(session); + expect(existsSync(worktreePath), session.readAll()).toBe(false); + expect(git(state.workspaceRoot, 'worktree', 'list', '--porcelain')).not.toContain(worktreePath); + expect(git(state.workspaceRoot, 'branch', '--list', 'cleanup-fixture')).toBe(''); + expect(session.exitInfo?.exitCode).toBe(0); + }); + + it.each(['untracked', 'committed'])('keeps %s work and prints its recovery path and branch', async kind => { + const { state, session, worktreePath } = await launch(); + const file = path.join(worktreePath, 'valuable.txt'); + await writeFile(file, 'session work\n'); + if (kind === 'committed') { + git(worktreePath, 'add', '.'); + git(worktreePath, 'commit', '-qm', 'Preserve session work'); + } + await exitInteractive(session); + expect(await readFile(file, 'utf8')).toBe('session work\n'); + expect(session.readAll()).toContain('Kept worktree'); + expect(session.readAll()).toContain('(branch cleanup-fixture) with changes'); + expect(session.readAll()).toContain(worktreePath); + expect(git(state.workspaceRoot, 'branch', '--list', 'cleanup-fixture')).toContain('cleanup-fixture'); + }); + + it('preserves the existing tmux-child worktree behavior', async () => { + const { session, worktreePath } = await launch(true); + await exitInteractive(session); + expect(existsSync(worktreePath)).toBe(true); + expect(session.readAll()).not.toContain('Removed worktree'); + }); + + it.each([ + { patch: false, tmux: false }, + { patch: true, tmux: false }, + { patch: true, tmux: true }, + ])('cleans up only non-tmux command worktrees (patch=$patch, tmux=$tmux)', async ({ patch, tmux }) => { + const server = await createMockAutohandAINativeSequenceServer([{ content: 'WORKTREE_COMMAND_DONE' }]); + servers.push(server); + const state = await createTempAutohandHome({ config: { + provider: 'autohandai', + autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'tuistory-key', model: 'moa', baseUrl: server.baseUrl }, + features: { autohand_inference: true, automaticSpecialists: false }, + agent: { autoMemory: false, maxIterations: 4, sessionRetryLimit: 0 }, + network: { maxRetries: 0, retryDelay: 0 }, + ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, + } }); + states.push(state); + git(state.workspaceRoot, 'config', 'user.name', 'Worktree Tests'); + git(state.workspaceRoot, 'config', 'user.email', 'tests@autohand.ai'); + await writeFile(path.join(state.workspaceRoot, '.gitignore'), '.autohand/\n'); + git(state.workspaceRoot, 'add', '.'); + git(state.workspaceRoot, 'commit', '-qm', 'Initial fixture'); + const session = await launchBuiltAutohand([ + '--worktree', 'cleanup-fixture', '--config', state.configPath, '--offline', '--yes', '-p', 'Respond without editing files.', + ...(patch ? ['--patch'] : []), + ], { autohandHome: state.autohandHome, cwd: state.workspaceRoot, + env: { AUTOHAND_TMUX_LAUNCHED: tmux ? '1' : undefined } }); + sessions.push(session); + await waitForExit(session, 30_000); + expect(session.exitInfo?.exitCode, session.readAll()).toBe(0); + expect(server.requests.length).toBeGreaterThan(0); + expect(existsSync(`${state.workspaceRoot}-cleanup-fixture`), session.readAll()).toBe(tmux); + const branches = git(state.workspaceRoot, 'branch', '--list', 'cleanup-fixture'); + if (tmux) expect(branches).toContain('cleanup-fixture'); + else expect(branches).toBe(''); + }); +}); diff --git a/tests/utils/sessionWorktree.spec.ts b/tests/utils/sessionWorktree.spec.ts index 169e8d151..3b1609284 100644 --- a/tests/utils/sessionWorktree.spec.ts +++ b/tests/utils/sessionWorktree.spec.ts @@ -30,6 +30,7 @@ describe('prepareSessionWorktree', () => { it('creates a new worktree and branch for auto-named sessions', () => { mockSpawnSync .mockReturnValueOnce({ status: 0, stdout: '/tmp/repo\n', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: 'refs/heads/main\n', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); @@ -42,9 +43,9 @@ describe('prepareSessionWorktree', () => { expect(result.repoRoot).toBe('/tmp/repo'); expect(result.createdBranch).toBe(true); expect(result.branchName).toMatch(/^autohand-cli-/); - expect(mockSpawnSync).toHaveBeenCalledTimes(3); + expect(mockSpawnSync).toHaveBeenCalledTimes(4); - const addArgs = mockSpawnSync.mock.calls[2]?.[1] as string[]; + const addArgs = mockSpawnSync.mock.calls[3]?.[1] as string[]; expect(addArgs[0]).toBe('worktree'); expect(addArgs[1]).toBe('add'); expect(addArgs[2]).toBe('-b'); @@ -55,6 +56,7 @@ describe('prepareSessionWorktree', () => { it('uses an existing branch when the requested branch already exists', () => { mockSpawnSync .mockReturnValueOnce({ status: 0, stdout: '/workspace/proj\n', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: 'refs/heads/main\n', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); @@ -67,7 +69,7 @@ describe('prepareSessionWorktree', () => { expect(result.branchName).toBe('feature-fix-42'); expect(result.createdBranch).toBe(false); - const addArgs = mockSpawnSync.mock.calls[2]?.[1] as string[]; + const addArgs = mockSpawnSync.mock.calls[3]?.[1] as string[]; expect(addArgs).toEqual([ 'worktree', 'add', @@ -81,6 +83,7 @@ describe('prepareSessionWorktree', () => { mockSpawnSync .mockReturnValueOnce({ status: 0, stdout: '/tmp/repo\n', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: 'refs/heads/main\n', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); @@ -92,7 +95,7 @@ describe('prepareSessionWorktree', () => { expect(result.worktreePath).toBe('/tmp/repo-feature-2'); - const addArgs = mockSpawnSync.mock.calls[2]?.[1] as string[]; + const addArgs = mockSpawnSync.mock.calls[3]?.[1] as string[]; expect(addArgs).toEqual([ 'worktree', 'add', @@ -120,6 +123,7 @@ describe('prepareSessionWorktree', () => { it('normalizes invalid custom names to a safe default branch', () => { mockSpawnSync .mockReturnValueOnce({ status: 0, stdout: '/tmp/repo\n', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: 'refs/heads/main\n', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); @@ -131,7 +135,7 @@ describe('prepareSessionWorktree', () => { expect(result.branchName).toBe('autohand-worktree'); - const addArgs = mockSpawnSync.mock.calls[2]?.[1] as string[]; + const addArgs = mockSpawnSync.mock.calls[3]?.[1] as string[]; expect(addArgs).toEqual([ 'worktree', 'add', @@ -144,6 +148,7 @@ describe('prepareSessionWorktree', () => { it('throws when git worktree add fails', () => { mockSpawnSync .mockReturnValueOnce({ status: 0, stdout: '/tmp/repo\n', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: 'refs/heads/main\n', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'fatal: add failed' }); diff --git a/tests/worktreeSafety.spec.ts b/tests/worktreeSafety.spec.ts new file mode 100644 index 000000000..c98243127 --- /dev/null +++ b/tests/worktreeSafety.spec.ts @@ -0,0 +1,174 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { WorktreeManager } from '../src/actions/worktree.js'; +import { enterAgentSessionWorktree, exitAgentSessionWorktree } from '../src/core/agent/AgentCommandRuntime.js'; +import { DEFAULT_TOOL_DEFINITIONS, ToolManager } from '../src/core/toolManager.js'; +import { PermissionManager } from '../src/permissions/PermissionManager.js'; +import type { SessionWorktreeInfo } from '../src/utils/sessionWorktree.js'; +import type { AgentAction } from '../src/types.js'; + +const roots: string[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); +}); + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +async function fixture() { + const container = await mkdtemp(path.join(os.tmpdir(), 'autohand-worktree-safety-')); + roots.push(container); + const root = path.join(container, 'repo'); + await mkdir(root); + git(root, 'init', '-b', 'main'); + git(root, 'config', 'user.name', 'Worktree Tests'); + git(root, 'config', 'user.email', 'tests@autohand.ai'); + await writeFile(path.join(root, 'tracked.txt'), 'original\n'); + git(root, 'add', '.'); + git(root, 'commit', '-qm', 'Initial fixture'); + const host = { + runtime: { workspaceRoot: root }, + sessionWorktreeState: null as (SessionWorktreeInfo & { originalWorkspaceRoot: string }) | null, + switchWorkspaceContext: vi.fn(async (workspaceRoot: string) => { host.runtime.workspaceRoot = workspaceRoot; }), + }; + await enterAgentSessionWorktree(host, 'safety'); + const worktreePath = host.runtime.workspaceRoot; + return { root, host, worktreePath }; +} + +describe('session worktree preservation with real Git', () => { + it.each(['untracked', 'modified', 'staged'])('keeps %s files on exit_worktree with default arguments', async kind => { + const { root, host, worktreePath } = await fixture(); + const file = path.join(worktreePath, kind === 'untracked' ? 'new.txt' : 'tracked.txt'); + await writeFile(file, 'work that must survive\n'); + if (kind === 'staged') git(worktreePath, 'add', '.'); + + const output = await exitAgentSessionWorktree(host); + + expect(existsSync(file), output).toBe(true); + expect(await readFile(file, 'utf8')).toBe('work that must survive\n'); + expect(output).toContain(`Kept worktree ${worktreePath} (branch safety) with changes`); + expect(output).toContain('kept: has changes'); + expect(host.runtime.workspaceRoot).toBe(root); + expect(host.sessionWorktreeState).toBeNull(); + }); + + it('keeps a clean worktree containing commits not on the original branch', async () => { + const { root, host, worktreePath } = await fixture(); + await writeFile(path.join(worktreePath, 'committed.txt'), 'committed work\n'); + git(worktreePath, 'add', '.'); + git(worktreePath, 'commit', '-qm', 'Keep this commit'); + const head = git(worktreePath, 'rev-parse', 'HEAD'); + + const output = await exitAgentSessionWorktree(host); + + expect(existsSync(worktreePath), output).toBe(true); + expect(git(root, 'rev-parse', 'safety')).toBe(head); + expect(git(worktreePath, 'status', '--porcelain')).toBe(''); + expect(output).toContain('Kept worktree'); + }); + + it('removes a clean session worktree and only its newly created branch', async () => { + const { root, host, worktreePath } = await fixture(); + await exitAgentSessionWorktree(host); + expect(existsSync(worktreePath)).toBe(false); + expect(git(root, 'branch', '--list', 'safety')).toBe(''); + }); + + it('normalizes an equivalent registered path before removing a clean worktree', async () => { + const { root, worktreePath } = await fixture(); + await new WorktreeManager(root).remove(`${worktreePath}${path.sep}`, { deleteBranch: true }); + expect(existsSync(worktreePath)).toBe(false); + expect(git(root, 'branch', '--list', 'safety')).toBe(''); + }); + + it('compares commits against the original branch even after the original checkout switches branches', async () => { + const { root, host, worktreePath } = await fixture(); + await writeFile(path.join(worktreePath, 'committed.txt'), 'work\n'); + git(worktreePath, 'add', '.'); + git(worktreePath, 'commit', '-qm', 'Not on original main'); + git(root, 'checkout', '-b', 'other-branch', 'safety'); + const output = await exitAgentSessionWorktree(host); + expect(existsSync(worktreePath), output).toBe(true); + }); + + it.each([{ keep: true }, {}, { keep: true, discard_changes: true }])('allows a non-discarding exit without a deletion prompt: %j', async args => { + const { host, worktreePath } = await fixture(); + await writeFile(path.join(worktreePath, 'new.txt'), 'keep\n'); + const confirmApproval = vi.fn(); + const manager = new ToolManager({ + definitions: DEFAULT_TOOL_DEFINITIONS, confirmApproval, + executor: async action => { + if (action.type !== 'exit_worktree') throw new Error('Unexpected tool'); + return { success: true, output: await exitAgentSessionWorktree(host, action.keep, action.discard_changes) }; + }, + authorization: { permissionManager: new PermissionManager({ mode: 'interactive' }) }, + }); + const [result] = await manager.execute([{ tool: 'exit_worktree', args }]); + expect(result.success).toBe(true); + expect(existsSync(worktreePath)).toBe(true); + expect(confirmApproval).not.toHaveBeenCalled(); + }); + + it('does not treat the legacy force option as authority to discard dirty files', async () => { + const { root, worktreePath } = await fixture(); + const file = path.join(worktreePath, 'new.txt'); + await writeFile(file, 'keep\n'); + const output = await new WorktreeManager(root).remove(worktreePath, { force: true }); + expect(existsSync(file), output).toBe(true); + expect(output).toContain('kept: has changes'); + }); + + it('does not report a dirty merged worktree as removed during cleanup', async () => { + const { root, worktreePath } = await fixture(); + await writeFile(path.join(worktreePath, 'new.txt'), 'keep\n'); + const result = await new WorktreeManager(root).cleanup({ removeMerged: true }); + expect(existsSync(worktreePath)).toBe(true); + expect(result.removed).not.toContain(worktreePath); + }); + + it.each(['allow_once', 'deny'] as const)('requires the deletion approval path before a discard (%s)', async decision => { + const { host, worktreePath } = await fixture(); + await writeFile(path.join(worktreePath, 'new.txt'), 'keep unless approved\n'); + const confirmApproval = vi.fn().mockResolvedValue({ decision }); + const executor = vi.fn(async (action: AgentAction) => { + if (action.type !== 'exit_worktree') throw new Error('Unexpected tool'); + return { success: true as const, output: await exitAgentSessionWorktree(host, action.keep, action.discard_changes) }; + }); + const manager = new ToolManager({ + definitions: DEFAULT_TOOL_DEFINITIONS, + executor, + confirmApproval, + authorization: { permissionManager: new PermissionManager({ mode: 'interactive' }) }, + }); + + const [result] = await manager.execute([{ tool: 'exit_worktree', args: { discard_changes: true } }]); + + expect(confirmApproval).toHaveBeenCalledOnce(); + expect(confirmApproval.mock.calls[0]?.[1]).toMatchObject({ tool: 'delete_path' }); + expect(result.success).toBe(decision === 'allow_once'); + expect(existsSync(worktreePath)).toBe(decision !== 'allow_once'); + expect(executor).toHaveBeenCalledTimes(decision === 'allow_once' ? 1 : 0); + }); + + it('denies explicit discard in the restricted client profile before invoking its executor', async () => { + const executor = vi.fn().mockResolvedValue({ success: true, output: 'removed' }); + const confirmApproval = vi.fn().mockResolvedValue({ decision: 'allow_once' }); + const manager = new ToolManager({ + definitions: DEFAULT_TOOL_DEFINITIONS, executor, confirmApproval, clientContext: 'restricted', + authorization: { permissionManager: new PermissionManager({ mode: 'unrestricted' }) }, + }); + + const [result] = await manager.execute([{ tool: 'exit_worktree', args: { discard_changes: true } }]); + + expect(result.success).toBe(false); + expect(executor).not.toHaveBeenCalled(); + expect(confirmApproval).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/worktreeSessionTools.spec.ts b/tests/worktreeSessionTools.spec.ts index 5f6b6f95f..5153bfac7 100644 --- a/tests/worktreeSessionTools.spec.ts +++ b/tests/worktreeSessionTools.spec.ts @@ -18,6 +18,8 @@ describe('Worktree session tools', () => { const def = DEFAULT_TOOL_DEFINITIONS.find((item) => item.name === 'exit_worktree'); expect(def).toBeDefined(); expect(def!.parameters?.properties).toHaveProperty('keep'); + expect(def!.parameters?.properties).toHaveProperty('discard_changes'); + expect(def!.requiresApproval).toBe(true); }); it('categorizes enter_worktree as meta', () => { From 00e85c6701222a2c967e7710ababac68d0dc3bc4 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 23 Oct 2026 23:18:49 +1300 Subject: [PATCH 22/38] Include the commercial licence in npm packages --- package.json | 1 + tests/packageLicense.test.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 tests/packageLicense.test.ts diff --git a/package.json b/package.json index 0d11c4608..6e10c35b5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "main": "dist/index.js", "files": [ + "COMMERCIAL.md", "dist", "scripts/ensure-node-pty-helper-permissions.mjs", "scripts/ensure-ink-cursor-intent.mjs", diff --git a/tests/packageLicense.test.ts b/tests/packageLicense.test.ts new file mode 100644 index 000000000..d0396f942 --- /dev/null +++ b/tests/packageLicense.test.ts @@ -0,0 +1,17 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +describe('npm package licensing documents', () => { + it('ships the Apache licence, commercial terms and README', () => { + const output = execFileSync('bun', ['pm', 'pack', '--dry-run', '--ignore-scripts'], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }); + for (const name of ['LICENSE', 'COMMERCIAL.md', 'README.md']) { + expect(output.split('\n').some(line => line.startsWith('packed ') && line.endsWith(` ${name}`)), name).toBe(true); + } + }); +}); From 591d5d58d7191e87bb2a2f96acc7cbb1e2efadef Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Mon, 26 Oct 2026 16:53:26 +1300 Subject: [PATCH 23/38] Respect Chrome profile locks and report the Free search allowance Fall back through the connected Chrome bridge and verified account search before returning one clear locked-profile error. Keep hosted search available at the daily cap, preserve its upgrade/reset sentence, print it once per session and show remaining UTC-day allowance in the provider menu. Cover the lock paths, cap handling and the built terminal. Co-authored-by: Autohand Evolve --- docs/config-reference.md | 4 +- src/actions/web.ts | 57 +++++++++++----- src/commands/search.ts | 15 ++++- src/core/actionExecutor.ts | 25 +++++-- src/core/toolManager.ts | 2 +- src/search/managedWebSearch.ts | 26 ++++++-- src/search/providers.ts | 2 +- src/testing/fixtures/free-search-fetch.mjs | 13 ++++ src/testing/scenarios/freeSearchScenario.ts | 14 ++++ tests/commands/searchUsage.test.ts | 17 +++++ tests/search/accountSearchConfig.test.ts | 3 +- tests/search/browserProfileLock.test.ts | 74 +++++++++++++++++++++ tests/search/managedWebSearch.test.ts | 5 ++ tests/tuistory/free-search.tuistory.test.ts | 27 ++++++++ tests/webActionExecutor.spec.ts | 18 ++++- 15 files changed, 264 insertions(+), 38 deletions(-) create mode 100644 src/testing/fixtures/free-search-fetch.mjs create mode 100644 src/testing/scenarios/freeSearchScenario.ts create mode 100644 tests/commands/searchUsage.test.ts create mode 100644 tests/search/browserProfileLock.test.ts create mode 100644 tests/tuistory/free-search.tuistory.test.ts diff --git a/docs/config-reference.md b/docs/config-reference.md index d5d819b60..7e8e96868 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -3471,6 +3471,6 @@ Fantail ignores this setting; local sessions do not advertise it. `search.provider` accepts `autohand`, `browser-profile`, `exa`, `brave`, `parallel`, `google`, and `duckduckgo`. An explicit `--search-engine` wins over the saved setting in interactive, patch, automatic, RPC, and ACP modes. -When no provider is selected, a signed-in account with server-verified paid access uses Autohand Web Search. Lite, Pro, Pro Plus, Max, Team, and Enterprise include this service through `https://dev.autohand.ai/api/native/web-search`; Free users configure an existing provider during standard or quick setup, or with `/search`. Queries are limited to 1,024 characters, 10 results, and 30 requests per account per minute. Access uses the selected Autohand account and existing account credentials; customers do not need Cloudflare credentials. A failed hosted search reports its status and does not change providers. +When no provider is selected, a signed-in account with server-verified access uses Autohand Web Search. Verified Free accounts include 10 web searches a day, shared with Code Web for the same account and reset at 00:00 UTC. `/search` shows the remaining allowance. At the cap, the tool returns the upgrade link and reset time, and the CLI prints that notice once per session. Lite, Pro, Pro Plus, Max, Team, and Enterprise retain their included search access. Queries are limited to 1,024 characters, 10 results, and 30 native requests per account per minute. Access uses the selected Autohand account and existing account credentials; customers do not need Cloudflare credentials. A failed hosted search reports its status and does not change providers. -Browser Profile is the local default. Connect Chromium through `/browser` or provide an available Chrome/Brave profile. A missing, locked, or blocked profile produces a setup error instead of silently switching to Google. Exa, Brave, and Parallel require `search.exaApiKey`, `search.braveApiKey`, or `search.parallelApiKey`, respectively, or `EXA_API_KEY`, `BRAVE_SEARCH_API_KEY`, or `PARALLEL_API_KEY`. Onboarding never saves a keyed provider with a blank key. Google and DuckDuckGo can be selected explicitly; automated requests may receive CAPTCHA challenges. +Browser Profile is the local default. Connect Chromium through `/browser` or provide an available Chrome/Brave profile. Before launching the profile, Autohand checks its `SingletonLock`, including Chromium's symlink lock. With Chrome open, search uses the connected Chrome bridge first, then eligible Autohand Web Search, then one line directing you to `/search`. A locked profile with no fallback is not offered to the model. A closed profile keeps the existing browser search path. Exa, Brave, and Parallel require `search.exaApiKey`, `search.braveApiKey`, or `search.parallelApiKey`, respectively, or `EXA_API_KEY`, `BRAVE_SEARCH_API_KEY`, or `PARALLEL_API_KEY`. Onboarding never saves a keyed provider with a blank key. Google and DuckDuckGo can be selected explicitly; automated requests may receive CAPTCHA challenges. diff --git a/src/actions/web.ts b/src/actions/web.ts index a747ce5c5..88e2336ff 100644 --- a/src/actions/web.ts +++ b/src/actions/web.ts @@ -9,7 +9,9 @@ import * as https from 'https'; import * as http from 'http'; -import { existsSync } from 'fs'; +import { existsSync, lstatSync, readdirSync } from 'fs'; +import { homedir } from 'node:os'; +import * as path from 'node:path'; import { spawn } from 'child_process'; import { getCommandCoordination, signalCoordinatedProcess, spawnCoordinatedProcess, waitForProcessPublication } from '../session/peers/CommandCoordinationGate.js'; import { hasBrowserBridgeOutput } from '../browser/browserToolBridge.js'; @@ -80,6 +82,7 @@ let globalSearchConfig: SearchConfig = { provider: 'browser-profile' }; let managedSearch: ManagedWebSearch | undefined; +let managedSearchAvailable = false; let searchConfigurationRevision = 0; /** @@ -94,6 +97,7 @@ export function configureSearchFromSettings( providerOverride?: SearchConfig['provider'], ): void { managedSearch = undefined; + managedSearchAvailable = false; searchConfigurationRevision += 1; configureSearch({ provider: providerOverride ?? settings.provider ?? 'browser-profile', @@ -112,11 +116,14 @@ export async function configureSearchFromAccount( const revision = searchConfigurationRevision; const identity = managedSearchIdentity(config); const client = identity ? new ManagedWebSearch(identity) : undefined; - const included = !providerOverride && !config.search?.provider && client ? await client.available(signal) : false; + const selected = providerOverride ?? config.search?.provider; + const checkIncluded = !selected || selected === 'browser-profile' || selected === 'autohand'; + const included = checkIncluded && client ? await client.available(signal) : false; if (revision !== searchConfigurationRevision) return; signal?.throwIfAborted(); - if (included) configureSearch({ provider: 'autohand' }); + if (included && !selected) configureSearch({ provider: 'autohand' }); managedSearch = client; + managedSearchAvailable = included; } /** @@ -148,9 +155,12 @@ export function isSearchConfigured(): boolean { switch (config.provider) { case 'autohand': - return !!managedSearch; - case 'browser-profile': - return hasBrowserBridgeOutput() || !!findChromePath(); + return managedSearchAvailable; + case 'browser-profile': { + if (hasBrowserBridgeOutput() || managedSearchAvailable) return true; + const profile = findBrowserProfile(); + return !!findChromePath() && !!profile && !isBrowserProfileLocked(profile.userDataDir); + } case 'exa': return !!exaKey; case 'brave': @@ -607,7 +617,7 @@ export async function webSearch(query: string, options: WebSearchOptions = {}): switch (provider) { case 'autohand': - if (!managedSearch) throw new Error('Sign in to Autohand for included web search (Lite and above), or configure your own provider with /search.'); + if (!managedSearch) throw new Error('Sign in to Autohand for included web search, or configure your own provider with /search.'); return managedSearch.search(enhancedQuery, maxResults, options.signal); case 'browser-profile': return browserProfileSearch( @@ -986,17 +996,23 @@ async function browserProfileSearch( } } + const profile = findBrowserProfile(); + throwIfAborted(signal); + if (profile && isBrowserProfileLocked(profile.userDataDir)) { + if (managedSearchAvailable && managedSearch) return managedSearch.search(query, maxResults, signal); + throw new Error("Chrome is open on your profile, so browser search can't run. Pick a provider with /search."); + } const chromePath = findChromePath(); if (!chromePath) { + if (managedSearchAvailable && managedSearch) return managedSearch.search(query, maxResults, signal); throw new Error( 'No connected Chromium bridge or Chrome/Chromium installation was found. Connect Chromium or configure another provider with /search.' ); } // Find a user profile to use - const profile = await findBrowserProfile(); - throwIfAborted(signal); if (!profile) { + if (managedSearchAvailable && managedSearch) return managedSearch.search(query, maxResults, signal); throw new Error('Browser Profile search needs a browser profile. Connect Chromium or choose a provider with /search.'); } @@ -1140,13 +1156,8 @@ function parseBrowserSearchResults(payload: string, maxResults: number): WebSear * Detect user's browser profile to use for searching. * Returns the profile directory and user data dir for Chrome/Chromium/Brave/Edge. */ -async function findBrowserProfile(): Promise<{ userDataDir: string; profileDirectory: string; browser: string } | null> { - const os = await import('node:os'); - const path = await import('node:path'); - const fs = await import('fs-extra'); - const { pathExists } = fs; - - const homeDir = os.homedir(); +function findBrowserProfile(): { userDataDir: string; profileDirectory: string; browser: string } | null { + const homeDir = homedir(); const platform = process.platform; // Define browser data roots by platform @@ -1178,12 +1189,12 @@ async function findBrowserProfile(): Promise<{ userDataDir: string; profileDirec // Find the first browser with a valid profile for (const browser of browserRoots) { - if (!(await pathExists(browser.userDataDir))) { + if (!existsSync(browser.userDataDir)) { continue; } try { - const entries = await fs.readdir(browser.userDataDir); + const entries = readdirSync(browser.userDataDir); const profiles = entries.filter((entry: string) => entry === 'Default' || entry.startsWith('Profile ') ); @@ -1205,6 +1216,16 @@ async function findBrowserProfile(): Promise<{ userDataDir: string; profileDirec return null; } +function isBrowserProfileLocked(userDataDir: string): boolean { + try { + // Chromium's lock can be a dangling symlink; existsSync follows it and misses the lock. + lstatSync(path.join(userDataDir, 'SingletonLock')); + return true; + } catch (error) { + return !(error instanceof Error && 'code' in error && error.code === 'ENOENT'); + } +} + /** * Fetch and extract content from a URL */ diff --git a/src/commands/search.ts b/src/commands/search.ts index 183197eab..9751c4e59 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -38,8 +38,19 @@ export async function search(ctx: SearchContext): Promise { // Provider selection const identity = config ? managedSearchIdentity(config) : undefined; - const included = identity ? await new ManagedWebSearch(identity).available() : false; - const providerOptions: ModalOption[] = SEARCH_PROVIDER_OPTIONS.filter(option => option.value !== 'autohand' || included).map(option => ({ ...option })); + const capability = identity ? await new ManagedWebSearch(identity).status() : null; + const included = capability?.available ?? false; + if (capability?.tier === 'free' && capability.usage) { + console.log(chalk.gray(`${capability.usage.remaining} of 10 free searches left today`)); + console.log(chalk.gray('Resets at 00:00 UTC.')); + console.log(); + } + const providerOptions: ModalOption[] = SEARCH_PROVIDER_OPTIONS.filter(option => option.value !== 'autohand' || included).map(option => ({ + ...option, + ...(option.value === 'autohand' && capability?.tier === 'free' && capability.usage ? { + description: `${capability.usage.remaining} of 10 free searches left today. Resets at 00:00 UTC.`, + } : {}), + })); try { const result = await showModal({ diff --git a/src/core/actionExecutor.ts b/src/core/actionExecutor.ts index 311659b56..58f2e5ebb 100644 --- a/src/core/actionExecutor.ts +++ b/src/core/actionExecutor.ts @@ -67,6 +67,7 @@ import { applyFormatter } from '../actions/formatters.js'; import { applyNotebookEdit } from '../actions/notebook.js'; import { loadCustomCommand, saveCustomCommand } from './customCommands.js'; import { webSearch, fetchUrl, getPackageInfo, formatSearchResults, formatPackageInfo } from '../actions/web.js'; +import { ManagedSearchCapError } from '../search/managedWebSearch.js'; import { webRepo, formatRepoInfo, formatRepoDir } from '../actions/webRepo.js'; import { projectTracker } from '../actions/projectTracker.js'; import { installSubAgentFromCatalog, searchSubAgentsCatalog } from '../actions/subAgentsCatalog.js'; @@ -339,6 +340,7 @@ const PEER_DIRECT_WRITE_ACTIONS = new Set([ export const MAX_SEARCH_CACHE_ENTRIES = 100; export class ActionExecutor { + private searchCapNoticeSession: string | undefined; private readonly runtime: AgentExecutorDeps['runtime']; private readonly files: AgentExecutorDeps['files']; private readonly resolveWorkspacePath: AgentExecutorDeps['resolveWorkspacePath']; @@ -3067,12 +3069,23 @@ export class ActionExecutor { } console.log(chalk.cyan(`\n🔍 Searching web: "${action.query}"...`)); const { hasBrowserBridgeOutput, invokeBrowserTool } = await import('../browser/browserToolBridge.js'); - const results = await webSearch(action.query, { - maxResults: action.max_results, - searchType: action.search_type, - browserToolInvoker: hasBrowserBridgeOutput() ? invokeBrowserTool : undefined, - signal: context?.signal, - }); + let results: Awaited>; + try { + results = await webSearch(action.query, { + maxResults: action.max_results, + searchType: action.search_type, + browserToolInvoker: hasBrowserBridgeOutput() ? invokeBrowserTool : undefined, + signal: context?.signal, + }); + } catch (error) { + if (!(error instanceof ManagedSearchCapError)) throw error; + const session = this.deps.getCurrentSessionId?.() ?? this.sessionId ?? ''; + if (this.searchCapNoticeSession !== session) { + this.searchCapNoticeSession = session; + console.log(chalk.yellow(error.message)); + } + return error.message; + } const formatted = formatSearchResults(results); console.log(chalk.gray(formatted.split('\n').slice(0, 10).join('\n'))); if (results.length > 3) { diff --git a/src/core/toolManager.ts b/src/core/toolManager.ts index 009ee6eb2..bc231d92d 100644 --- a/src/core/toolManager.ts +++ b/src/core/toolManager.ts @@ -1561,7 +1561,7 @@ export const DEFAULT_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'web_search', - description: 'Search the web for up-to-date information about packages, libraries, frameworks, documentation, changelogs, and more. Use this when you need current information that may have changed after your training data.', + description: 'Search the web for up-to-date information about packages, libraries, frameworks, documentation, changelogs, and more. Use this when you need current information that may have changed after your training data. If a daily limit is returned, relay the complete upgrade and reset sentence verbatim and finish using the evidence already available.', parameters: { type: 'object', properties: { diff --git a/src/search/managedWebSearch.ts b/src/search/managedWebSearch.ts index 944589f2c..f879b3972 100644 --- a/src/search/managedWebSearch.ts +++ b/src/search/managedWebSearch.ts @@ -19,6 +19,12 @@ export function managedSearchIdentity(config: SearchAccountConfig): ManagedSearc } const input = z.object({ query: z.string().trim().min(1).max(1024), limit: z.number().int().min(1).max(10) }); +const capability = z.object({ available: z.boolean(), provider: z.literal('cloudflare'), tier: z.string(), usage: z.object({ + limit: z.literal(10), remaining: z.number().int().min(0).max(10), resetAt: z.string().datetime(), +}).nullable().optional() }); +export class ManagedSearchCapError extends Error { + constructor(message: string) { super(message); this.name = 'ManagedSearchCapError'; } +} const result = z.object({ provider: z.literal('cloudflare'), query: z.string().max(1024), results: z.array(z.object({ title: z.string().max(10_000), url: z.string().url().refine(url => /^https?:\/\//.test(url)), snippet: z.string().max(60_000), })).max(10) }); @@ -81,26 +87,34 @@ export class ManagedWebSearch { ...(body ? { body: JSON.stringify(body) } : {}), }); if (signal.aborted || !response.ok) { - discardResponseBody(response); + if (!signal.aborted && response.status === 429 && response.headers.get('Content-Type')?.includes('application/json')) { + let body: unknown; + try { body = await readJson(response, signal); } catch { signal.throwIfAborted(); } + const problem = z.object({ error: z.object({ code: z.literal('web_search_cap_hit'), message: z.string().min(1).max(500).refine(text => !/[\r\n]/.test(text)) }) }).safeParse(body); + if (problem.success) throw new ManagedSearchCapError(problem.data.error.message); + } else discardResponseBody(response); signal.throwIfAborted(); const advice = response.status === 401 ? 'Sign in again.' - : response.status === 402 ? 'Included search requires Lite or above. Configure your own provider with /search on Free.' + : response.status === 402 ? 'Verify your email or configure your own provider with /search.' : response.status === 429 ? 'Wait a moment before searching again.' : 'Try again later or choose a provider with /search.'; throw new Error(`Autohand Web Search failed (${response.status}). ${advice}`); } return readJson(response, signal); } - async available(signal?: AbortSignal): Promise { + async status(signal?: AbortSignal): Promise | null> { try { - const capability = z.object({ available: z.boolean(), provider: z.literal('cloudflare'), tier: z.string() }).parse(await this.request('GET', undefined, signal)); - return capability.available; + return capability.parse(await this.request('GET', undefined, signal)); } catch (error) { if (signal?.aborted) throw error; - return false; + return null; } } + async available(signal?: AbortSignal): Promise { + return (await this.status(signal))?.available ?? false; + } + async search(query: string, limit: number, signal?: AbortSignal): Promise { return result.parse(await this.request('POST', input.parse({ query, limit }), signal)).results; } diff --git a/src/search/providers.ts b/src/search/providers.ts index 3c76cae1c..8b0f622f9 100644 --- a/src/search/providers.ts +++ b/src/search/providers.ts @@ -2,7 +2,7 @@ import type { SearchProvider } from '../types.js'; export const SEARCH_PROVIDERS = ['autohand', 'browser-profile', 'exa', 'google', 'brave', 'duckduckgo', 'parallel'] as const satisfies readonly SearchProvider[]; export const SEARCH_PROVIDER_OPTIONS = [ - { value: 'autohand', label: 'Autohand Web Search', description: 'Included with Lite and above; uses your signed-in account' }, + { value: 'autohand', label: 'Autohand Web Search', description: 'Free: 10 web searches a day; Lite and above: included' }, { value: 'browser-profile', label: 'Browser Profile', description: 'Uses connected Chromium or your Chrome/Brave profile; no API key' }, { value: 'exa', label: 'Exa.ai', description: 'Requires your Exa API key' }, { value: 'brave', label: 'Brave Search', description: 'Requires your Brave API key' }, diff --git a/src/testing/fixtures/free-search-fetch.mjs b/src/testing/fixtures/free-search-fetch.mjs new file mode 100644 index 000000000..0d31beb57 --- /dev/null +++ b/src/testing/fixtures/free-search-fetch.mjs @@ -0,0 +1,13 @@ +const realFetch = globalThis.fetch; +globalThis.fetch = async (input, options) => { + const url = input instanceof Request ? input.url : String(input); + if (url === 'https://dev.autohand.ai/api/native/web-search') { + if (options?.method !== 'GET') throw new Error('This terminal fixture only reads search availability.'); + return Response.json({ available: true, tier: 'free', provider: 'cloudflare', usage: { + limit: 10, remaining: Number(process.env.AUTOHAND_TEST_SEARCH_REMAINING), resetAt: '2026-10-27T00:00:00.000Z', + } }); + } + const parsed = new URL(url); + if (['127.0.0.1', 'localhost'].includes(parsed.hostname)) return realFetch(input, options); + throw new Error('Unexpected outbound request in the Free search terminal fixture.'); +}; diff --git a/src/testing/scenarios/freeSearchScenario.ts b/src/testing/scenarios/freeSearchScenario.ts new file mode 100644 index 000000000..3e065ed35 --- /dev/null +++ b/src/testing/scenarios/freeSearchScenario.ts @@ -0,0 +1,14 @@ +import type { Session } from 'tuistory'; + +export async function inspectFreeSearchAllowance(session: Session, remaining: number): Promise { + await session.waitForText('❯', { timeout: 20_000 }); + await session.type('/search'); + await session.press('enter'); + await session.waitForText(`${remaining} of 10 free searches left today`, { timeout: 15_000 }); + await session.waitForText('Resets at 00:00 UTC.'); + await session.waitForText('Select search provider:'); + await session.waitForText('Autohand'); + await session.press('escape'); + await session.waitForText('Search configuration cancelled.'); + await session.waitForText('❯'); +} diff --git a/tests/commands/searchUsage.test.ts b/tests/commands/searchUsage.test.ts new file mode 100644 index 000000000..244c16fc6 --- /dev/null +++ b/tests/commands/searchUsage.test.ts @@ -0,0 +1,17 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { search } from '../../src/commands/search.js'; +import { showModal } from '../../src/ui/ink/components/Modal.js'; + +vi.mock('../../src/ui/ink/components/Modal.js', () => ({ showModal: vi.fn().mockResolvedValue(null), showPassword: vi.fn() })); +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + +describe('/search Free daily usage', () => { + it.each([7, 0])('shows %s remaining and retains Autohand as a selectable provider', async remaining => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ available: true, tier: 'free', provider: 'cloudflare', usage: { limit: 10, remaining, resetAt: '2026-10-27T00:00:00.000Z' } }))); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + await search({ config: { configPath: '/fixture', auth: { token: 'ahc_verified_free' } } }); + expect(log.mock.calls.some(args => String(args[0]).includes(`${remaining} of 10 free searches left today`))).toBe(true); + expect(log.mock.calls.some(args => String(args[0]).includes('Resets at 00:00 UTC.'))).toBe(true); + expect(showModal).toHaveBeenLastCalledWith(expect.objectContaining({ options: expect.arrayContaining([expect.objectContaining({ value: 'autohand' })]) })); + }); +}); diff --git a/tests/search/accountSearchConfig.test.ts b/tests/search/accountSearchConfig.test.ts index b2a94a221..c47ce0d9b 100644 --- a/tests/search/accountSearchConfig.test.ts +++ b/tests/search/accountSearchConfig.test.ts @@ -23,7 +23,8 @@ describe('account-aware search configuration', () => { await configureSearchFromAccount({ auth: { token: 'ahc_old' }, search: { provider: 'autohand' } }); await configureSearchFromAccount({ search: { provider: 'exa', exaApiKey: 'own-key' } }, 'brave'); expect(getSearchConfig().provider).toBe('brave'); - expect(fetch).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][1]).toHaveProperty('method', 'GET'); await expect(webSearch('docs', { provider: 'autohand' })).rejects.toThrow('Sign in'); }); it('keeps Free on local providers and reports missing browser setup without a Google fallback', async () => { diff --git a/tests/search/browserProfileLock.test.ts b/tests/search/browserProfileLock.test.ts new file mode 100644 index 000000000..78a45c3bb --- /dev/null +++ b/tests/search/browserProfileLock.test.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from 'node:events'; +import { mkdtemp, mkdir, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { configureSearchFromAccount, isSearchConfigured, webSearch } from '../../src/actions/web.js'; + +const fixture = vi.hoisted(() => ({ home: '', spawn: vi.fn() })); +vi.mock('node:os', async original => ({ ...await original(), homedir: () => fixture.home })); +vi.mock('fs', async original => { + const fs = await original(); + return { ...fs, existsSync: (file: string) => /(?:Google Chrome|google-chrome|chrome\.exe)$/.test(String(file)) || fs.existsSync(file) }; +}); +vi.mock('child_process', async original => ({ ...await original(), spawn: fixture.spawn })); + +let root: string; +beforeEach(async () => { + fixture.home = await mkdtemp(path.join(tmpdir(), 'autohand-profile-lock-')); + root = process.platform === 'darwin' ? path.join(fixture.home, 'Library/Application Support/Google/Chrome') + : process.platform === 'win32' ? path.join(fixture.home, 'Google/Chrome/User Data') : path.join(fixture.home, '.config/google-chrome'); + vi.stubEnv('LOCALAPPDATA', fixture.home); + await mkdir(path.join(root, 'Default'), { recursive: true }); + fixture.spawn.mockReset().mockImplementation(() => { + const child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(), pid: 123 }); + queueMicrotask(() => { + child.stdout.write(''); + child.emit('close', 0); + }); + return child; + }); + await configureSearchFromAccount({ search: { provider: 'browser-profile' } }); +}); +afterEach(async () => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); await rm(fixture.home, { recursive: true, force: true }); }); + +describe('Chrome profile SingletonLock', () => { + it('does not offer a locked profile without a fallback, including Chromium dangling symlink locks', async () => { + await symlink('host-12345', path.join(root, 'SingletonLock')); + expect(isSearchConfigured()).toBe(false); + await expect(webSearch('docs')).rejects.toThrow("Chrome is open on your profile, so browser search can't run. Pick a provider with /search."); + expect(fixture.spawn).not.toHaveBeenCalled(); + }); + it('uses included Free search instead of launching the locked profile', async () => { + await symlink('host-12345', path.join(root, 'SingletonLock')); + const fetch = vi.fn().mockResolvedValueOnce(Response.json({ available: true, tier: 'free', provider: 'cloudflare', usage: { limit: 10, remaining: 10, resetAt: '2026-10-27T00:00:00.000Z' } })) + .mockResolvedValueOnce(Response.json({ provider: 'cloudflare', query: 'docs', results: [{ title: 'Managed result', url: 'https://example.org/', snippet: 'Source' }] })); + vi.stubGlobal('fetch', fetch); + await configureSearchFromAccount({ auth: { token: 'ahc_verified_free' }, search: { provider: 'browser-profile' } }); + expect(isSearchConfigured()).toBe(true); + expect(await webSearch('docs')).toHaveProperty('0.title', 'Managed result'); + expect(fixture.spawn).not.toHaveBeenCalled(); + }); + it('uses the connected Chrome bridge before the managed fallback', async () => { + await symlink('host-12345', path.join(root, 'SingletonLock')); + const invoke = vi.fn().mockImplementation(async name => name === 'browser_execute_js' ? JSON.stringify([{ title: 'Bridge result', url: 'https://example.org/', snippet: '' }]) : 'ok'); + expect(await webSearch('docs', { browserToolInvoker: invoke })).toHaveProperty('0.title', 'Bridge result'); + expect(fixture.spawn).not.toHaveBeenCalled(); + }); + it('keeps the hosted tool available at the cap and never launches a locked profile to bypass it', async () => { + await symlink('host-12345', path.join(root, 'SingletonLock')); + const message = "You've used your 10 free web searches today. Upgrade to Pro for more: https://dev.autohand.ai/search/upgrade?source=cli. Resets in 2h 5m."; + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(Response.json({ available: true, tier: 'free', provider: 'cloudflare', usage: { limit: 10, remaining: 0, resetAt: '2026-10-27T00:00:00.000Z' } })) + .mockResolvedValueOnce(Response.json({ error: { code: 'web_search_cap_hit', message } }, { status: 429 }))); + await configureSearchFromAccount({ auth: { token: 'ahc_verified_free' }, search: { provider: 'browser-profile' } }); + await expect(webSearch('docs')).rejects.toThrow(message); + expect(isSearchConfigured()).toBe(true); + expect(fixture.spawn).not.toHaveBeenCalled(); + }); + it('still launches the browser profile when Chrome is closed', async () => { + expect(isSearchConfigured()).toBe(true); + expect(await webSearch('docs')).toHaveProperty('0.title', 'Profile result'); + expect(fixture.spawn).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/search/managedWebSearch.test.ts b/tests/search/managedWebSearch.test.ts index b7cef47cd..08436900e 100644 --- a/tests/search/managedWebSearch.test.ts +++ b/tests/search/managedWebSearch.test.ts @@ -4,6 +4,11 @@ import { ManagedWebSearch, managedSearchIdentity } from '../../src/search/manage afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.useRealTimers(); }); describe('included Autohand web search', () => { const identity = { token: 'ahc_search_fixture', accountId: 'selected-account' }; + it('preserves the exact Free cap notice instead of replacing it with generic retry advice', async () => { + const message = "You've used your 10 free web searches today. Upgrade to Pro for more: https://dev.autohand.ai/search/upgrade?source=cli. Resets in 2h 5m."; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ error: { code: 'web_search_cap_hit', message } }, { status: 429 }))); + await expect(new ManagedWebSearch(identity).search('docs', 5)).rejects.toThrow(message); + }); it('uses account credentials and selected account only on the first-party endpoint', async () => { const fetch = vi.fn().mockResolvedValueOnce(Response.json({ available: true, tier: 'lite', provider: 'cloudflare' })) .mockResolvedValueOnce(Response.json({ provider: 'cloudflare', query: 'docs', results: [{ title: 'Docs', url: 'https://autohand.ai/docs', snippet: 'Source' }] })); diff --git a/tests/tuistory/free-search.tuistory.test.ts b/tests/tuistory/free-search.tuistory.test.ts new file mode 100644 index 000000000..c60e01a96 --- /dev/null +++ b/tests/tuistory/free-search.tuistory.test.ts @@ -0,0 +1,27 @@ +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { inspectFreeSearchAllowance } from '../../src/testing/scenarios/freeSearchScenario.js'; +import { createTempAutohandHome, exitInteractive, launchBuiltAutohand } from './helpers/autohandTuistory.js'; + +describe('Free web search allowance in the built terminal', () => { + it.each([7, 0])('shows %s searches left, supports cancel, and exits cleanly', async remaining => { + const state = await createTempAutohandHome({ config: { + provider: 'openrouter', openrouter: { apiKey: 'fixture', model: 'fixture' }, auth: { token: 'ahc_verified_free' }, + search: { provider: 'browser-profile' }, agent: { autoMemory: false }, + ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, + } }); + let session: Awaited> | undefined; + try { + const preload = fileURLToPath(new URL('../../src/testing/fixtures/free-search-fetch.mjs', import.meta.url)); + session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, cols: 140, rows: 45, + env: { NODE_OPTIONS: `--import=${preload}`, AUTOHAND_TEST_SEARCH_REMAINING: String(remaining) }, + }); + await inspectFreeSearchAllowance(session, remaining); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + } finally { + session?.close(); await session?.waitForExit(5000); await state.cleanup(); + } + }); +}); diff --git a/tests/webActionExecutor.spec.ts b/tests/webActionExecutor.spec.ts index 808f97707..4236207b1 100644 --- a/tests/webActionExecutor.spec.ts +++ b/tests/webActionExecutor.spec.ts @@ -5,7 +5,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { FileActionManager } from '../src/actions/filesystem.js'; -import { configureSearch } from '../src/actions/web.js'; +import { configureSearch, configureSearchFromAccount } from '../src/actions/web.js'; import { resolveBrowserToolResponse, setBrowserBridgeOutput, @@ -17,6 +17,22 @@ import type { AgentRuntime } from '../src/types.js'; describe('web tool dispatch', () => { afterEach(() => { shutdownBrowserToolBridge(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('returns the cap line to the model every time and prints it once per session', async () => { + const message = "You've used your 10 free web searches today. Upgrade to Pro for more: https://dev.autohand.ai/search/upgrade?source=cli. Resets in 2h 5m."; + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (_url, init) => init.method === 'GET' + ? Response.json({ available: true, tier: 'free', provider: 'cloudflare' }) + : Response.json({ error: { code: 'web_search_cap_hit', message } }, { status: 429 }))); + await configureSearchFromAccount({ auth: { token: 'ahc_verified_free' } }); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const runtime = { config: { configPath: '', openrouter: { apiKey: 'test', model: 'model' } }, workspaceRoot: '/repo', options: {} } as AgentRuntime; + const executor = new ActionExecutor({ runtime, files: {} as FileActionManager, sessionId: 'cap-session', resolveWorkspacePath: file => `/repo/${file}`, confirmDangerousAction: vi.fn().mockResolvedValue(true) }); + expect(await executor.execute({ type: 'web_search', query: 'docs' })).toBe(message); + expect(await executor.execute({ type: 'web_search', query: 'news' })).toBe(message); + expect(log.mock.calls.filter(args => String(args[0]).includes(message))).toHaveLength(1); }); it('routes web_search through the connected Chromium bridge', async () => { From 59f665e76a78e92223ec0d3bdfc981f3a3b57f15 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Mon, 26 Oct 2026 19:09:47 +1300 Subject: [PATCH 24/38] Use the approved Chrome fallback and Free search notices Name web searches in the UTC allowance line and explain both options when Chrome holds the profile lock. Keep cap fixtures aligned with the server notice and verify the exact allowance in the built terminal. Co-authored-by: Autohand Evolve --- src/actions/web.ts | 2 +- src/commands/search.ts | 5 ++--- src/testing/scenarios/freeSearchScenario.ts | 2 +- tests/commands/searchUsage.test.ts | 6 +++--- tests/search/browserProfileLock.test.ts | 4 ++-- tests/search/managedWebSearch.test.ts | 2 +- tests/webActionExecutor.spec.ts | 2 +- 7 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/actions/web.ts b/src/actions/web.ts index 88e2336ff..89213b1ac 100644 --- a/src/actions/web.ts +++ b/src/actions/web.ts @@ -1000,7 +1000,7 @@ async function browserProfileSearch( throwIfAborted(signal); if (profile && isBrowserProfileLocked(profile.userDataDir)) { if (managedSearchAvailable && managedSearch) return managedSearch.search(query, maxResults, signal); - throw new Error("Chrome is open on your profile, so browser search can't run. Pick a provider with /search."); + throw new Error("Chrome is open on your profile, so browser search can't run. Close Chrome, or pick another provider with /search."); } const chromePath = findChromePath(); if (!chromePath) { diff --git a/src/commands/search.ts b/src/commands/search.ts index 9751c4e59..c9f8d624a 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -41,14 +41,13 @@ export async function search(ctx: SearchContext): Promise { const capability = identity ? await new ManagedWebSearch(identity).status() : null; const included = capability?.available ?? false; if (capability?.tier === 'free' && capability.usage) { - console.log(chalk.gray(`${capability.usage.remaining} of 10 free searches left today`)); - console.log(chalk.gray('Resets at 00:00 UTC.')); + console.log(chalk.gray(`${capability.usage.remaining} of 10 free web searches left today. Resets at 00:00 UTC.`)); console.log(); } const providerOptions: ModalOption[] = SEARCH_PROVIDER_OPTIONS.filter(option => option.value !== 'autohand' || included).map(option => ({ ...option, ...(option.value === 'autohand' && capability?.tier === 'free' && capability.usage ? { - description: `${capability.usage.remaining} of 10 free searches left today. Resets at 00:00 UTC.`, + description: `${capability.usage.remaining} of 10 free web searches left today. Resets at 00:00 UTC.`, } : {}), })); diff --git a/src/testing/scenarios/freeSearchScenario.ts b/src/testing/scenarios/freeSearchScenario.ts index 3e065ed35..183ba702a 100644 --- a/src/testing/scenarios/freeSearchScenario.ts +++ b/src/testing/scenarios/freeSearchScenario.ts @@ -4,7 +4,7 @@ export async function inspectFreeSearchAllowance(session: Session, remaining: nu await session.waitForText('❯', { timeout: 20_000 }); await session.type('/search'); await session.press('enter'); - await session.waitForText(`${remaining} of 10 free searches left today`, { timeout: 15_000 }); + await session.waitForText(`${remaining} of 10 free web searches left today. Resets at 00:00 UTC.`, { timeout: 15_000 }); await session.waitForText('Resets at 00:00 UTC.'); await session.waitForText('Select search provider:'); await session.waitForText('Autohand'); diff --git a/tests/commands/searchUsage.test.ts b/tests/commands/searchUsage.test.ts index 244c16fc6..ef57760ef 100644 --- a/tests/commands/searchUsage.test.ts +++ b/tests/commands/searchUsage.test.ts @@ -10,8 +10,8 @@ describe('/search Free daily usage', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ available: true, tier: 'free', provider: 'cloudflare', usage: { limit: 10, remaining, resetAt: '2026-10-27T00:00:00.000Z' } }))); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); await search({ config: { configPath: '/fixture', auth: { token: 'ahc_verified_free' } } }); - expect(log.mock.calls.some(args => String(args[0]).includes(`${remaining} of 10 free searches left today`))).toBe(true); - expect(log.mock.calls.some(args => String(args[0]).includes('Resets at 00:00 UTC.'))).toBe(true); - expect(showModal).toHaveBeenLastCalledWith(expect.objectContaining({ options: expect.arrayContaining([expect.objectContaining({ value: 'autohand' })]) })); + const usage = `${remaining} of 10 free web searches left today. Resets at 00:00 UTC.`; + expect(log.mock.calls.some(args => String(args[0]).includes(usage))).toBe(true); + expect(showModal).toHaveBeenLastCalledWith(expect.objectContaining({ options: expect.arrayContaining([expect.objectContaining({ value: 'autohand', description: usage })]) })); }); }); diff --git a/tests/search/browserProfileLock.test.ts b/tests/search/browserProfileLock.test.ts index 78a45c3bb..e0a018077 100644 --- a/tests/search/browserProfileLock.test.ts +++ b/tests/search/browserProfileLock.test.ts @@ -37,7 +37,7 @@ describe('Chrome profile SingletonLock', () => { it('does not offer a locked profile without a fallback, including Chromium dangling symlink locks', async () => { await symlink('host-12345', path.join(root, 'SingletonLock')); expect(isSearchConfigured()).toBe(false); - await expect(webSearch('docs')).rejects.toThrow("Chrome is open on your profile, so browser search can't run. Pick a provider with /search."); + await expect(webSearch('docs')).rejects.toThrow("Chrome is open on your profile, so browser search can't run. Close Chrome, or pick another provider with /search."); expect(fixture.spawn).not.toHaveBeenCalled(); }); it('uses included Free search instead of launching the locked profile', async () => { @@ -58,7 +58,7 @@ describe('Chrome profile SingletonLock', () => { }); it('keeps the hosted tool available at the cap and never launches a locked profile to bypass it', async () => { await symlink('host-12345', path.join(root, 'SingletonLock')); - const message = "You've used your 10 free web searches today. Upgrade to Pro for more: https://dev.autohand.ai/search/upgrade?source=cli. Resets in 2h 5m."; + const message = "You've used your 10 free web searches today. They reset in 2h 5m. For more, upgrade to Pro: https://dev.autohand.ai/search/upgrade?source=cli"; vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(Response.json({ available: true, tier: 'free', provider: 'cloudflare', usage: { limit: 10, remaining: 0, resetAt: '2026-10-27T00:00:00.000Z' } })) .mockResolvedValueOnce(Response.json({ error: { code: 'web_search_cap_hit', message } }, { status: 429 }))); await configureSearchFromAccount({ auth: { token: 'ahc_verified_free' }, search: { provider: 'browser-profile' } }); diff --git a/tests/search/managedWebSearch.test.ts b/tests/search/managedWebSearch.test.ts index 08436900e..ff475ad41 100644 --- a/tests/search/managedWebSearch.test.ts +++ b/tests/search/managedWebSearch.test.ts @@ -5,7 +5,7 @@ afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.useRealTimers(); describe('included Autohand web search', () => { const identity = { token: 'ahc_search_fixture', accountId: 'selected-account' }; it('preserves the exact Free cap notice instead of replacing it with generic retry advice', async () => { - const message = "You've used your 10 free web searches today. Upgrade to Pro for more: https://dev.autohand.ai/search/upgrade?source=cli. Resets in 2h 5m."; + const message = "You've used your 10 free web searches today. They reset in 2h 5m. For more, upgrade to Pro: https://dev.autohand.ai/search/upgrade?source=cli"; vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ error: { code: 'web_search_cap_hit', message } }, { status: 429 }))); await expect(new ManagedWebSearch(identity).search('docs', 5)).rejects.toThrow(message); }); diff --git a/tests/webActionExecutor.spec.ts b/tests/webActionExecutor.spec.ts index 4236207b1..859234d66 100644 --- a/tests/webActionExecutor.spec.ts +++ b/tests/webActionExecutor.spec.ts @@ -22,7 +22,7 @@ describe('web tool dispatch', () => { }); it('returns the cap line to the model every time and prints it once per session', async () => { - const message = "You've used your 10 free web searches today. Upgrade to Pro for more: https://dev.autohand.ai/search/upgrade?source=cli. Resets in 2h 5m."; + const message = "You've used your 10 free web searches today. They reset in 2h 5m. For more, upgrade to Pro: https://dev.autohand.ai/search/upgrade?source=cli"; vi.stubGlobal('fetch', vi.fn().mockImplementation(async (_url, init) => init.method === 'GET' ? Response.json({ available: true, tier: 'free', provider: 'cloudflare' }) : Response.json({ error: { code: 'web_search_cap_hit', message } }, { status: 429 }))); From 072a02fa4a482d07055411eefb719f5048a1592f Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Wed, 28 Oct 2026 16:24:18 +1300 Subject: [PATCH 25/38] Use 272k context windows for OpenAI GPT-5.6 and GPT-6 --- src/core/context/tokenizer.ts | 2 ++ src/providers/models.json | 8 +++++--- tests/core/context.spec.ts | 13 +++++++++++++ tests/providers/modelCatalog.test.ts | 4 ++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/core/context/tokenizer.ts b/src/core/context/tokenizer.ts index 380edd728..e9d8084f4 100644 --- a/src/core/context/tokenizer.ts +++ b/src/core/context/tokenizer.ts @@ -66,6 +66,8 @@ function inferContextWindow(model: string): number | undefined { if (/^claude-sonnet-4[-.]5(?:$|[-.])/u.test(modelName)) return 1_000_000; if (/^claude-(?:haiku|opus)-4[-.]\d(?:$|[-.])/u.test(modelName)) return 200_000; + if (/^gpt-(?:5\.6|6(?:\.\d+)?)(?:$|-)/u.test(modelName)) return 272_000; + if (normalized.startsWith('gpt-5.5')) return 1_050_000; if (normalized.startsWith('gpt-5.4') && !normalized.includes('mini') && !normalized.includes('nano')) return 1_050_000; if ( diff --git a/src/providers/models.json b/src/providers/models.json index 3f4100a36..54b3f6b44 100644 --- a/src/providers/models.json +++ b/src/providers/models.json @@ -112,9 +112,11 @@ "defaultModel": "gpt-5.4", "runtimeDefaultModel": "gpt-5.4", "models": [ - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", + { "id": "gpt-5.6-sol", "contextWindow": 272000 }, + { "id": "gpt-5.6-terra", "contextWindow": 272000 }, + { "id": "gpt-5.6-luna", "contextWindow": 272000 }, + { "id": "gpt-6-astra", "contextWindow": 272000 }, + { "id": "gpt-6.1-sol", "contextWindow": 272000 }, "gpt-5.5", "gpt-5.5-pro", "gpt-5.4", diff --git a/tests/core/context.spec.ts b/tests/core/context.spec.ts index eb98e2ee8..96c11d80b 100644 --- a/tests/core/context.spec.ts +++ b/tests/core/context.spec.ts @@ -87,6 +87,19 @@ function fillConversation(cm: ConversationManager, count: number, contentLength describe('context/tokenizer', () => { describe('getContextWindow', () => { + it.each(['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-6-astra', 'gpt-6.1-sol', 'openai/gpt-6-astra'])('uses the 272k context window for %s', (model) => { + expect(getContextWindow(model)).toBe(272_000); + }); + + it('keeps the environment override above the GPT-6 inference and explicit metadata', () => { + vi.stubEnv(CONTEXT_ENV_VARS.CONTEXT_WINDOW, '123456'); + try { + expect(getContextWindow('openai/gpt-6-astra', 272_000)).toBe(123456); + } finally { + vi.unstubAllEnvs(); + } + }); + it('returns known model context windows', () => { expect(getContextWindow('anthropic/claude-4-sonnet')).toBe(200_000); expect(getContextWindow('anthropic/claude-sonnet-5')).toBe(1_000_000); diff --git a/tests/providers/modelCatalog.test.ts b/tests/providers/modelCatalog.test.ts index 9f3e6c02c..19b670e64 100644 --- a/tests/providers/modelCatalog.test.ts +++ b/tests/providers/modelCatalog.test.ts @@ -43,6 +43,10 @@ describe("modelCatalog", () => { "gpt-5.6-luna", "gpt-5.4", ])); + for (const id of ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-6-astra', 'gpt-6.1-sol']) { + expect(getProviderModelOptions('openai')).toContainEqual(expect.objectContaining({ id, contextWindow: 272_000 })); + } + expect(getProviderDefaultModel('openai')).toBe('gpt-5.4'); expect(getProviderDefaultModel("xai")).toBe("grok-4.5"); expect(getProviderModelIds("xai")).toEqual(expect.arrayContaining([ "grok-4.5", From bffe436283b9bd9644be1d14c184fcf44e25d461 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Wed, 28 Oct 2026 17:27:17 +1300 Subject: [PATCH 26/38] Allow Team and Enterprise accounts to start Cloud CLI sessions --- docs/cloud-blog-draft.md | 4 ++-- docs/cloud.md | 4 ++-- docs/features.md | 2 +- src/cloud/CloudClient.ts | 2 +- src/cloud/launchCloud.tsx | 3 ++- tests/cloud/CloudClient.test.ts | 8 ++++++++ tests/cloud/launchCloud.test.ts | 20 +++++++++++++++++--- tests/tuistory/cloud-cli.tuistory.test.ts | 14 +++++++++++--- 8 files changed, 44 insertions(+), 13 deletions(-) diff --git a/docs/cloud-blog-draft.md b/docs/cloud-blog-draft.md index 4faef798d..ead163360 100644 --- a/docs/cloud-blog-draft.md +++ b/docs/cloud-blog-draft.md @@ -4,7 +4,7 @@ Editorial draft. Keep unpublished until CLI and Desktop release readiness and na Start a hosted agent from the terminal, close the terminal while it works, and reconnect to the saved conversation. Autohand Cloud adds `--cloud`, `/cloud`, `/handoff`, and `/open` to the CLI. The same session can be reviewed in Code Web and an updated Autohand Desktop app. -Cloud CLI sessions are available only to Pro, Pro+, and Max accounts. Your selected account still needs active billing, model capacity, and permission to operate resources. A paid plan does not grant access to a repository that you have not authorized. +Starting in 0.9.10, Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Your selected account still needs active billing, model capacity, and permission to operate resources. A paid plan does not grant access to a repository that you have not authorized. ## Start a task where you work @@ -83,7 +83,7 @@ Desktop links carry only a session ID and account ID. The remote cloud window ha The native CLI route authenticates a single account credential and forwards only allowlisted operations through the existing hosted API. It rechecks membership, ownership, write permissions, plan eligibility, model capacity, and repository grants. Browser cookies mixed with native bearer credentials are rejected. Responses remain private and are not cached. -Only Pro, Pro+, and Max can admit new cloud work through this interface. Owned stop and queue cleanup remain available after a downgrade. If durable Jobs are unavailable, admission fails instead of falling back to execution tied to the terminal request. +Starting in 0.9.10, Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Owned stop and queue cleanup remain available after a downgrade. If durable Jobs are unavailable, admission fails instead of falling back to execution tied to the terminal request. Local `--yes`, `--yolo`, custom MCP settings, system prompts, and tool-scope overrides cannot bypass hosted policy. Unsupported combinations fail before cloud launch. Local restrictions that Cloud cannot represent also fail closed. Read-only plan mode can preserve deny rules limited to mutating workspace operations; other custom restrictions require local execution. diff --git a/docs/cloud.md b/docs/cloud.md index f9c37f6cd..da40d2471 100644 --- a/docs/cloud.md +++ b/docs/cloud.md @@ -1,7 +1,7 @@ # Autohand Cloud from your terminal -Cloud CLI sessions are available only to **Pro, Pro+, and Max** accounts. Free, -Lite, Team, and Enterprise accounts cannot start cloud work through this interface. +Starting in 0.9.10, Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. +Free and Lite accounts cannot start cloud work through this interface. The selected account also needs write access, active billing, available model capacity, and repository authorization. Sign in with `autohand login`. diff --git a/docs/features.md b/docs/features.md index 9e14f3838..acd845850 100644 --- a/docs/features.md +++ b/docs/features.md @@ -39,7 +39,7 @@ Autohand is an autonomous LLM-powered coding agent designed to work directly in - [ ] Search history and command palette ## Session Management -- [x] [Cloud sessions](cloud.md): hosted execution, reconnect, queue steering, approvals, and local/cloud handoff for Pro, Pro+, and Max +- [x] [Cloud sessions](cloud.md): hosted execution, reconnect, queue steering, approvals, and local/cloud handoff. Starting in 0.9.10, Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. - [x] Auto-save to `~/.autohand/sessions` - [x] Resume with `/resume` or `autohand resume ` - [x] History tracking (interactions, tool outputs, agent thoughts) diff --git a/src/cloud/CloudClient.ts b/src/cloud/CloudClient.ts index 731c5cf5f..0dfb5c83d 100644 --- a/src/cloud/CloudClient.ts +++ b/src/cloud/CloudClient.ts @@ -115,7 +115,7 @@ export class CloudClient { try { detail = z.object({ error: z.object({ message: z.string().max(1000), code: z.string().max(100).optional() }) }).parse(await readBoundedJson(response, 8192)); } catch { discardResponseBody(response); } const message = response.status === 401 ? 'Sign in again with autohand login to reconnect to Cloud.' - : response.status === 402 ? 'Cloud CLI sessions are available on Pro, Pro+, and Max. Upgrade in Console.' + : response.status === 402 ? 'Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Upgrade in Console.' : response.status === 403 || response.status === 404 ? 'This cloud session is unavailable in the selected account.' : detail?.error.message ?? 'Autohand Cloud could not complete this request. Reconnect to check the saved session before retrying.'; throw new CloudRequestError(message, response.status, detail?.error.code); diff --git a/src/cloud/launchCloud.tsx b/src/cloud/launchCloud.tsx index 82200257a..ff3366f67 100644 --- a/src/cloud/launchCloud.tsx +++ b/src/cloud/launchCloud.tsx @@ -119,7 +119,8 @@ export async function launchCloud(context: CloudLaunchContext, options: CLIOptio conversation = (await client.detail(selected)).conversation; } else { const plan = await client.call('account', z.object({ tier: z.string().nullable(), paymentAccess: z.unknown().nullish() })); - if (plan.paymentAccess || !['pro', 'pro_plus', 'max'].includes(plan.tier ?? '')) throw new Error('Cloud CLI sessions are available on Pro, Pro+, and Max. Upgrade in Console.'); + if (plan.paymentAccess) throw new Error("Cloud CLI sessions are paused while there's a billing issue on this account. Check billing in Console."); + if (!['pro', 'pro_plus', 'max', 'team', 'enterprise'].includes(plan.tier ?? '')) throw new Error('Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Upgrade in Console.'); prompt = prompt ?? await input(context.snapshot ? 'Task for the cloud agent after handoff' : 'Task for your cloud agent') ?? undefined; if (!prompt?.trim()) return { kind: 'cancelled' }; const models = (await client.call('models', modelsSchema)).models; diff --git a/tests/cloud/CloudClient.test.ts b/tests/cloud/CloudClient.test.ts index 8fd14b939..e4249778a 100644 --- a/tests/cloud/CloudClient.test.ts +++ b/tests/cloud/CloudClient.test.ts @@ -57,6 +57,14 @@ describe('cloud session identity and HTTPS boundary', () => { expect(request).toHaveBeenCalledOnce(); } }); + it.each(['free', 'lite'])('retains the server 402 status and updated upgrade message for %s', async () => { + const request = vi.fn().mockResolvedValue(Response.json({ error: { code: 'plan_required', message: 'Upgrade required' } }, { status: 402 })); + await expect(new CloudClient(identity, request).create('list files', 'auto', id)).rejects.toMatchObject({ + status: 402, + message: 'Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Upgrade in Console.', + }); + expect(request).toHaveBeenCalledOnce(); + }); it('rejects malformed responses and cancels an oversized response body', async () => { const invalid = vi.fn().mockResolvedValue(Response.json({ conversation: { id: 'foreign' } })); await expect(new CloudClient(identity, invalid).create('Task', 'auto', id)).rejects.toThrow(); diff --git a/tests/cloud/launchCloud.test.ts b/tests/cloud/launchCloud.test.ts index 6106b93ea..49cd2e6db 100644 --- a/tests/cloud/launchCloud.test.ts +++ b/tests/cloud/launchCloud.test.ts @@ -7,11 +7,11 @@ import { SessionTransferClient } from '../../src/session/transfer/transfer-clien const id = '11111111-1111-4111-8111-111111111111', workspaceId = '22222222-2222-4222-8222-222222222222'; const conversation = { id, title: 'Repair parser', model: 'moa', updated_at: '2026-10-12' }; const config: LoadedConfig = { configPath: '/fixture/config.json', auth: { token: 'cloud-fixture-token-1234567890', user: { id: 'ada', email: 'ada@autohand.test', name: 'Ada' } }, api: { accountId: 'personal_fixture' } }; -function fixture(tier = 'pro', workspaceStatus = 'ready') { +function fixture(tier = 'pro', workspaceStatus = 'ready', paymentAccess: unknown = null) { const request = vi.fn(async (url, init) => { const path = new URL(String(url)).pathname; if (path.endsWith('/session')) return Response.json({ session: { user: { id: 'ada' }, account: { account: { id: 'personal_fixture' } } } }); - if (path.endsWith('/account')) return Response.json({ tier, paymentAccess: null }); + if (path.endsWith('/account')) return Response.json({ tier, paymentAccess }); if (path.endsWith('/models')) return Response.json({ models: [{ id: 'moa', available: true }, { id: 'auto', available: true }] }); if (path.endsWith('/sandbox-providers')) return Response.json({ providers: [{ id: 'cloudflare', available: true, name: 'Cloud' }] }); if (path.endsWith('/workspaces')) return init?.method === 'POST' @@ -59,13 +59,27 @@ describe('cloud launch orchestration', () => { expect(dependencies.view).toHaveBeenCalledOnce(); }); it('checks supported plans before provisioning or uploading local context', async () => { - for (const plan of ['free', 'lite', 'team', 'enterprise', 'unknown']) { + for (const plan of ['free', 'lite', 'unknown']) { const dependencies = fixture(plan); await expect(launchCloud({ config, workspaceRoot: '/fixture/parser' }, { cloud: true, prompt: 'Repair parser' }, dependencies)).rejects.toThrow('Pro'); expect(dependencies.request.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true); expect(dependencies.view).not.toHaveBeenCalled(); } }); + it.each(['pro', 'pro_plus', 'max', 'team', 'enterprise'])('starts a cloud session for %s without changing the selected account', async tier => { + const dependencies = fixture(tier); + await expect(launchCloud({ config, workspaceRoot: '/fixture/parser' }, { cloud: true, prompt: 'list files' }, dependencies)).resolves.toMatchObject({ kind: 'detached', conversationId: id, accountId: 'personal_fixture' }); + expect(dependencies.view).toHaveBeenCalledOnce(); + const send = dependencies.request.mock.calls.find(([url]) => new URL(String(url)).pathname.endsWith('/messages')); + expect(JSON.parse(String(send?.[1]?.body)).content).toBe('list files'); + expect(new Headers(send?.[1]?.headers).get('X-Autohand-Account-Id')).toBe('personal_fixture'); + }); + it.each(['pro', 'team', 'enterprise'])('keeps billing-paused %s accounts from provisioning or sending work', async tier => { + const dependencies = fixture(tier, 'ready', { reason: 'past_due' }); + await expect(launchCloud({ config, workspaceRoot: '/fixture/parser' }, { cloud: true, prompt: 'list files' }, dependencies)).rejects.toThrow("Cloud CLI sessions are paused while there's a billing issue on this account. Check billing in Console."); + expect(dependencies.request.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true); + expect(dependencies.view).not.toHaveBeenCalled(); + }); it('refuses a resume link from another account before making a network request', async () => { const dependencies = fixture(); await expect(launchCloud({ config, workspaceRoot: '/fixture/parser' }, { cloud: true, resume: `https://dev.autohand.ai/c/${id}?account=foreign` }, dependencies)).rejects.toThrow('account'); diff --git a/tests/tuistory/cloud-cli.tuistory.test.ts b/tests/tuistory/cloud-cli.tuistory.test.ts index 7310f4ca7..743156182 100644 --- a/tests/tuistory/cloud-cli.tuistory.test.ts +++ b/tests/tuistory/cloud-cli.tuistory.test.ts @@ -108,9 +108,17 @@ describe('built CLI cloud terminal flow', () => { const calls = await requests(state); expect(calls.filter(call => call.method === 'POST').map(call => call.route)).toEqual([`conversations/${cloudScenarioId}/stop`]); }); - it('refuses an unsupported plan before provisioning or sending a task', async () => { - const { state, session } = await launch(['--prompt', 'Repair parser'], 'lite'); await waitForExit(session); - expect(await session.readAll()).toContain('Pro, Pro+, and Max'); + it.each(['team', 'enterprise'])('starts a %s cloud session from the terminal', async tier => { + const { state, session } = await launch(['--prompt', 'list files'], tier); + await session.waitForText('Cloud agent connected'); + const calls = await requests(state); + expect(calls.find(call => call.route.endsWith('/messages'))?.body).toMatchObject({ content: 'list files' }); + await session.press(['ctrl', 'c']); await waitForExit(session); + expect(session.exitInfo?.exitCode).toBe(0); + }); + it.each(['free', 'lite'])('refuses %s before provisioning or sending a task', async tier => { + const { state, session } = await launch(['--prompt', 'Repair parser'], tier); await waitForExit(session); + expect(await session.readAll()).toContain('Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Upgrade in Console.'); expect((await requests(state)).every(call => call.method === 'GET')).toBe(true); expect(session.exitInfo?.exitCode).not.toBe(0); }); From 8f94c3cf4bedf5129176842fa5157af4287d701a Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Wed, 28 Oct 2026 19:10:15 +1300 Subject: [PATCH 27/38] Enforce accepted-plan permissions and refresh model and quota guidance --- docs/providers.md | 2 +- src/core/agent/AgentCommandRuntime.ts | 13 ++++-- src/core/agent/InstructionRunner.ts | 3 ++ src/core/toolManager.ts | 1 + src/modes/planMode/PlanModeManager.ts | 7 +++ src/permissions/PermissionManager.ts | 36 ++++++++++++++- src/providers/AutohandAIProvider.ts | 3 +- src/providers/LLMGatewayClient.ts | 22 ++++++++- src/providers/models.json | 29 ++++++------ src/ui/promptCallback.ts | 13 +++--- tests/core/agent/AgentPlanReview.test.ts | 11 +++++ .../InstructionRunner.command-mode.test.ts | 25 +++++++++++ tests/permissions/planEditApproval.test.ts | 45 +++++++++++++++++++ tests/providers/AutohandAIProvider.test.ts | 4 +- tests/providers/LLMGatewayClient.spec.ts | 16 +++++++ tests/providers/cliCatalogPolicy.test.ts | 32 +++++++++++++ tests/providers/modelCatalog.test.ts | 15 ++----- .../cli-model-availability.tuistory.test.ts | 17 +++++++ tests/tuistory/plan-review.tuistory.test.ts | 30 ++++++++++++- 19 files changed, 281 insertions(+), 43 deletions(-) create mode 100644 tests/permissions/planEditApproval.test.ts create mode 100644 tests/providers/cliCatalogPolicy.test.ts create mode 100644 tests/tuistory/cli-model-availability.tuistory.test.ts diff --git a/docs/providers.md b/docs/providers.md index 56b94a748..893bc63ea 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -75,7 +75,7 @@ Bundled provider model choices live in `src/providers/models.json` and packaged Autohand AI is the preferred first-party provider. Use `autohandai` with `plan: "cloud"` for Autohand-hosted Fantail and Moa models at `https://api.autohand.ai/v1`, or `plan: "local"` for Apple Silicon MLX local inference. -The catalogue also carries Weka with `cliSupported: false`. This lets the CLI describe the model without putting it in setup, `/model`, team-model, ACP, or RPC selection. Weka accepts structured `state` and `questions` at `POST https://api.autohand.ai/v1/decisions`; use it from the API or Console Playground. +Weka is available through the Autohand API and Agent SDKs, not in the CLI. Call it at `POST https://api.autohand.ai/v1/decisions`. Fantail uses a 256k input context window and a 16k maximum output. Moa retains its 1M input context and 262,144-token output contract. These limits and the available cloud model list are read from `src/providers/models.json` (or a validated catalog override), not duplicated in provider code. If a selected model requires a higher account tier or an Autohand AI request quota is exhausted, the CLI identifies the exhausted window, shows its exact reset in the computer's local timezone with a relative duration, and includes the trusted upgrade link returned by the inference service. With Autohand AI active, `/usage` and the `/status` Usage tab show the same 5-hour, 24-hour, weekly, and monthly quota windows before they are exhausted, alongside request, uncached-input-token, and output-token throughput. Pro provides 1K requests, 500K uncached input tokens, and 80K output tokens per minute; Max provides exactly four times each limit. diff --git a/src/core/agent/AgentCommandRuntime.ts b/src/core/agent/AgentCommandRuntime.ts index 11b7a5ede..299ae5fea 100644 --- a/src/core/agent/AgentCommandRuntime.ts +++ b/src/core/agent/AgentCommandRuntime.ts @@ -357,8 +357,11 @@ export function parseAgentSlashCommand(_host: AgentCommandRuntimeHost, input: st } export async function confirmAgentDangerousAction(host: AgentCommandRuntimeHost, message: string, context?: { tool?: string; path?: string; command?: string }): Promise { + const forcePrompt = context?.tool + ? host.permissionManager?.hasPlanPermissionPolicy?.({ ...context, tool: context.tool }) === true + : false; const normalizedYolo = normalizeYoloInput(host.runtime.options.yolo as string | boolean | undefined); - if (normalizedYolo && context?.tool) { + if (!forcePrompt && normalizedYolo && context?.tool) { try { const pattern = parseYoloPattern(normalizedYolo); if (isToolAllowedByYolo(context.tool, pattern)) { @@ -369,7 +372,7 @@ export async function confirmAgentDangerousAction(host: AgentCommandRuntimeHost, } } - if (host.runtime.options.yes || host.runtime.options.unrestricted || host.runtime.config.ui?.autoConfirm) { + if (!forcePrompt && (host.runtime.options.yes || host.runtime.options.unrestricted || host.runtime.config.ui?.autoConfirm)) { return { decision: 'allow_once' }; } @@ -382,7 +385,7 @@ export async function confirmAgentDangerousAction(host: AgentCommandRuntimeHost, if (host.confirmationCallback) { decision = normalizePermissionPromptResponse(await withProgramStatusWait('permission', message, () => host.confirmationCallback(message, context))); } else if (isExternalCallbackEnabled()) { - decision = normalizePermissionPromptResponse(await withProgramStatusWait('permission', message, () => unifiedConfirm(message, promptContext))); + decision = normalizePermissionPromptResponse(await withProgramStatusWait('permission', message, () => unifiedConfirm(message, promptContext, { forcePrompt }))); } else { host.notificationService.notify( { body: message, reason: 'confirmation' }, @@ -395,7 +398,7 @@ export async function confirmAgentDangerousAction(host: AgentCommandRuntimeHost, if (wasRaw) { safeSetRawMode(process.stdin as NodeJS.ReadStream, false); } - return unifiedConfirm(message, promptContext); + return unifiedConfirm(message, promptContext, { forcePrompt }); })); } @@ -643,6 +646,7 @@ export async function handleAgentExitPlanMode( if (selectedOption) { const accepted = await adoptPlanFileEdits(plan); const config = planManager.acceptPlan(selectedOption.id); + host.permissionManager?.setPlanEditApproval(config.autoAcceptEdits ? 'auto' : 'manual'); console.log(chalk.green(`\n✓ Plan accepted: ${selectedOption.label}`)); if (accepted.edited) { @@ -671,6 +675,7 @@ export async function handleAgentExitPlanMode( // Default: accept with manual approve if result wasn't recognized const accepted = await adoptPlanFileEdits(plan); planManager.acceptPlan('manual_approve'); + host.permissionManager?.setPlanEditApproval('manual'); console.log(chalk.green('\n✓ Plan accepted with manual approval for edits.\n')); host.conversation.addSystemNote( 'Plan accepted with option: manual_approve. You may now proceed to execution.' diff --git a/src/core/agent/InstructionRunner.ts b/src/core/agent/InstructionRunner.ts index a54b60539..09662b1f4 100644 --- a/src/core/agent/InstructionRunner.ts +++ b/src/core/agent/InstructionRunner.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { finalizeSessionGitSnapshot } from '../../actions/gitSessionSnapshot.js'; +import { getPlanModeManager } from '../../commands/plan.js'; import chalk from 'chalk'; import type { HookManager, HookExecutionResult } from '../HookManager.js'; import type { ComputerUseLifecycle } from '../../computer/ComputerUseLifecycle.js'; @@ -763,6 +764,8 @@ export class InstructionRunner { // are disabled. Otherwise, in-flight streaming output bypasses writeAbove // and writes directly to stdout while regions are still active, corrupting // the fixed-region composer box (overlapping borders, leaked tool data). + host.permissionManager?.setPlanEditApproval(undefined); + getPlanModeManager().completeExecution(); cleanupEsc(); stopPreparation(); host.stopStatusUpdates(); diff --git a/src/core/toolManager.ts b/src/core/toolManager.ts index 009ee6eb2..30d7086e3 100644 --- a/src/core/toolManager.ts +++ b/src/core/toolManager.ts @@ -2984,6 +2984,7 @@ export class ToolManager { } shouldPrompt = permissionHookPromptOverride ?? shouldPrompt; } + if (this.permissionManager.requiresPlanApproval?.(permissionContext)) shouldPrompt = true; if (shouldPrompt) { const decision = normalizePermissionPromptResponse( await this.confirmApproval( diff --git a/src/modes/planMode/PlanModeManager.ts b/src/modes/planMode/PlanModeManager.ts index 5315469d6..9fda38272 100644 --- a/src/modes/planMode/PlanModeManager.ts +++ b/src/modes/planMode/PlanModeManager.ts @@ -179,6 +179,13 @@ export class PlanModeManager extends EventEmitter { this.emit('execution:started', this.state.plan); } + completeExecution(): void { + if (this.state.phase !== 'executing') return; + this.state.phase = 'completed'; + this.disable(); + this.emit('execution:completed', this.state.plan); + } + /** * Get list of read-only tools allowed in plan mode */ diff --git a/src/permissions/PermissionManager.ts b/src/permissions/PermissionManager.ts index 08b07abae..8eb0cb754 100644 --- a/src/permissions/PermissionManager.ts +++ b/src/permissions/PermissionManager.ts @@ -37,6 +37,11 @@ const READ_ONLY_REVIEW_TOOLS = new Set([ 'git_status', 'git_list_untracked', 'git_diff', 'git_diff_range', 'git_log', 'checksum', ]); +const PLAN_FILE_EDIT_TOOLS = new Set([ + 'write_file', 'append_file', 'apply_patch', 'notebook_edit', 'search_replace', + 'format_file', 'multi_file_edit', 'delete_path', 'rename_path', 'copy_path', +]); + /** * Default security blacklist - always blocked patterns for sensitive files and dangerous commands. * These are merged with user settings and cannot be overridden by allowLists. @@ -394,7 +399,10 @@ export class PermissionManager { // Pattern-based checks (AFTER security blacklist, BEFORE session cache) const patternDecision = this.checkPatterns(context); - if (patternDecision && (!this.readOnlyReview || !patternDecision.allowed)) { + const requestedTool = context.requestedTool ?? context.tool; + const planFileEdit = this.planEditApproval !== undefined && PLAN_FILE_EDIT_TOOLS.has(requestedTool); + const planOperation = this.hasPlanPermissionPolicy(context); + if (patternDecision && (!patternDecision.allowed || !planOperation) && (!this.readOnlyReview || !patternDecision.allowed)) { return patternDecision; } @@ -433,6 +441,14 @@ export class PermissionManager { if (this.readOnlyReview) return { allowed: true, reason: 'read_only_review' }; + if (planFileEdit) { + if (this.mode === 'restricted') return { allowed: false, reason: 'mode_restricted' }; + return this.planEditApproval === 'auto' + ? { allowed: true, reason: 'user_approved' } + : { allowed: false, reason: 'default' }; + } + if (planOperation) return { allowed: false, reason: this.mode === 'restricted' ? 'mode_restricted' : 'default' }; + if (cachedDecision === true) { return { allowed: true, reason: 'user_approved', cached: true }; } @@ -496,6 +512,24 @@ export class PermissionManager { return { allowed: false, reason: 'default' }; } + private planEditApproval: 'auto' | 'manual' | undefined; + + setPlanEditApproval(approval: 'auto' | 'manual' | undefined): void { + this.planEditApproval = approval; + } + + hasPlanPermissionPolicy(context: PermissionContext): boolean { + return this.planEditApproval !== undefined && ( + PLAN_FILE_EDIT_TOOLS.has(context.requestedTool ?? context.tool) + || context.tool === 'write_file' || context.tool === 'run_command' || context.tool === 'shell' + ); + } + + requiresPlanApproval(context: PermissionContext): boolean { + return this.hasPlanPermissionPolicy(context) + && !(this.planEditApproval === 'auto' && PLAN_FILE_EDIT_TOOLS.has(context.requestedTool ?? context.tool)); + } + /** * Record a user's decision using the legacy boolean API. * Approved permissions are saved to the project allowList when possible. diff --git a/src/providers/AutohandAIProvider.ts b/src/providers/AutohandAIProvider.ts index d7e194ec0..ef4762cda 100644 --- a/src/providers/AutohandAIProvider.ts +++ b/src/providers/AutohandAIProvider.ts @@ -34,13 +34,14 @@ export function getAutohandAICloudModelCliUnsupportedReason( model: string | undefined, ): string | undefined { const normalized = normalizeAutohandAICloudModelId(model); + if (normalized === "weka") return "Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model."; const entry = getProviderModelOptions("autohandai").find( (candidate) => candidate.id === normalized, ); if (entry?.cliSupported !== false) return undefined; const name = entry.id === "weka" ? "Weka" : (entry.displayName ?? entry.id); - return `${name} is available through the Autohand API and Console Playground. CLI execution is not supported yet.`; + return `${name} is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.`; } export interface AutohandAICloudModelDefinition { diff --git a/src/providers/LLMGatewayClient.ts b/src/providers/LLMGatewayClient.ts index ff4b32355..091bb6016 100644 --- a/src/providers/LLMGatewayClient.ts +++ b/src/providers/LLMGatewayClient.ts @@ -114,6 +114,7 @@ interface StructuredGatewayError { scope?: string; resetAt?: number; upgradeUrl?: string; + resetUrl?: string; } function structuredGatewayError(value: unknown): StructuredGatewayError | undefined { @@ -132,6 +133,7 @@ function structuredGatewayError(value: unknown): StructuredGatewayError | undefi ? { resetAt: error.resetAt } : {}), ...(typeof error.upgradeUrl === "string" ? { upgradeUrl: error.upgradeUrl } : {}), + ...(typeof error.resetUrl === "string" ? { resetUrl: error.resetUrl } : {}), }; } @@ -230,10 +232,28 @@ function buildAutohandQuotaMessage(error: StructuredGatewayError): string { const upgradeMessage = upgradeUrl ? `\nUpgrade your Autohand Code plan for more usage: ${upgradeUrl}` : ""; + const resetUrl = ['window_5h', 'window_24h', 'window_week'].includes(error.scope ?? '') + ? trustedAutohandResetUrl(error.resetUrl) + : undefined; + const resetMessage = resetUrl + ? `\nSkip the wait: use 100 reset credits to reset this window now. Packs start at US$10: ${resetUrl}` + : ''; return `Autohand AI ${AUTOHAND_QUOTA_SCOPE_LABELS[scope]} reached.` + `\n${error.message ?? "Your current request quota is exhausted."}` + `\n${formatAutohandQuotaReset(error.resetAt)}` - + upgradeMessage; + + upgradeMessage + + resetMessage; +} + +function trustedAutohandResetUrl(value: string | undefined): string | undefined { + if (!value) return undefined; + try { + const url = new URL(value); + return url.origin === 'https://console.autohand.ai' && url.pathname === '/billing' + && !url.username && !url.password && !url.hash ? url.toString() : undefined; + } catch { + return undefined; + } } function buildAutohandTokenThroughputMessage(error: StructuredGatewayError): string { diff --git a/src/providers/models.json b/src/providers/models.json index 3f4100a36..525525d3f 100644 --- a/src/providers/models.json +++ b/src/providers/models.json @@ -39,24 +39,17 @@ {"id": "qwen3.8-27b", "displayName": "qwen3.8-27b", "description": "Pro+, Max, Team and Enterprise", "contextWindow": 262144, "maxTokens": 16000, "toolCalls": true}, {"id": "muse-wonder-1.0", "displayName": "Muse Wonder 1.0", "description": "Lite and above", "contextWindow": 262144, "maxTokens": 16000, "toolCalls": true, "reasoningEffort": "high", "reasoningEfforts": ["low", "medium", "high", "xhigh"]}, {"id": "qwen3.8-max", "displayName": "Qwen 3.8 Max", "description": "Lite and above", "contextWindow": 262144, "maxTokens": 16000, "toolCalls": true}, - {"id": "gemini-4-argon", "displayName": "Gemini 4 Argon", "description": "Max and Enterprise", "contextWindow": 1048576, "maxTokens": 16000, "toolCalls": true, "reasoningEffort": "high", "reasoningEfforts": ["low", "medium", "high"]}, - { - "id": "weka", - "displayName": "Weka (API + Console only)", - "description": "Typed decision model for noul, choice, and score questions; not runnable in the CLI", - "api": "autohand-decisions", - "endpoint": "/decisions", - "contextWindow": 32000, - "maxTokens": 1024, - "toolCalls": false, - "cliSupported": false - } + {"id": "gemini-4-argon", "displayName": "Gemini 4 Argon", "description": "Max and Enterprise", "contextWindow": 1048576, "maxTokens": 16000, "toolCalls": true, "reasoningEffort": "high", "reasoningEfforts": ["low", "medium", "high"]} ] }, "openrouter": { - "defaultModel": "anthropic/claude-sonnet-5", - "runtimeDefaultModel": "anthropic/claude-sonnet-5", + "defaultModel": "anthropic/claude-sonnet-5-5", + "runtimeDefaultModel": "anthropic/claude-sonnet-5-5", "models": [ + {"id": "anthropic/claude-sonnet-5-5", "displayName": "Claude Sonnet 5.5"}, + {"id": "anthropic/claude-opus-5-5", "displayName": "Claude Opus 5.5"}, + {"id": "anthropic/claude-haiku-5-5", "displayName": "Claude Haiku 5.5"}, + {"id": "anthropic/claude-fable-5-1", "displayName": "Claude Fable 5.1"}, { "id": "openrouter/auto", "displayName": "OpenRouter Auto" }, { "id": "anthropic/claude-opus-5-fast", "displayName": "Claude Opus 5 (Fast)", "contextWindow": 1000000, "maxTokens": 128000, "reasoningEffort": "high" }, { "id": "anthropic/claude-opus-5", "displayName": "Claude Opus 5", "contextWindow": 1000000, "maxTokens": 128000, "reasoningEffort": "high" }, @@ -83,9 +76,13 @@ ] }, "anthropic": { - "defaultModel": "claude-sonnet-5", - "runtimeDefaultModel": "claude-sonnet-5", + "defaultModel": "claude-sonnet-5-5", + "runtimeDefaultModel": "claude-sonnet-5-5", "models": [ + {"id": "claude-sonnet-5-5", "displayName": "Claude Sonnet 5.5"}, + {"id": "claude-opus-5-5", "displayName": "Claude Opus 5.5"}, + {"id": "claude-haiku-5-5", "displayName": "Claude Haiku 5.5"}, + {"id": "claude-fable-5-1", "displayName": "Claude Fable 5.1"}, { "id": "claude-opus-5", "displayName": "Claude Opus 5", "contextWindow": 1000000, "maxTokens": 128000, "reasoningEffort": "high" }, { "id": "claude-sonnet-5", "displayName": "Claude Sonnet 5", "contextWindow": 1000000, "maxTokens": 128000, "reasoningEffort": "high" }, { "id": "claude-fable-5", "displayName": "Claude Fable 5", "contextWindow": 1000000, "maxTokens": 128000, "reasoningEffort": "high" }, diff --git a/src/ui/promptCallback.ts b/src/ui/promptCallback.ts index ba61a9154..912106b6b 100644 --- a/src/ui/promptCallback.ts +++ b/src/ui/promptCallback.ts @@ -81,7 +81,8 @@ async function sendExternalRequest(request: ExternalPromptRequest): Promise { // External callback mode if (isExternalCallbackEnabled()) { @@ -103,16 +104,18 @@ export async function confirm( } // Check for unrestricted mode (auto-confirm) - if (process.env.AUTOHAND_NON_INTERACTIVE === '1' || + if (!options.forcePrompt && (process.env.AUTOHAND_NON_INTERACTIVE === '1' || process.env.CI === '1' || - process.env.AUTOHAND_YES === '1') { + process.env.AUTOHAND_YES === '1')) { return { decision: 'allow_once' }; } + if (options.forcePrompt && !process.stdin.isTTY) return { decision: 'deny_once' }; + // Interactive mode - use Modal const commandPrefix = getCommandPrefix(context?.command); const alternativeKind = commandPrefix ? 'Command' : 'Path'; - const options: ModalOption[] = [ + const choices: ModalOption[] = [ { label: t('commands.permissions.prompt.yes'), value: 'allow_once' }, { label: t('commands.permissions.prompt.no'), value: 'deny_once' }, { label: t('commands.permissions.prompt.allowOnce'), value: 'allow_session' }, @@ -127,7 +130,7 @@ export async function confirm( const result = await showModal({ title: message, - options, + options: choices, initialIndex: 0 }); diff --git a/tests/core/agent/AgentPlanReview.test.ts b/tests/core/agent/AgentPlanReview.test.ts index dd2c8e8f8..1669d083c 100644 --- a/tests/core/agent/AgentPlanReview.test.ts +++ b/tests/core/agent/AgentPlanReview.test.ts @@ -13,6 +13,7 @@ const opener = vi.hoisted(() => ({ openPlanFile: vi.fn(async () => 'code') })); vi.mock('../../../src/ui/planAcceptModal.js', () => ({ showPlanAcceptModal: modal.showPlanAcceptModal })); vi.mock('../../../src/modes/planMode/openPlanFile.js', () => ({ openPlanFile: opener.openPlanFile })); +import { PermissionManager } from '../../../src/permissions/PermissionManager.js'; import { getPlanModeManager } from '../../../src/commands/plan.js'; import { handleAgentExitPlanMode, @@ -45,6 +46,7 @@ function createHost() { const notes: string[] = []; const host = { runtime: { options: {}, workspaceRoot: '/repo' }, + permissionManager: new PermissionManager({ mode: 'unrestricted' }), conversation: { addSystemNote: (note: string) => { notes.push(note); } }, withModalPause: (callback: () => Promise) => callback(), resetConversationContext: vi.fn(async () => {}), @@ -134,6 +136,15 @@ describe('plan presentation and review', () => { expect(opener.openPlanFile).toHaveBeenCalledExactlyOnceWith(expectedPath); }); + it.each(['auto_accept', 'clear_context_auto_accept', 'manual_approve'])('applies %s to the actual permission manager', async option => { + modal.showPlanAcceptModal.mockResolvedValue({ type: 'option', optionId: option }); + vi.spyOn(PlanFileStorage.prototype, 'loadPlan').mockResolvedValue(plan()); + const { host } = createHost(); + await handleAgentExitPlanMode(host); + expect(host.permissionManager.checkPermission({ tool: 'write_file', path: '/repo/main.ts' }).allowed).toBe(option !== 'manual_approve'); + expect(host.permissionManager.checkPermission({ tool: 'run_command', command: 'npm test' }).allowed).toBe(false); + }); + it('accepts an untouched plan and lists its steps for execution', async () => { modal.showPlanAcceptModal.mockResolvedValue({ type: 'option', optionId: 'manual_approve' }); vi.spyOn(PlanFileStorage.prototype, 'loadPlan').mockResolvedValue(plan()); diff --git a/tests/core/agent/InstructionRunner.command-mode.test.ts b/tests/core/agent/InstructionRunner.command-mode.test.ts index 28ff89a96..a92824c5c 100644 --- a/tests/core/agent/InstructionRunner.command-mode.test.ts +++ b/tests/core/agent/InstructionRunner.command-mode.test.ts @@ -18,6 +18,8 @@ import { import { HookManager } from '../../../src/core/HookManager.js'; import { ComputerUseLifecycle } from '../../../src/computer/ComputerUseLifecycle.js'; import { ApiError, classifyApiError } from '../../../src/providers/errors.js'; +import { getPlanModeManager } from '../../../src/commands/plan.js'; +import { PermissionManager } from '../../../src/permissions/PermissionManager.js'; import { ProviderNotConfiguredError } from '../../../src/providers/ProviderFactory.js'; function overrideStreamTTY( @@ -1159,3 +1161,26 @@ it('skips automatic specialists for an explicitly scoped native instruction', as expect(await new InstructionRunner(host).run('Inspect again')).toBe(true); expect(host.prepareSpecialists).toHaveBeenCalledTimes(1); }); + + +describe('accepted plan execution cleanup', () => { + it.each(['completed', 'failed', 'cancelled'])('restores normal permissions and iteration mode after %s execution', async outcome => { + const host = createHost(); + host.permissionManager = new PermissionManager({ mode: 'interactive' }); + host.runReactLoop = vi.fn(async controller => { + const manager = getPlanModeManager(); + manager.enable(); + manager.setPlan({ id: 'accepted', steps: [], rawText: 'Plan', createdAt: 1 }); + manager.acceptPlan('auto_accept'); + host.permissionManager!.setPlanEditApproval('auto'); + if (outcome === 'failed') throw new Error('Execution failed'); + if (outcome === 'cancelled') controller.abort(); + return { status: 'completed' }; + }); + await new InstructionRunner(host).run('execute the accepted plan'); + expect(getPlanModeManager().isEnabled()).toBe(false); + expect(getPlanModeManager().getPhase()).toBe('completed'); + expect(host.permissionManager.checkPermission({ tool: 'write_file', path: '/repo/main.ts' }).allowed).toBe(false); + expect(getPlanModeManager().getPromptIndicator()).toBe(''); + }); +}); diff --git a/tests/permissions/planEditApproval.test.ts b/tests/permissions/planEditApproval.test.ts new file mode 100644 index 000000000..07d35d390 --- /dev/null +++ b/tests/permissions/planEditApproval.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import { confirmAgentDangerousAction } from '../../src/core/agent/AgentCommandRuntime.js'; +import { PermissionManager } from '../../src/permissions/PermissionManager.js'; +import { getPermissionPolicyDisposition } from '../../src/permissions/types.js'; + +describe('accepted-plan permissions', () => { + it('does not let YOLO or auto-confirm skip the selected manual approval callback', async () => { + const manager = new PermissionManager({ mode: 'unrestricted' }); + manager.setPlanEditApproval('manual'); + const confirmationCallback = vi.fn(async () => ({ decision: 'deny_once' as const })); + const host = { runtime: { options: { yes: true, unrestricted: true, yolo: 'allow:*' }, config: { ui: { autoConfirm: true } } }, permissionManager: manager, confirmationCallback, hookManager: { executeHooks: vi.fn(async () => []) } }; + await expect(confirmAgentDangerousAction(host, 'Write main.ts', { tool: 'write_file', path: '/repo/main.ts' })).resolves.toMatchObject({ decision: 'deny_once' }); + expect(confirmationCallback).toHaveBeenCalledOnce(); + }); + it('auto-accepts file edits without granting shell, dependency or git approvals', () => { + const manager = new PermissionManager({ mode: 'interactive' }); + manager.setPlanEditApproval('auto'); + for (const tool of ['write_file', 'append_file', 'apply_patch', 'search_replace', 'notebook_edit', 'format_file', 'multi_file_edit', 'delete_path', 'rename_path', 'copy_path']) { + expect(manager.checkPermission({ tool: 'write_file', requestedTool: tool, path: '/repo/main.ts' }).allowed).toBe(true); + } + for (const tool of ['run_command', 'shell', 'git_checkout', 'add_dependency', 'remove_dependency']) { + expect(manager.checkPermission({ tool: tool === 'run_command' || tool === 'shell' ? tool : 'write_file', requestedTool: tool, command: 'npm test', path: '/repo/package.json' }).allowed).toBe(false); + } + manager.setPlanEditApproval(undefined); + expect(manager.checkPermission({ tool: 'write_file', path: '/repo/main.ts' }).allowed).toBe(false); + }); + it('manual approval overrides YOLO, saved approvals and broad allow patterns', async () => { + const manager = new PermissionManager({ mode: 'unrestricted', rememberSession: true, allowList: ['write_file:*'], allPathsAllowed: true }); + manager.setPlanEditApproval('manual'); + const edit = { tool: 'write_file', path: '/repo/main.ts' }; + await manager.applyPromptDecision(edit, { decision: 'allow_session' }); + expect(getPermissionPolicyDisposition(manager.checkPermission(edit))).toBe('prompt'); + manager.setPlanEditApproval(undefined); + expect(manager.checkPermission(edit).allowed).toBe(true); + }); + it('never overrides a blacklist, explicit deny or read-only mode', () => { + const denied = new PermissionManager({ denyList: ['write_file:*'] }); + denied.setPlanEditApproval('auto'); + expect(denied.checkPermission({ tool: 'write_file', path: '/repo/main.ts' }).allowed).toBe(false); + expect(denied.checkPermission({ tool: 'write_file', path: '.env' }).reason).toBe('blacklisted'); + const restricted = new PermissionManager({ mode: 'restricted' }); + restricted.setPlanEditApproval('auto'); + expect(restricted.checkPermission({ tool: 'write_file', path: '/repo/main.ts' }).allowed).toBe(false); + }); +}); diff --git a/tests/providers/AutohandAIProvider.test.ts b/tests/providers/AutohandAIProvider.test.ts index f535599a1..fbb74cfae 100644 --- a/tests/providers/AutohandAIProvider.test.ts +++ b/tests/providers/AutohandAIProvider.test.ts @@ -69,7 +69,7 @@ describe("AutohandAIProvider", () => { apiKey: "test-autohand-key", model, })).toThrow( - "Weka is available through the Autohand API and Console Playground. CLI execution is not supported yet.", + "Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.", ); }, ); @@ -88,7 +88,7 @@ describe("AutohandAIProvider", () => { model: "weka", messages: [{ role: "user", content: "Decide" }], })).rejects.toThrow( - "Weka is available through the Autohand API and Console Playground. CLI execution is not supported yet.", + "Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.", ); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/tests/providers/LLMGatewayClient.spec.ts b/tests/providers/LLMGatewayClient.spec.ts index 041320fb0..0c3d17796 100644 --- a/tests/providers/LLMGatewayClient.spec.ts +++ b/tests/providers/LLMGatewayClient.spec.ts @@ -1534,3 +1534,19 @@ describe('LLMGatewayClient', () => { }); }); }); + + +describe('Autohand reset-credit guidance', () => { + afterEach(() => vi.unstubAllGlobals()); + it.each(['window_5h', 'window_24h', 'window_week'])('renders a trusted billing reset link for %s', async scope => { + const resetUrl = 'https://console.autohand.ai/billing?account=team_1&from=cli-limit'; + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ error: { type: 'rate_limited', scope, message: 'Window exhausted', resetAt: 1900000000, resetUrl } }, { status: 429 }))); + const client = new LLMGatewayClient({ apiKey: 'test-key', model: 'fantail' }, { maxRetries: 0 }, { serviceName: 'Autohand AI', credentialName: 'API key', accountName: 'account' }); + await expect(client.complete({ messages: [{ role: 'user', content: 'hello' }] })).rejects.toThrow(`Skip the wait: use 100 reset credits to reset this window now. Packs start at US$10: ${resetUrl}`); + }); + it.each(['https://evil.test/billing', 'http://console.autohand.ai/billing', 'https://console.autohand.ai/upgrade/'])('does not render an untrusted reset destination %s', async resetUrl => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ error: { type: 'rate_limited', scope: 'window_5h', resetUrl } }, { status: 429 }))); + const client = new LLMGatewayClient({ apiKey: 'test-key', model: 'fantail' }, { maxRetries: 0 }, { serviceName: 'Autohand AI', credentialName: 'API key', accountName: 'account' }); + await expect(client.complete({ messages: [{ role: 'user', content: 'hello' }] })).rejects.not.toThrow('Skip the wait:'); + }); +}); diff --git a/tests/providers/cliCatalogPolicy.test.ts b/tests/providers/cliCatalogPolicy.test.ts new file mode 100644 index 000000000..81fcac6c3 --- /dev/null +++ b/tests/providers/cliCatalogPolicy.test.ts @@ -0,0 +1,32 @@ +import { afterAll, describe, expect, it, vi } from 'vitest'; +const previousCatalog = vi.hoisted(() => { + const previous = { catalog: process.env.AUTOHAND_MODELS_CATALOG, home: process.env.AUTOHAND_HOME }; + process.env.AUTOHAND_MODELS_CATALOG = process.cwd() + '/src/providers/models.json'; + process.env.AUTOHAND_HOME = process.cwd() + '/.f0257-hermetic-home'; + return previous; +}); +afterAll(() => { + if (previousCatalog.catalog === undefined) delete process.env.AUTOHAND_MODELS_CATALOG; + else process.env.AUTOHAND_MODELS_CATALOG = previousCatalog.catalog; + if (previousCatalog.home === undefined) delete process.env.AUTOHAND_HOME; + else process.env.AUTOHAND_HOME = previousCatalog.home; +}); +import { getProviderModelOptions, getProviderRunnableModelOptions, getProviderDefaultModel, getProviderRuntimeDefaultModel } from '../../src/providers/modelCatalog.js'; +import { getAutohandAICloudModelCliUnsupportedReason } from '../../src/providers/AutohandAIProvider.js'; + +describe('CLI hosted model availability', () => { + it('does not list Weka even for clients that ignore cliSupported', () => { + expect(getProviderModelOptions('autohandai').some(model => model.id === 'weka')).toBe(false); + expect(getProviderRunnableModelOptions('autohandai').some(model => model.id === 'weka')).toBe(false); + }); + it.each(['weka', 'autohand/weka', 'autohandai/weka'])('rejects %s without depending on a catalog entry', model => { + expect(getAutohandAICloudModelCliUnsupportedReason(model)).toBe('Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.'); + }); + it.each(['anthropic', 'openrouter'])('offers the Claude 5 family with Sonnet 5.5 first on %s', provider => { + const prefix = provider === 'openrouter' ? 'anthropic/' : ''; + const models = getProviderModelOptions(provider).map(model => model.id); + expect(models.slice(0, 4)).toEqual(['claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1'].map(id => prefix + id)); + expect(getProviderDefaultModel(provider)).toBe(prefix + 'claude-sonnet-5-5'); + expect(getProviderRuntimeDefaultModel(provider)).toBe(prefix + 'claude-sonnet-5-5'); + }); +}); diff --git a/tests/providers/modelCatalog.test.ts b/tests/providers/modelCatalog.test.ts index 9f3e6c02c..769afd8a3 100644 --- a/tests/providers/modelCatalog.test.ts +++ b/tests/providers/modelCatalog.test.ts @@ -51,17 +51,17 @@ describe("modelCatalog", () => { "grok-4.20-reasoning", ])); expect(getProviderDefaultModel("autohandai")).toBe("moa"); - expect(getProviderDefaultModel("anthropic")).toBe("claude-sonnet-5"); + expect(getProviderDefaultModel("anthropic")).toBe("claude-sonnet-5-5"); expect(getProviderModelIds("anthropic")).toEqual(expect.arrayContaining([ - "claude-sonnet-5", + "claude-sonnet-5-5", "claude-opus-5", "claude-fable-5", "claude-opus-4-8", "claude-haiku-4-5", ])); - expect(getProviderDefaultModel("openrouter")).toBe("anthropic/claude-sonnet-5"); + expect(getProviderDefaultModel("openrouter")).toBe("anthropic/claude-sonnet-5-5"); expect(getProviderModelIds("openrouter")).toEqual(expect.arrayContaining([ - "anthropic/claude-sonnet-5", + "anthropic/claude-sonnet-5-5", "anthropic/claude-opus-5", "anthropic/claude-fable-5", "anthropic/claude-opus-4.8", @@ -81,13 +81,6 @@ describe("modelCatalog", () => { expect.objectContaining({ id: "muse-wonder-1.0", contextWindow: 262_144, maxTokens: 16_000, toolCalls: true }), expect.objectContaining({ id: "qwen3.8-max", contextWindow: 262_144, maxTokens: 16_000, toolCalls: true }), expect.objectContaining({ id: "gemini-4-argon", contextWindow: 1_048_576, maxTokens: 16_000, toolCalls: true }), - expect.objectContaining({ - id: "weka", - contextWindow: 32_000, - maxTokens: 1_024, - cliSupported: false, - description: expect.stringContaining("not runnable in the CLI"), - }), ]); expect(getProviderRunnableModelIds("autohandai")).toEqual(["fantail", "moa", "auto", "gpt-5.6-luna", "deepseek-v4-flash", "gpt-6-sol", "grok-4.7", "qwen3.8-27b", "muse-wonder-1.0", "qwen3.8-max", "gemini-4-argon"]); rmSync(dir, { recursive: true, force: true }); diff --git a/tests/tuistory/cli-model-availability.tuistory.test.ts b/tests/tuistory/cli-model-availability.tuistory.test.ts new file mode 100644 index 000000000..efe424a06 --- /dev/null +++ b/tests/tuistory/cli-model-availability.tuistory.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from 'vitest'; +import { createTempAutohandHome, launchBuiltAutohand, waitForExit } from './helpers/autohandTuistory.js'; + +it('explains the API and Agent SDK route when Weka is selected explicitly', async () => { + const state = await createTempAutohandHome({ config: { + provider: 'autohandai', features: { autohand_inference: true }, + autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'fixture-only', model: 'fantail' }, + } }); + const session = await launchBuiltAutohand(['--model', 'weka', '--config', state.configPath], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + try { + await waitForExit(session, 20_000); + expect(await session.readAll()).toContain('Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.'); + } finally { + session.close(); + await state.cleanup(); + } +}); diff --git a/tests/tuistory/plan-review.tuistory.test.ts b/tests/tuistory/plan-review.tuistory.test.ts index bab70f8b7..6e854d31c 100644 --- a/tests/tuistory/plan-review.tuistory.test.ts +++ b/tests/tuistory/plan-review.tuistory.test.ts @@ -30,11 +30,12 @@ afterEach(async () => { for (const state of states.splice(0)) await state.cleanup(); }); -async function launch() { +async function launch(executeEdits = false, unrestricted = false) { const server = await createMockAutohandAINativeSequenceServer([ { content: 'Drafting a plan.', toolCall: { id: 'first-plan', name: 'plan', args: { notes: FIRST_PLAN_NOTES } } }, { content: 'Refining the plan.', toolCall: { id: 'refined-plan', name: 'plan', args: { notes: REFINED_PLAN_NOTES } } }, { content: 'Presenting the plan.', toolCall: { id: 'review-plan', name: 'exit_plan_mode', args: {} } }, + ...(executeEdits ? [{ content: 'Implementing the accepted plan.', toolCall: { id: 'approved-edit', name: 'write_file', args: { path: 'approved-plan.txt', contents: 'accepted plan edit' } } }] : []), { content: 'PLAN_REVIEW_COMPLETE' }, ]); servers.push(server); @@ -42,6 +43,7 @@ async function launch() { provider: 'autohandai', autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'tuistory-test-key', model: 'moa', baseUrl: server.baseUrl }, features: { autohand_inference: true }, + permissions: { mode: unrestricted ? 'unrestricted' : 'interactive' }, agent: { autoMemory: false, maxIterations: 6, sessionRetryLimit: 0 }, network: { maxRetries: 0, retryDelay: 0 }, ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, @@ -78,6 +80,32 @@ function toolResult(server: MockNativeToolServer, toolCallId: string): string | } describe('plan review', () => { + it('auto-accepts the chosen plan edit and restores the normal composer afterward', async () => { + const { session, state } = await launch(true); + await planUntilReview(session); + await session.type('3'); + await session.waitForText('PLAN_REVIEW_COMPLETE', { timeout: 30_000 }); + expect(await readFile(path.join(state.workspaceRoot, 'approved-plan.txt'), 'utf8')).toBe('accepted plan edit'); + await session.waitIdle(); + const current = session.getTerminalData(); + const visible = current.lines.slice(-current.rows).map(line => line.spans.map(span => span.text).join('')).join('\n'); + expect(visible.split('\n').slice(-5).join('\n')).not.toContain('[EXEC]'); + await exitInteractive(session); + }); + + it('prompts for a manual plan edit even when the session started unrestricted', async () => { + const { session, state } = await launch(true, true); + await planUntilReview(session); + await session.type('2'); + await session.waitForText('Allow Once', { timeout: 20_000 }); + await expect(readFile(path.join(state.workspaceRoot, 'approved-plan.txt'), 'utf8')).rejects.toThrow(); + await session.press('down'); + await session.press('enter'); + await session.waitForText('PLAN_REVIEW_COMPLETE', { timeout: 30_000 }); + await expect(readFile(path.join(state.workspaceRoot, 'approved-plan.txt'), 'utf8')).rejects.toThrow(); + await exitInteractive(session); + }); + it('shows the plan as written, goes straight to review after a refinement, and says where the file is', async () => { const { session, state } = await launch(); From 4022f985a63f4210a92bd76d3a378ae7cfd39abb Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Wed, 28 Oct 2026 19:39:20 +1300 Subject: [PATCH 28/38] Update model catalogue and terminal regression expectations --- tests/core/agent/AutohandAIModelTierPolicy.test.ts | 4 ++-- tests/github/modelCatalogDistributionWorkflow.test.ts | 9 +-------- tests/modes/acp/types.test.ts | 4 ++-- tests/tuistory/built-cli-research.tuistory.test.ts | 4 ++-- tests/tuistory/cli-model-availability.tuistory.test.ts | 7 ++++--- 5 files changed, 11 insertions(+), 17 deletions(-) diff --git a/tests/core/agent/AutohandAIModelTierPolicy.test.ts b/tests/core/agent/AutohandAIModelTierPolicy.test.ts index a308ebe78..77da893b8 100644 --- a/tests/core/agent/AutohandAIModelTierPolicy.test.ts +++ b/tests/core/agent/AutohandAIModelTierPolicy.test.ts @@ -207,7 +207,7 @@ describe('resolveAutohandAIModelForTier', () => { expect(resolveAutohandAIModelForTier({ provider: 'openrouter', plan: undefined, model: 'anthropic/claude-5-sonnet', tier: 'pro' })).toBe('anthropic/claude-5-sonnet'); }); - it('rejects Weka with its API and Console route instead of treating it as an unknown chat model', async () => { + it('rejects Weka with its API and Agent SDK route instead of treating it as an unknown chat model', async () => { const { resolveAutohandAIModelForTier } = await import('../../../src/core/agent/AutohandAIModelTierPolicy.js'); expect(() => resolveAutohandAIModelForTier({ @@ -216,7 +216,7 @@ describe('resolveAutohandAIModelForTier', () => { model: 'weka', tier: 'pro', })).toThrow( - 'Weka is available through the Autohand API and Console Playground. CLI execution is not supported yet.', + 'Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.', ); }); }); diff --git a/tests/github/modelCatalogDistributionWorkflow.test.ts b/tests/github/modelCatalogDistributionWorkflow.test.ts index f7754f331..2d70f7b1e 100644 --- a/tests/github/modelCatalogDistributionWorkflow.test.ts +++ b/tests/github/modelCatalogDistributionWorkflow.test.ts @@ -60,15 +60,8 @@ describe("model catalog distribution automation", () => { "muse-wonder-1.0": expect.objectContaining({ provider: "autohandai", contextWindow: 262_144, maxTokens: 16_000 }), "qwen3.8-max": expect.objectContaining({ provider: "autohandai", contextWindow: 262_144, maxTokens: 16_000 }), "gemini-4-argon": expect.objectContaining({ provider: "autohandai", contextWindow: 1_048_576, maxTokens: 16_000 }), - weka: expect.objectContaining({ - api: "autohand-decisions", - baseUrl: "https://api.autohand.ai/v1", - cliSupported: false, - contextWindow: 32_000, - endpoint: "/decisions", - provider: "autohandai", - }), }); + expect(catalog.autohandai).not.toHaveProperty("weka"); expect(catalog.anthropic["claude-sonnet-5"]).toEqual(expect.objectContaining({ api: "anthropic-messages", baseUrl: "https://api.anthropic.com", diff --git a/tests/modes/acp/types.test.ts b/tests/modes/acp/types.test.ts index fd20c1bbc..855caeacd 100644 --- a/tests/modes/acp/types.test.ts +++ b/tests/modes/acp/types.test.ts @@ -510,7 +510,7 @@ describe("resolveDefaultModel()", () => { }, } as any); - expect(resolveDefaultModel(config)).toBe("anthropic/claude-sonnet-5"); + expect(resolveDefaultModel(config)).toBe("anthropic/claude-sonnet-5-5"); }); it("returns model for autohandai provider settings when autohand_inference is enabled", () => { @@ -531,7 +531,7 @@ describe("resolveDefaultModel()", () => { it("returns fallback model when provider config has no model", () => { const config = makeConfig({ openrouter: undefined } as any); - expect(resolveDefaultModel(config)).toBe("anthropic/claude-sonnet-5"); + expect(resolveDefaultModel(config)).toBe("anthropic/claude-sonnet-5-5"); }); it("defaults to openrouter when provider is not specified", () => { diff --git a/tests/tuistory/built-cli-research.tuistory.test.ts b/tests/tuistory/built-cli-research.tuistory.test.ts index 38d320906..e79fbfec4 100644 --- a/tests/tuistory/built-cli-research.tuistory.test.ts +++ b/tests/tuistory/built-cli-research.tuistory.test.ts @@ -828,7 +828,7 @@ describe('interactive built CLI Tuistory tests: processes, research, usage, sett }); it('persists an Anthropic provider selection and restores it after restart', async () => { - const selectedModel = 'claude-sonnet-5'; + const selectedModel = 'claude-sonnet-5-5'; const apiKey = 'sk-ant-tuistory-key-long-enough'; const authServer = await createMockAuthServer(); mockAuthServers.push(authServer); @@ -863,7 +863,7 @@ describe('interactive built CLI Tuistory tests: processes, research, usage, sett await session.type(apiKey); await session.press('enter'); await session.waitForText('Select a model', { timeout: 10_000 }); - await selectModalOptionByLabel(session, 'Claude Sonnet 5'); + await selectModalOptionByLabel(session, 'Claude Sonnet 5.5'); await session.waitForText('Anthropic configured successfully!', { timeout: 10_000 }); const screen = await session.text({ timeout: 10_000, diff --git a/tests/tuistory/cli-model-availability.tuistory.test.ts b/tests/tuistory/cli-model-availability.tuistory.test.ts index efe424a06..5ebc7d6c0 100644 --- a/tests/tuistory/cli-model-availability.tuistory.test.ts +++ b/tests/tuistory/cli-model-availability.tuistory.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest'; -import { createTempAutohandHome, launchBuiltAutohand, waitForExit } from './helpers/autohandTuistory.js'; +import { createTempAutohandHome, launchBuiltAutohand, exitInteractive } from './helpers/autohandTuistory.js'; it('explains the API and Agent SDK route when Weka is selected explicitly', async () => { const state = await createTempAutohandHome({ config: { @@ -8,8 +8,9 @@ it('explains the API and Agent SDK route when Weka is selected explicitly', asyn } }); const session = await launchBuiltAutohand(['--model', 'weka', '--config', state.configPath], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); try { - await waitForExit(session, 20_000); - expect(await session.readAll()).toContain('Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.'); + await session.waitForText('Weka is available through the Autohand API and Agent SDKs, not in the CLI.', { timeout: 20_000 }); + expect(session.readAll().replace(/\s+/g, ' ')).toContain('Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.'); + await exitInteractive(session); } finally { session.close(); await state.cleanup(); From c8f30e04437a7ac89bddecabdab2f88537f90d85 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Wed, 28 Oct 2026 19:52:13 +1300 Subject: [PATCH 29/38] Keep the Weka terminal check focused on the selection guidance --- tests/tuistory/cli-model-availability.tuistory.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/tuistory/cli-model-availability.tuistory.test.ts b/tests/tuistory/cli-model-availability.tuistory.test.ts index 5ebc7d6c0..ff3a9ae71 100644 --- a/tests/tuistory/cli-model-availability.tuistory.test.ts +++ b/tests/tuistory/cli-model-availability.tuistory.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest'; -import { createTempAutohandHome, launchBuiltAutohand, exitInteractive } from './helpers/autohandTuistory.js'; +import { createTempAutohandHome, launchBuiltAutohand } from './helpers/autohandTuistory.js'; it('explains the API and Agent SDK route when Weka is selected explicitly', async () => { const state = await createTempAutohandHome({ config: { @@ -10,7 +10,6 @@ it('explains the API and Agent SDK route when Weka is selected explicitly', asyn try { await session.waitForText('Weka is available through the Autohand API and Agent SDKs, not in the CLI.', { timeout: 20_000 }); expect(session.readAll().replace(/\s+/g, ' ')).toContain('Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.'); - await exitInteractive(session); } finally { session.close(); await state.cleanup(); From 94758429bcddbb64422c99b4ee9ea19b196854b1 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Wed, 28 Oct 2026 19:59:15 +1300 Subject: [PATCH 30/38] Make team recovery explicit and honor native shell selection Report terminal task outcomes without keeping the busy animation alive, and provide inspection, confirmed retries, worker shutdown, and separate hide controls. Display the configured team shortcut in the panel and help. Route Git Bash installations through the native Windows installer. Use a shared shell executable and dialect for command tools, live output, model context, and diagnostics, preserving CMD quoting and resource coordination. Add real-terminal recovery coverage and dedicated Windows compatibility jobs. Co-authored-by: Autohand Evolve --- .github/workflows/ci.yml | 35 +++++++ README.md | 2 + docs/config-reference.md | 6 +- docs/teams.md | 12 ++- install.sh | 56 +++++++++++ src/actions/command.ts | 8 +- src/commands/team.ts | 36 ++++++- src/config.ts | 12 +++ src/core/agent/SystemPromptBuilder.ts | 4 + src/core/agent/TeamActivityBridge.ts | 2 +- src/core/toolManager.ts | 2 +- src/feedback/environment.ts | 11 ++- src/keybindings/profiles.ts | 5 + src/session/peers/CommandCoordinationGate.ts | 4 +- .../scenarios/teamTerminalStatusScenario.ts | 30 ++++++ src/testing/snapshots/shortcut-help-wide.txt | 16 +-- src/types.ts | 2 + src/ui/ink/AgentUI.tsx | 23 ++++- src/ui/ink/ShortcutsHelpPanel.tsx | 1 + src/ui/ink/StatusLine.tsx | 17 ++-- src/ui/ink/TeamPanel.tsx | 99 +++++++++++++++++-- src/ui/shellCommand.ts | 31 ++---- src/ui/taskPanelModel.ts | 15 +++ src/utils/childProcessEnv.ts | 1 + src/utils/shellExecution.ts | 69 +++++++++++++ tests/ci/workflowCheckout.test.ts | 10 +- tests/commands/team.test.ts | 29 ++++++ tests/config/shellSettings.test.ts | 6 ++ tests/core/agent/SystemPromptBuilder.test.ts | 8 ++ tests/core/agent/TeamActivityBridge.test.ts | 2 +- tests/feedback/environment.test.ts | 13 ++- tests/tuistory/team-activity.tuistory.test.ts | 38 +++++-- tests/ui/ink/ShortcutsHelpPanel.test.tsx | 5 + tests/ui/ink/StatusLine.test.tsx | 31 +++++- tests/ui/ink/TeamPanel.test.tsx | 72 +++++++++++++- tests/unixInstallerPlatforms.test.ts | 38 ++++--- tests/utils/shellExecution.test.ts | 60 +++++++++++ tests/windowsShell.integration.test.ts | 74 ++++++++++++++ 38 files changed, 795 insertions(+), 90 deletions(-) create mode 100644 src/utils/shellExecution.ts create mode 100644 tests/utils/shellExecution.test.ts create mode 100644 tests/windowsShell.integration.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d40836e5b..d24782fb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,14 @@ jobs: AUTOHAND_SKIP_COMPUTER_CONTROL_INSTALL: '1' run: sh src/testing/scenarios/fresh-alpha-install.sh ./binaries/autohand-test ci-alpha-smoke + - name: Verify Git Bash shell execution + if: runner.os == 'Windows' + shell: bash + timeout-minutes: 10 + run: | + cygpath --version + bun run test:ci tests/windowsShell.integration.test.ts tests/ui/ink/TeamPanel.test.tsx + - name: Verify binary (Unix) if: runner.os != 'Windows' run: | @@ -209,3 +217,30 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Windows --help smoke test failed with exit code $LASTEXITCODE" } + + windows-tuistory: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build CLI for terminal tests + run: bun run build + + - name: Verify native team recovery from Git Bash + shell: bash + run: bun run test:tuistory tests/tuistory/team-activity.tuistory.test.ts diff --git a/README.md b/README.md index 64354d59f..6bb8f6203 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Interactive sessions assess repository readiness in the background while you wor The shell installer includes [Zit](https://getzit.org) v0.1.2 by default on supported macOS and Linux platforms, preserving an existing installation and verifying release checksums. Use `--no-zit` or `AUTOHAND_SKIP_ZIT_INSTALL=1` to opt out. The Windows installer supports `-NoZit` and the same environment variable; it preserves an existing Zit installation and reports that native Windows binaries are currently unavailable. `autohand --zit` requires Git 2.38 or newer. Installing Zit does not initialize repositories. +On Windows, running `install.sh` from Git Bash, MSYS, or Cygwin delegates to the native PowerShell installer and preserves installation options. Autohand launched from Git Bash uses Bash for command tools; see [shell settings](docs/config-reference.md#shell-settings) to choose an executable explicitly. + The npm package includes checksum-verified Zit binaries for macOS and Linux on ARM64 and x64. `npm install -g autohand-cli` and `bun install -g autohand-cli` download them with the package, including when lifecycle scripts are disabled. Autohand uses `ZIT_BIN`, an existing `zit` on `PATH`, or its packaged binary, in that order. Rust and a separate Zit installation are not required. Native Windows Zit binaries are currently unavailable. - **Terminal-Native Agent**: Understands your codebase and executes approved changes from the CLI diff --git a/docs/config-reference.md b/docs/config-reference.md index acf759e22..380af2bb7 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -1683,7 +1683,7 @@ git add -A && git commit -m "feat: add user dashboard with charts" ## Shell Settings -Control what shell commands started by Autohand can see. This covers the `run_command` tool, `!` terminal commands, and streaming or interactive shells. Hook commands and MCP server processes have their own launchers and are not affected. +Choose the shell used by `run_command`, foreground and background `!` commands, and streaming shells, and control their environment. Hook commands and MCP server processes have their own launchers and are not affected. ```json { @@ -1700,6 +1700,8 @@ Control what shell commands started by Autohand can see. This covers the `run_co | Field | Type | Default | Description | | ------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------- | +| `executable` | string | auto | Shell executable or full path, such as `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh.exe`, or `/bin/zsh` | +| `dialect` | string | inferred | `posix`, `cmd`, or `powershell`; requires `executable`. Controls command invocation and the syntax described to the agent | | `env.inherit` | string | `all` | `all` passes the whole parent environment; `essential` keeps PATH, HOME, USER, SHELL, TMPDIR, locale and terminal variables (plus their Windows equivalents); `none` starts empty | | `env.include` | string[] | `[]` | Variable names or globs to add back on top of the inherited set | | `env.exclude` | string[] | `[]` | Variable names or globs removed after inheritance and includes | @@ -1707,6 +1709,8 @@ Control what shell commands started by Autohand can see. This covers the `run_co Variables Autohand needs to run its own tooling (`AUTOHAND_*`, `AUTOHAND_CLI`, `AUTOHAND_HOME`, `CODEX_HOME`) are always present. Explicit per-command overrides supplied by a tool call still win over the policy. +By default, macOS/Linux use `SHELL` or `/bin/sh`. Native Windows uses `ComSpec` or `cmd.exe`. When launched from Git Bash (`MSYSTEM` is set), Autohand resolves its Bash executable with `cygpath.exe` and uses POSIX command syntax. If that conversion fails, configure the full Windows path to `bash.exe`. Bug reports identify the selected command shell and dialect separately from the parent shell. + ## Network Settings ```json diff --git a/docs/teams.md b/docs/teams.md index 698c55519..37cd368a4 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -77,6 +77,10 @@ transport cannot be confused with terminal output. | `/team create [name]` | Create a team or reuse the active team with the same name | | `/team status` | Show team name, active/completed status, member count, task progress, member list with status indicators, and the full task list | | `/team view` | Open the live team view without asking the agent for a status report | +| `/team errors` | Inspect failed task IDs, errors, and available partial output | +| `/team retry ` | Retry one failed task on the active team | +| `/team retry-failed` | Retry failed tasks while preserving successful and cancelled tasks | +| `/team hide` | Hide the team panel and keep workers available | | `/team shutdown` | Gracefully shut down all teammates | Example `/team status` output: @@ -179,13 +183,17 @@ Open or close the expanded view with: - **Ctrl+T** as the portable terminal shortcut. - **`/team view`** to open it explicitly. -The expanded `TeamPanel` renders below the fixed status line and above the composer, and shows: +The footer shows the shortcut from your current keybindings. The expanded team view takes over the composer while it is open, and shows: -- **Header** -- Team name with an active/inactive indicator. +- **Header** -- Team name and whether workers are available or the team is closed. - **Task list** -- Progress count (N/M done) and each task with its status icon, subject, and assigned owner. - **Teammate list** -- Each teammate with a status icon, name, agent type, and its effective provider and model while it is working. +Use the arrow keys to select a task and Enter to inspect its error and available output. Press Esc to return to the list. Press **r** to retry the selected failed task, **a** to retry all failed tasks, or **s** to shut down the team; each action asks for confirmation. Press **h**, Esc from the list, or the displayed team shortcut to hide the view. Hiding keeps workers running and available for reuse; shutdown stops them. + +When no tasks remain pending or in progress, the footer stops animating and reports successful, failed, and cancelled counts. A finished batch can contain failures. Retry commands start new attempts only for failed tasks on an active team. Previous attempts remain available in `/agents view` within its bounded session history; creating a new team replaces the current task list. + Creating a team from the default interactive editing mode also changes the session to **AUTO** so teammates can proceed without approval prompts. An explicit PLAN or YOLO choice is preserved. ACP/RPC sessions keep their client- diff --git a/install.sh b/install.sh index 414974373..0477f4d9f 100755 --- a/install.sh +++ b/install.sh @@ -199,6 +199,58 @@ install_computer_control() { fi } +install_windows() { + local _channel="$1" + shift + local _powershell="" _candidate _tmp_dir _script _url _status _clean=0 _no_cache=0 + for _candidate in powershell.exe pwsh.exe powershell pwsh; do + if command -v "$_candidate" >/dev/null 2>&1; then + _powershell="$_candidate" + break + fi + done + [ -n "$_powershell" ] || err "PowerShell is required for Windows installation. Open PowerShell and run the Windows installer: https://autohand.ai/install.ps1" + for _candidate in "$@"; do + case "$_candidate" in + --clean) _clean=1 ;; + --no-cache) _no_cache=1 ;; + esac + done + _tmp_dir=$(mktemp -d) || return 1 + _script="$_tmp_dir/install.ps1" + _url="https://github.com/${REPO}/releases/latest/download/install.ps1" + if [ -n "${AUTOHAND_VERSION:-}" ] && [ "$AUTOHAND_VERSION" != "latest" ]; then + _url="https://github.com/${REPO}/releases/download/v${AUTOHAND_VERSION#v}/install.ps1" + fi + if ! curl -fsSL "$_url" -o "$_script"; then + rm -rf "$_tmp_dir" + err "Could not download the Windows installer from $_url" + fi + if command -v cygpath >/dev/null 2>&1; then + _script=$(cygpath -w "$_script") || { rm -rf "$_tmp_dir"; return 1; } + fi + set -- -NoProfile -ExecutionPolicy Bypass -File "$_script" + [ "$_channel" != "alpha" ] || set -- "$@" -Alpha + [ "${AUTOHAND_INSTALL_FRESH:-0}" != "1" ] || set -- "$@" -Fresh + [ "${AUTOHAND_SKIP_ZIT_INSTALL:-0}" != "1" ] || set -- "$@" -NoZit + [ "$_clean" != "1" ] || set -- "$@" -Clean + [ "$_no_cache" != "1" ] || set -- "$@" -NoCache + if [ -n "${AUTOHAND_VERSION:-}" ] && [ "$AUTOHAND_VERSION" != "latest" ]; then + set -- "$@" -Version "${AUTOHAND_VERSION#v}" + fi + if [ -n "${AUTOHAND_INSTALL_DIR:-}" ]; then + local _install_dir="$AUTOHAND_INSTALL_DIR" + if command -v cygpath >/dev/null 2>&1; then + _install_dir=$(cygpath -w "$_install_dir") || { rm -rf "$_tmp_dir"; return 1; } + fi + set -- "$@" -InstallDir "$_install_dir" + fi + info "Using the native Windows installer (PowerShell)." + if "$_powershell" "$@"; then _status=0; else _status=$?; fi + rm -rf "$_tmp_dir" + return "$_status" +} + main() { printf "${BLUE}" cat << 'EOF' @@ -245,6 +297,10 @@ EOF get_architecture || return 1 local _arch="$RETVAL" + case "$_arch" in + windows-*) install_windows "$_channel" "$@"; return $? ;; + esac + local _version="${AUTOHAND_VERSION:-latest}" local _asset_name="autohand-${_arch}.tar.gz" local _url diff --git a/src/actions/command.ts b/src/actions/command.ts index cea0279af..d9ee2277c 100644 --- a/src/actions/command.ts +++ b/src/actions/command.ts @@ -10,6 +10,7 @@ import { isAbsolute, join } from 'node:path'; import { buildAutohandChildProcessEnv } from '../utils/childProcessEnv.js'; import { prepareCommandCoordination } from '../session/peers/CommandCoordinationGate.js'; import { CommandOutputCapture } from '../utils/commandOutputCapture.js'; +import { resolveShellExecution } from '../utils/shellExecution.js'; const DEFAULT_KILL_GRACE_PERIOD_MS = 1_000; @@ -141,6 +142,8 @@ export async function runCommand( const workDir = options.directory ? (isAbsolute(options.directory) ? options.directory : join(cwd, options.directory)) : cwd; + const shellExecution = options.shell ? resolveShellExecution([cmd, ...args].join(' ')) : undefined; + const execution = shellExecution ?? { file: cmd, args }; const coordinated = await prepareCommandCoordination({ file: cmd, args, cwd: workDir }, options.signal); if (options.signal?.aborted) { const error = new CommandAbortedError(); @@ -155,7 +158,8 @@ export async function runCommand( // Build spawn options const spawnOptions: SpawnOptions = { cwd: workDir, - shell: options.shell ?? false, + shell: false, + windowsVerbatimArguments: shellExecution?.windowsVerbatimArguments, env: buildAutohandChildProcessEnv(options.env), ...(coordinated ? { detached: true } : {}), }; @@ -176,7 +180,7 @@ export async function runCommand( // uncaught exceptions from crashing the process. let child; try { - child = spawn(cmd, args, spawnOptions); + child = spawn(execution.file, execution.args, spawnOptions); coordinated?.observe(child); } catch (error) { const spawnError = toCommandSpawnError(error, cmd, workDir); diff --git a/src/commands/team.ts b/src/commands/team.ts index 911de6196..439833778 100644 --- a/src/commands/team.ts +++ b/src/commands/team.ts @@ -17,6 +17,10 @@ export const metadata: SlashCommand = { { name: 'create', description: 'Create a new team' }, { name: 'view', description: 'Open the live team activity view' }, { name: 'status', description: 'Show team status' }, + { name: 'errors', description: 'Inspect failed tasks and their output' }, + { name: 'retry', description: 'Retry one failed task by ID' }, + { name: 'retry-failed', description: 'Retry all failed tasks' }, + { name: 'hide', description: 'Hide the panel without stopping teammates' }, { name: 'shutdown', description: 'Shut down all teammates' }, ], }; @@ -40,6 +44,10 @@ export async function team(ctx: TeamCommandContext, args: string[]): Promise')} Create a new team`, ` ${chalk.cyan('/team view')} Open the live team activity view`, ` ${chalk.cyan('/team status')} Show current team status`, + ` ${chalk.cyan('/team errors')} Inspect failed tasks`, + ` ${chalk.cyan('/team retry ')} Retry a failed task`, + ` ${chalk.cyan('/team retry-failed')} Retry all failed tasks`, + ` ${chalk.cyan('/team hide')} Hide the panel; keep workers available`, ` ${chalk.cyan('/team shutdown')} Shut down all teammates`, ` ${chalk.cyan('/team')} Show this help`, '', @@ -53,7 +61,32 @@ export async function team(ctx: TeamCommandContext, args: string[]): Promise task.status === 'failed'); + if (!failures.length) return 'No failed team tasks.'; + return failures.map(task => [ + `${task.id} · ${task.subject}`, + task.error ?? 'No error details recorded.', + ...(task.output ? [task.output] : []), + ].join('\n')).join('\n\n'); + } + + case 'retry': + case 'retry-failed': { + if (ctx.teamManager.getTeam()?.status !== 'active') return 'This team is closed. Create a new team to start work.'; + const failures = ctx.teamManager.tasks.listTasks().filter(task => task.status === 'failed'); + const selected = subcommand === 'retry' ? failures.filter(task => task.id === args[1]) : failures; + if (subcommand === 'retry' && !selected.length) return 'Only failed tasks can be retried. Use /team errors to choose a task ID.'; + if (!selected.length) return 'No failed team tasks to retry.'; + for (const task of selected) ctx.teamManager.updateTask(task.id, { status: 'pending' }); + return `Retrying ${selected.length} failed task${selected.length === 1 ? '' : 's'}. Successful and cancelled tasks are unchanged. Previous attempts remain in /agents view.`; } case 'create': { @@ -121,6 +154,7 @@ export async function team(ctx: TeamCommandContext, args: string[]): Promise { const env = input.env ?? process.env; - const shellPath = env.SHELL ?? env.ComSpec; + const parentShellPath = env.SHELL ?? env.ComSpec; + let executionShell: ReturnType | undefined; + try { executionShell = getShellExecutionProfile({ environment: env }); } catch { /* Keep error reporting available when shell resolution fails. */ } + const shellName = executionShell ? path.basename(executionShell.executable.replaceAll('\\', '/')) : 'unresolved'; + const parentShell = parentShellPath ? path.basename(parentShellPath.replaceAll('\\', '/')) : undefined; const size = process.stdout.isTTY ? `${process.stdout.columns}x${process.stdout.rows}` : undefined; const facts: Record = { os: `${process.platform} ${os.release()} (${process.arch})`, runtime: process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.version}`, - shell: shellPath ? path.basename(shellPath.replaceAll('\\', '/')) : undefined, + shell: shellName, + shellDialect: executionShell?.dialect, + parentShell: parentShell !== shellName ? parentShell : undefined, terminal: [env.TERM_PROGRAM, env.TERM_PROGRAM_VERSION].filter(Boolean).join(' '), term: env.TERM, multiplexer: env.TMUX ? 'tmux' : env.STY ? 'screen' : undefined, diff --git a/src/keybindings/profiles.ts b/src/keybindings/profiles.ts index 5a89cce32..068fe3085 100644 --- a/src/keybindings/profiles.ts +++ b/src/keybindings/profiles.ts @@ -203,6 +203,11 @@ export function formatChord(chord: Chord): string { return parts.filter(Boolean).join(' + '); } +export function actionShortcutHint(keybindings: ResolvedKeybindings, action: KeybindingAction, fallback: string): string { + const chord = keybindings.bindings[action][0]; + return chord ? formatChord(chord).replaceAll(' + ', '+') : fallback; +} + export function normalizeChord(chord: Chord): string { return [chord.ctrl ? 'ctrl' : null, chord.meta ? 'meta' : null, chord.shift ? 'shift' : null, chord.key] .filter(Boolean) diff --git a/src/session/peers/CommandCoordinationGate.ts b/src/session/peers/CommandCoordinationGate.ts index 5d5559e9b..07ee25246 100644 --- a/src/session/peers/CommandCoordinationGate.ts +++ b/src/session/peers/CommandCoordinationGate.ts @@ -73,11 +73,11 @@ export function signalCoordinatedProcess(child: ChildProcess, signal: NodeJS.Sig child.kill(signal); } -export async function spawnCoordinatedProcess(command: ResourceCommand, options: SpawnOptions = {}, signal?: AbortSignal): Promise { +export async function spawnCoordinatedProcess(command: ResourceCommand, options: SpawnOptions = {}, signal?: AbortSignal, execution: Pick = command): Promise { const coordinated = await prepareCommandCoordination(command, signal); try { signal?.throwIfAborted(); - const child = spawn(command.file, command.args, { ...options, cwd: command.cwd, ...(coordinated ? { detached: true } : {}) }); + const child = spawn(execution.file, execution.args, { ...options, cwd: command.cwd, ...(coordinated ? { detached: true } : {}) }); coordinated?.observe(child); return child; } catch (error) { diff --git a/src/testing/scenarios/teamTerminalStatusScenario.ts b/src/testing/scenarios/teamTerminalStatusScenario.ts index b7cc75268..646c611e9 100644 --- a/src/testing/scenarios/teamTerminalStatusScenario.ts +++ b/src/testing/scenarios/teamTerminalStatusScenario.ts @@ -26,3 +26,33 @@ export async function inspectTerminalTeamOutcomes(session: Session): Promise<{ t }); return { team, tasks }; } + +export async function recoverFailedTeam(session: Session) { + await session.press(['ctrl', 't']); + await session.waitForText('Enter inspect'); + await session.press('enter'); + const failure = await session.text({ timeout: 10_000, waitFor: text => text.includes('SECURITY_TASK_FAILED') }); + await session.press('escape'); + await session.waitForText('Enter inspect'); + await session.press('r'); + await session.waitForText('Retry Review authentication?'); + await session.press('y'); + await session.waitForText('Retrying 1 failed task'); + await session.press(['ctrl', 't']); + const retried = await session.text({ timeout: 30_000, waitFor: text => text.includes('1 succeeded') && text.includes('1 cancelled') }); + await session.press('s'); + await session.waitForText('Shut down this team'); + await session.press('n'); + await session.waitForText('Enter inspect'); + await session.press('s'); + await session.waitForText('Shut down this team'); + await session.press('y'); + const closed = await session.text({ timeout: 15_000, waitFor: text => text.includes('has been shut down') && text.includes('❯') }); + await session.type('/team create replacement'); + await session.press('enter'); + const replacement = await session.text({ timeout: 10_000, waitFor: text => text.includes('Team "replacement" created') }); + await session.type('/team shutdown'); + await session.press('enter'); + await session.waitForText('Team "replacement" has been shut down'); + return { failure, retried, closed, replacement }; +} diff --git a/src/testing/snapshots/shortcut-help-wide.txt b/src/testing/snapshots/shortcut-help-wide.txt index 2a32569b4..790487470 100644 --- a/src/testing/snapshots/shortcut-help-wide.txt +++ b/src/testing/snapshots/shortcut-help-wide.txt @@ -3,9 +3,13 @@ Keyboard shortcuts Compose Session Transcript / Commands shift + tab Change mode /whatityped Typed history @ Mention files enter Queue while working ctrl + o Expand output -$ Skills ↑ / ↓ Select queued message pgup / pgdn Scroll -! Shell command enter Steer selected message ctrl+f Find text -shift + enter New line ctrl+enter Steer queued input ctrl+home First message -alt + enter New line esc Interrupt ctrl+end / esc Back to bottom -tab Accept suggestion ctrl+c Clear input / exit ↑ / ↓ Recall prompt -enter Send prompt ? Close shortcuts +$ Skills ctrl + t Team panel pgup / pgdn Scroll +! Shell command alt + t Team panel ctrl+f Find text +shift + enter New line ctrl + g Goals panel ctrl+home First message +alt + enter New line alt + g Goals panel ctrl+end / esc Back to bottom +tab Accept suggestion ↑ / ↓ Select queued message ↑ / ↓ Recall prompt +enter Send prompt enter Steer selected message + ctrl+enter Steer queued input + esc Interrupt + ctrl+c Clear input / exit + ? Close shortcuts diff --git a/src/types.ts b/src/types.ts index e90ac60b5..586bb2581 100644 --- a/src/types.ts +++ b/src/types.ts @@ -575,6 +575,8 @@ export interface RunConfigOverlaySnapshot { } export interface ShellSettings { + executable?: string; + dialect?: 'posix' | 'cmd' | 'powershell'; /** Environment inherited by commands Autohand runs; see docs/config-reference.md. */ env?: { inherit?: 'all' | 'essential' | 'none'; diff --git a/src/ui/ink/AgentUI.tsx b/src/ui/ink/AgentUI.tsx index db2d529de..83fb4ce39 100644 --- a/src/ui/ink/AgentUI.tsx +++ b/src/ui/ink/AgentUI.tsx @@ -54,6 +54,7 @@ import { TextBuffer } from '../textBuffer.js'; import { handleTextBufferKey, type KeyHandlerResult } from '../textBufferKeyHandler.js'; import { DEFAULT_KEYBINDINGS, + actionShortcutHint, matchesChord, normalizeChord, parseChord, @@ -868,7 +869,8 @@ export function AgentUI({ const { stdout } = useStdout(); const [, redrawFullScreen] = useState(0); const fullScreenKeys = useRef(false); - fullScreenKeys.current = Boolean(fullScreenTranscript) && !state.agentRunsPanelVisible; + const teamViewVisible = state.teamPanelVisible && Boolean(state.teamActivity?.team); + fullScreenKeys.current = Boolean(fullScreenTranscript) && !state.agentRunsPanelVisible && !teamViewVisible; const { colors } = useTheme(); const { t } = useTranslation(); const slashCommands = state.runtimeSlashCommands ?? slashCommandProps; @@ -2628,17 +2630,17 @@ export function AgentUI({ }, []); const handlePaste = useCallback((pastedText: string) => { - if (state.agentRunsPanelVisible || (isWorkingRef.current && !enableQueueInputRef.current)) { + if (state.agentRunsPanelVisible || teamViewVisible || (isWorkingRef.current && !enableQueueInputRef.current)) { return; } insertPastedText(pastedText); - }, [insertPastedText, state.agentRunsPanelVisible]); + }, [insertPastedText, state.agentRunsPanelVisible, teamViewVisible]); // Ink owns bracketed-paste framing at the stdin parser boundary. Its paste // channel buffers split protocol markers and keeps pasted bytes out of // useInput, so the composer receives the complete payload exactly once. usePaste(handlePaste); - useInput(stableHandleInput, { isActive: !state.agentRunsPanelVisible }); + useInput(stableHandleInput, { isActive: !state.agentRunsPanelVisible && !teamViewVisible }); // Memoize tool outputs to prevent unnecessary re-renders // Static items use the entry id as key and never re-render @@ -2798,6 +2800,12 @@ export function AgentUI({ onMessage={onMessageAgentRun} onCtrlC={onCtrlC} /> + ) : teamViewVisible && state.teamActivity?.team ? ( + onToggleTeamPanelRef.current?.()} + onCommand={command => onInstructionRef.current(command)} onCtrlC={onCtrlC} /> ) : ; - if (fullScreenTranscript) return state.agentRunsPanelVisible ? {bottom} : {bottom} : entry.isExpanded)?.id ?? expandedToolOutput?.id} rows={windowSize.rows ?? 24} columns={windowSize.columns ?? 80} footer={bottom} @@ -3213,6 +3221,7 @@ interface StatusSectionProps { completionStats: { elapsed: string; tokens: string; status?: TurnCompletionStatus } | null; activityItems?: ActivityItem[]; teamActivity?: TeamActivitySnapshot; + teamShortcut?: string; teamPanelVisible: boolean; goalActivity?: GoalSessionSnapshot; goalPanelVisible: boolean; @@ -3323,6 +3332,7 @@ const StatusSection = memo(function StatusSection({ completionStats, activityItems = [], teamActivity, + teamShortcut, teamPanelVisible, goalActivity, goalPanelVisible, @@ -3363,6 +3373,7 @@ const StatusSection = memo(function StatusSection({ provider={provider} model={model} teamActivity={teamActivity} + teamShortcut={teamShortcut} lineExtension={lineExtension} onSpinnerFrame={onWorkingSpinnerFrame} /> @@ -3420,6 +3431,7 @@ const StatusSection = memo(function StatusSection({ prev.completionStats?.status === next.completionStats?.status && prev.activityItems === next.activityItems && prev.teamActivity === next.teamActivity && + prev.teamShortcut === next.teamShortcut && prev.teamPanelVisible === next.teamPanelVisible && prev.goalActivity === next.goalActivity && prev.goalPanelVisible === next.goalPanelVisible && @@ -3918,6 +3930,7 @@ const FixedBottom = memo(function FixedBottom({ completionStats={completionStats} activityItems={activityItems} teamActivity={teamActivity} + teamShortcut={actionShortcutHint(keybindings, 'toggleTeamPanel', '/team view')} teamPanelVisible={teamPanelVisible} goalActivity={goalActivity} goalPanelVisible={goalPanelVisible} diff --git a/src/ui/ink/ShortcutsHelpPanel.tsx b/src/ui/ink/ShortcutsHelpPanel.tsx index 3b1ab464c..4c675ae15 100644 --- a/src/ui/ink/ShortcutsHelpPanel.tsx +++ b/src/ui/ink/ShortcutsHelpPanel.tsx @@ -39,6 +39,7 @@ export const ShortcutsHelpPanel = memo(function ShortcutsHelpPanel({ ] }, { title: 'Session', rows: [ ...bindingRows('cycleMode', 'Change mode'), ['enter', 'Queue while working'], + ...bindingRows('toggleTeamPanel', 'Team panel'), ...bindingRows('toggleGoals', 'Goals panel'), ['↑ / ↓', 'Select queued message'], ['enter', 'Steer selected message'], ['ctrl+enter', 'Steer queued input'], ['esc', 'Interrupt'], ['ctrl+c', 'Clear input / exit'], diff --git a/src/ui/ink/StatusLine.tsx b/src/ui/ink/StatusLine.tsx index 3497ad674..e0beffb05 100644 --- a/src/ui/ink/StatusLine.tsx +++ b/src/ui/ink/StatusLine.tsx @@ -10,6 +10,7 @@ import { useTheme } from '../theme/ThemeContext.js'; import { useTranslation } from '../i18n/index.js'; import type { Theme } from '../theme/Theme.js'; import type { TeamActivitySnapshot } from '../../core/teams/types.js'; +import { summarizeTaskOutcomes } from '../taskPanelModel.js'; export type LineSegmentColor = | 'text' @@ -52,6 +53,7 @@ export interface StatusLineProps { model?: string; /** Live background team state shown even while the lead is idle. */ teamActivity?: TeamActivitySnapshot; + teamShortcut?: string; /** Optional extension points for status-line text segments. */ lineExtension?: LineExtension; /** Fires with each spinner frame so the terminal tab can animate in step. */ @@ -81,13 +83,13 @@ function WorkingSpinner({ onFrame }: { onFrame?: (frame: number) => void }) { export function buildTeamActivitySegment( snapshot: TeamActivitySnapshot | undefined, + shortcut = 'ctrl+t', ): { segment?: LineSegment; busy: boolean } { - if (!snapshot?.team) return { busy: false }; - const done = snapshot.tasks.filter((task) => task.status === 'completed').length; + if (!snapshot?.team || snapshot.team.status !== 'active') return { busy: false }; + const outcome = summarizeTaskOutcomes(snapshot.tasks); const working = snapshot.team.members.filter((member) => member.status === 'working').length; const spawning = snapshot.team.members.filter((member) => member.status === 'spawning').length; - const open = snapshot.tasks.filter((task) => task.status !== 'completed').length; - const busy = snapshot.team.status === 'active' && (working > 0 || spawning > 0 || open > 0); + const busy = working > 0 || spawning > 0 || outcome.open > 0; const workerLabel = working > 0 ? `${working} working` : spawning > 0 @@ -98,8 +100,8 @@ export function buildTeamActivitySegment( busy, segment: { id: 'team-activity', - text: `${snapshot.team.name} · ${done}/${snapshot.tasks.length} done · ${workerLabel} · cmd+t team`, - color: busy ? 'warning' : 'success', + text: `${snapshot.team.name} · ${outcome.settled ? outcome.label : `${outcome.completed}/${outcome.total} done · ${workerLabel}`} · ${shortcut} team`, + color: outcome.failed > 0 ? 'error' : busy || outcome.cancelled > 0 ? 'warning' : 'success', }, }; } @@ -223,12 +225,13 @@ function StatusLineComponent({ tokens, queueCount = 0, teamActivity, + teamShortcut, lineExtension, onSpinnerFrame, }: StatusLineProps) { const { colors, theme } = useTheme(); const { t } = useTranslation(); - const teamStatus = buildTeamActivitySegment(teamActivity); + const teamStatus = buildTeamActivitySegment(teamActivity, teamShortcut); const defaultSegments = [ ...(isWorking ? buildStatusSegments(status, elapsed, tokens, queueCount, t('ui.escToCancel')) : []), ...(teamStatus.segment ? [teamStatus.segment] : []), diff --git a/src/ui/ink/TeamPanel.tsx b/src/ui/ink/TeamPanel.tsx index 18eaff59b..8b3e11c56 100644 --- a/src/ui/ink/TeamPanel.tsx +++ b/src/ui/ink/TeamPanel.tsx @@ -3,15 +3,32 @@ * Copyright 2025 Autohand AI LLC * SPDX-License-Identifier: Apache-2.0 */ -import React, { memo } from 'react'; -import { Box, Text } from 'ink'; +import React, { memo, useCallback, useRef, useState } from 'react'; +import { Box, Text, useInput, type Key } from 'ink'; +import wrapAnsi from 'wrap-ansi'; import type { Team, TeamTask } from '../../core/teams/types.js'; import { useTheme } from '../theme/ThemeContext.js'; import type { ColorToken } from '../theme/types.js'; +import { summarizeTaskOutcomes } from '../taskPanelModel.js'; +import { sanitizeAnnouncementText } from '../../announcements/AnnouncementContent.js'; +import { DEFAULT_KEYBINDINGS, actionShortcutHint, type ResolvedKeybindings } from '../../keybindings/profiles.js'; export interface TeamPanelProps { team: Team; tasks: TeamTask[]; + onHide?: () => void; + onCommand?: (command: string) => void; + onCtrlC?: () => void; + keybindings?: ResolvedKeybindings; + terminalRows?: number; + terminalColumns?: number; +} + +type TeamPanelMode = { kind: 'list' } | { kind: 'details'; offset: number } + | { kind: 'confirm'; command: string; question: string }; + +function clean(value: string): string { + return sanitizeAnnouncementText(value, { maxCharacters: 8192, preserveParagraphs: true }); } const StatusIcon = memo(({ status }: { status: string }) => { @@ -32,24 +49,86 @@ const StatusIcon = memo(({ status }: { status: string }) => { }); StatusIcon.displayName = 'StatusIcon'; -export const TeamPanel = memo(({ team, tasks }: TeamPanelProps) => { +export const TeamPanel = memo(({ team, tasks, onHide, onCommand, onCtrlC, + keybindings = DEFAULT_KEYBINDINGS, terminalRows = 24, terminalColumns = 80 }: TeamPanelProps) => { const { theme } = useTheme(); - const done = tasks.filter((t) => t.status === 'completed').length; + const outcome = summarizeTaskOutcomes(tasks); + const done = outcome.completed; + const [selectedId, setSelectedId] = useState(tasks[0]?.id); + const [mode, setMode] = useState({ kind: 'list' }); + const selectedIndex = Math.max(0, tasks.findIndex(task => task.id === selectedId)); + const selected = tasks[selectedIndex]; + const interactive = Boolean(onHide && onCommand); + const rows = Math.max(2, terminalRows - 9); + const hideHint = `${actionShortcutHint(keybindings, 'toggleTeamPanel', '/team hide')} hide`; + const details = selected ? clean([selected.error ?? 'No error recorded.', selected.output ?? 'No output recorded.'].join('\n\n')) : 'Task unavailable.'; + const detailLines = wrapAnsi(details, Math.max(1, terminalColumns - 2), { hard: true, trim: false }).split('\n'); + + const handleInput = useRef<(input: string, key: Key) => void>(() => {}); + handleInput.current = (input, key) => { + if (key.ctrl && input === 'c') { onCtrlC?.(); return; } + if (keybindings.matches('toggleTeamPanel', { input, ctrl: key.ctrl, shift: key.shift, meta: key.meta })) { onHide?.(); return; } + if (key.escape) { + if (mode.kind === 'list') onHide?.(); + else setMode({ kind: 'list' }); + return; + } + if (mode.kind === 'confirm') { + if (input.toLowerCase() === 'y') { + onHide?.(); + onCommand?.(mode.command); + } else if (input.toLowerCase() === 'n') setMode({ kind: 'list' }); + return; + } + if (mode.kind === 'details') { + if (key.upArrow || key.downArrow) setMode({ kind: 'details', offset: Math.max(0, + Math.min(Math.max(0, detailLines.length - rows), mode.offset + (key.downArrow ? 1 : -1))) }); + return; + } + if (key.upArrow || key.downArrow) { + setSelectedId(tasks[Math.max(0, Math.min(tasks.length - 1, selectedIndex + (key.downArrow ? 1 : -1)))]?.id); + } else if (key.return && selected) setMode({ kind: 'details', offset: 0 }); + else if (input.toLowerCase() === 'h') onHide?.(); + else if (input.toLowerCase() === 's' && team.status === 'active') { + setMode({ kind: 'confirm', command: '/team shutdown', question: 'Shut down this team and stop its workers?' }); + } else if (input.toLowerCase() === 'r' && selected?.status === 'failed' && team.status === 'active') { + setMode({ kind: 'confirm', command: `/team retry ${selected.id}`, question: `Retry ${clean(selected.subject)}?` }); + } else if (input.toLowerCase() === 'a' && outcome.failed > 0 && team.status === 'active') { + setMode({ kind: 'confirm', command: '/team retry-failed', question: `Retry ${outcome.failed} failed tasks?` }); + } + }; + useInput(useCallback((input, key) => handleInput.current(input, key), []), { isActive: interactive }); + + if (mode.kind !== 'list') return + Team: {clean(team.name)} + {mode.kind === 'confirm' ? {mode.question} y confirm · n keep team : <> + {clean(selected?.subject ?? 'Task unavailable')} · {selected?.status} + {detailLines.slice(mode.offset, mode.offset + rows).map((line, index) => {line})} + ↑/↓ scroll · Esc back + } + ; + + const taskLimit = interactive ? Math.max(1, Math.floor(rows / 2)) : tasks.length; + const taskStart = Math.max(0, selectedIndex - taskLimit + 1); + const visibleTasks = tasks.slice(taskStart, taskStart + taskLimit); return ( - Team: {team.name} - {theme.fg(team.status === 'active' ? 'success' : 'muted', `● ${team.status}`)} - {theme.fg('muted', '· Cmd+T close · Ctrl+T portable')} + Team: {clean(team.name)} + {theme.fg(team.status === 'active' ? 'success' : 'muted', `● ${team.status === 'active' ? 'workers available' : 'closed'}`)} + {theme.fg('muted', `· ${hideHint}`)} + {outcome.settled ? {theme.fg(outcome.failed ? 'error' : 'muted', outcome.label)} : null} + {interactive ? ↑/↓ select · Enter inspect · r retry selected · a retry failed · s shut down team · h hide : null} Tasks [{done}/{tasks.length} done] - {tasks.map((task) => ( + {visibleTasks.map((task) => ( + {interactive ? {task.id === selected?.id ? '›' : ' '} : null} - {task.subject} + {clean(task.subject)} {task.status === 'failed' || task.status === 'cancelled' ? ( {theme.fg(task.status === 'failed' ? 'error' : 'muted', `[${task.status}]`)} ) : null} @@ -61,7 +140,7 @@ export const TeamPanel = memo(({ team, tasks }: TeamPanelProps) => { Teammates - {team.members.map((member) => ( + {team.members.slice(0, interactive ? Math.max(1, rows - visibleTasks.length - 2) : team.members.length).map((member) => ( {member.name} diff --git a/src/ui/shellCommand.ts b/src/ui/shellCommand.ts index ccc22b5ad..4d698d205 100644 --- a/src/ui/shellCommand.ts +++ b/src/ui/shellCommand.ts @@ -22,6 +22,7 @@ import { buildAutohandChildProcessEnv } from '../utils/childProcessEnv.js'; import { writeAutohandDebugLine } from '../utils/debugLog.js'; import { prepareCommandCoordination, spawnCoordinatedProcess, waitForProcessPublication } from '../session/peers/CommandCoordinationGate.js'; import { CommandOutputCapture } from '../utils/commandOutputCapture.js'; +import { resolveShellExecution } from '../utils/shellExecution.js'; export type { BackgroundProcessCompletion } from '../actions/command.js'; @@ -378,7 +379,7 @@ interface PtyProcess { interface NodePtyModule { spawn( file: string, - args?: string[], + args?: string[] | string, options?: { name?: string; cols?: number; @@ -545,9 +546,11 @@ export async function executeShellCommandAsync( throw new ShellCommandAbortedError(); } + const execution = resolveShellExecution(trimmedCommand); const child = await spawnCoordinatedProcess({ file: trimmedCommand, args: [], cwd: cwd ?? process.cwd() }, { - shell: true, detached: SUPPORTS_PROCESS_GROUP_SIGNALS, stdio: ['ignore', 'pipe', 'pipe'], env: buildAutohandChildProcessEnv(), - }, options.signal); + windowsVerbatimArguments: execution.windowsVerbatimArguments, + shell: false, detached: SUPPORTS_PROCESS_GROUP_SIGNALS, stdio: ['ignore', 'pipe', 'pipe'], env: buildAutohandChildProcessEnv(), + }, options.signal, execution); return new Promise((resolve, reject) => { const stdout = new CommandOutputCapture(); @@ -685,22 +688,6 @@ export function supportsPtyExecution( return (runtimeVersions as { bun?: string }).bun === undefined; } -function getPtyShellLaunch(command: string): { file: string; args: string[] } { - if (process.platform === 'win32') { - const comspec = process.env.ComSpec || 'cmd.exe'; - return { - file: comspec, - args: ['/d', '/s', '/c', command], - }; - } - - const shell = process.env.SHELL || '/bin/sh'; - return { - file: shell, - args: ['-lc', command], - }; -} - export async function executeStreamingShellCommand( command: string, cwd?: string, @@ -758,12 +745,12 @@ export async function executeStreamingShellCommand( return executeShellCommandAsync(trimmedCommand, cwd, DEFAULT_SHELL_TIMEOUT_MS, options); } - const { file, args } = getPtyShellLaunch(trimmedCommand); - const coordinated = await prepareCommandCoordination({ file, args, cwd: cwd ?? process.cwd() }, options.signal); + const { file, args, windowsVerbatimArguments } = resolveShellExecution(trimmedCommand); + const coordinated = await prepareCommandCoordination({ file: trimmedCommand, args: [], cwd: cwd ?? process.cwd() }, options.signal); let ptyProcess: PtyProcess; try { options.signal?.throwIfAborted(); - ptyProcess = nodePty.spawn(file, args, { + ptyProcess = nodePty.spawn(file, windowsVerbatimArguments ? args.join(' ') : args, { name: process.env.TERM || 'xterm-256color', cols: Math.max(20, options.columns ?? process.stdout.columns ?? 80), rows: Math.max(10, options.rows ?? process.stdout.rows ?? 24), diff --git a/src/ui/taskPanelModel.ts b/src/ui/taskPanelModel.ts index a77855ba3..7c82dfda9 100644 --- a/src/ui/taskPanelModel.ts +++ b/src/ui/taskPanelModel.ts @@ -11,6 +11,21 @@ export type TaskPanelStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled'; +export function summarizeTaskOutcomes(tasks: readonly { status: TaskPanelStatus }[]) { + const counts = { pending: 0, in_progress: 0, completed: 0, failed: 0, cancelled: 0 }; + for (const task of tasks) counts[task.status]++; + const open = counts.pending + counts.in_progress; + const settled = tasks.length > 0 && open === 0; + const outcome = counts.failed > 0 ? 'Finished with failures' : counts.cancelled > 0 ? 'Finished with cancellations' : 'Finished'; + const label = [ + outcome, + `${counts.completed} succeeded`, + ...(counts.failed ? [`${counts.failed} failed`] : []), + ...(counts.cancelled ? [`${counts.cancelled} cancelled`] : []), + ].join(' · '); + return { ...counts, total: tasks.length, open, settled, label }; +} + export interface TaskPanelRow { id?: string; title: string; diff --git a/src/utils/childProcessEnv.ts b/src/utils/childProcessEnv.ts index 5f9942e3f..3fac889ad 100644 --- a/src/utils/childProcessEnv.ts +++ b/src/utils/childProcessEnv.ts @@ -30,6 +30,7 @@ export const ESSENTIAL_CHILD_ENV_KEYS: readonly string[] = [ 'TERM', 'TERM_PROGRAM', 'COLORTERM', 'SSH_AUTH_SOCK', // Windows needs these to locate system binaries and profiles. 'SystemRoot', 'SYSTEMROOT', 'ComSpec', 'COMSPEC', 'PATHEXT', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'ProgramData', + 'MSYSTEM', 'MSYS2_ARG_CONV_EXCL', 'MSYS_NO_PATHCONV', ]; const ALWAYS_PRESENT_PREFIX = 'AUTOHAND_'; diff --git a/src/utils/shellExecution.ts b/src/utils/shellExecution.ts new file mode 100644 index 000000000..338e0dc4f --- /dev/null +++ b/src/utils/shellExecution.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import type { ShellSettings } from '../types.js'; + +type ShellDialect = NonNullable; +type ShellProfileSettings = Pick; + +export interface ShellExecutionContext { + platform?: NodeJS.Platform; + environment?: NodeJS.ProcessEnv; + settings?: ShellProfileSettings; +} + +let configured: ShellProfileSettings | undefined; +let gitBashPath: { key: string; executable: string } | undefined; + +export function configureShellExecution(settings: ShellProfileSettings | undefined): void { + configured = settings?.executable ? { executable: settings.executable, dialect: settings.dialect } : undefined; + gitBashPath = undefined; +} + +function inferDialect(executable: string): ShellDialect { + const name = path.win32.basename(executable.replaceAll('/', '\\')).replace(/\.exe$/iu, '').toLowerCase(); + if (name === 'cmd') return 'cmd'; + if (name === 'pwsh' || name === 'powershell') return 'powershell'; + return 'posix'; +} + +function nativeGitBashPath(environment: NodeJS.ProcessEnv): string { + const shell = environment.SHELL || '/bin/bash'; + if (/^[a-z]:[\\/]/iu.test(shell)) return shell; + const key = `${shell}\n${environment.PATH ?? environment.Path ?? ''}`; + if (gitBashPath?.key === key) return gitBashPath.executable; + try { + const executable = execFileSync('cygpath.exe', ['-w', shell], { + env: environment, encoding: 'utf8', timeout: 1500, windowsHide: true, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + if (!/^[a-z]:[\\/]/iu.test(executable)) throw new Error('Expected a Windows shell path'); + gitBashPath = { key, executable }; + return executable; + } catch { + throw new Error('Git Bash was detected but its shell path could not be resolved. Set shell.executable to the full Windows path of bash.exe.'); + } +} + +export function getShellExecutionProfile(context: ShellExecutionContext = {}): { executable: string; dialect: ShellDialect } { + const environment = context.environment ?? process.env; + const platform = context.platform ?? process.platform; + const settings = context.settings ?? configured; + const executable = settings?.executable ?? (platform === 'win32' + ? environment.MSYSTEM ? nativeGitBashPath(environment) : environment.ComSpec || environment.COMSPEC || 'cmd.exe' + : environment.SHELL || '/bin/sh'); + return { executable, dialect: settings?.dialect ?? inferDialect(executable) }; +} + +export function resolveShellExecution(command: string, context: ShellExecutionContext = {}): { file: string; args: string[]; dialect: ShellDialect; windowsVerbatimArguments?: boolean } { + const profile = getShellExecutionProfile(context); + const args = profile.dialect === 'cmd' ? ['/d', '/s', '/c', `"${command}"`] + : profile.dialect === 'powershell' ? ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command] + : ['-lc', command]; + return { file: profile.executable, args, dialect: profile.dialect, + ...(profile.dialect === 'cmd' ? { windowsVerbatimArguments: true } : {}) }; +} diff --git a/tests/ci/workflowCheckout.test.ts b/tests/ci/workflowCheckout.test.ts index d5e379711..34ddd99ae 100644 --- a/tests/ci/workflowCheckout.test.ts +++ b/tests/ci/workflowCheckout.test.ts @@ -91,9 +91,13 @@ describe('CI workflow checkout', () => { const workflowJobs = readWorkflowJobs().filter((job) => job.workflow === workflow); const tuistoryJobs = workflowJobs.filter((job) => job.body.includes('test:tuistory')); - expect(tuistoryJobs.map((job) => job.name), workflow).toEqual(['tuistory']); - expect(tuistoryJobs[0]?.body, workflow).not.toContain('run: bun run test:ci'); - expect(tuistoryJobs[0]?.body, workflow).not.toMatch(/run: bun run test\s*$/mu); + expect(tuistoryJobs.map((job) => job.name), workflow).toEqual( + workflow === 'ci.yml' ? ['tuistory', 'windows-tuistory'] : ['tuistory'], + ); + for (const job of tuistoryJobs) { + expect(job.body, workflow).not.toContain('run: bun run test:ci'); + expect(job.body, workflow).not.toMatch(/run: bun run test\s*$/mu); + } } }); diff --git a/tests/commands/team.test.ts b/tests/commands/team.test.ts index bac14c447..216f7cd6e 100644 --- a/tests/commands/team.test.ts +++ b/tests/commands/team.test.ts @@ -37,6 +37,35 @@ function createMockTeamManager(hasTeam = true) { } describe('/team command', () => { + it('offers explicit recovery and retries only failed tasks', async () => { + const manager = new TeamManager({ leadSessionId: 'lead', workspacePath: process.cwd() }); + manager.createTeam('recovery'); + const success = manager.tasks.createTask({ subject: 'Successful work', description: '' }); + const failure = manager.tasks.createTask({ subject: 'Broken review', description: '' }); + const cancelled = manager.tasks.createTask({ subject: 'Cancelled work', description: '' }); + manager.tasks.updateTask(success.id, { status: 'completed' }); + manager.tasks.updateTask(failure.id, { status: 'failed', error: 'Provider unavailable' }); + manager.stopTask(cancelled.id); + + expect(await team({ teamManager: manager }, ['errors'])).toContain('Provider unavailable'); + expect(await team({ teamManager: manager }, ['retry', success.id])).toContain('Only failed tasks'); + expect(await team({ teamManager: manager }, ['retry-failed'])).toContain('Retrying 1 failed task'); + expect(manager.tasks.getTask(failure.id)?.status).toBe('pending'); + expect(manager.tasks.getTask(success.id)?.status).toBe('completed'); + expect(manager.tasks.getTask(cancelled.id)?.status).toBe('cancelled'); + }); + + it('hides the panel after successful shutdown without replacing failed outcomes', async () => { + const manager = new TeamManager({ leadSessionId: 'lead', workspacePath: process.cwd() }); + manager.createTeam('recovery'); + const failure = manager.tasks.createTask({ subject: 'Broken review', description: '' }); + manager.tasks.updateTask(failure.id, { status: 'failed', error: 'Provider unavailable' }); + const onToggleTeamView = vi.fn(); + await team({ teamManager: manager, onToggleTeamView }, ['shutdown']); + expect(onToggleTeamView).toHaveBeenCalledWith(false); + expect(manager.tasks.getTask(failure.id)?.status).toBe('failed'); + expect(await team({ teamManager: manager }, ['retry-failed'])).toContain('closed'); + }); it('opens the live team view without requiring a status prompt', async () => { const onToggleTeamView = vi.fn(); const result = await team({ diff --git a/tests/config/shellSettings.test.ts b/tests/config/shellSettings.test.ts index c8c2968da..c5916bda2 100644 --- a/tests/config/shellSettings.test.ts +++ b/tests/config/shellSettings.test.ts @@ -20,6 +20,8 @@ describe('shell.env config validation', () => { vi.restoreAllMocks(); const { configureChildProcessEnvPolicy } = await import('../../src/utils/childProcessEnv.js'); configureChildProcessEnvPolicy(undefined); + const { configureShellExecution } = await import('../../src/utils/shellExecution.js'); + configureShellExecution(undefined); await fse.remove(testDir); }); @@ -37,6 +39,10 @@ describe('shell.env config validation', () => { }); it.each([ + [{ executable: 42 }, 'shell.executable must be a non-empty string'], + [{ executable: '' }, 'shell.executable must be a non-empty string'], + [{ executable: 'bash', dialect: 'unknown' }, 'shell.dialect must be'], + [{ dialect: 'powershell' }, 'shell.dialect requires shell.executable'], [{ env: { inherit: 'some' } }, 'shell.env.inherit must be "all", "essential", or "none"'], [{ env: { exclude: 'PATH' } }, 'shell.env.exclude must be a list of variable names or globs'], [{ env: { set: { CI: 1 } } }, 'shell.env.set must map variable names to strings'], diff --git a/tests/core/agent/SystemPromptBuilder.test.ts b/tests/core/agent/SystemPromptBuilder.test.ts index 5a4558a75..37afcaab2 100644 --- a/tests/core/agent/SystemPromptBuilder.test.ts +++ b/tests/core/agent/SystemPromptBuilder.test.ts @@ -35,6 +35,14 @@ describe('SystemPromptBuilder', () => { }); } + it('tells the model which shell executable and dialect command tools use', async () => { + const prompt = await createBuilder({ runtime: { options: {}, workspaceRoot: process.cwd(), config: { + shell: { executable: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' }, + } } }).build(); + expect(prompt).toContain('Command shell: "C:\\\\Program Files\\\\PowerShell\\\\7\\\\pwsh.exe"'); + expect(prompt).toContain('Command dialect: powershell'); + }); + it('includes the tool-choice rubric and compact tool catalog without runtime schemas', async () => { const builder = createBuilder(); diff --git a/tests/core/agent/TeamActivityBridge.test.ts b/tests/core/agent/TeamActivityBridge.test.ts index 0315fbb0c..551441861 100644 --- a/tests/core/agent/TeamActivityBridge.test.ts +++ b/tests/core/agent/TeamActivityBridge.test.ts @@ -108,6 +108,6 @@ describe('TeamActivityBridge', () => { manager.tasks.updateTask(tasks[2]!.id, { status: 'cancelled' }); manager.tasks.setTaskOutput(tasks[2]!.id, 'Stopped by lead'); - expect(notifyUser).toHaveBeenCalledExactlyOnceWith('Team "review" finished: 1/3 completed, 1 failed, 1 cancelled.'); + expect(notifyUser).toHaveBeenCalledExactlyOnceWith('Team "review" finished: 1/3 completed, 1 failed, 1 cancelled. Inspect with /team view; stop workers with /team shutdown.'); }); }); diff --git a/tests/feedback/environment.test.ts b/tests/feedback/environment.test.ts index cc0982376..a8be6f7f2 100644 --- a/tests/feedback/environment.test.ts +++ b/tests/feedback/environment.test.ts @@ -4,10 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ import os from 'node:os'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { collectReportEnvironment } from '../../src/feedback/environment.js'; +import { configureShellExecution } from '../../src/utils/shellExecution.js'; + +afterEach(() => configureShellExecution(undefined)); describe('collectReportEnvironment', () => { + it('reports the selected execution shell rather than the inherited parent shell', () => { + configureShellExecution({ executable: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' }); + expect(collectReportEnvironment({ env: { SHELL: '/usr/bin/bash' } })).toMatchObject({ + shell: 'pwsh.exe', shellDialect: 'powershell', parentShell: 'bash', + }); + }); it('describes the operating system and JavaScript runtime', () => { const environment = collectReportEnvironment({ env: {} }); @@ -63,7 +72,7 @@ describe('collectReportEnvironment', () => { expect(Object.keys(environment).sort()).toEqual( expect.arrayContaining(['os', 'runtime']), ); - for (const key of ['shell', 'terminal', 'term', 'multiplexer', 'locale', 'ssh', 'ci', 'provider', 'model', 'mode']) { + for (const key of ['terminal', 'term', 'multiplexer', 'locale', 'ssh', 'ci', 'provider', 'model', 'mode']) { expect(environment).not.toHaveProperty(key); } for (const value of Object.values(environment)) { diff --git a/tests/tuistory/team-activity.tuistory.test.ts b/tests/tuistory/team-activity.tuistory.test.ts index 671346d18..65a24780b 100644 --- a/tests/tuistory/team-activity.tuistory.test.ts +++ b/tests/tuistory/team-activity.tuistory.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createServer, type Server } from 'node:http'; import type { Session } from 'tuistory'; -import { inspectTerminalTeamOutcomes } from '../../src/testing/scenarios/teamTerminalStatusScenario.js'; +import { inspectTerminalTeamOutcomes, recoverFailedTeam } from '../../src/testing/scenarios/teamTerminalStatusScenario.js'; import { createTempAutohandHome, exitInteractive, @@ -19,8 +19,9 @@ const sessions: Session[] = []; const tempStates: TuistoryTempState[] = []; const servers: Server[] = []; -async function createTeamSequenceServer(failTeammate = false): Promise { +async function createTeamSequenceServer(failTeammate: boolean | 'once' = false): Promise { let leadTurn = 0; + let teammateFailures = 0; const server = createServer((request, response) => { const chunks: Buffer[] = []; request.on('data', (chunk: Buffer) => chunks.push(chunk)); @@ -41,6 +42,8 @@ async function createTeamSequenceServer(failTeammate = false): Promise { let content: string; let delayMs = 0; + const rejectTeammate = isSecurityTeammate && Boolean(failTeammate) && (failTeammate !== 'once' || teammateFailures === 0); + if (rejectTeammate) teammateFailures++; if (isSecurityTeammate) { content = JSON.stringify({ finalResponse: 'SECURITY_TASK_DONE', toolCalls: [] }); delayMs = 5_000; @@ -100,7 +103,7 @@ async function createTeamSequenceServer(failTeammate = false): Promise { } setTimeout(() => { - if (failTeammate && isSecurityTeammate) { + if (rejectTeammate) { response.writeHead(400, { 'content-type': 'application/json' }); response.end(JSON.stringify({ error: { message: 'SECURITY_TASK_FAILED', type: 'invalid_request_error' } })); return; @@ -164,7 +167,7 @@ describe('interactive team activity', () => { const activeScreen = await session.text({ timeout: 60_000, waitFor: (text) => text.includes('team-e2e · 0/1 done') - && text.includes('cmd+t team') + && text.includes('ctrl+t team') && text.includes('AUTO'), }); expect(activeScreen).toContain('Review authentication'); @@ -188,14 +191,15 @@ describe('interactive team activity', () => { await session.text({ timeout: 45_000, - waitFor: (text) => text.includes('team-e2e · 1/1 done') + waitFor: (text) => text.includes('1 succeeded') && text.includes('Review authentication'), }); + await session.press(['ctrl', 't']); await session.type('/tasks'); await session.press('enter'); const completedScreen = await session.text({ timeout: 10_000, - waitFor: (text) => text.includes('Tasks [1/1 done]'), + waitFor: (text) => /Tasks\s+1\/1 done/u.test(text), }); expect(completedScreen).toContain('Review authentication'); @@ -236,4 +240,26 @@ describe('interactive team activity', () => { await exitInteractive(session); }, 90_000); + + it('recovers a failed task, stops the native workers, and starts a new team', async () => { + const baseUrl = await createTeamSequenceServer('once'); + const state = await createTempAutohandHome({ config: { + openrouter: { baseUrl, model: 'openai/gpt-4o-mini' }, + agent: { maxIterations: 8, sessionRetryLimit: 0 }, network: { maxRetries: 0 }, + ui: { promptSuggestions: false }, features: { automaticSpecialists: false }, + } }); + tempStates.push(state); + const session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, rows: 40, waitForDataTimeout: 15_000, + }); + sessions.push(session); + await inspectTerminalTeamOutcomes(session); + const recovery = await recoverFailedTeam(session); + expect(recovery.failure).toContain('SECURITY_TASK_FAILED'); + expect(recovery.retried).toContain('1 succeeded'); + expect(recovery.retried).toContain('1 cancelled'); + expect(recovery.closed).toContain('has been shut down'); + expect(recovery.replacement).toContain('Team "replacement" created'); + await exitInteractive(session); + }, 120_000); }); diff --git a/tests/ui/ink/ShortcutsHelpPanel.test.tsx b/tests/ui/ink/ShortcutsHelpPanel.test.tsx index a7f302e50..f754c07ef 100644 --- a/tests/ui/ink/ShortcutsHelpPanel.test.tsx +++ b/tests/ui/ink/ShortcutsHelpPanel.test.tsx @@ -9,6 +9,11 @@ import { resolveKeybindings } from '../../../src/keybindings/profiles.js'; afterEach(cleanup); describe('shortcut help', () => { + it('includes the team and goal panel shortcuts', () => { + const view = render(); + expect(view.lastFrame()).toContain('Team panel'); + expect(view.lastFrame()).toContain('Goals panel'); + }); it.each([false, true])('shows the fixed queue steering shortcut with fullScreen=%s', async fullScreen => { const view = render(); await new Promise(resolve => setImmediate(resolve)); diff --git a/tests/ui/ink/StatusLine.test.tsx b/tests/ui/ink/StatusLine.test.tsx index 17dfba354..ba9a74f89 100644 --- a/tests/ui/ink/StatusLine.test.tsx +++ b/tests/ui/ink/StatusLine.test.tsx @@ -9,7 +9,7 @@ import { render } from 'ink-testing-library'; import { describe, expect, it, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import path from 'node:path'; -import { StatusLine, WORKING_SPINNER_INTERVAL_MS, formatLineSegments, mergeLineExtensions } from '../../../src/ui/ink/StatusLine.js'; +import { StatusLine, WORKING_SPINNER_INTERVAL_MS, buildTeamActivitySegment, formatLineSegments, mergeLineExtensions } from '../../../src/ui/ink/StatusLine.js'; import { ThemeProvider } from '../../../src/ui/theme/ThemeContext.js'; import { I18nProvider } from '../../../src/ui/i18n/index.js'; @@ -24,6 +24,33 @@ function renderStatusLine(props: React.ComponentProps) { } describe('StatusLine extensions', () => { + it('settles a failed team batch without leaving a spinner timer running', async () => { + vi.useFakeTimers(); + const baseline = vi.getTimerCount(); + try { + const teamActivity = { + team: { name: 'recovery', createdAt: '', leadSessionId: 'lead', status: 'active' as const, + members: [{ name: 'reviewer', agentName: 'reviewer', pid: 123, status: 'idle' as const }] }, + tasks: ['completed', 'failed', 'failed', 'cancelled'].map((status, index) => ({ + id: String(index), subject: 'Review', description: '', blockedBy: [], createdAt: '', + status: status as 'completed' | 'failed' | 'cancelled', + })), + }; + expect(buildTeamActivitySegment(teamActivity).busy).toBe(false); + const screen = renderStatusLine({ isWorking: false, status: '', teamActivity }); + await vi.advanceTimersByTimeAsync(WORKING_SPINNER_INTERVAL_MS * 2); + expect(screen.lastFrame()).toContain('Finished with failures'); + expect(screen.lastFrame()).toContain('1 succeeded'); + expect(screen.lastFrame()).toContain('2 failed'); + expect(screen.lastFrame()).toContain('1 cancelled'); + expect(screen.lastFrame()).toContain('ctrl+t'); + expect(vi.getTimerCount()).toBe(baseline); + screen.unmount(); + await vi.waitFor(() => expect(vi.getTimerCount()).toBe(baseline)); + } finally { + vi.useRealTimers(); + } + }); it('reports each spinner frame while working so the terminal tab can follow it', async () => { vi.useFakeTimers(); try { @@ -107,7 +134,7 @@ describe('StatusLine extensions', () => { expect(frame).toContain('prompt-shrink'); expect(frame).toContain('1/3 done'); expect(frame).toContain('1 working'); - expect(frame).toContain('cmd+t'); + expect(frame).toContain('ctrl+t'); }); it('appends custom status segments after default active-turn chrome', () => { diff --git a/tests/ui/ink/TeamPanel.test.tsx b/tests/ui/ink/TeamPanel.test.tsx index edea241c5..7f2b6faf9 100644 --- a/tests/ui/ink/TeamPanel.test.tsx +++ b/tests/ui/ink/TeamPanel.test.tsx @@ -4,16 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import React from 'react'; import { stripVTControlCharacters } from 'node:util'; -import { render } from 'ink-testing-library'; +import { cleanup, render } from 'ink-testing-library'; import { TeamPanel } from '../../../src/ui/ink/TeamPanel.js'; import { TeammateView } from '../../../src/ui/ink/TeammateView.js'; import { ThemeProvider } from '../../../src/ui/theme/ThemeContext.js'; import { Theme } from '../../../src/ui/theme/Theme.js'; import { loadTheme } from '../../../src/ui/theme/loader.js'; import type { Team, TeamTask } from '../../../src/core/teams/types.js'; +import { AgentUI, createInitialUIState } from '../../../src/ui/ink/AgentUI.js'; +import { I18nProvider } from '../../../src/ui/i18n/index.js'; +import { resolveKeybindings } from '../../../src/keybindings/profiles.js'; + +afterEach(cleanup); const mockTeam: Team = { name: 'code-cleanup', @@ -37,11 +42,72 @@ function renderWithTheme(element: React.ReactElement) { } describe('TeamPanel', () => { + it('shows and handles the configured team shortcut', async () => { + const onHide = vi.fn(); + const view = renderWithTheme(); + expect(view.lastFrame()).toContain('ctrl+y hide'); + await new Promise(resolve => setImmediate(resolve)); + view.stdin.write('\u0019'); + await vi.waitFor(() => expect(onHide).toHaveBeenCalledOnce()); + }); + it('owns recovery keys while the lead is working without sending them as a new prompt', async () => { + const onInstruction = vi.fn(); + const onToggleTeamPanel = vi.fn(); + const view = render( {}} onCtrlC={() => {}} />); + await new Promise(resolve => setImmediate(resolve)); + view.stdin.write('s'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Shut down this team')); + view.stdin.write('y'); + await vi.waitFor(() => expect(onInstruction).toHaveBeenCalledExactlyOnceWith('/team shutdown')); + expect(onToggleTeamPanel).toHaveBeenCalledOnce(); + }); + it('inspects a failure and confirms a selected retry without rerunning successful work', async () => { + const onCommand = vi.fn(); + const onHide = vi.fn(); + const view = renderWithTheme(); + await new Promise(resolve => setImmediate(resolve)); + view.stdin.write('\u001b[B'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('› × Write API docs')); + view.stdin.write('\r'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Provider unavailable')); + expect(view.lastFrame()).toContain('Partial findings retained'); + view.stdin.write('\u001b'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Enter inspect')); + view.stdin.write('r'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Retry')); + expect(onCommand).not.toHaveBeenCalled(); + view.stdin.write('y'); + await vi.waitFor(() => expect(onCommand).toHaveBeenCalledWith('/team retry task-002')); + expect(onHide).toHaveBeenCalledOnce(); + }); + + it('distinguishes hiding from shutting down and permits cancellation of shutdown', async () => { + const onCommand = vi.fn(); + const onHide = vi.fn(); + const view = renderWithTheme(); + await new Promise(resolve => setImmediate(resolve)); + view.stdin.write('s'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Shut down')); + view.stdin.write('n'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Enter inspect')); + expect(onCommand).not.toHaveBeenCalled(); + view.stdin.write('h'); + await vi.waitFor(() => expect(onHide).toHaveBeenCalledOnce()); + expect(onCommand).not.toHaveBeenCalled(); + }); it('should render team name and status', () => { const { lastFrame } = renderWithTheme(); const output = lastFrame(); expect(output).toContain('code-cleanup'); - expect(output).toContain('Cmd+T close'); + expect(output).toContain('ctrl+t hide'); }); it('should render task count', () => { diff --git a/tests/unixInstallerPlatforms.test.ts b/tests/unixInstallerPlatforms.test.ts index 5bac90535..1c4d33ec7 100644 --- a/tests/unixInstallerPlatforms.test.ts +++ b/tests/unixInstallerPlatforms.test.ts @@ -9,24 +9,22 @@ const functions = installer.replace(/\nmain "\$@" \|\| exit 1\s*$/, ''); const alpineHost = await fs.pathExists('/etc/alpine-release'); describe.skipIf(process.platform === 'win32')('Unix installer platform compatibility', () => { - async function run(system: string, libc: string, fullInstall = false) { + async function run(system: string, libc: string, fullInstall = false, args: string[] = [], windowsExit = 0) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'installer-platform-')); try { - const bin = path.join(root, 'bin'); - await fs.ensureDir(bin); const commands = { uname: `case "$1" in -s) printf '%s\\n' '${system}' ;; -m) printf 'x86_64\\n' ;; esac`, ldd: `printf '%s\\n' '${libc}' >&2`, - curl: 'exit 22', + curl: `case "$*" in *install.ps1*) while [ "$#" -gt 0 ]; do if [ "$1" = '-o' ]; then shift; printf '# fixture\\n' > "$1"; return 0; fi; shift; done ;; esac; return 22`, + powershell: `printf '<%s>\\n' "$@"; return ${windowsExit}`, + cygpath: `shift; printf '%s\\n' "$1"`, }; - for (const [name, body] of Object.entries(commands)) { - await fs.writeFile(path.join(bin, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); - } + const fixtures = Object.entries(commands).map(([name, body]) => `${name}() {\n${body}\n}`).join('\n'); const script = path.join(root, 'probe.sh'); - await fs.writeFile(script, fullInstall ? installer : `${functions}\nget_architecture\nprintf '%s\\n' "$RETVAL"\n`); - return spawnSync('/bin/sh', [script], { - encoding: 'utf8', timeout: 5_000, - env: { ...process.env, PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`, AUTOHAND_VERSION: '0.9.8', + await fs.writeFile(script, `${fixtures}\n${fullInstall ? installer : `${functions}\nget_architecture\nprintf '%s\\n' "$RETVAL"\n`}`); + return spawnSync('/bin/sh', [script, ...args], { + encoding: 'utf8', timeout: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin:/usr/sbin:/sbin', AUTOHAND_VERSION: '0.9.8', AUTOHAND_INSTALL_DIR: path.join(root, 'install'), AUTOHAND_INSTALL_FIRST_RUN: 'no', AUTOHAND_SKIP_ZIT_INSTALL: '1' }, }); } finally { @@ -34,6 +32,24 @@ describe.skipIf(process.platform === 'win32')('Unix installer platform compatibi } } + it.each(['MINGW64_NT-10.0', 'MSYS_NT-10.0', 'CYGWIN_NT-10.0'])( + 'hands %s installation to PowerShell with the original options', async system => { + const result = await run(system, '', true, ['--alpha', '--fresh', '--no-zit']); + expect(result.status, result.stdout + result.stderr).toBe(0); + for (const option of ['<-File>', '<-Alpha>', '<-Fresh>', '<-NoZit>', '<-Version>', '<0.9.8>', '<-InstallDir>']) { + expect(result.stdout).toContain(option); + } + expect(result.stdout + result.stderr).not.toContain('autohand-windows-x64.tar.gz'); + }, + ); + + it('propagates failure from the Windows installer', async () => { + const result = await run('MINGW64_NT-10.0', '', true, [], 7); + expect(result.status).not.toBe(0); + expect(result.stdout).toContain('<-File>'); + expect(result.stdout).not.toContain('Autohand CLI is ready'); + }); + it('selects a musl-specific Linux asset', async () => { const result = await run('Linux', 'musl libc 1.2.5'); expect(result.status, result.stderr).toBe(0); diff --git a/tests/utils/shellExecution.test.ts b/tests/utils/shellExecution.test.ts new file mode 100644 index 000000000..0a3e0da87 --- /dev/null +++ b/tests/utils/shellExecution.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { configureShellExecution, resolveShellExecution } from '../../src/utils/shellExecution.js'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runCommand } from '../../src/actions/command.js'; +import { executeShellCommandAsync, executeStreamingShellCommand } from '../../src/ui/shellCommand.js'; +import { withCommandCoordination } from '../../src/session/peers/CommandCoordinationGate.js'; +import type { ResourceCommand, ResourceCoordinatorClient } from '../../src/session/peers/ResourceCoordinator.js'; + +afterEach(() => configureShellExecution(undefined)); + +describe('shell execution profiles', () => { + it.skipIf(process.platform === 'win32')('uses the selected shell for command tools and foreground execution', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'autohand-shell-profile-')); + try { + const executable = path.join(directory, 'chosen shell'); + await writeFile(executable, '#!/bin/sh\nprintf "PROFILE_SELECTED\\n"\nexec /bin/sh "$@"\n', { mode: 0o755 }); + configureShellExecution({ executable, dialect: 'posix' }); + const command = await runCommand('printf hello', [], directory, { shell: true }); + expect(command.stdout).toContain('PROFILE_SELECTED'); + expect(command.stdout).toContain('hello'); + const foreground = await executeShellCommandAsync('printf hello', directory); + expect(foreground.output).toContain('PROFILE_SELECTED'); + const streaming = await executeStreamingShellCommand('printf hello', directory, { preferPty: false }); + expect(streaming.output).toContain('PROFILE_SELECTED'); + const checked: ResourceCommand[] = []; + const coordinator = { + commandRequirements: async (command: ResourceCommand) => { checked.push(command); return []; }, + beginLaunch: async () => {}, publishLaunch: async () => {}, finishLaunch: async () => {}, + } as ResourceCoordinatorClient; + await withCommandCoordination({ coordinator }, () => executeShellCommandAsync('printf hello', directory)); + expect(checked[0]).toEqual({ file: 'printf hello', args: [], cwd: directory }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + it('keeps a native Windows command in CMD when Git Bash is not selected', () => { + expect(resolveShellExecution('echo hello', { platform: 'win32', environment: { ComSpec: 'C:\\Windows\\cmd.exe' } })) + .toEqual({ file: 'C:\\Windows\\cmd.exe', args: ['/d', '/s', '/c', '"echo hello"'], dialect: 'cmd', windowsVerbatimArguments: true }); + }); + + it('uses a Git Bash native executable rather than CMD', () => { + expect(resolveShellExecution('printf "%s" "$VALUE"', { platform: 'win32', environment: { + MSYSTEM: 'MINGW64', SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe', + } })).toEqual({ file: 'C:\\Program Files\\Git\\bin\\bash.exe', args: ['-lc', 'printf "%s" "$VALUE"'], dialect: 'posix' }); + }); + + it('preserves spaces and quoting when PowerShell is explicitly selected', () => { + configureShellExecution({ executable: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' }); + expect(resolveShellExecution('Write-Output "hello world"', { platform: 'win32', environment: {} })) + .toEqual({ file: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'Write-Output "hello world"'], dialect: 'powershell' }); + }); + + it('honors an explicitly configured POSIX shell on Unix', () => { + configureShellExecution({ executable: '/bin/sh' }); + expect(resolveShellExecution('printf ok', { platform: 'linux', environment: { SHELL: '/bin/zsh' } })) + .toEqual({ file: '/bin/sh', args: ['-lc', 'printf ok'], dialect: 'posix' }); + }); +}); diff --git a/tests/windowsShell.integration.test.ts b/tests/windowsShell.integration.test.ts new file mode 100644 index 000000000..baac0157f --- /dev/null +++ b/tests/windowsShell.integration.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { configureShellExecution, getShellExecutionProfile } from '../src/utils/shellExecution.js'; +import { executeShellCommandAsync, executeStreamingShellCommand } from '../src/ui/shellCommand.js'; +import { runCommand } from '../src/actions/command.js'; + +afterEach(() => { configureShellExecution(undefined); vi.unstubAllEnvs(); }); + +describe.skipIf(process.platform !== 'win32')('native Windows shell compatibility', () => { + it('executes Git Bash commands using the native Bash executable', async () => { + vi.stubEnv('MSYSTEM', 'MINGW64'); + vi.stubEnv('SHELL', '/usr/bin/bash'); + expect(getShellExecutionProfile().dialect).toBe('posix'); + const result = await executeShellCommandAsync('printf "%s" "BASH_NATIVE_OK"'); + expect(result.success, result.error).toBe(true); + expect(result.output).toContain('BASH_NATIVE_OK'); + }); + + it.each([ + ['cmd.exe', 'echo CMD_NATIVE_OK', 'CMD_NATIVE_OK'], + ['pwsh.exe', 'Write-Output "POWERSHELL_NATIVE_OK"', 'POWERSHELL_NATIVE_OK'], + ])('uses the explicitly configured %s for both command paths', async (executable, command, expected) => { + configureShellExecution({ executable }); + const tool = await runCommand(command, [], process.cwd(), { shell: true, timeout: 15000 }); + expect(tool.code, tool.stderr).toBe(0); + expect(tool.stdout).toContain(expected); + const foreground = await executeShellCommandAsync(command, process.cwd(), 15000); + expect(foreground.success, foreground.error).toBe(true); + expect(foreground.output).toContain(expected); + }); + + it('preserves CMD quoting of executable paths and JavaScript strings in all command paths', async () => { + configureShellExecution({ executable: 'cmd.exe' }); + const command = `"${process.execPath}" -e "process.stdout.write('QUOTED_NATIVE_OK')"`; + const tool = await runCommand(command, [], process.cwd(), { shell: true, timeout: 15000 }); + expect(tool.stdout, tool.stderr).toContain('QUOTED_NATIVE_OK'); + const foreground = await executeShellCommandAsync(command); + expect(foreground.output, foreground.error).toContain('QUOTED_NATIVE_OK'); + const streaming = await executeStreamingShellCommand(command, process.cwd(), { preferPty: true }); + expect(streaming.output, streaming.error).toContain('QUOTED_NATIVE_OK'); + }); + + it('hands a Git Bash sh installation to real PowerShell without losing spaced paths or options', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'autohand-git-bash-')); + try { + const unixPath = (value: string) => execFileSync('cygpath.exe', ['-u', value], { encoding: 'utf8' }).trim(); + const bin = path.join(directory, 'mock bin'); + await mkdir(bin); + const stub = path.join(directory, 'stub.ps1'); + const record = path.join(directory, 'handoff.json'); + const installDir = path.join(directory, 'install with spaces'); + await writeFile(stub, [ + 'param([switch]$Alpha, [switch]$Fresh, [switch]$NoZit, [string]$Version, [string]$InstallDir)', + '@{ alpha=$Alpha.IsPresent; fresh=$Fresh.IsPresent; noZit=$NoZit.IsPresent; version=$Version; installDir=$InstallDir } | ConvertTo-Json | Set-Content -LiteralPath $env:AUTOHAND_HANDOFF_RECORD', + ].join('\n')); + await writeFile(path.join(bin, 'curl'), '#!/bin/sh\nwhile [ "$#" -gt 0 ]; do if [ "$1" = "-o" ]; then shift; cp "$AUTOHAND_HANDOFF_STUB" "$1"; exit 0; fi; shift; done; exit 1\n', { mode: 0o755 }); + const result = spawnSync('bash.exe', ['-lc', 'export PATH="$AUTOHAND_MOCK_BIN:$PATH"; sh "$AUTOHAND_INSTALL_SCRIPT" --alpha --fresh --no-zit'], { + encoding: 'utf8', timeout: 30000, + env: { ...process.env, AUTOHAND_MOCK_BIN: unixPath(bin), AUTOHAND_INSTALL_SCRIPT: unixPath(path.resolve('install.sh')), + AUTOHAND_HANDOFF_STUB: unixPath(stub), AUTOHAND_HANDOFF_RECORD: record, + AUTOHAND_INSTALL_DIR: installDir, AUTOHAND_VERSION: '0.9.9', AUTOHAND_INSTALL_FIRST_RUN: 'no' }, + }); + expect(result.status, result.stdout + result.stderr).toBe(0); + const handoff = JSON.parse((await readFile(record, 'utf8')).replace(/^\uFEFF/u, '')); + expect(handoff).toMatchObject({ alpha: true, fresh: true, noZit: true, version: '0.9.9' }); + expect(path.normalize(handoff.installDir)).toBe(path.normalize(installDir)); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, 45000); +}); From 658a89e5693238e78466772e150f198fc0f92570 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Wed, 28 Oct 2026 21:09:09 +1300 Subject: [PATCH 31/38] Complete the environment choice in paid Cloud terminal tests --- tests/tuistory/cloud-cli.tuistory.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/tuistory/cloud-cli.tuistory.test.ts b/tests/tuistory/cloud-cli.tuistory.test.ts index 743156182..d300d47f4 100644 --- a/tests/tuistory/cloud-cli.tuistory.test.ts +++ b/tests/tuistory/cloud-cli.tuistory.test.ts @@ -110,6 +110,10 @@ describe('built CLI cloud terminal flow', () => { }); it.each(['team', 'enterprise'])('starts a %s cloud session from the terminal', async tier => { const { state, session } = await launch(['--prompt', 'list files'], tier); + await session.waitForText('Cloud environment'); + await session.press('down'); + await session.waitForText('▸ 2. Start a hosted conversation without a repository'); + await session.press('enter'); await session.waitForText('Cloud agent connected'); const calls = await requests(state); expect(calls.find(call => call.route.endsWith('/messages'))?.body).toMatchObject({ content: 'list files' }); From 4aca484ca0c9010a7d60f7c2cfddf382ba0a959a Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Wed, 28 Oct 2026 21:25:29 +1300 Subject: [PATCH 32/38] Wait for the visible permission modal before keyboard navigation --- tests/tuistory/program-status.tuistory.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/tuistory/program-status.tuistory.test.ts b/tests/tuistory/program-status.tuistory.test.ts index 910713c3c..2b60e92d5 100644 --- a/tests/tuistory/program-status.tuistory.test.ts +++ b/tests/tuistory/program-status.tuistory.test.ts @@ -52,6 +52,7 @@ describe('OSC7501 built terminal lifecycle', () => { ]); await submitProgramStatusPrompt(session, 'Write the proof file'); await vi.waitFor(() => expect(programStatusReports(session)).toContainEqual(expect.objectContaining({ state: 'blocked', kind: 'permission' })), { timeout: 15_000 }); + await session.waitForText('Allow Once'); await session.press('down'); await session.press('up'); await session.press('enter'); From f45a1fb7e1fb577cf80246f753a8fee9de0e36a9 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 29 Oct 2026 02:06:27 +1300 Subject: [PATCH 33/38] Clarify Cloud CLI plan and version requirements --- docs/cloud-blog-draft.md | 4 ++-- docs/cloud.md | 2 +- docs/features.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cloud-blog-draft.md b/docs/cloud-blog-draft.md index ead163360..7c8b9e0c3 100644 --- a/docs/cloud-blog-draft.md +++ b/docs/cloud-blog-draft.md @@ -4,7 +4,7 @@ Editorial draft. Keep unpublished until CLI and Desktop release readiness and na Start a hosted agent from the terminal, close the terminal while it works, and reconnect to the saved conversation. Autohand Cloud adds `--cloud`, `/cloud`, `/handoff`, and `/open` to the CLI. The same session can be reviewed in Code Web and an updated Autohand Desktop app. -Starting in 0.9.10, Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Your selected account still needs active billing, model capacity, and permission to operate resources. A paid plan does not grant access to a repository that you have not authorized. +Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Team and Enterprise need CLI 0.9.10 or later. Your selected account still needs active billing, model capacity, and permission to operate resources. A paid plan does not grant access to a repository that you have not authorized. ## Start a task where you work @@ -83,7 +83,7 @@ Desktop links carry only a session ID and account ID. The remote cloud window ha The native CLI route authenticates a single account credential and forwards only allowlisted operations through the existing hosted API. It rechecks membership, ownership, write permissions, plan eligibility, model capacity, and repository grants. Browser cookies mixed with native bearer credentials are rejected. Responses remain private and are not cached. -Starting in 0.9.10, Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Owned stop and queue cleanup remain available after a downgrade. If durable Jobs are unavailable, admission fails instead of falling back to execution tied to the terminal request. +Only these plans can start new cloud work. Owned stop and queue cleanup remain available after a downgrade. If durable Jobs are unavailable, admission fails instead of falling back to execution tied to the terminal request. Local `--yes`, `--yolo`, custom MCP settings, system prompts, and tool-scope overrides cannot bypass hosted policy. Unsupported combinations fail before cloud launch. Local restrictions that Cloud cannot represent also fail closed. Read-only plan mode can preserve deny rules limited to mutating workspace operations; other custom restrictions require local execution. diff --git a/docs/cloud.md b/docs/cloud.md index da40d2471..479f723f1 100644 --- a/docs/cloud.md +++ b/docs/cloud.md @@ -1,6 +1,6 @@ # Autohand Cloud from your terminal -Starting in 0.9.10, Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. +Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Team and Enterprise need CLI 0.9.10 or later. Free and Lite accounts cannot start cloud work through this interface. The selected account also needs write access, active billing, available model capacity, and repository authorization. Sign in with `autohand login`. diff --git a/docs/features.md b/docs/features.md index acd845850..5d6ea844b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -39,7 +39,7 @@ Autohand is an autonomous LLM-powered coding agent designed to work directly in - [ ] Search history and command palette ## Session Management -- [x] [Cloud sessions](cloud.md): hosted execution, reconnect, queue steering, approvals, and local/cloud handoff. Starting in 0.9.10, Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. +- [x] [Cloud sessions](cloud.md): hosted execution, reconnect, queue steering, approvals, and local/cloud handoff. Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Team and Enterprise need CLI 0.9.10 or later. - [x] Auto-save to `~/.autohand/sessions` - [x] Resume with `/resume` or `autohand resume ` - [x] History tracking (interactions, tool outputs, agent thoughts) From 8a10d301499fda18d0b99ab4cb77fc8169fd170b Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 29 Oct 2026 02:58:47 +1300 Subject: [PATCH 34/38] Clarify Cloud plan requirements in the feature list --- docs/features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features.md b/docs/features.md index 5d6ea844b..02f172a2a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -39,7 +39,7 @@ Autohand is an autonomous LLM-powered coding agent designed to work directly in - [ ] Search history and command palette ## Session Management -- [x] [Cloud sessions](cloud.md): hosted execution, reconnect, queue steering, approvals, and local/cloud handoff. Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Team and Enterprise need CLI 0.9.10 or later. +- [x] [Cloud sessions](cloud.md): hosted execution, reconnect, queue steering, approvals, and local/cloud handoff. Needs Pro, Pro+, Max, Team or Enterprise (Team and Enterprise from CLI 0.9.10). - [x] Auto-save to `~/.autohand/sessions` - [x] Resume with `/resume` or `autohand resume ` - [x] History tracking (interactions, tool outputs, agent thoughts) From 3e69631ee8a3abe0d6785b799b69fece2a7db82b Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 29 Oct 2026 09:59:29 +1300 Subject: [PATCH 35/38] Clarify Weka access through Console --- docs/providers.md | 2 +- src/providers/AutohandAIProvider.ts | 4 ++-- tests/providers/AutohandAIProvider.test.ts | 4 ++-- tests/providers/cliCatalogPolicy.test.ts | 2 +- tests/tuistory/cli-model-availability.tuistory.test.ts | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 893bc63ea..d27766171 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -75,7 +75,7 @@ Bundled provider model choices live in `src/providers/models.json` and packaged Autohand AI is the preferred first-party provider. Use `autohandai` with `plan: "cloud"` for Autohand-hosted Fantail and Moa models at `https://api.autohand.ai/v1`, or `plan: "local"` for Apple Silicon MLX local inference. -Weka is available through the Autohand API and Agent SDKs, not in the CLI. Call it at `POST https://api.autohand.ai/v1/decisions`. +Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Call it at `POST https://api.autohand.ai/v1/decisions`. Fantail uses a 256k input context window and a 16k maximum output. Moa retains its 1M input context and 262,144-token output contract. These limits and the available cloud model list are read from `src/providers/models.json` (or a validated catalog override), not duplicated in provider code. If a selected model requires a higher account tier or an Autohand AI request quota is exhausted, the CLI identifies the exhausted window, shows its exact reset in the computer's local timezone with a relative duration, and includes the trusted upgrade link returned by the inference service. With Autohand AI active, `/usage` and the `/status` Usage tab show the same 5-hour, 24-hour, weekly, and monthly quota windows before they are exhausted, alongside request, uncached-input-token, and output-token throughput. Pro provides 1K requests, 500K uncached input tokens, and 80K output tokens per minute; Max provides exactly four times each limit. diff --git a/src/providers/AutohandAIProvider.ts b/src/providers/AutohandAIProvider.ts index ef4762cda..50c0d9c85 100644 --- a/src/providers/AutohandAIProvider.ts +++ b/src/providers/AutohandAIProvider.ts @@ -34,14 +34,14 @@ export function getAutohandAICloudModelCliUnsupportedReason( model: string | undefined, ): string | undefined { const normalized = normalizeAutohandAICloudModelId(model); - if (normalized === "weka") return "Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model."; + if (normalized === "weka") return "Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model."; const entry = getProviderModelOptions("autohandai").find( (candidate) => candidate.id === normalized, ); if (entry?.cliSupported !== false) return undefined; const name = entry.id === "weka" ? "Weka" : (entry.displayName ?? entry.id); - return `${name} is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.`; + return `${name} is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.`; } export interface AutohandAICloudModelDefinition { diff --git a/tests/providers/AutohandAIProvider.test.ts b/tests/providers/AutohandAIProvider.test.ts index fbb74cfae..42ef80490 100644 --- a/tests/providers/AutohandAIProvider.test.ts +++ b/tests/providers/AutohandAIProvider.test.ts @@ -69,7 +69,7 @@ describe("AutohandAIProvider", () => { apiKey: "test-autohand-key", model, })).toThrow( - "Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.", + "Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.", ); }, ); @@ -88,7 +88,7 @@ describe("AutohandAIProvider", () => { model: "weka", messages: [{ role: "user", content: "Decide" }], })).rejects.toThrow( - "Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.", + "Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.", ); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/tests/providers/cliCatalogPolicy.test.ts b/tests/providers/cliCatalogPolicy.test.ts index 81fcac6c3..4881351f5 100644 --- a/tests/providers/cliCatalogPolicy.test.ts +++ b/tests/providers/cliCatalogPolicy.test.ts @@ -20,7 +20,7 @@ describe('CLI hosted model availability', () => { expect(getProviderRunnableModelOptions('autohandai').some(model => model.id === 'weka')).toBe(false); }); it.each(['weka', 'autohand/weka', 'autohandai/weka'])('rejects %s without depending on a catalog entry', model => { - expect(getAutohandAICloudModelCliUnsupportedReason(model)).toBe('Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.'); + expect(getAutohandAICloudModelCliUnsupportedReason(model)).toBe('Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.'); }); it.each(['anthropic', 'openrouter'])('offers the Claude 5 family with Sonnet 5.5 first on %s', provider => { const prefix = provider === 'openrouter' ? 'anthropic/' : ''; diff --git a/tests/tuistory/cli-model-availability.tuistory.test.ts b/tests/tuistory/cli-model-availability.tuistory.test.ts index ff3a9ae71..55b16378d 100644 --- a/tests/tuistory/cli-model-availability.tuistory.test.ts +++ b/tests/tuistory/cli-model-availability.tuistory.test.ts @@ -1,15 +1,15 @@ import { expect, it } from 'vitest'; import { createTempAutohandHome, launchBuiltAutohand } from './helpers/autohandTuistory.js'; -it('explains the API and Agent SDK route when Weka is selected explicitly', async () => { +it('explains the API, Agent SDK and Console routes when Weka is selected explicitly', async () => { const state = await createTempAutohandHome({ config: { provider: 'autohandai', features: { autohand_inference: true }, autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'fixture-only', model: 'fantail' }, } }); const session = await launchBuiltAutohand(['--model', 'weka', '--config', state.configPath], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); try { - await session.waitForText('Weka is available through the Autohand API and Agent SDKs, not in the CLI.', { timeout: 20_000 }); - expect(session.readAll().replace(/\s+/g, ' ')).toContain('Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.'); + await session.waitForText('Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI.', { timeout: 20_000 }); + expect(session.readAll().replace(/\s+/g, ' ')).toContain('Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.'); } finally { session.close(); await state.cleanup(); From 9cbe55b1ed92d7230e85405f35af39d0c88d3f62 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 29 Oct 2026 10:14:37 +1300 Subject: [PATCH 36/38] Align Weka tier-policy test with Console availability --- tests/core/agent/AutohandAIModelTierPolicy.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/agent/AutohandAIModelTierPolicy.test.ts b/tests/core/agent/AutohandAIModelTierPolicy.test.ts index 77da893b8..f9911106d 100644 --- a/tests/core/agent/AutohandAIModelTierPolicy.test.ts +++ b/tests/core/agent/AutohandAIModelTierPolicy.test.ts @@ -207,7 +207,7 @@ describe('resolveAutohandAIModelForTier', () => { expect(resolveAutohandAIModelForTier({ provider: 'openrouter', plan: undefined, model: 'anthropic/claude-5-sonnet', tier: 'pro' })).toBe('anthropic/claude-5-sonnet'); }); - it('rejects Weka with its API and Agent SDK route instead of treating it as an unknown chat model', async () => { + it('rejects Weka with its API, Agent SDK and Console route instead of treating it as an unknown chat model', async () => { const { resolveAutohandAIModelForTier } = await import('../../../src/core/agent/AutohandAIModelTierPolicy.js'); expect(() => resolveAutohandAIModelForTier({ @@ -216,7 +216,7 @@ describe('resolveAutohandAIModelForTier', () => { model: 'weka', tier: 'pro', })).toThrow( - 'Weka is available through the Autohand API and Agent SDKs, not in the CLI. Pick another model with /model or --model.', + 'Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.', ); }); }); From 5bce11f56103d185f368b14fb93991b8f2cd6e4e Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 29 Oct 2026 12:05:10 +1300 Subject: [PATCH 37/38] Keep project tools, private imports, and terminal checks reliable Keep explicitly created project tools usable without loading untrusted files, and replace developer-specific trust fixtures with portable temporary paths. Correct the integration of workspace trust from PR #680. Combine Claude permission and MCP discovery, reject symlinked project settings, and write imported credentials atomically with private file permissions. Cover the import boundaries and saved connector settings through real files and the built CLI for PRs #686 and #699. Complete the Cloud environment choice through a shared terminal scenario for PR #711, while preserving the verified modal keyboard readiness checks. Check queue content and composer placement in the same captured terminal frame. Reproduce interrupted redraws and permanent displacement without relaxing steering action deadlines or the required composer position. Co-authored-by: Autohand Evolve --- src/core/toolsRegistry.ts | 12 ++-- src/import/importers/ClaudeImporter.ts | 37 +++++++---- src/testing/scenarios/cloudScenario.ts | 9 +++ .../scenarios/queuedSteeringScenario.ts | 39 +++++------ tests/config/workspaceTrust.config.test.ts | 2 +- tests/core/toolsRegistry.trust.test.ts | 18 ++++- tests/import/ClaudeImporter.test.ts | 11 +++- tests/import/claudePermissions.test.ts | 33 ++++++++++ tests/permissions/workspaceTrust.test.ts | 2 +- tests/testing/queuedSteeringScenario.test.ts | 66 +++++++++++++++++++ .../claude-permission-import.tuistory.test.ts | 13 +++- tests/tuistory/cloud-cli.tuistory.test.ts | 8 +-- .../queued-steering-recovery.tuistory.test.ts | 2 +- 13 files changed, 197 insertions(+), 55 deletions(-) create mode 100644 tests/testing/queuedSteeringScenario.test.ts diff --git a/src/core/toolsRegistry.ts b/src/core/toolsRegistry.ts index aba6c701c..e0e450dce 100644 --- a/src/core/toolsRegistry.ts +++ b/src/core/toolsRegistry.ts @@ -82,9 +82,8 @@ export function createToolsRegistry( const registry = new ToolsRegistry([{ scope: 'user', dir: userToolsDir }]); if (workspaceRoot) { const projectDir = path.join(workspaceRoot, PROJECT_DIR_NAME, 'tools'); - if (options.projectToolsTrusted) { - registry.addLocation({ scope: 'project', dir: projectDir }); - } else { + registry.addLocation({ scope: 'project', dir: projectDir }); + if (!options.projectToolsTrusted) { registry.noteUntrustedProjectTools(projectDir); } } @@ -125,7 +124,7 @@ export class ToolsRegistry { this.metaToolRecords.clear(); this.diagnostics = []; for (const location of this.locations) { - await fs.ensureDir(location.dir); + if (this.isLoadableLocation(location)) await fs.ensureDir(location.dir); } await this.loadMetaToolDefinitions(); } @@ -372,6 +371,7 @@ export class ToolsRegistry { this.diagnostics.push({ file: this.untrustedProjectToolsDir, reason: UNTRUSTED_PROJECT_TOOLS_REASON }); } for (const location of this.locations) { + if (!this.isLoadableLocation(location)) continue; try { const exists = await fs.pathExists(location.dir); if (!exists) { @@ -417,6 +417,10 @@ export class ToolsRegistry { return location; } + private isLoadableLocation(location: ToolsRegistryLocation): boolean { + return location.scope !== 'project' || location.dir !== this.untrustedProjectToolsDir; + } + private async readDefinition(filePath: string): Promise { if (!await fs.pathExists(filePath)) { return null; diff --git a/src/import/importers/ClaudeImporter.ts b/src/import/importers/ClaudeImporter.ts index 33aa5f0b7..0ed3bbcfe 100644 --- a/src/import/importers/ClaudeImporter.ts +++ b/src/import/importers/ClaudeImporter.ts @@ -23,6 +23,7 @@ import YAML from 'yaml'; import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { BaseImporter } from './BaseImporter.js'; import { ClaudePermissionImport } from '../ClaudePermissionImport.js'; +import { atomicWriteFile } from '../../utils/atomicFile.js'; /** * A single parsed Claude event from a session JSONL file. @@ -85,19 +86,15 @@ export class ClaudeImporter extends BaseImporter { } override async detect(): Promise { - return await super.detect() || await this.permissionImport().scan() > 0; + return await super.detect() || await this.permissionImport().scan() > 0 + || await fse.pathExists(path.join(path.dirname(this.resolvedHomePath), '.claude.json')) + || await fse.pathExists(path.join(path.resolve(this.hookImportOptions.workspaceRoot ?? process.cwd()), '.mcp.json')); } async previewSettings(): Promise { return this.permissionImport().preview(); } - async detect(): Promise { - return await super.detect() - || await fse.pathExists(path.join(path.dirname(this.resolvedHomePath), '.claude.json')) - || await fse.pathExists(path.join(path.resolve(this.hookImportOptions.workspaceRoot ?? process.cwd()), '.mcp.json')); - } - // --------------------------------------------------------------- // scan() // --------------------------------------------------------------- @@ -277,6 +274,7 @@ export class ClaudeImporter extends BaseImporter { for (const { servers, file } of destinations) { try { if (!Object.keys(servers).length) continue; + if (servers === project) await this.assertProjectMcpDestination(workspace, file); const current = await this.readConfigFile(file); const mcp = isRecord(current.mcp) ? current.mcp : {}; const existing = Array.isArray(mcp.servers) ? mcp.servers : []; @@ -306,6 +304,7 @@ export class ClaudeImporter extends BaseImporter { const merged = [...existing, ...additions]; if (servers === project) await this.ignoreLocalSettings(workspace); await fse.ensureDir(path.dirname(file)); + if (servers === project) await this.assertProjectMcpDestination(workspace, file); const output = { ...current, mcp: { ...mcp, servers: merged } }; await this.writeConfigFile(file, output); success += additions.length; @@ -319,6 +318,19 @@ export class ClaudeImporter extends BaseImporter { }); } + private async assertProjectMcpDestination(workspace: string, file: string): Promise { + const root = await fse.realpath(workspace); + const directory = path.dirname(file); + if (await fse.pathExists(directory) && await fse.realpath(directory) !== path.join(root, '.autohand')) { + throw new Error('Project MCP settings directory escapes its repository'); + } + try { + if ((await fse.lstat(file)).isSymbolicLink()) throw new Error('Project MCP settings file is a symlink'); + } catch (error) { + if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT') throw error; + } + } + // Keep imported credentials out of Git without changing the shared .gitignore. private async ignoreLocalSettings(workspace: string): Promise { let excludeFile: string; @@ -362,13 +374,10 @@ export class ClaudeImporter extends BaseImporter { private async writeConfigFile(file: string, config: Record): Promise { const extension = path.extname(file).toLowerCase(); - if (extension === '.toml') { - await fse.writeFile(file, stringifyToml(config as never)); - } else if (/\.ya?ml$/.test(extension)) { - await fse.writeFile(file, YAML.stringify(config)); - } else { - await fse.writeJson(file, config, { spaces: 2 }); - } + const content = extension === '.toml' ? stringifyToml(config) + : /\.ya?ml$/.test(extension) ? YAML.stringify(config) : JSON.stringify(config, null, 2) + '\n'; + await atomicWriteFile(file, content); + await fse.chmod(file, 0o600); } // --------------------------------------------------------------- diff --git a/src/testing/scenarios/cloudScenario.ts b/src/testing/scenarios/cloudScenario.ts index 537c54945..81e442750 100644 --- a/src/testing/scenarios/cloudScenario.ts +++ b/src/testing/scenarios/cloudScenario.ts @@ -9,6 +9,15 @@ export function cloudViewport(session: Pick): string } export const cloudScenarioId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +export async function startCloudWithoutRepository(session: Pick): Promise { + await session.waitForText('Cloud environment'); + await session.press('down'); + await session.waitForText('▸ 2. Start a hosted conversation without a repository'); + await session.press('enter'); + await session.waitForText('Cloud agent connected'); +} + export async function seedCloudScenario(directory: string, tier = 'pro', review = false, queueSize = 0, handoff = false): Promise { const file = path.join(directory, 'cloud-fetch.mjs'); const queued = Array.from({ length: queueSize }, (_, index) => ({ id: `bbbbbbbb-bbbb-4bbb-8bbb-${String(index).padStart(12, '0')}`, state: 'queued', revision: 3, content: `Correction ${index + 1}` })); diff --git a/src/testing/scenarios/queuedSteeringScenario.ts b/src/testing/scenarios/queuedSteeringScenario.ts index ee2eb7d98..517719220 100644 --- a/src/testing/scenarios/queuedSteeringScenario.ts +++ b/src/testing/scenarios/queuedSteeringScenario.ts @@ -1,4 +1,5 @@ import type { Session } from 'tuistory'; +import { waitForTerminalScreen } from '../drivers/tuistory-driver.js'; export async function startQueuedSteeringTurn(session: Session): Promise { await session.waitForText('❯', { timeout: 20_000 }); @@ -8,43 +9,37 @@ export async function startQueuedSteeringTurn(session: Session): Promise { } export async function steerPendingMessage( - session: Session, + session: Pick, options: { sequence: string; selected: boolean; fullScreen?: boolean }, ): Promise { - const row = () => session.getTerminalData().lines - .slice(-session.currentRows) - .map(line => line.spans.map(span => span.text).join('').trimStart().startsWith('❯')) - .lastIndexOf(true); - await session.waitIdle(); - const baseline = row(); - const checkAnchor = async () => { - await session.waitIdle(); - if (options.fullScreen && (baseline < 0 || row() !== baseline)) throw new Error(`Composer moved from row ${baseline} to ${row()}`); - }; + const row = (screen: string) => screen.split('\n').slice(-session.currentRows) + .map(line => line.trimStart().startsWith('❯')).lastIndexOf(true); + const initial = options.fullScreen ? await waitForTerminalScreen(session, { + timeout: 10_000, + waitFor: screen => row(screen) >= session.currentRows - 7 && row(screen) < session.currentRows, + }) : ''; + const baseline = row(initial); + const waitForState = (predicate: (screen: string) => boolean, timeout = 10_000) => waitForTerminalScreen(session, { + timeout, + waitFor: screen => predicate(screen) && (!options.fullScreen || row(screen) === baseline), + }); const messages = options.selected ? ['QUEUE_LATER: leave this for the following turn', 'STEER_NOW: mention the worktrees too'] : ['STEER_NOW: mention the worktrees too', 'QUEUE_LATER: leave this for the following turn']; for (let index = 0; index < messages.length; index++) { await session.type(messages[index]); await session.press('enter'); - await session.waitForText('Queue · ' + (index + 1) + ' pending', { timeout: 10_000 }); - await checkAnchor(); + await waitForState(screen => screen.includes('Queue · ' + (index + 1) + ' pending')); } if (options.selected) { await session.press('down'); await session.press('down'); - await session.waitForText('› 2. STEER_NOW', { timeout: 5_000 }); - await checkAnchor(); + await waitForState(screen => screen.includes('› 2. STEER_NOW'), 5_000); } else { await session.type('KEEP_THIS_DRAFT'); } session.writeRaw(options.sequence); - const screen = await session.text({ - timeout: 10_000, - waitFor: text => text.includes('Queue · 1 pending') && text.includes('QUEUE_LATER') - && (options.selected || text.includes('❯ KEEP_THIS_DRAFT')), - }); - await checkAnchor(); - return screen; + return waitForState(text => text.includes('Queue · 1 pending') && text.includes('QUEUE_LATER') + && (options.selected || text.includes('❯ KEEP_THIS_DRAFT'))); } export function pendingQueueSnapshot(screen: string): string[] { diff --git a/tests/config/workspaceTrust.config.test.ts b/tests/config/workspaceTrust.config.test.ts index 5a2076f1a..9b19cfc7a 100644 --- a/tests/config/workspaceTrust.config.test.ts +++ b/tests/config/workspaceTrust.config.test.ts @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { loadConfig } from '../../src/config.js'; import { trustWorkspace } from '../../src/permissions/workspaceTrust.js'; -const SCRATCH_DIR = path.join(os.homedir(), 'Documents/autohand/digital-team/charlie-cox/scratch'); +const SCRATCH_DIR = os.tmpdir(); let root: string; let configPath: string; diff --git a/tests/core/toolsRegistry.trust.test.ts b/tests/core/toolsRegistry.trust.test.ts index aae5a41fa..fdacde440 100644 --- a/tests/core/toolsRegistry.trust.test.ts +++ b/tests/core/toolsRegistry.trust.test.ts @@ -8,6 +8,7 @@ import fs from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; import { createToolsRegistry } from '../../src/core/toolsRegistry.js'; +import { normalizeMetaToolDefinition } from '../../src/core/metaTools/schema.js'; const definition = (name: string, scope: 'project' | 'user') => ({ name, @@ -16,7 +17,7 @@ const definition = (name: string, scope: 'project' | 'user') => ({ handler: `echo ${scope}`, }); -const SCRATCH_DIR = path.join(os.homedir(), 'Documents/autohand/digital-team/charlie-cox/scratch'); +const SCRATCH_DIR = os.tmpdir(); let root: string; let userDir: string; @@ -34,6 +35,21 @@ afterEach(async () => { }); describe('project meta-tools and workspace trust', () => { + it('creates an explicitly requested project tool without loading other untrusted project tools', async () => { + const workspace = path.join(root, 'workspace'); + await fs.writeJson(path.join(workspace, '.autohand', 'tools', 'untrusted.json'), definition('untrusted', 'project')); + const registry = createToolsRegistry(workspace, userDir); + await registry.initialize(); + const created = normalizeMetaToolDefinition({ ...definition('requested', 'project'), scope: 'project' }); + if (!created) throw new Error('Invalid fixture'); + await registry.saveMetaTool(created); + expect(registry.getMetaTool('requested')).toMatchObject({ scope: 'project' }); + expect(registry.getMetaTool('untrusted')).toBeUndefined(); + expect(registry.getRegistryEntries({ includeDisabled: true }).map(tool => tool.name)).toContain('requested'); + await registry.initialize(); + expect(registry.getMetaTool('requested')).toBeUndefined(); + expect(registry.getMetaTool('untrusted')).toBeUndefined(); + }); it('loads no project meta-tools from an untrusted workspace', async () => { await fs.writeJson(path.join(root, 'workspace', '.autohand', 'tools', 'deploy.json'), definition('deploy', 'project')); const registry = createToolsRegistry(path.join(root, 'workspace'), userDir, { projectToolsTrusted: false }); diff --git a/tests/import/ClaudeImporter.test.ts b/tests/import/ClaudeImporter.test.ts index 51f436233..ac7c310d4 100644 --- a/tests/import/ClaudeImporter.test.ts +++ b/tests/import/ClaudeImporter.test.ts @@ -40,6 +40,9 @@ vi.mock('fs-extra', () => ({ writeFile: vi.fn().mockResolvedValue(undefined), copy: vi.fn().mockResolvedValue(undefined), stat: vi.fn(), + realpath: vi.fn(async (file: string) => file), + lstat: vi.fn(async () => ({ isSymbolicLink: () => false })), + chmod: vi.fn().mockResolvedValue(undefined), }, })); @@ -136,7 +139,7 @@ describe('ClaudeImporter', () => { const result = await importer.import(['mcp']); expect(result.imported.get('mcp')).toEqual({ success: 1, failed: 0, skipped: 0 }); - expect(vi.mocked(fse.writeJson).mock.calls[0]?.[1]).toMatchObject({ + expect(JSON.parse(atomicFileMocks.atomicWriteFile.mock.calls[0]?.[1])).toMatchObject({ mcp: { servers: [{ name: 'valid', command: 'run', transport: 'stdio', autoConnect: false, localOnly: true }] }, }); }); @@ -163,9 +166,9 @@ describe('ClaudeImporter', () => { skipped: 1, skipReasons: { 'already exists': 1 }, }); - const writes = vi.mocked(fse.writeJson).mock.calls; + const writes = atomicFileMocks.atomicWriteFile.mock.calls; expect(writes.some(call => String(call[0]) === userConfig)).toBe(false); - const localWrite = writes.find(call => String(call[0]).endsWith('.autohand/settings.local.json'))?.[1] as any; + const localWrite = JSON.parse(writes.find(call => String(call[0]).endsWith('.autohand/settings.local.json'))?.[1]); expect(localWrite.mcp.servers).toEqual(expect.arrayContaining([ { name: 'project', command: 'shared', transport: 'stdio', autoConnect: false, localOnly: true }, { name: 'keep', command: 'incoming', transport: 'stdio', autoConnect: false, localOnly: true }, @@ -191,6 +194,7 @@ describe('ClaudeImporter', () => { expect(result.imported.get('mcp')).toMatchObject({ success: 0, failed: 1 }); expect(result.errors).toEqual([expect.objectContaining({ category: 'mcp', item: 'shared', error: expect.stringContaining('environment variable placeholders') })]); expect(fse.writeJson).not.toHaveBeenCalled(); + expect(atomicFileMocks.atomicWriteFile).not.toHaveBeenCalled(); }); it('reports existing MCP servers as skipped without rewriting their config', async () => { @@ -219,6 +223,7 @@ describe('ClaudeImporter', () => { }); expect(fse.ensureDir).not.toHaveBeenCalled(); expect(fse.writeJson).not.toHaveBeenCalled(); + expect(atomicFileMocks.atomicWriteFile).not.toHaveBeenCalled(); expect(fse.writeFile).not.toHaveBeenCalled(); }); diff --git a/tests/import/claudePermissions.test.ts b/tests/import/claudePermissions.test.ts index b994d655d..845e1852f 100644 --- a/tests/import/claudePermissions.test.ts +++ b/tests/import/claudePermissions.test.ts @@ -41,6 +41,39 @@ describe('Claude permission settings import', () => { const options = () => ({ sourceHome, workspaceRoot, configPath }); + it('detects project permission settings when the Claude home and MCP configs are absent', async () => { + await fs.remove(sourceHome); + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.json'), { permissions: { allow: ['Read'] } }); + await expect(new ClaudeImporter(options()).detect()).resolves.toBe(true); + }); + + it.each(['directory', 'file'])('keeps imported MCP credentials out of a symlinked project %s', async kind => { + await fs.outputJson(path.join(workspaceRoot, '.mcp.json'), { mcpServers: { private: { + command: 'fixture-server', env: { TOKEN: 'MCP_FIXTURE_CREDENTIAL' }, + } } }); + const outside = path.join(root, 'outside'); + await fs.ensureDir(outside); + const target = path.join(outside, 'settings.json'); + const original = '{"untouched":true}\n'; + if (kind === 'directory') await fs.symlink(outside, path.join(workspaceRoot, '.autohand'), 'junction'); + else { + await fs.ensureDir(path.join(workspaceRoot, '.autohand')); + await fs.writeFile(target, original); + await fs.symlink(target, path.join(workspaceRoot, '.autohand', 'settings.local.json')); + } + const result = await new ClaudeImporter(options()).import(['mcp']); + expect(result.errors).toContainEqual(expect.objectContaining({ category: 'mcp' })); + if (kind === 'directory') expect(await fs.readdir(outside)).toEqual([]); + else expect(await fs.readFile(target, 'utf8')).toBe(original); + }); + + it.skipIf(process.platform === 'win32')('creates private project MCP configuration files', async () => { + await fs.outputJson(path.join(workspaceRoot, '.mcp.json'), { mcpServers: { private: { command: 'fixture-server' } } }); + const result = await new ClaudeImporter(options()).import(['mcp']); + expect(result.errors).toEqual([]); + expect((await fs.stat(path.join(workspaceRoot, '.autohand', 'settings.local.json'))).mode & 0o777).toBe(0o600); + }); + async function seed() { await fs.outputJson(path.join(sourceHome, 'settings.json'), { env: { API_KEY: 'SOURCE_ENV_MUST_NOT_BE_COPIED' }, diff --git a/tests/permissions/workspaceTrust.test.ts b/tests/permissions/workspaceTrust.test.ts index cc9a15679..86986df6c 100644 --- a/tests/permissions/workspaceTrust.test.ts +++ b/tests/permissions/workspaceTrust.test.ts @@ -14,7 +14,7 @@ import { trustWorkspace, } from '../../src/permissions/workspaceTrust.js'; -const SCRATCH_DIR = path.join(os.homedir(), 'Documents/autohand/digital-team/charlie-cox/scratch'); +const SCRATCH_DIR = os.tmpdir(); let tempDir: string; let storePath: string; diff --git a/tests/testing/queuedSteeringScenario.test.ts b/tests/testing/queuedSteeringScenario.test.ts new file mode 100644 index 000000000..12fdeafbc --- /dev/null +++ b/tests/testing/queuedSteeringScenario.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from 'tuistory'; +import { pendingQueueSnapshot, steerPendingMessage } from '../../src/testing/scenarios/queuedSteeringScenario.js'; + +afterEach(() => vi.useRealTimers()); + +function repaintingSession(permanentDrift = false) { + const currentRows = 42; + let queued = 0; + let draft = ''; + let steered = false; + let repainting = false; + const frame = (row: number) => { + const lines = Array(currentRows).fill(''); + if (queued) { + lines[31] = `Queue · ${queued} pending · ctrl+enter steer next`; + lines[32] = steered ? '1. QUEUE_LATER: leave this for the following turn' : '1. STEER_NOW: mention the worktrees too'; + if (queued === 2) lines[33] = '2. QUEUE_LATER: leave this for the following turn'; + } + lines[row] = `❯ ${draft}`; + return '\n' + lines.join('\n'); + }; + const text = vi.fn(async () => { + vi.advanceTimersByTime(50); + const intermediate = repainting || (permanentDrift && queued > 0); + repainting = false; + return frame(intermediate ? 4 : 37); + }); + return { + currentRows, + text, + waitIdle: vi.fn(async () => {}), + waitForText: vi.fn(async () => frame(repainting ? 4 : 37)), + getTerminalData: vi.fn((): ReturnType => ({ + cols: 120, rows: currentRows, cursor: [0, 0], cursorVisible: false, cursorStyle: 'block', offset: 0, totalLines: currentRows, + lines: frame(repainting ? 4 : 37).split('\n').slice(-currentRows).map(value => ({ + spans: [{ text: value, fg: null, bg: null, flags: 0, width: value.length }], + })), + })), + type: vi.fn(async value => { draft = value; }), + press: vi.fn(async () => { queued++; draft = ''; repainting = true; }), + writeRaw: vi.fn(() => { queued = 1; steered = true; repainting = true; }), + }; +} + +describe('queued steering terminal assertions', () => { + it('checks queued content and the original composer position in the same complete frame', async () => { + vi.useFakeTimers(); + const session = repaintingSession(); + const screen = await steerPendingMessage(session, { sequence: '\x1b[13;5u', selected: false, fullScreen: true }); + expect(screen.split('\n').slice(-session.currentRows)[37]).toBe('❯ KEEP_THIS_DRAFT'); + expect(pendingQueueSnapshot(screen)).toEqual([ + 'Queue · 1 pending · ctrl+enter steer next', '1. QUEUE_LATER: leave this for the following turn', + ]); + expect(vi.getTimerCount()).toBe(0); + }); + + it('still rejects a composer that remains displaced after queueing', async () => { + vi.useFakeTimers(); + const session = repaintingSession(true); + await expect(steerPendingMessage(session, { sequence: '\x1b[13;5u', selected: false, fullScreen: true })).rejects.toThrow(); + expect(session.press).toHaveBeenCalledOnce(); + expect(session.writeRaw).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/tests/tuistory/claude-permission-import.tuistory.test.ts b/tests/tuistory/claude-permission-import.tuistory.test.ts index 96b3a76de..dea35eb93 100644 --- a/tests/tuistory/claude-permission-import.tuistory.test.ts +++ b/tests/tuistory/claude-permission-import.tuistory.test.ts @@ -12,7 +12,12 @@ describe('built Claude permission import', () => { await fs.outputJson(legacy, { raw: { env: { TOKEN: 'SCRUB_LEGACY_ENV' } } }); const beforeConfig = await fs.readFile(state.configPath, 'utf8'); const beforeLegacy = await fs.readFile(legacy, 'utf8'); - const args = ['--path', state.workspaceRoot, '--config', state.configPath, 'import', 'claude', '--categories', 'settings']; + const projectFile = path.join(state.workspaceRoot, '.autohand', 'settings.local.json'); + const hadProjectSettings = await fs.pathExists(projectFile); + await fs.outputJson(path.join(state.workspaceRoot, '.mcp.json'), { mcpServers: { + 'private-fixture': { command: 'fixture-server', env: { TOKEN: 'MCP_TERMINAL_FIXTURE_SECRET' } }, + } }); + const args = ['--path', state.workspaceRoot, '--config', state.configPath, 'import', 'claude', '--categories', 'settings,mcp']; try { const preview = await launchBuiltAutohand([...args, '--dry-run'], { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { CLAUDE_CONFIG_DIR: source }, rows: 48 }); try { @@ -25,6 +30,7 @@ describe('built Claude permission import', () => { expect(text).not.toContain('NEVER_COPY_ENV'); expect(await fs.readFile(state.configPath, 'utf8')).toBe(beforeConfig); expect(await fs.readFile(legacy, 'utf8')).toBe(beforeLegacy); + expect(await fs.pathExists(projectFile)).toBe(hadProjectSettings); } finally { preview.close(); } const imported = await launchBuiltAutohand(args, { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { CLAUDE_CONFIG_DIR: source }, rows: 48 }); try { @@ -33,9 +39,12 @@ describe('built Claude permission import', () => { const text = await imported.text({ immediate: true }); expect(text).toContain('Translated 2 allow and 1 deny'); expect(text).toContain('Scrubbed raw Claude settings'); - expect(text).not.toMatch(/NEVER_COPY_ENV|SCRUB_LEGACY_ENV/); + expect(text).not.toMatch(/NEVER_COPY_ENV|SCRUB_LEGACY_ENV|MCP_TERMINAL_FIXTURE_SECRET/); expect((await fs.readJson(state.configPath)).permissions.denyList).toContain('run_command:rm*'); expect(await fs.readJson(legacy)).not.toHaveProperty('raw'); + expect((await fs.readJson(projectFile)).mcp.servers).toContainEqual(expect.objectContaining({ + name: 'private-fixture', autoConnect: false, localOnly: true, + })); } finally { imported.close(); } } finally { await state.cleanup(); } }); diff --git a/tests/tuistory/cloud-cli.tuistory.test.ts b/tests/tuistory/cloud-cli.tuistory.test.ts index d300d47f4..f3fbc7d4e 100644 --- a/tests/tuistory/cloud-cli.tuistory.test.ts +++ b/tests/tuistory/cloud-cli.tuistory.test.ts @@ -2,7 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; -import { seedCloudScenario, cloudScenarioId, cloudViewport } from '../../src/testing/scenarios/cloudScenario.js'; +import { seedCloudScenario, cloudScenarioId, cloudViewport, startCloudWithoutRepository } from '../../src/testing/scenarios/cloudScenario.js'; import { createTempAutohandHome, exitInteractive, launchBuiltAutohand, waitForExit, type TuistoryTempState } from './helpers/autohandTuistory.js'; const sessions: Session[] = [], states: TuistoryTempState[] = []; @@ -110,11 +110,7 @@ describe('built CLI cloud terminal flow', () => { }); it.each(['team', 'enterprise'])('starts a %s cloud session from the terminal', async tier => { const { state, session } = await launch(['--prompt', 'list files'], tier); - await session.waitForText('Cloud environment'); - await session.press('down'); - await session.waitForText('▸ 2. Start a hosted conversation without a repository'); - await session.press('enter'); - await session.waitForText('Cloud agent connected'); + await startCloudWithoutRepository(session); const calls = await requests(state); expect(calls.find(call => call.route.endsWith('/messages'))?.body).toMatchObject({ content: 'list files' }); await session.press(['ctrl', 'c']); await waitForExit(session); diff --git a/tests/tuistory/queued-steering-recovery.tuistory.test.ts b/tests/tuistory/queued-steering-recovery.tuistory.test.ts index cb933273c..093adda96 100644 --- a/tests/tuistory/queued-steering-recovery.tuistory.test.ts +++ b/tests/tuistory/queued-steering-recovery.tuistory.test.ts @@ -119,7 +119,7 @@ describe('queued steering and streamed tool-call recovery in the built CLI', () await vi.waitFor(() => expect(server.requests).toHaveLength(1), { timeout: 15_000 }); const screen = await steerPendingMessage(session, options); if (options.fullScreen) { - const lines = (await session.text()).split('\n'); + const lines = screen.split('\n').slice(-session.currentRows); const composerRow = lines.findLastIndex(line => line.trimStart().startsWith('❯')); expect(composerRow).toBeGreaterThanOrEqual(session.currentRows - 7); expect(composerRow).toBeLessThan(session.currentRows); From 79a26efbea50aa0e351c4b5fbdfc6502fef25b8e Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 29 Oct 2026 12:40:10 +1300 Subject: [PATCH 38/38] Verify team recovery with colored terminal output Exercise the selected retry flow with plain and truecolor themes, and strip terminal control sequences before comparing the visible task row. Reproduce the Windows CI failure without changing runtime rendering or recovery behavior. Co-authored-by: Autohand Evolve --- tests/ui/ink/TeamPanel.test.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/ui/ink/TeamPanel.test.tsx b/tests/ui/ink/TeamPanel.test.tsx index 7f2b6faf9..4728f85bf 100644 --- a/tests/ui/ink/TeamPanel.test.tsx +++ b/tests/ui/ink/TeamPanel.test.tsx @@ -67,15 +67,16 @@ describe('TeamPanel', () => { await vi.waitFor(() => expect(onInstruction).toHaveBeenCalledExactlyOnceWith('/team shutdown')); expect(onToggleTeamPanel).toHaveBeenCalledOnce(); }); - it('inspects a failure and confirms a selected retry without rerunning successful work', async () => { + it.each(['none', 'truecolor'] as const)('inspects a failure and confirms a selected retry without rerunning successful work in %s mode', async colorMode => { const onCommand = vi.fn(); const onHide = vi.fn(); - const view = renderWithTheme(); + ]} onCommand={onCommand} onHide={onHide} />); await new Promise(resolve => setImmediate(resolve)); view.stdin.write('\u001b[B'); - await vi.waitFor(() => expect(view.lastFrame()).toContain('› × Write API docs')); + await vi.waitFor(() => expect(stripVTControlCharacters(view.lastFrame() ?? '')).toContain('› × Write API docs')); view.stdin.write('\r'); await vi.waitFor(() => expect(view.lastFrame()).toContain('Provider unavailable')); expect(view.lastFrame()).toContain('Partial findings retained');