Skip to content

Commit 46f8444

Browse files
yyq1025claude
andcommitted
daemon+menubar: bundle claude binary, auth via keychain OAuth (drop system-claude resolution)
Switch from resolving the user's system claude to spawning the SDK's own bundled per-platform binary (Desktop-style), validated end-to-end by spike (env-token auth + re-signed SEA runs + notarized, dmg 178MB): - run-query: omit pathToClaudeCodeExecutable (SDK require.resolve's its platform package); inject CLAUDE_CODE_OAUTH_TOKEN + remote_mobile entrypoint via spawn env. Token is one-shot per spawn — the binary treats env tokens as inference-only and never self-refreshes; a loop alive past token expiry 401s until respawn (known limitation, self-heal signposted). - router: sendPrompt mints a fresh token (OAuthRefreshManager.ensureFresh — the CCR bridge's keeper, shared) before ensureSessionLoop; missing/expired login surfaces as turn_failed with actionable "claude /login" copy. - index: drop claude-binary resolution + claudeStatus/recheckClaudeBinary daemon API; thread ensureFreshToken into router deps + bridge inbound. - menubar: drop the claude engine-health menu row and the shell-env inheritance (PATH probing is moot — we ship the binary). - electron-builder: bundle @anthropic-ai/claude-agent-sdk-* (asarUnpack; executables can't spawn from asar). - protocol: minClaudeVersion is now informational (bundled binary version is pinned by the app release). Prerequisite: a Claude Code login on the Mac (keychain credentials written by `claude /login` or Claude Desktop) — sidecode reads and refreshes that entry (rotation-safe writeback) but never initiates a login itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dc59413 commit 46f8444

8 files changed

Lines changed: 128 additions & 300 deletions

File tree

packages/daemon/src/claude-binary.ts

Lines changed: 0 additions & 118 deletions
This file was deleted.

packages/daemon/src/index.ts

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,6 @@ import {
1010
reattachBridgedSessions,
1111
summarizeReattach,
1212
} from "./bridge/startup-reattach.js";
13-
import {
14-
type ClaudeStatus,
15-
getClaudeStatus,
16-
recheckClaudeBinary,
17-
resolveClaudeBinary,
18-
} from "./claude-binary.js";
1913
import { deleteDaemonLock, writeDaemonLock } from "./daemon-lock.js";
2014
import { GitWatcherRegistry } from "./git-watch.js";
2115
import { resolveSidecodeHome } from "./home.js";
@@ -50,18 +44,6 @@ export interface Daemon {
5044
pairedClientCount(): number;
5145
/** Number of currently authenticated WebRTC peers. */
5246
authenticatedPeerCount(): number;
53-
/**
54-
* Status of the Claude Code executable the SDK spawns (SIDECODE_CLAUDE_PATH
55-
* → PATH). The menubar reads this to show a version / "not found" prompt;
56-
* queries fail fast with the reason when not ok. Cached — see
57-
* recheckClaudeBinary() to force a re-resolve after the user installs claude.
58-
*/
59-
claudeStatus(): ClaudeStatus;
60-
/**
61-
* Force a fresh resolve of the Claude Code executable (the menubar's
62-
* "Recheck", e.g. after the user installs it). Updates the cache + returns it.
63-
*/
64-
recheckClaudeBinary(): ClaudeStatus;
6547
/**
6648
* Mint a fresh pair offer pointing at this daemon. Pure: derived from the
6749
* daemon's identity + the given serviceName — no per-offer state, no
@@ -101,20 +83,14 @@ export async function start(options: DaemonOptions = {}): Promise<Daemon> {
10183
const identity = loadOrCreateIdentity(home);
10284
const knownClients = KnownClients.load(home);
10385

104-
// Resolve the Claude Code executable the SDK will spawn (SIDECODE_CLAUDE_PATH
105-
// → PATH). Non-fatal: if unresolved the daemon still starts and surfaces it
106-
// via claudeStatus() so the menubar can prompt; queries fail fast with the
107-
// reason. Relies on process.env reflecting the user's shell env — the menubar
108-
// inherits it before start() (Finder-launched GUI apps get launchd's minimal
109-
// PATH; terminal-launched `sidecode up` already has it).
110-
const claude = resolveClaudeBinary();
111-
if (claude.ok) {
112-
console.log(
113-
`claude: ${claude.path}${claude.version ? ` (${claude.version})` : ""} [${claude.source}]`,
114-
);
115-
} else {
116-
console.warn(`claude: UNRESOLVED — ${claude.error}`);
117-
}
86+
// sidecode spawns the SDK's OWN bundled claude binary (shipped in the .app)
87+
// and authenticates it by passing the user's keychain OAuth token via env at
88+
// spawn time (run-query + OAuthRefreshManager). No system-claude resolution,
89+
// no PATH probing — we ship the version. The one prerequisite is that the
90+
// user has logged in to Claude Code once (keychain `Claude Code-credentials`,
91+
// via `claude /login` or Claude Desktop); a missing login surfaces per-turn
92+
// as a `turn_failed` with a "run claude /login" message.
93+
//
11894
// Mark sidecode-driven sessions with a remote-mobile entrypoint (not the SDK
11995
// default `sdk-ts`) so they stay visible in the user's `claude --resume`
12096
// picker, which hides sessions whose entrypoint is in the SDK set. Respect a
@@ -191,7 +167,7 @@ export async function start(options: DaemonOptions = {}): Promise<Daemon> {
191167
// against claude.ai's own copy (dedup-by-uuid verified — no double
192168
// bubble, no origin tracking). Wiring this also flips every attached
193169
// transport to bidirectional.
194-
onInboundPrompt: (sessionId, prompt) => {
170+
onInboundPrompt: async (sessionId, prompt) => {
195171
const runtime = runtimeManager.get(sessionId);
196172
if (runtime === undefined) {
197173
// No local runtime for this bridged session. Linking a cse_ to a
@@ -210,9 +186,17 @@ export async function start(options: DaemonOptions = {}): Promise<Daemon> {
210186
// refinement is M3. pushPrompt is UNCHANGED from the local/iOS path —
211187
// bridge / local / iOS prompts share one code path.
212188
try {
213-
ensureSessionLoop(runtime, { mode: "resume" });
189+
// Same per-spawn token as the iOS/local path — the bundled binary
190+
// gets it via env. A creds failure surfaces as turn_failed (forwarded
191+
// to claude.ai by the bridge mirror) rather than a silent drop.
192+
const oauthToken = await oauth.ensureFresh();
193+
ensureSessionLoop(runtime, { mode: "resume", oauthToken });
214194
pushPrompt(runtime, prompt.text, prompt.images, prompt.uuid);
215195
} catch (err) {
196+
runtime.addEvent({
197+
kind: "turn_failed",
198+
error: err instanceof Error ? err.message : String(err),
199+
});
216200
console.log(
217201
`[sidecode] bridge inbound prompt failed for ${sessionId}: ${
218202
err instanceof Error ? err.message : String(err)
@@ -342,6 +326,9 @@ export async function start(options: DaemonOptions = {}): Promise<Daemon> {
342326
},
343327
listSidecodeSessions: (opts) => listSidecodeSessions(home, opts),
344328
isShuttingDown: () => shuttingDown,
329+
// Per-spawn OAuth token for the bundled binary — same shared keychain
330+
// manager the CCR bridge uses (one keeper per process).
331+
ensureFreshToken: () => oauth.ensureFresh(),
345332
gitWatchers,
346333
epoch,
347334
});
@@ -417,8 +404,6 @@ export async function start(options: DaemonOptions = {}): Promise<Daemon> {
417404
fingerprint: identity.fingerprint,
418405
pairedClientCount: () => knownClients.list().length,
419406
authenticatedPeerCount: () => webrtc.authenticatedCount(),
420-
claudeStatus: () => getClaudeStatus(),
421-
recheckClaudeBinary: () => recheckClaudeBinary(),
422407
createPairOffer: (serviceName) => {
423408
const { encoded } = createPairOffer(identity, serviceName);
424409
return { encoded };

packages/daemon/src/router.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
SLASH_COMMANDS,
1414
} from "@sidecodeapp/protocol";
1515
import type { BridgeService } from "./bridge/bridge-service.js";
16+
import { OAuthRefreshError } from "./bridge/oauth-refresh.js";
1617
import type { CommandHandler } from "./command.js";
1718
import type { GitWatcherRegistry } from "./git-watch.js";
1819
import {
@@ -105,6 +106,16 @@ export interface RouterDeps {
105106
* used.
106107
*/
107108
queryFactory?: SessionLoopOptions["queryFactory"];
109+
/**
110+
* Mint a fresh OAuth access token for a claude spawn (OAuthRefreshManager
111+
* .ensureFresh in production: reads keychain, refreshes if near expiry,
112+
* writes back). The sendPrompt handler calls this right before
113+
* `ensureSessionLoop` and hands the result in as `oauthToken`. Rejects
114+
* (OAuthRefreshError) when there are no credentials / re-login is needed —
115+
* the handler turns that into a `turn_failed`. Omitted on the test path
116+
* (`queryFactory` set), which spawns no real binary.
117+
*/
118+
ensureFreshToken?: () => Promise<string>;
108119
/**
109120
* Per-daemon registry of `GitWatcher`s keyed by `cwd`. Shared across
110121
* connections so two iOS clients on the same project re-use one watch
@@ -176,6 +187,23 @@ function getOrCreateGitSubs(ctx: { state: Map<string, unknown> }): GitSubsMap {
176187
return created;
177188
}
178189

190+
/** User-facing message for an `ensureFreshToken` failure before a spawn. The
191+
* bundled binary needs a Claude login (keychain `Claude Code-credentials`,
192+
* written by `claude /login` or Claude Desktop); sidecode never logs in
193+
* itself. Maps OAuthRefreshError kinds to actionable copy. */
194+
function authErrorMessage(err: unknown): string {
195+
if (err instanceof OAuthRefreshError) {
196+
if (err.kind === "no_credentials") {
197+
return "Not signed in to Claude. Run `claude /login` (or sign in with Claude Desktop), then try again.";
198+
}
199+
if (err.kind === "needs_relogin") {
200+
return "Your Claude login has expired. Run `claude /login` again, then retry.";
201+
}
202+
return "Couldn't reach Claude to refresh your login. Check your connection and try again.";
203+
}
204+
return err instanceof Error ? err.message : String(err);
205+
}
206+
179207
/**
180208
* Wire up the authenticated-command dispatcher.
181209
*
@@ -723,6 +751,27 @@ export function createCommandHandler(deps: RouterDeps): CommandHandler {
723751
}
724752
}
725753

754+
// Mint a fresh OAuth token for the spawn (the bundled binary gets
755+
// it via env). Failure (no creds / re-login) surfaces in-band as a
756+
// `turn_failed` so iOS renders it in the transcript, then we ACK the
757+
// RPC and stop — no spawn. Skipped on the test path (queryFactory).
758+
let oauthToken: string | undefined;
759+
if (deps.queryFactory === undefined && deps.ensureFreshToken) {
760+
try {
761+
oauthToken = await deps.ensureFreshToken();
762+
} catch (err) {
763+
runtime.addEvent({
764+
kind: "turn_failed",
765+
error: authErrorMessage(err),
766+
});
767+
ctx.send({
768+
type: "sendPrompt.response",
769+
requestId: cmd.requestId,
770+
});
771+
return;
772+
}
773+
}
774+
726775
// Idempotent — second sendPrompt for same session reuses the
727776
// existing loop. mode/cwd/model are only consulted on the
728777
// FIRST call (when ensureSessionLoop actually spawns the SDK
@@ -735,6 +784,7 @@ export function createCommandHandler(deps: RouterDeps): CommandHandler {
735784
mode,
736785
cwd: cmd.cwd,
737786
model: cmd.model,
787+
oauthToken,
738788
queryFactory: deps.queryFactory,
739789
});
740790
// pushPrompt emits turn_started synchronously before the SDK

0 commit comments

Comments
 (0)