a482082529
The claude-code provider spawns the bundled `claude` CLI via
@cline/sdk → ai-sdk-provider-claude-code → @anthropic-ai/claude-agent-sdk.
On macOS, when that subprocess is launched from inside the VS Code extension
host it performs IDE auto-connect, which shells out to `osascript`
(`tell application …`) to identify/activate the editor — raising the macOS
"control other applications" (Apple Events / Automation) permission prompt.
There is no AppleScript/osascript anywhere in the JS dependency tree (the only
match is the build-only `vite`); the Apple Event is sent by the spawned `claude`
binary, which contains 26 osascript calls. Every one is user-action-gated
(clipboard image, screenshot, terminal-setup, Claude-Desktop deep link, browser
detection) except the IDE auto-connect path, which is the only one that fires
without user action when launched inside an IDE — exactly the reported symptom
(appears on use, harmless when declined, since we never use the connection).
Fix: spawn the agent with the macOS IDE integration disabled via the provider's
`options.env` passthrough — `CLAUDE_CODE_AUTO_CONNECT_IDE=0` hard-returns out of
the binary's auto-connect gate (`mK("0") === true`), and
`CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1` skips the extension-install deep link.
`options.env` is seeded with the full `process.env` so the default environment
inheritance is unchanged (login under HOME, proxy/CA vars preserved) — only the
two switches are added. The env flows end to end into the spawned child
(verified through the provider's getBaseProcessEnv merge and the agent SDK's
spawn). runEditTurn is the sole agent-spawn site, so this covers every flow.
Unit test asserts the agent is constructed with the two switches set and the
rest of the environment preserved. 250 unit + typecheck + build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
4.8 KiB
TypeScript
122 lines
4.8 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { extractReplacement, runEditTurn } from "../src/liveTurn";
|
|
|
|
// A fake Agent that replays a scripted event list to subscribers, supports abort,
|
|
// and resolves agent.run() with a completed (or aborted) result.
|
|
const runs = { count: 0 };
|
|
function fakeSdk(events: any[]) {
|
|
return {
|
|
Agent: class {
|
|
private listeners: ((e: any) => void)[] = [];
|
|
constructor(_cfg: unknown) {}
|
|
subscribe(fn: (e: any) => void) {
|
|
this.listeners.push(fn);
|
|
return () => {
|
|
this.listeners = this.listeners.filter((l) => l !== fn);
|
|
};
|
|
}
|
|
// The real SDK's abort() is a no-op before run() creates its AbortController,
|
|
// so this fake does NOT cooperate with a pre-abort — proving runEditTurn's own
|
|
// short-circuit, not the fake's leniency.
|
|
abort() {}
|
|
async run(_input: string) {
|
|
runs.count += 1;
|
|
for (const e of events) for (const l of this.listeners) l(e);
|
|
return { status: "completed", outputText: "EDITED", runId: "r1" };
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("extractReplacement", () => {
|
|
it("returns plain text untouched", () => {
|
|
expect(extractReplacement("Hello world.", "x")).toBe("Hello world.");
|
|
});
|
|
it("strips a wrapping fence", () => {
|
|
expect(extractReplacement("```\nHello world.\n```", "x")).toBe("Hello world.");
|
|
});
|
|
it("strips a language-tagged fence", () => {
|
|
expect(extractReplacement("```markdown\nHello.\n```\n", "x")).toBe("Hello.");
|
|
});
|
|
it("leaves inner fences alone", () => {
|
|
const inner = "before\n```js\ncode\n```\nafter";
|
|
expect(extractReplacement(inner, "x")).toBe(inner);
|
|
});
|
|
it("keeps the fence when the original selection was itself fence-wrapped", () => {
|
|
const sel = "```python\nold()\n```";
|
|
const out = "```python\nnew()\n```";
|
|
expect(extractReplacement(out, sel)).toBe(out);
|
|
});
|
|
it("still strips when the selection was plain but the model added a fence", () => {
|
|
expect(extractReplacement("```\nplain\n```", "plain old text")).toBe("plain");
|
|
});
|
|
});
|
|
|
|
describe("runEditTurn macOS automation prompt (#59)", () => {
|
|
// The claude-code provider spawns the bundled `claude` CLI, which on macOS
|
|
// performs IDE auto-connect when launched inside VS Code — sending Apple Events
|
|
// that raise the "control other applications" prompt. The extension only
|
|
// consumes the text result and never needs that connection, so we disable it.
|
|
it("spawns the agent with IDE auto-connect + auto-install disabled, preserving the rest of the environment", async () => {
|
|
vi.resetModules();
|
|
const cfgs: any[] = [];
|
|
vi.doMock("@cline/sdk", () => ({
|
|
Agent: class {
|
|
constructor(cfg: any) {
|
|
cfgs.push(cfg);
|
|
}
|
|
subscribe() {
|
|
return () => {};
|
|
}
|
|
abort() {}
|
|
async run() {
|
|
return { status: "completed", outputText: "X", runId: "r" };
|
|
}
|
|
},
|
|
}));
|
|
process.env.__WGL_TEST_SENTINEL__ = "keep-me";
|
|
await runEditTurn("do it", "old");
|
|
const cfg = cfgs[0];
|
|
// mK("0") === true in the claude binary → hard-disables IDE auto-connect,
|
|
// so no osascript / Apple Event fires.
|
|
expect(cfg.options?.env?.CLAUDE_CODE_AUTO_CONNECT_IDE).toBe("0");
|
|
// Belt-and-suspenders: skip the IDE-extension auto-install (deep-link path).
|
|
expect(cfg.options?.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL).toBe("1");
|
|
// The full process environment must survive (login under HOME, proxy/CA vars).
|
|
expect(cfg.options?.env?.__WGL_TEST_SENTINEL__).toBe("keep-me");
|
|
delete process.env.__WGL_TEST_SENTINEL__;
|
|
vi.doUnmock("@cline/sdk");
|
|
vi.resetModules();
|
|
});
|
|
});
|
|
|
|
describe("runEditTurn progress + cancel", () => {
|
|
it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
|
|
vi.resetModules();
|
|
vi.doMock("@cline/sdk", () =>
|
|
fakeSdk([
|
|
{ type: "assistant-text-delta", text: "ED", accumulatedText: "ED" },
|
|
{ type: "usage-updated", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
|
]),
|
|
);
|
|
const seen: string[] = [];
|
|
const turn = await runEditTurn("do it", "old", { onProgress: (s) => seen.push(s.phase) });
|
|
expect(turn.replacement).toBe("EDITED");
|
|
expect(seen).toContain("writing");
|
|
vi.doUnmock("@cline/sdk");
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("a pre-aborted AbortSignal short-circuits before run() and throws (INV-47)", async () => {
|
|
vi.resetModules();
|
|
vi.doMock("@cline/sdk", () => fakeSdk([]));
|
|
runs.count = 0;
|
|
const ac = new AbortController();
|
|
ac.abort();
|
|
await expect(runEditTurn("do it", "old", { signal: ac.signal })).rejects.toThrow(/aborted/);
|
|
expect(runs.count).toBe(0); // the turn never ran — no proposal could be produced
|
|
vi.doUnmock("@cline/sdk");
|
|
vi.resetModules();
|
|
});
|
|
});
|