-
Notifications
You must be signed in to change notification settings - Fork 0
feat(server): select GitHub CLI accounts per project #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; | |
|
|
||
| import { | ||
| TrimmedNonEmptyString, | ||
| type GitHubCliAccount, | ||
| type SourceControlRepositoryVisibility, | ||
| type VcsError, | ||
| } from "@t3tools/contracts"; | ||
|
|
@@ -35,6 +36,24 @@ export const PinnedGitHubCredential = Context.Reference<{ | |
| readonly credentialFingerprint: string; | ||
| } | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); | ||
|
|
||
| /** | ||
| * The `gh` login a command's checkout is set to use, or null for the CLI's | ||
| * active login. Read once when the service is built; the server provides it | ||
| * from settings (see `GitHubCliAccountSelection.ts`). | ||
| */ | ||
| export class GitHubCliAccountSelection extends Context.Reference<{ | ||
| readonly forCwd: (cwd: string) => Effect.Effect<GitHubCliAccount | null>; | ||
| }>("t3/sourceControl/GitHubCliAccountSelection", { | ||
| defaultValue: () => ({ forCwd: () => Effect.succeed(null) }), | ||
| }) {} | ||
|
|
||
| /** gh reads github.com and GHE.com tenancies from GH_TOKEN, every other host from GH_ENTERPRISE_TOKEN. */ | ||
| function tokenEnv(host: string, token: string): NodeJS.ProcessEnv { | ||
| return host === "github.com" || host.endsWith(".ghe.com") | ||
| ? { GH_TOKEN: token, GITHUB_TOKEN: token } | ||
| : { GH_ENTERPRISE_TOKEN: token, GITHUB_ENTERPRISE_TOKEN: token }; | ||
| } | ||
|
|
||
| export const AllowGitHubReserve = Context.Reference<boolean>( | ||
| "t3/sourceControl/AllowGitHubReserve", | ||
| { defaultValue: () => false }, | ||
|
|
@@ -117,6 +136,19 @@ export class GitHubCliRateLimitError extends Schema.TaggedError<GitHubCliRateLim | |
| } | ||
| } | ||
|
|
||
| export class GitHubCliAccountUnavailableError extends Schema.TaggedError<GitHubCliAccountUnavailableError>()( | ||
| "GitHubCliAccountUnavailableError", | ||
| { ...gitHubCliFailureFields, host: Schema.String, login: Schema.String }, | ||
| ) { | ||
| get detail(): string { | ||
| return `GitHub CLI account ${this.login} on ${this.host} is not signed in. Run \`gh auth login\` for it or choose another account in Source Control settings.`; | ||
| } | ||
|
|
||
| override get message(): string { | ||
| return `GitHub CLI failed in execute: ${this.detail}`; | ||
| } | ||
| } | ||
|
|
||
| export class GitHubPullRequestNotFoundError extends Schema.TaggedError<GitHubPullRequestNotFoundError>()( | ||
| "GitHubPullRequestNotFoundError", | ||
| gitHubCliFailureFields, | ||
|
|
@@ -204,6 +236,7 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubReposi | |
| export const GitHubCliError = Schema.Union([ | ||
| GitHubCliUnavailableError, | ||
| GitHubCliAuthenticationError, | ||
| GitHubCliAccountUnavailableError, | ||
| GitHubCliRateLimitError, | ||
| GitHubPullRequestNotFoundError, | ||
| GitHubCliCommandError, | ||
|
|
@@ -399,6 +432,34 @@ export const make = Effect.gen(function* () { | |
| const process = yield* VcsProcess.VcsProcess; | ||
| const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | ||
| const limits = yield* SourceControlRateLimit.SourceControlRateLimit; | ||
| const accountSelection = yield* GitHubCliAccountSelection; | ||
|
|
||
| // `gh auth token --user` reads one login's token from the CLI's store without | ||
| // switching the active login. Blank env tokens so an ambient GH_TOKEN cannot answer for it. | ||
| const accountTokens = yield* Cache.makeWith( | ||
| (key: string) => { | ||
| const [host = "", login = ""] = key.split("\0"); | ||
| return process | ||
| .run({ | ||
| operation: "GitHubCli.accountToken", | ||
| command: "gh", | ||
| args: ["auth", "token", "--hostname", host, "--user", login], | ||
| cwd: globalThis.process.cwd(), | ||
| timeoutMs: DEFAULT_TIMEOUT_MS, | ||
| env: { ...tokenEnv(host, ""), GH_DEBUG: "" }, | ||
| }) | ||
| .pipe( | ||
| Effect.map((result) => result.stdout.trim()), | ||
| Effect.flatMap((token) => | ||
| token ? Effect.succeed(Redacted.make(token)) : Effect.fail(null), | ||
| ), | ||
| ); | ||
| }, | ||
| { | ||
| capacity: 16, | ||
| timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(1) : Duration.zero), | ||
| }, | ||
| ); | ||
|
|
||
| const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")( | ||
| function* (input) { | ||
|
|
@@ -411,9 +472,33 @@ export const make = Effect.gen(function* () { | |
| }); | ||
| } | ||
| const token = credential === null ? undefined : Redacted.value(credential.token); | ||
| // A pinned credential already names its account; otherwise use the checkout's selection. | ||
| // A selected login that cannot be read fails the command rather than running as another. | ||
| const account = credential === null ? yield* accountSelection.forCwd(input.cwd) : null; | ||
| const accountToken = | ||
| account === null | ||
| ? null | ||
| : yield* Cache.get(accountTokens, `${account.host.toLowerCase()}\0${account.login}`).pipe( | ||
| // Only the token is kept. Never attach credential lookup output to an error. | ||
| Effect.mapError( | ||
| () => | ||
| new GitHubCliAccountUnavailableError({ | ||
| command: "gh", | ||
| cwd: input.cwd, | ||
| host: account.host, | ||
| login: account.login, | ||
| cause: new Error("`gh auth token --user` did not return a token."), | ||
| }), | ||
| ), | ||
| ); | ||
| const env = | ||
| credential === null | ||
| ? input.env | ||
| ? account === null || accountToken === null | ||
| ? input.env | ||
| : { | ||
| ...tokenEnv(account.host.toLowerCase(), Redacted.value(accountToken)), | ||
| ...input.env, | ||
| } | ||
|
Comment on lines
+496
to
+501
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
rg -nP -C3 'GH_ENTERPRISE_TOKEN|isEnterprise|TokenForHost' --glob '*.go' . 2>/dev/null | head -50
rg -nP -C3 '\bgithub\.execute\s*\(|\bexecuteRaw\s*\(' apps/server/src | head -120Repository: codemode-studio/t3code Length of output: 8639 🌐 Web query:
💡 Result: Inspection citation: inspection_758e6aed594a52a686a0ee715ee46bdf <source_evidence> Citations:
🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- helper definitions ---'
rg -n -C8 'function (tokenEnv|commandHosts|targetsVerifiedHost)|const (tokenEnv|commandHosts|targetsVerifiedHost)|export .*tokenEnv|export .*commandHosts|export .*targetsVerifiedHost' apps/server/src/sourceControl/GitHubCli.ts
printf '%s\n' '--- focused tests ---'
rg -n -C8 'GH_HOST|GH_ENTERPRISE_TOKEN|enterprise|commandHosts|targetsVerifiedHost|accountToken|accountSelection' apps/server/src/sourceControl/GitHubCli.test.ts apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts
printf '%s\n' '--- execute input and direct callers with host-bearing arguments ---'
rg -n -C5 'interface.*execute|type.*execute|execute:.*GitHubCli|GitHubCli.*execute|github\.execute\(' apps/server/src/sourceControl apps/server/src/pullRequest -g '*.ts' | head -260Repository: codemode-studio/t3code Length of output: 38866 🏁 Script executed: #!/bin/bash
set -eu
cat -n apps/server/src/sourceControl/GitHubCli.ts | sed -n '62,92p'Repository: codemode-studio/t3code Length of output: 1813 Sensitive Data Exposure Reachability: Internal Scope the selected enterprise token to a verified target host. Allow the selected token only when Reject unverified targets before injecting the token+ const commandTargetHosts = commandHosts(input.args);
const env =
credential === null
- ? account === null || accountToken === null
+ ? account === null ||
+ accountToken === null ||
+ commandTargetHosts.length === 0 ||
+ !commandTargetHosts.every((target) => target === account.host.toLowerCase())
? input.env
: {
- ...tokenEnv(account.host.toLowerCase(), Redacted.value(accountToken)),
...input.env,
+ ...tokenEnv(account.host.toLowerCase(), Redacted.value(accountToken)),
}🤖 Prompt for AI Agents |
||
| : { | ||
| ...input.env, | ||
| GH_HOST: credential.host, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: codemode-studio/t3code
Length of output: 9306
🏁 Script executed:
Repository: codemode-studio/t3code
Length of output: 42317
🏁 Script executed:
Repository: codemode-studio/t3code
Length of output: 42429
Scope quota and rate-limit state to the selected account.
SourceControlRateLimitandGitHubGraphQlBudgetsupport credential-scoped state, butGitHubCli.executedoes not deriveCredentialScopefrom the selected account. For an unpinned command, it uses the ambient scope, which defaults to"". The rate-limit key therefore remains shared across selected accounts on the same host.The quota cache also uses only the host for unpinned accounts. Its
rate_limitprobe callsexecuteRawwithcwd: globalThis.process.cwd(), so account selection uses the environment cwd instead ofinput.cwd. The probe can observe one account's quota, while the command later runs with the account selected for its project.Resolve the selected account before the guards. Use its stable identity, such as
host\0login, in the quota key andSourceControlRateLimit.CredentialScope. Run therate_limitprobe with the command'scwdand selected account. This prevents one account's quota or rate-limit pause from gating another account.🤖 Prompt for AI Agents