diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index af80ec302..a268a3e50 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -52,6 +52,7 @@ import type { FsService } from "../fs/service"; import type { McpAppsService } from "../mcp-apps/service"; import type { PosthogPluginService } from "../posthog-plugin/service"; import type { ProcessTrackingService } from "../process-tracking/service"; +import { loadSessionEnvOverrides } from "../session-env/loader"; import type { SleepService } from "../sleep/service"; import type { AgentAuthAdapter, McpToolInstallations } from "./auth-adapter"; import { discoverExternalPlugins } from "./discover-plugins"; @@ -982,6 +983,27 @@ When creating pull requests, add the following footer at the end of the PR descr return taskId ? all.filter((s) => s.taskId === taskId) : all; } + /** + * Resolve env-var overrides set by the SessionStart-style hooks of the most + * recently active agent session for `taskId`. + * + * Used by git/gh operations triggered from the UI (Commit, Create PR) so + * they pick up the same hook env the agent itself sees — most importantly + * the SSH_AUTH_SOCK that Secretive's hook re-points at the Secretive agent + * for commit signing. Returns an empty object when there is no session for + * the task or when no hook output is available. + */ + public async getSessionEnvForTask( + taskId: string, + ): Promise> { + const candidates = this.listSessions(taskId) + .filter((s) => !!s.config.sessionId) + .sort((a, b) => b.lastActivityAt - a.lastActivityAt); + const session = candidates[0]; + if (!session?.config.sessionId) return {}; + return loadSessionEnvOverrides(session.config.sessionId); + } + /** * Get sessions that were interrupted for a specific reason. * Optionally filter by repoPath to get only sessions for a specific repo. diff --git a/apps/code/src/main/services/git/service.test.ts b/apps/code/src/main/services/git/service.test.ts index ed891e798..e09d7388b 100644 --- a/apps/code/src/main/services/git/service.test.ts +++ b/apps/code/src/main/services/git/service.test.ts @@ -23,6 +23,7 @@ vi.mock("../../utils/logger.js", () => ({ }, })); +import type { AgentService } from "../agent/service"; import type { LlmGatewayService } from "../llm-gateway/service"; import { GitService } from "./service"; @@ -31,7 +32,10 @@ describe("GitService.getPrChangedFiles", () => { beforeEach(() => { vi.clearAllMocks(); - service = new GitService({} as LlmGatewayService); + service = new GitService( + {} as LlmGatewayService, + { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + ); }); it("flattens paginated GH API results and maps file statuses", async () => { @@ -139,7 +143,10 @@ describe("GitService.getGhAuthToken", () => { beforeEach(() => { vi.clearAllMocks(); - service = new GitService({} as LlmGatewayService); + service = new GitService( + {} as LlmGatewayService, + { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + ); }); it("returns the authenticated GitHub CLI token", async () => { @@ -197,7 +204,10 @@ describe("GitService.getPrUrlForBranch", () => { beforeEach(() => { vi.clearAllMocks(); - service = new GitService({} as LlmGatewayService); + service = new GitService( + {} as LlmGatewayService, + { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + ); }); it("returns the PR URL for a branch via gh pr list", async () => { diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index a40eeaa8e..ab3004b0b 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -40,6 +40,7 @@ import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { AgentService } from "../agent/service"; import type { LlmGatewayService } from "../llm-gateway/service"; import { CreatePrSaga } from "./create-pr-saga"; import type { @@ -117,10 +118,31 @@ export class GitService extends TypedEventEmitter { constructor( @inject(MAIN_TOKENS.LlmGatewayService) private readonly llmGateway: LlmGatewayService, + @inject(MAIN_TOKENS.AgentService) + private readonly agentService: AgentService, ) { super(); } + /** + * Resolve env-var overrides set by the agent's SessionStart hooks for the + * given task. Used so UI-triggered git/gh operations (Commit, Create PR) + * see the same env (notably `SSH_AUTH_SOCK` re-pointed at Secretive) as + * the agent's bash tool. Returns `undefined` if there's nothing to apply. + */ + private async getSessionEnv( + taskId: string | undefined, + ): Promise | undefined> { + if (!taskId) return undefined; + try { + const env = await this.agentService.getSessionEnvForTask(taskId); + return Object.keys(env).length > 0 ? env : undefined; + } catch (err) { + log.warn("Failed to load session env for task", { taskId, err }); + return undefined; + } + } + private async getStateSnapshot( directoryPath: string, options?: { @@ -438,6 +460,7 @@ export class GitService extends TypedEventEmitter { branch?: string, setUpstream = false, signal?: AbortSignal, + env?: Record, ): Promise { const saga = new PushSaga(); const result = await saga.run({ @@ -446,6 +469,7 @@ export class GitService extends TypedEventEmitter { branch: branch || undefined, setUpstream, signal, + env, }); if (!result.success) { return { success: false, message: result.error }; @@ -495,6 +519,7 @@ export class GitService extends TypedEventEmitter { directoryPath: string, remote = "origin", signal?: AbortSignal, + env?: Record, ): Promise { const currentBranch = await getCurrentBranch(directoryPath); if (!currentBranch) { @@ -507,6 +532,7 @@ export class GitService extends TypedEventEmitter { currentBranch, true, signal, + env, ); return { success: pushResult.success, @@ -580,6 +606,8 @@ export class GitService extends TypedEventEmitter { }); }; + const sessionEnv = await this.getSessionEnv(input.taskId); + const saga = new CreatePrSaga( { getCurrentBranch: (dir) => getCurrentBranch(dir), @@ -588,14 +616,16 @@ export class GitService extends TypedEventEmitter { getChangedFilesHead: (dir) => this.getChangedFilesHead(dir), generateCommitMessage: (dir) => this.generateCommitMessage(dir, input.conversationContext), - commit: (dir, msg, opts) => this.commit(dir, msg, opts), + commit: (dir, msg, opts) => + this.commit(dir, msg, { ...opts, envOverride: sessionEnv }), getSyncStatus: (dir) => this.getGitSyncStatus(dir), - push: (dir) => this.push(dir), - publish: (dir) => this.publish(dir), + push: (dir) => + this.push(dir, "origin", undefined, false, undefined, sessionEnv), + publish: (dir) => this.publish(dir, "origin", undefined, sessionEnv), generatePrTitleAndBody: (dir) => this.generatePrTitleAndBody(dir, input.conversationContext), createPr: (dir, title, body, draft) => - this.createPrViaGh(dir, title, body, draft), + this.createPrViaGh(dir, title, body, draft, sessionEnv), onProgress: emitProgress, }, log, @@ -678,6 +708,8 @@ export class GitService extends TypedEventEmitter { allowEmpty?: boolean; stagedOnly?: boolean; taskId?: string; + /** Pre-resolved session env. Internal — used by createPr to avoid re-loading. */ + envOverride?: Record; }, ): Promise { const fail = (msg: string): CommitOutput => ({ @@ -689,11 +721,15 @@ export class GitService extends TypedEventEmitter { if (!message.trim()) return fail("Commit message is required"); + const { envOverride, ...sagaOptions } = options ?? {}; + const env = envOverride ?? (await this.getSessionEnv(options?.taskId)); + const saga = new CommitSaga(); const result = await saga.run({ baseDir: directoryPath, message: message.trim(), - ...options, + env, + ...sagaOptions, }); if (!result.success) return fail(result.error); @@ -905,6 +941,7 @@ export class GitService extends TypedEventEmitter { title?: string, body?: string, draft?: boolean, + env?: Record, ): Promise<{ success: boolean; message: string; prUrl: string | null }> { const prFooter = "\n\n---\n*Created with [PostHog Code](https://posthog.com/code?ref=pr)*"; @@ -918,7 +955,7 @@ export class GitService extends TypedEventEmitter { } if (draft) args.push("--draft"); - const result = await execGh(args, { cwd: directoryPath }); + const result = await execGh(args, { cwd: directoryPath, env }); if (result.exitCode !== 0) { return { success: false, diff --git a/apps/code/src/main/services/session-env/loader.test.ts b/apps/code/src/main/services/session-env/loader.test.ts new file mode 100644 index 000000000..db50891a6 --- /dev/null +++ b/apps/code/src/main/services/session-env/loader.test.ts @@ -0,0 +1,135 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadSessionEnvOverrides } from "./loader"; + +describe("loadSessionEnvOverrides", () => { + const SESSION_ID = "test-session-id"; + let configDir: string; + let sessionDir: string; + let originalConfigDir: string | undefined; + + beforeEach(async () => { + configDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-env-test-")); + sessionDir = path.join(configDir, "session-env", SESSION_ID); + await fs.mkdir(sessionDir, { recursive: true }); + originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + }); + + afterEach(async () => { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + } + await fs.rm(configDir, { recursive: true, force: true }); + }); + + const writeHook = (name: string, content: string) => + fs.writeFile(path.join(sessionDir, name), content); + + it("returns empty when CLAUDE_CONFIG_DIR is unset", async () => { + delete process.env.CLAUDE_CONFIG_DIR; + expect(await loadSessionEnvOverrides(SESSION_ID)).toEqual({}); + }); + + it("returns empty when session dir does not exist", async () => { + expect(await loadSessionEnvOverrides("missing-session")).toEqual({}); + }); + + it("returns empty when no hook files match", async () => { + await writeHook("ignored.txt", "export FOO=bar\n"); + expect(await loadSessionEnvOverrides(SESSION_ID)).toEqual({}); + }); + + it("parses simple export statements from a SessionStart hook", async () => { + await writeHook("sessionstart-hook-0.sh", "export FOO=bar\n"); + const overrides = await loadSessionEnvOverrides(SESSION_ID); + expect(overrides.FOO).toBe("bar"); + }); + + it("captures values produced by `printf %q` shell quoting", async () => { + const value = "/Users/alice/Library/foo bar/socket.ssh"; + await writeHook( + "sessionstart-hook-0.sh", + `printf 'export SSH_AUTH_SOCK=%q\\n' ${JSON.stringify(value)} | source /dev/stdin\n` + + // also test the expected hook output format directly + `export SSH_AUTH_SOCK='${value}'\n`, + ); + const overrides = await loadSessionEnvOverrides(SESSION_ID); + expect(overrides.SSH_AUTH_SOCK).toBe(value); + }); + + it("merges exports from multiple hook files in sorted order", async () => { + await writeHook("sessionstart-hook-0.sh", "export FIRST=one\n"); + await writeHook("sessionstart-hook-1.sh", "export SECOND=two\n"); + await writeHook("setup-hook-0.sh", "export THIRD=three\n"); + const overrides = await loadSessionEnvOverrides(SESSION_ID); + expect(overrides.FIRST).toBe("one"); + expect(overrides.SECOND).toBe("two"); + expect(overrides.THIRD).toBe("three"); + }); + + it("ignores files that don't match the SDK hook naming convention", async () => { + await writeHook("setup.sh", "export SHOULD_NOT_LOAD=1\n"); + await writeHook("sessionstart-hook-abc.sh", "export ALSO_NO=1\n"); + await writeHook("sessionstart-hook-0.sh", "export YES=1\n"); + const overrides = await loadSessionEnvOverrides(SESSION_ID); + expect(overrides).toEqual({ YES: "1" }); + }); + + it("does not return vars that already match the parent process env", async () => { + process.env.UNCHANGED_VAR = "same"; + await writeHook("sessionstart-hook-0.sh", "export UNCHANGED_VAR=same\n"); + try { + const overrides = await loadSessionEnvOverrides(SESSION_ID); + expect(overrides.UNCHANGED_VAR).toBeUndefined(); + } finally { + delete process.env.UNCHANGED_VAR; + } + }); + + it("handles paths with spaces and quotes safely", async () => { + const dirWithSpaces = path.join(configDir, "session-env", "weird id"); + await fs.mkdir(dirWithSpaces, { recursive: true }); + await fs.writeFile( + path.join(dirWithSpaces, "sessionstart-hook-0.sh"), + "export SPACED=ok\n", + ); + const overrides = await loadSessionEnvOverrides("weird id"); + expect(overrides.SPACED).toBe("ok"); + }); + + it("returns empty object on bash failure without throwing", async () => { + await writeHook("sessionstart-hook-0.sh", "exit 1\nexport NEVER=set\n"); + // sourcing a script that exits cuts the env -0 short, but we should + // gracefully degrade rather than throw. + const overrides = await loadSessionEnvOverrides(SESSION_ID); + expect(overrides.NEVER).toBeUndefined(); + }); + + it("falls back to empty object if bash is missing", async () => { + // Skip this test on systems where bash exists at /bin/bash — + // we only smoke-check that errors are swallowed. + const realPath = process.env.PATH; + process.env.PATH = ""; + try { + const overrides = await loadSessionEnvOverrides(SESSION_ID); + // bash may still be found via absolute path; either outcome is fine. + expect(typeof overrides).toBe("object"); + } finally { + process.env.PATH = realPath; + } + }); + + it("does not leak BASH_VERSION or other shell internals", async () => { + await writeHook("sessionstart-hook-0.sh", "export USEFUL=yes\n"); + const overrides = await loadSessionEnvOverrides(SESSION_ID); + expect(overrides.BASH_VERSION).toBeUndefined(); + expect(overrides.SHLVL).toBeUndefined(); + expect(overrides._).toBeUndefined(); + expect(overrides.USEFUL).toBe("yes"); + }); +}); diff --git a/apps/code/src/main/services/session-env/loader.ts b/apps/code/src/main/services/session-env/loader.ts new file mode 100644 index 000000000..3ed49e826 --- /dev/null +++ b/apps/code/src/main/services/session-env/loader.ts @@ -0,0 +1,158 @@ +import { spawn } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { logger } from "../../utils/logger"; + +const log = logger.scope("session-env"); + +/** + * Matches the file naming convention used by Claude Agent SDK to write + * SessionStart/Setup/CwdChanged/FileChanged hook output. The SDK reads files + * matching this pattern under `/session-env//` + * and sources them before running its bash tool. + * + * Mirrors `ZI8` in @anthropic-ai/claude-agent-sdk/cli.js. + */ +const HOOK_FILE_RE = + /^(setup|sessionstart|cwdchanged|filechanged)-hook-\d+\.sh$/; + +/** + * Bash-internal vars we never want to propagate to git/gh subprocesses — they + * either have shell-only meaning or just add noise. Anything else that bash + * produces but the parent didn't have is treated as a genuine override. + */ +const BASH_INTERNAL_VARS = new Set([ + "_", + "BASHOPTS", + "BASH_ARGC", + "BASH_ARGV", + "BASH_LINENO", + "BASH_SOURCE", + "BASH_VERSINFO", + "BASH_VERSION", + "DIRSTACK", + "EUID", + "GROUPS", + "HOSTNAME", + "HOSTTYPE", + "IFS", + "MACHTYPE", + "OPTIND", + "OSTYPE", + "PIPESTATUS", + "PPID", + "PS1", + "PS2", + "PS3", + "PS4", + "PWD", + "OLDPWD", + "RANDOM", + "SECONDS", + "SHELLOPTS", + "SHLVL", + "UID", +]); + +const PARSE_TIMEOUT_MS = 5000; + +function shellSingleQuote(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} + +/** + * Load env-var overrides produced by Claude Agent SDK SessionStart-style + * hooks for a given session. + * + * The SDK writes one `-hook-.sh` file per hook into + * `/session-env//`, each containing shell + * `export VAR=value` lines (e.g. `export SSH_AUTH_SOCK=...` from a Secretive + * code-signing hook). The SDK sources these into its bash subprocess before + * each tool command. Mirroring that here lets git/gh commands triggered from + * the UI see the same env — most importantly, the SSH_AUTH_SOCK that + * Secretive's hook re-points at the Secretive agent for commit signing. + * + * Returns only the vars whose post-source value differs from the current + * process env. Empty object if `CLAUDE_CONFIG_DIR` is unset, the session dir + * does not exist, no hook files are present, or bash fails. + */ +export async function loadSessionEnvOverrides( + sessionId: string, +): Promise> { + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + if (!claudeConfigDir) return {}; + + const sessionDir = path.join(claudeConfigDir, "session-env", sessionId); + + let entries: string[]; + try { + entries = await fs.readdir(sessionDir); + } catch { + return {}; + } + + const files = entries.filter((f) => HOOK_FILE_RE.test(f)).sort(); + if (files.length === 0) return {}; + + const filePaths = files.map((f) => path.join(sessionDir, f)); + const sourceCmd = filePaths + .map((p) => `. ${shellSingleQuote(p)} 2>/dev/null || true`) + .join("; "); + const cmd = `${sourceCmd}; env -0`; + + return new Promise((resolve) => { + let settled = false; + const finish = (overrides: Record) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(overrides); + }; + + const timer = setTimeout(() => { + log.warn("Timed out loading session env hooks", { + sessionId, + files: files.length, + }); + try { + proc.kill("SIGKILL"); + } catch {} + finish({}); + }, PARSE_TIMEOUT_MS); + + const proc = spawn("bash", ["-c", cmd], { + stdio: ["ignore", "pipe", "ignore"], + env: process.env, + }); + + const chunks: Buffer[] = []; + proc.stdout.on("data", (c) => chunks.push(c as Buffer)); + proc.on("error", (err) => { + log.warn("Failed to spawn bash for session env", { + sessionId, + err: err.message, + }); + finish({}); + }); + proc.on("close", (code) => { + if (code !== 0) { + log.warn("bash exited non-zero loading session env", { + sessionId, + code, + }); + } + const out = Buffer.concat(chunks).toString("utf8"); + const overrides: Record = {}; + for (const entry of out.split("\0")) { + if (!entry) continue; + const eq = entry.indexOf("="); + if (eq <= 0) continue; + const key = entry.slice(0, eq); + if (BASH_INTERNAL_VARS.has(key)) continue; + const value = entry.slice(eq + 1); + if (process.env[key] !== value) overrides[key] = value; + } + finish(overrides); + }); + }); +} diff --git a/packages/git/src/gh.ts b/packages/git/src/gh.ts index 8a76cc4be..090830fdd 100644 --- a/packages/git/src/gh.ts +++ b/packages/git/src/gh.ts @@ -12,12 +12,12 @@ export interface GhExecResult { export async function execGh( args: string[], - options: { cwd?: string } = {}, + options: { cwd?: string; env?: Record } = {}, ): Promise { try { const { stdout, stderr } = await execFileAsync("gh", args, { cwd: options.cwd, - env: process.env, + env: options.env ? { ...process.env, ...options.env } : process.env, }); return { stdout, stderr, exitCode: 0 }; } catch (error) { diff --git a/packages/git/src/git-saga.ts b/packages/git/src/git-saga.ts index 2332a72dc..c2cfa7ea9 100644 --- a/packages/git/src/git-saga.ts +++ b/packages/git/src/git-saga.ts @@ -5,6 +5,12 @@ import { getGitOperationManager } from "./operation-manager"; export interface GitSagaInput { baseDir: string; signal?: AbortSignal; + /** + * Extra env vars merged on top of the clean env when spawning the git + * subprocess. Used to pass through SessionStart-hook env so UI-triggered + * commits see the same `SSH_AUTH_SOCK` (etc.) the agent does. + */ + env?: Record; } export abstract class GitSaga< @@ -29,7 +35,7 @@ export abstract class GitSaga< this._git = git; return this.executeGitOperations(input); }, - { signal: input.signal }, + { signal: input.signal, env: input.env }, ); } diff --git a/packages/git/src/operation-manager.ts b/packages/git/src/operation-manager.ts index 404ced127..38cfa0b61 100644 --- a/packages/git/src/operation-manager.ts +++ b/packages/git/src/operation-manager.ts @@ -43,6 +43,13 @@ export interface ExecuteOptions { signal?: AbortSignal; timeoutMs?: number; waitForExternalLock?: boolean; + /** + * Extra env vars merged on top of `getCleanEnv()` for the spawned git + * subprocess. Used to pass through SessionStart-hook env (e.g. + * `SSH_AUTH_SOCK` re-pointed at Secretive) so commit signing works for + * UI-triggered commits. + */ + env?: Record; } class GitOperationManagerImpl { @@ -87,18 +94,20 @@ class GitOperationManagerImpl { options?: ExecuteOptions, ): Promise { const state = this.getRepoState(repoPath); + const env = { + ...getCleanEnv(), + GIT_OPTIONAL_LOCKS: "0", + ...options?.env, + }; if (options?.signal) { const scopedGit = createGitClient(repoPath, { abortSignal: options.signal, }); - return operation( - scopedGit.env({ ...getCleanEnv(), GIT_OPTIONAL_LOCKS: "0" }), - ); + return operation(scopedGit.env(env)); } - const git = state.client.env({ ...getCleanEnv(), GIT_OPTIONAL_LOCKS: "0" }); - return operation(git); + return operation(state.client.env(env)); } async executeWrite( @@ -118,16 +127,18 @@ class GitOperationManagerImpl { } } + const env = { ...getCleanEnv(), ...options?.env }; + await state.lock.acquireWrite(); try { if (options?.signal) { const scopedGit = createGitClient(repoPath, { abortSignal: options.signal, }); - return await operation(scopedGit.env(getCleanEnv())); + return await operation(scopedGit.env(env)); } - return await operation(state.client.env(getCleanEnv())); + return await operation(state.client.env(env)); } catch (error) { if (options?.signal?.aborted) { await removeLock(repoPath).catch(() => {});