Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/proposals/thread-initiator-github-pr-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Keep thread initiator GitHub PR identity with shared HOME

## Goal

Slack broker Codex sessions should keep using the Slack thread initiator's bound GitHub account for `gh pr create` and related GitHub operations, even when the runtime uses the shared VM `HOME` for app/runtime files.

## Current state

- Auth profile runtimes now intentionally use the VM `HOME` so Codex/Gemini can share durable user-level files.
- The broker-provided `gh` wrapper resolves a token from `/slack/github-token/resolve` based on the current session workspace and injects it as `GH_TOKEN`.
- The shared VM `HOME` can also contain a normal GitHub CLI login at `~/.config/gh/hosts.yml` and git credential helpers in `~/.gitconfig`.
- If a session command bypasses the wrapper or a git credential lookup reads the shared `gh` config, PRs can be created with the shared HOME account instead of the thread initiator account.

## Proposed changes

1. Keep the shared runtime `HOME`; do not revert to per-profile HOME.
2. Give each auth-profile runtime an isolated broker-managed `GH_CONFIG_DIR` next to its `codex-home`.
3. Start Codex app-server and broker-managed git/codex setup commands with global GitHub token env stripped and the isolated `GH_CONFIG_DIR` set.
4. Add session git credential config through environment entries so GitHub credential requests reset shared HOME helpers and route through the broker `gh` wrapper (`gh auth git-credential`).
5. Keep the existing broker `gh` wrapper behavior: resolve the token from the session workspace and pass only that token to real `gh`.
6. Forward stdin through the `gh` wrapper so `git credential` helper calls can pass credential input through to real `gh`.
7. Include GitHub's `workflow` OAuth scope in new PR identity bindings so fork syncs and branch pushes that contain upstream workflow-file history can succeed.

## If we do not change it

The shared HOME account can leak into PR creation or git credential flows, causing PRs to be authored by the wrong GitHub account even when the Slack initiator has a valid binding.

## After the change

- `HOME` remains shared for runtime/user files.
- `CODEX_HOME` remains per auth profile.
- Direct real `gh` calls no longer see the shared HOME login by default from a broker session.
- `gh` on PATH continues to use the broker wrapper and the thread initiator token.
- Direct git credential requests for GitHub use the broker wrapper token instead of shared HOME credentials.
- New GitHub PR identity bindings request `workflow`; existing bindings that lack it may need to rebind before they can update stale forks containing workflow changes.

## Acceptance criteria

- Tests prove app-server env keeps shared `HOME`, per-profile `CODEX_HOME`, isolated `GH_CONFIG_DIR`, and no global GitHub token env.
- Tests prove git setup env includes the same isolated `GH_CONFIG_DIR` and overrides GitHub credential helpers to use broker `gh`.
- Tests prove the `gh` wrapper still injects only the broker-resolved token into real `gh` while preserving the isolated `GH_CONFIG_DIR` and forwarding stdin.
- Manual validation in this session shows a Slack session workspace resolves `pengx17` through broker `gh`, direct real `gh` cannot read shared credentials when `GH_CONFIG_DIR` is isolated, and git credential helper input reaches real `gh` through the wrapper.
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export function loadConfig(env = process.env): AppConfig {
githubApiBaseUrl: env.GITHUB_API_BASE_URL ?? "https://api.github.com",
githubOAuthScopes: getCsvList(env, "GITHUB_OAUTH_SCOPES").length > 0
? getCsvList(env, "GITHUB_OAUTH_SCOPES")
: ["repo", "read:user", "user:email"],
: ["repo", "read:user", "user:email", "workflow"],
defaultGitHubLogin: getOptional(env, "BROKER_DEFAULT_GITHUB_LOGIN"),
defaultGitHubToken: getOptional(env, "BROKER_DEFAULT_GITHUB_TOKEN"),
port,
Expand Down
65 changes: 40 additions & 25 deletions src/services/codex/app-server-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Readable } from "node:stream";

import { logger } from "../../logger.js";
import { ensureDir, fileExists } from "../../utils/fs.js";
import { withoutGlobalGitHubTokenEnv } from "../../utils/github-env.js";
import { withBrokerGitHubEnv } from "../../utils/github-env.js";
import { resolveRuntimeToolPath } from "../../utils/runtime-paths.js";
import { syncUserCodexHome } from "./codex-home.js";
import { syncGeminiHome } from "./gemini-home.js";
Expand All @@ -25,6 +25,7 @@ export class AppServerProcess {
readonly #codexHome: string;
readonly #teamCodexHomePath: string | undefined;
readonly #runtimeHome: string;
readonly #githubCliConfigDir: string;
readonly #port: number;
readonly #openAiApiKey: string | undefined;
readonly #authJsonPath: string | undefined;
Expand Down Expand Up @@ -57,6 +58,7 @@ export class AppServerProcess {
this.#codexHome = options.codexHome;
this.#teamCodexHomePath = options.teamCodexHomePath;
this.#runtimeHome = resolveRuntimeHomePath();
this.#githubCliConfigDir = path.join(path.dirname(options.codexHome), "gh-config");
this.#port = options.port;
this.#openAiApiKey = options.openAiApiKey;
this.#authJsonPath = options.authJsonPath;
Expand Down Expand Up @@ -85,22 +87,25 @@ export class AppServerProcess {
const githubCliWrapper = await this.#ensureGitHubCliWrapper();
const tempadLinkServiceUrl = await this.#resolveTempadLinkServiceUrl();

const env: NodeJS.ProcessEnv = withoutGlobalGitHubTokenEnv({
...process.env,
CODEX_HOME: this.#codexHome,
...(this.#teamCodexHomePath ? { CODEX_TEAM_HOME: this.#teamCodexHomePath } : {}),
HOME: this.#runtimeHome,
BROKER_API_BASE: this.#brokerHttpBaseUrl,
BROKER_GH_HELPER:
process.env.BROKER_GH_HELPER?.trim() || resolveRuntimeToolPath("gh.js"),
BROKER_REAL_GH_PATH:
process.env.BROKER_REAL_GH_PATH?.trim() || githubCliWrapper.realGhPath || "",
TEMPAD_LINK_SERVICE_URL: tempadLinkServiceUrl,
BROKER_GIT_COAUTHOR_HELPER:
process.env.BROKER_GIT_COAUTHOR_HELPER?.trim() || resolveRuntimeToolPath("git-coauthor.js"),
BROKER_GEMINI_UI_HELPER:
process.env.BROKER_GEMINI_UI_HELPER?.trim() || resolveRuntimeToolPath("gemini-ui.js"),
PATH: `${githubCliWrapper.binDir}:${process.env.PATH ?? ""}`
const env: NodeJS.ProcessEnv = withBrokerGitHubEnv({
env: {
...process.env,
CODEX_HOME: this.#codexHome,
...(this.#teamCodexHomePath ? { CODEX_TEAM_HOME: this.#teamCodexHomePath } : {}),
HOME: this.#runtimeHome,
BROKER_API_BASE: this.#brokerHttpBaseUrl,
BROKER_GH_HELPER:
process.env.BROKER_GH_HELPER?.trim() || resolveRuntimeToolPath("gh.js"),
BROKER_REAL_GH_PATH:
process.env.BROKER_REAL_GH_PATH?.trim() || githubCliWrapper.realGhPath || "",
TEMPAD_LINK_SERVICE_URL: tempadLinkServiceUrl,
BROKER_GIT_COAUTHOR_HELPER:
process.env.BROKER_GIT_COAUTHOR_HELPER?.trim() || resolveRuntimeToolPath("git-coauthor.js"),
BROKER_GEMINI_UI_HELPER:
process.env.BROKER_GEMINI_UI_HELPER?.trim() || resolveRuntimeToolPath("gemini-ui.js"),
PATH: `${githubCliWrapper.binDir}:${process.env.PATH ?? ""}`
},
ghConfigDir: githubCliWrapper.configDir
});

if (this.#geminiHttpProxy) {
Expand Down Expand Up @@ -191,6 +196,7 @@ export class AppServerProcess {
}

await ensureDir(this.#codexHome);
await ensureDir(this.#githubCliConfigDir);
await syncUserCodexHome({
codexHome: this.#codexHome,
teamCodexHomePath: this.#teamCodexHomePath,
Expand Down Expand Up @@ -248,10 +254,12 @@ export class AppServerProcess {

async #ensureGitHubCliWrapper(): Promise<{
readonly binDir: string;
readonly configDir: string;
readonly realGhPath?: string | undefined;
}> {
const binDir = path.join(this.#runtimeHome, ".local", "broker-bin");
await ensureDir(binDir);
await ensureDir(this.#githubCliConfigDir);
const wrapperPath = path.join(binDir, "gh");
const wrapperScript = [
"#!/usr/bin/env bash",
Expand All @@ -266,6 +274,7 @@ export class AppServerProcess {
await fs.chmod(wrapperPath, 0o755);
return {
binDir,
configDir: this.#githubCliConfigDir,
realGhPath: process.env.BROKER_REAL_GH_PATH?.trim() || await findExecutableOnPath("gh", process.env.PATH)
};
}
Expand Down Expand Up @@ -307,11 +316,14 @@ export class AppServerProcess {
}

async #runCodex(args: string[]): Promise<string> {
const env: NodeJS.ProcessEnv = withoutGlobalGitHubTokenEnv({
...process.env,
CODEX_HOME: this.#codexHome,
...(this.#teamCodexHomePath ? { CODEX_TEAM_HOME: this.#teamCodexHomePath } : {}),
HOME: this.#runtimeHome
const env: NodeJS.ProcessEnv = withBrokerGitHubEnv({
env: {
...process.env,
CODEX_HOME: this.#codexHome,
...(this.#teamCodexHomePath ? { CODEX_TEAM_HOME: this.#teamCodexHomePath } : {}),
HOME: this.#runtimeHome
},
ghConfigDir: this.#githubCliConfigDir
});

if (this.#openAiApiKey) {
Expand Down Expand Up @@ -345,9 +357,12 @@ export class AppServerProcess {
}

async #runGit(args: string[]): Promise<string> {
const env: NodeJS.ProcessEnv = withoutGlobalGitHubTokenEnv({
...process.env,
HOME: this.#runtimeHome
const env: NodeJS.ProcessEnv = withBrokerGitHubEnv({
env: {
...process.env,
HOME: this.#runtimeHome
},
ghConfigDir: this.#githubCliConfigDir
});

return await new Promise<string>((resolve, reject) => {
Expand Down
2 changes: 1 addition & 1 deletion src/services/github-pr-identity-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export class GitHubPrIdentityService {
this.#defaultGitHubToken = normalizeString(options.defaultGitHubToken);
this.#githubApiBaseUrl = normalizeString(options.githubApiBaseUrl) ?? "https://api.github.com";
this.#githubHostname = resolveGitHubHostname(this.#githubApiBaseUrl);
this.#githubOAuthScopes = options.githubOAuthScopes?.length ? options.githubOAuthScopes : ["repo", "read:user", "user:email"];
this.#githubOAuthScopes = options.githubOAuthScopes?.length ? options.githubOAuthScopes : ["repo", "read:user", "user:email", "workflow"];
this.#ghPath = normalizeString(options.ghPath) ?? "gh";
}

Expand Down
16 changes: 14 additions & 2 deletions src/tools/gh.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import type { Readable } from "node:stream";
import { pathToFileURL } from "node:url";

import { withoutGlobalGitHubTokenEnv } from "../utils/github-env.js";
Expand All @@ -10,6 +11,7 @@ export interface GhWrapperOptions {
readonly cwd: string;
readonly argv: readonly string[];
readonly env: NodeJS.ProcessEnv;
readonly stdin?: Readable | undefined;
}

export interface GhWrapperResult {
Expand Down Expand Up @@ -90,11 +92,20 @@ function runRealGh(options: GhWrapperOptions & {
...withoutGlobalGitHubTokenEnv(options.env),
GH_TOKEN: options.token
},
stdio: ["ignore", "pipe", "pipe"]
stdio: ["pipe", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";

child.stdin.on("error", () => {
// Real gh may exit before consuming stdin for commands that do not read it.
});
if (options.stdin) {
options.stdin.pipe(child.stdin);
} else {
child.stdin.end();
}

child.stdout.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
Expand Down Expand Up @@ -134,7 +145,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
realGhPath,
cwd: process.cwd(),
argv: process.argv.slice(2),
env: process.env
env: process.env,
stdin: process.stdin
});
process.stdout.write(result.stdout);
process.stderr.write(result.stderr);
Expand Down
46 changes: 46 additions & 0 deletions src/utils/github-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,56 @@ const GLOBAL_GITHUB_TOKEN_ENV_KEYS = [
"BROKER_DEFAULT_GITHUB_TOKEN"
] as const;

const BROKER_GITHUB_GIT_CONFIG_ENTRIES = [
{
key: "credential.https://github.com.helper",
value: ""
},
{
key: "credential.https://github.com.helper",
value: "!gh auth git-credential"
}
] as const;

export function withoutGlobalGitHubTokenEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const sanitized: NodeJS.ProcessEnv = { ...env };
for (const key of GLOBAL_GITHUB_TOKEN_ENV_KEYS) {
delete sanitized[key];
}
return sanitized;
}

export function withBrokerGitHubEnv(options: {
readonly env: NodeJS.ProcessEnv;
readonly ghConfigDir: string;
readonly includeGitCredentialHelper?: boolean | undefined;
}): NodeJS.ProcessEnv {
const sanitized = withoutGlobalGitHubTokenEnv(options.env);
sanitized.GH_CONFIG_DIR = options.ghConfigDir;

if (options.includeGitCredentialHelper === false) {
return sanitized;
}

return withGitConfigEntries(sanitized, BROKER_GITHUB_GIT_CONFIG_ENTRIES);
}

function withGitConfigEntries(
env: NodeJS.ProcessEnv,
entries: ReadonlyArray<{
readonly key: string;
readonly value: string;
}>
): NodeJS.ProcessEnv {
const next: NodeJS.ProcessEnv = { ...env };
const existingCount = Number.parseInt(next.GIT_CONFIG_COUNT ?? "0", 10);
const startIndex = Number.isInteger(existingCount) && existingCount >= 0 ? existingCount : 0;

entries.forEach((entry, offset) => {
const index = startIndex + offset;
next[`GIT_CONFIG_KEY_${index}`] = entry.key;
next[`GIT_CONFIG_VALUE_${index}`] = entry.value;
});
next.GIT_CONFIG_COUNT = String(startIndex + entries.length);
return next;
}
Loading
Loading