Skip to content

Commit 2555de7

Browse files
committed
fix(handoff): require the Kiro IDE bundle instead of falling back to the ambiguous shim
Keeping `command: kiro` left a live path to the bug this entry exists to prevent: with Kiro.app absent but the shim present — the June QA machine, which had Kiro CLI and no IDE — resolveEntry reported the editor tile as available and open-in launched the router, which can be the CLI. Drop the shim entirely. The catalogue already expresses darwin-only + bundle-only by omitting `command`; xcode, terminal and warp all do it. Availability now requires Kiro.app, so the tile can only ever reach the IDE through LaunchServices. That makes preferMacOpenBundle unused, so revert it along with the resolveEntry split: CatalogueEntry and resolveEntry return byte-identical to main and the source diff is now purely additive — one catalogue entry and its comment. Tests: assert available:false when the bundle is missing even with the shim on PATH, and pin the route-level platform gate (POST open-in with editorId=kiro from linux/win32 is refused 400 BAD_REQUEST, distinguishable from the 409 the probe would return without the gate).
1 parent af4e46d commit 2555de7

3 files changed

Lines changed: 84 additions & 76 deletions

File tree

apps/daemon/src/routes/host-tools.ts

Lines changed: 36 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,9 @@ export interface CatalogueEntry {
4242
// macOS-only fallback: when the CLI shim is missing, look for an app
4343
// bundle by name and launch it via `open -a "<name>"`. Lets us list
4444
// Xcode / Qoder / Antigravity / Warp / IntelliJ without forcing users
45-
// to also install their CLI shim. `preferMacOpenBundle` flips it from
46-
// fallback to first choice.
45+
// to also install their CLI shim.
4746
macOpenBundle?: string | readonly string[];
4847
macOpenArgs?: (bundleName: string, resolvedDir: string) => string[];
49-
// On darwin, try `macOpenBundle` before the `$PATH` shim. For tools whose
50-
// bare shim is not a deterministic entry point, the app bundle is: it goes
51-
// through LaunchServices and lands on exactly one app. `command` stays as
52-
// the fallback so the entry still reports available when the bundle is
53-
// missing, and win32/linux resolution order is untouched.
54-
preferMacOpenBundle?: boolean;
5548
platforms?: RealPlatform[];
5649
excludedPlatforms?: RealPlatform[];
5750
}
@@ -65,18 +58,17 @@ export const CATALOGUE: ReadonlyArray<CatalogueEntry> = [
6558
{ id: 'cursor', label: 'Cursor', icon: 'sparkles', command: 'cursor', macOpenBundle: 'Cursor' },
6659
{ id: 'vscode', label: 'VS Code', icon: 'file-code', command: 'code', macOpenBundle: 'Visual Studio Code' },
6760
{ id: 'windsurf', label: 'Windsurf', icon: 'sparkles', command: 'windsurf', macOpenBundle: 'Windsurf' },
68-
// darwin-only: bare `kiro` is not a deterministic IDE entry point. Once the
69-
// opt-in command router is installed (v1.26.0+) `kiro` routes to whatever
70-
// the user set as their default, so `kiro set-default cli` would make this
71-
// tile open the terminal agent — see "Kiro Command Router" in
61+
// darwin-only: bare `kiro` is not a deterministic IDE entry point. The
62+
// opt-in command router (v1.26.0+) makes `kiro` follow whatever the user set
63+
// as their default, so `kiro set-default cli` would point this editor tile at
64+
// the terminal agent — see "Kiro Command Router" in
7265
// https://kiro.dev/docs/cli/reference/cli-commands/. `kiro ide <dir>` is not
73-
// the fix: with the router absent, `kiro` is a Code-OSS-style launcher that
74-
// treats `ide` as a path and opens a spurious `ide` entry. darwin is
75-
// deterministic because it resolves /Applications/Kiro.app first, which
76-
// reaches the IDE through LaunchServices in every router state; `command`
77-
// stays as the fallback when that bundle is missing. win32/linux stay
66+
// an alternative: with the router absent, `kiro` treats `ide` as a path and
67+
// opens a spurious `ide` entry. So this entry deliberately carries no
68+
// `command` shim — availability requires Kiro.app, which reaches the IDE
69+
// through LaunchServices in every router state. win32/linux stay
7870
// unadvertised until they have a verified deterministic launch path. (#6313)
79-
{ id: 'kiro', label: 'Kiro', icon: 'sparkles', command: 'kiro', macOpenBundle: 'Kiro', preferMacOpenBundle: true, platforms: ['darwin'] },
71+
{ id: 'kiro', label: 'Kiro', icon: 'sparkles', macOpenBundle: 'Kiro', platforms: ['darwin'] },
8072
{ id: 'zed', label: 'Zed', icon: 'edit', command: 'zed', macOpenBundle: 'Zed' },
8173
{ id: 'qoder', label: 'Qoder', icon: 'sparkles', command: 'qoder', macOpenBundle: ['Qoder', 'QoderWork'] },
8274
{ id: 'antigravity', label: 'Antigravity', icon: 'orbit', command: 'antigravity', macOpenBundle: ['Antigravity', 'Google Antigravity'] },
@@ -180,47 +172,35 @@ async function probeMacBundle(name: string | readonly string[]): Promise<{ name:
180172
return null;
181173
}
182174

183-
interface ResolvedEntry {
175+
async function resolveEntry(entry: CatalogueEntry): Promise<{
184176
available: boolean;
185177
resolvedPath?: string;
186178
launch?: { command: string; argsForDir: (resolvedDir: string) => string[] };
187-
}
188-
189-
async function resolveViaPathShim(entry: CatalogueEntry): Promise<ResolvedEntry | null> {
190-
if (!entry.command) return null;
191-
const resolved = await probeCommandOnPath(entry.command);
192-
if (!resolved) return null;
193-
return {
194-
available: true,
195-
resolvedPath: resolved,
196-
launch: { command: resolved, argsForDir: entry.commandArgs ?? ((resolvedDir) => [resolvedDir]) },
197-
};
198-
}
199-
200-
async function resolveViaMacBundle(entry: CatalogueEntry): Promise<ResolvedEntry | null> {
201-
if (!entry.macOpenBundle || process.platform !== 'darwin') return null;
202-
const bundle = await probeMacBundle(entry.macOpenBundle);
203-
if (!bundle) return null;
204-
return {
205-
available: true,
206-
resolvedPath: bundle.path,
207-
launch: {
208-
command: await resolveMacOpenCommand(),
209-
argsForDir: entry.macOpenArgs
210-
? ((resolvedDir) => entry.macOpenArgs?.(bundle.name, resolvedDir) ?? ['-a', bundle.name, resolvedDir])
211-
: ((resolvedDir) => ['-a', bundle.name, resolvedDir]),
212-
},
213-
};
214-
}
215-
216-
async function resolveEntry(entry: CatalogueEntry): Promise<ResolvedEntry> {
217-
const order =
218-
entry.preferMacOpenBundle === true && process.platform === 'darwin'
219-
? [resolveViaMacBundle, resolveViaPathShim]
220-
: [resolveViaPathShim, resolveViaMacBundle];
221-
for (const resolve of order) {
222-
const resolved = await resolve(entry);
223-
if (resolved) return resolved;
179+
}> {
180+
if (entry.command) {
181+
const resolved = await probeCommandOnPath(entry.command);
182+
if (resolved) {
183+
return {
184+
available: true,
185+
resolvedPath: resolved,
186+
launch: { command: resolved, argsForDir: entry.commandArgs ?? ((resolvedDir) => [resolvedDir]) },
187+
};
188+
}
189+
}
190+
if (entry.macOpenBundle && process.platform === 'darwin') {
191+
const bundle = await probeMacBundle(entry.macOpenBundle);
192+
if (bundle) {
193+
return {
194+
available: true,
195+
resolvedPath: bundle.path,
196+
launch: {
197+
command: await resolveMacOpenCommand(),
198+
argsForDir: entry.macOpenArgs
199+
? ((resolvedDir) => entry.macOpenArgs?.(bundle.name, resolvedDir) ?? ['-a', bundle.name, resolvedDir])
200+
: ((resolvedDir) => ['-a', bundle.name, resolvedDir]),
201+
},
202+
};
203+
}
224204
}
225205
return { available: false };
226206
}

apps/daemon/tests/host-tools-open-in-route.test.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import path from 'node:path';
1414
import { tmpdir } from 'node:os';
1515
import express from 'express';
1616
import type { Response } from 'express';
17-
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
17+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
1818

1919
import { registerHostToolsRoutes } from '../src/routes/host-tools.js';
2020
import type { RegisterHostToolsRoutesDeps } from '../src/routes/host-tools.js';
@@ -79,11 +79,11 @@ afterAll(async () => {
7979
await new Promise<void>((resolve) => server.close(() => resolve()));
8080
});
8181

82-
function postOpenIn(projectId: string) {
82+
function postOpenIn(projectId: string, editorId = 'cursor') {
8383
return fetch(`${baseUrl}/api/projects/${projectId}/open-in`, {
8484
method: 'POST',
8585
headers: { 'content-type': 'application/json' },
86-
body: JSON.stringify({ editorId: 'cursor' }),
86+
body: JSON.stringify({ editorId }),
8787
});
8888
}
8989

@@ -109,3 +109,36 @@ describe('POST /api/projects/:id/open-in launch reporting (#3871)', () => {
109109
expect(await resp.json()).toEqual({ ok: true, editorId: 'cursor', path: PROJECT_DIR });
110110
});
111111
});
112+
113+
// The darwin gate on the Kiro entry has to be enforced on the *launch* path,
114+
// not just filtered out of `GET /api/editors`. A client can POST any
115+
// `editorId` — it does not have to be one the list offered — so if the route
116+
// stopped consulting applicableForPlatform, a non-macOS POST for `kiro` would
117+
// fall through to the probe. That is the exact "editor tile launches the
118+
// terminal agent" hazard the bundle-only entry exists to prevent (#6313), so
119+
// the refusal is pinned here at the HTTP boundary.
120+
//
121+
// The 400/BAD_REQUEST pair matters: with the gate removed the request is still
122+
// refused, but as 409 EDITOR_NOT_AVAILABLE from the probe. Asserting the code
123+
// (not just a non-2xx status) keeps the two failure modes distinguishable.
124+
describe('POST /api/projects/:id/open-in platform gate — kiro is darwin-only (#6313)', () => {
125+
const ORIGINAL_PLATFORM = process.platform;
126+
127+
afterEach(() => {
128+
Object.defineProperty(process, 'platform', { value: ORIGINAL_PLATFORM, configurable: true });
129+
});
130+
131+
it.each(['linux', 'win32'] as const)(
132+
'refuses editorId=kiro with 400 BAD_REQUEST on %s',
133+
async (platform) => {
134+
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
135+
136+
const resp = await postOpenIn('p1', 'kiro');
137+
138+
expect(resp.status).toBe(400);
139+
const body = (await resp.json()) as { error: { code: string; message: string } };
140+
expect(body.error.code).toBe('BAD_REQUEST');
141+
expect(body.error.message).toBe(`Kiro is not available on ${platform}`);
142+
},
143+
);
144+
});

apps/daemon/tests/host-tools-routes.test.ts

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@ describe('host tools open-in launch plans', () => {
4848
});
4949
});
5050

51-
// Resolution-order coverage for the launch *arguments*, not just platform
52-
// applicability. `kiro` carries preferMacOpenBundle so darwin resolves
53-
// /Applications/Kiro.app ahead of the `$PATH` shim — bare `kiro` routes to the
54-
// user's default once the Kiro command router is installed, and `kiro ide
55-
// <dir>` is not a usable substitute (it adds a spurious `ide` entry when the
56-
// router is absent). These cases pin both halves so neither can regress.
57-
describe('host tools resolution order — preferMacOpenBundle', () => {
51+
// Resolution coverage for the launch *arguments*, not just platform
52+
// applicability. `kiro` is bundle-only: it declares no `command`, so the tile
53+
// can only ever reach Kiro.app and never the `kiro` shim, which routes to the
54+
// user's default once the Kiro command router is installed. These cases pin
55+
// both halves of that — the bundle launch, and the refusal to fall back to a
56+
// shim that may be the terminal agent. (#6313)
57+
describe('host tools resolution — kiro resolves through the IDE bundle only', () => {
5858
const DIR = '/tmp/open-design-project';
5959
const ORIGINAL_PLATFORM = process.platform;
6060

@@ -83,17 +83,17 @@ describe('host tools resolution order — preferMacOpenBundle', () => {
8383
expect(plan.args).not.toContain('ide');
8484
});
8585

86-
it('darwin: kiro still falls back to the $PATH shim when the app bundle is missing', async () => {
86+
it('darwin: kiro is unavailable when Kiro.app is missing, even with the $PATH shim installed', async () => {
8787
stubPlatform('darwin', ['/fake/bin/kiro', '/usr/bin/open']);
8888

8989
const plan = await resolveHostToolLaunchPlan('kiro', DIR);
9090

91-
expect(plan.available).toBe(true);
92-
expect(plan.command).toBe('/fake/bin/kiro');
93-
expect(plan.args).toEqual([DIR]);
91+
expect(plan.available).toBe(false);
92+
expect(plan.command).toBeUndefined();
93+
expect(plan.args).toBeUndefined();
9494
});
9595

96-
it('darwin: an unflagged entry still prefers the $PATH shim over its app bundle', async () => {
96+
it('darwin: an entry that declares a shim still prefers it over its app bundle', async () => {
9797
stubPlatform('darwin', ['/fake/bin/cursor', '/Applications/Cursor.app', '/usr/bin/open']);
9898

9999
const plan = await resolveHostToolLaunchPlan('cursor', DIR);
@@ -103,11 +103,6 @@ describe('host tools resolution order — preferMacOpenBundle', () => {
103103
expect(plan.command).toBe('/fake/bin/cursor');
104104
expect(plan.args).toEqual([DIR]);
105105
});
106-
107-
it('only kiro opts into bundle-first resolution', () => {
108-
const flagged = CATALOGUE.filter((e: CatalogueEntry) => e.preferMacOpenBundle === true);
109-
expect(flagged.map((e: CatalogueEntry) => e.id)).toEqual(['kiro']);
110-
});
111106
});
112107

113108
describe('platform gate — Warp and Kiro are darwin-only, cross-platform tools stay available everywhere', () => {

0 commit comments

Comments
 (0)