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
16 changes: 15 additions & 1 deletion packages/junior-github/src/credential-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ export const GITHUB_APP_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY";
export const GITHUB_INSTALLATION_ID_ENV = "GITHUB_INSTALLATION_ID";
export const GITHUB_AUTH_TOKEN_ENV = "GITHUB_TOKEN";
export const GITHUB_AUTH_TOKEN_PLACEHOLDER = "ghp_host_managed_credential";
/**
* Git config that makes git send the token placeholder to github.com.
*
* Git sends no auth header by itself, and sandbox egress adds credentials only
* to requests that carry a placeholder.
*/
export const GITHUB_GIT_PLACEHOLDER_HEADER = {
key: "http.https://github.com/.extraHeader",
value: `Authorization: Basic ${Buffer.from(
`x-access-token:${GITHUB_AUTH_TOKEN_PLACEHOLDER}`,
).toString("base64")}`,
};
const MAX_LEASE_MS = 60 * 60 * 1000;
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
const USER_REFRESH_TIMEOUT_MS = 20_000;
Expand Down Expand Up @@ -457,7 +469,9 @@ function createCredentialLease(
type: "lease",
lease: {
...(input.account ? { account: input.account } : undefined),
...(input.authorization ? { authorization: input.authorization } : undefined),
...(input.authorization
? { authorization: input.authorization }
: undefined),
expiresAt: new Date(input.expiresAtMs).toISOString(),
headerTransforms: (
input.domains ??
Expand Down
6 changes: 6 additions & 0 deletions packages/junior-github/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
GITHUB_APP_PRIVATE_KEY_ENV,
GITHUB_AUTH_TOKEN_ENV,
GITHUB_AUTH_TOKEN_PLACEHOLDER,
GITHUB_GIT_PLACEHOLDER_HEADER,
GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES,
GITHUB_INSTALLATION_ID_ENV,
USER_TOKEN_GRANTS,
Expand Down Expand Up @@ -387,6 +388,11 @@ export function githubPlugin(
await configureGit(ctx, "commit.gpgsign", "false");
await configureGit(ctx, "credential.helper", "");
await configureGit(ctx, "http.emptyAuth", "true");
await configureGit(
ctx,
GITHUB_GIT_PLACEHOLDER_HEADER.key,
GITHUB_GIT_PLACEHOLDER_HEADER.value,
);
},
async beforeToolExecute(ctx) {
if (ctx.tool.name !== "bash") {
Expand Down
10 changes: 9 additions & 1 deletion packages/junior-github/src/workspace-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
WorkspaceFinalize,
WorkspacePrepareHookContext,
} from "@sentry/junior-plugin-api";
import { GITHUB_GIT_PLACEHOLDER_HEADER } from "./credential-support.js";
import { isReservedSandboxDirectory } from "./sandbox-paths.js";

/** Clone missing GitHub repositories or refresh existing Workspace checkouts. */
Expand Down Expand Up @@ -43,6 +44,12 @@ export async function prepareWorkspace(
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_NOSYSTEM: "1",
};
// Git sends no auth by itself. The placeholder header asks egress to add
// GitHub credentials for clone and fetch.
const gitAuthConfig = [
"-c",
`${GITHUB_GIT_PLACEHOLDER_HEADER.key}=${GITHUB_GIT_PLACEHOLDER_HEADER.value}`,
];

const finalize: Array<{ path: string; repo: string; target: string }> = [];
for (const { owner, name, path, repo } of repos) {
Expand Down Expand Up @@ -124,6 +131,7 @@ export async function prepareWorkspace(
args: [
"-c",
"credential.helper=",
...gitAuthConfig,
"-C",
path,
"fetch",
Expand Down Expand Up @@ -163,7 +171,7 @@ export async function prepareWorkspace(
// merge-base, branch switches, and later refreshes.
const result = await ctx.sandbox.run({
cmd: "git",
args: ["clone", "--quiet", "--", cloneUrl, path],
args: [...gitAuthConfig, "clone", "--quiet", "--", cloneUrl, path],
cwd: ctx.sandbox.root,
env: gitEnv,
});
Expand Down
14 changes: 12 additions & 2 deletions packages/junior-github/tests/github-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import { mswServer } from "./msw";
const ORIGINAL_ENV = { ...process.env };

const db = {};
// Git sends the GitHub token placeholder so sandbox egress adds credentials.
const GIT_AUTH_CONFIG = [
"-c",
"http.https://github.com/.extraHeader=Authorization: Basic eC1hY2Nlc3MtdG9rZW46Z2hwX2hvc3RfbWFuYWdlZF9jcmVkZW50aWFs",
];

class TestTokenStore {
private refreshLock = Promise.resolve();
Expand Down Expand Up @@ -3334,6 +3339,7 @@ Conversation: \`local:test:old-conversation\`
"commit.gpgsign",
"credential.helper",
"http.emptyAuth",
"http.https://github.com/.extraHeader",
]);
});

Expand Down Expand Up @@ -3372,6 +3378,7 @@ Conversation: \`local:test:old-conversation\`
["-C", "repos/sentry", "rev-parse", "--is-inside-work-tree"],
["-rf", "--", "repos/sentry"],
[
...GIT_AUTH_CONFIG,
"clone",
"--quiet",
"--",
Expand All @@ -3382,6 +3389,7 @@ Conversation: \`local:test:old-conversation\`
["-C", "repos/junior", "rev-parse", "--is-inside-work-tree"],
["-rf", "--", "repos/junior"],
[
...GIT_AUTH_CONFIG,
"clone",
"--quiet",
"--",
Expand Down Expand Up @@ -3461,6 +3469,7 @@ Conversation: \`local:test:old-conversation\`
[
"-c",
"credential.helper=",
...GIT_AUTH_CONFIG,
"-C",
"repos/junior",
"fetch",
Expand All @@ -3473,7 +3482,7 @@ Conversation: \`local:test:old-conversation\`
["-C", "repos/junior", "reset", "--hard", "refs/remotes/origin/stable"],
["-C", "repos/junior", "clean", "-fd"],
]);
expect(runs.some((args) => args[0] === "clone")).toBe(false);
expect(runs.some((args) => args.includes("clone"))).toBe(false);
});

it("refreshes a valid checkout without an upstream and preserves setup outputs", async () => {
Expand Down Expand Up @@ -3604,6 +3613,7 @@ Conversation: \`local:test:old-conversation\`
["-C", "repos/junior", "rev-parse", "--is-inside-work-tree"],
["-rf", "--", "repos/junior"],
[
...GIT_AUTH_CONFIG,
"clone",
"--quiet",
"--",
Expand Down Expand Up @@ -3640,7 +3650,7 @@ Conversation: \`local:test:old-conversation\`
if (input.args?.includes("--symbolic-full-name")) {
return { exitCode: 1, stderr: "", stdout: "" };
}
if (input.args?.[0] === "clone") {
if (input.args?.includes("clone")) {
cloneAttempts += 1;
if (cloneAttempts === 1) {
return { exitCode: 130, stderr: "interrupted", stdout: "" };
Expand Down
4 changes: 3 additions & 1 deletion packages/junior-tts/skills/tts/scripts/speech.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ export async function generateSpeech(text, output, delivery = "playful") {
const file = await open(path, "wx", 0o600);
let completed = false;
try {
// The plugin's host proxy supplies Authorization. Never read a key here.
// The placeholder asks the host proxy to supply the real Authorization.
// Never read a key here.
const response = await fetch(ENDPOINT, {
method: "POST",
redirect: "error",
signal: AbortSignal.timeout(120_000),
headers: {
Authorization: "Bearer host_managed_credential",
"Content-Type": "application/json",
"ai-gateway-protocol-version": "0.0.1",
"ai-speech-model-specification-version": "4",
Expand Down
5 changes: 4 additions & 1 deletion packages/junior-tts/tests/speech.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ test("Gateway request keeps style separate and writes WAV without a Sandbox key"
const fetch = t.mock.method(globalThis, "fetch", async (url, init) => {
assert.equal(url, "https://ai-gateway.vercel.sh/v4/ai/speech-model");
assert.equal(init.redirect, "error");
assert.equal(new Headers(init.headers).has("authorization"), false);
assert.equal(
new Headers(init.headers).get("authorization"),
"Bearer host_managed_credential",
);
assert.equal(init.headers["ai-model-id"], "google/gemini-3.8-flash-tts");
assert.equal(init.headers["ai-speech-model-specification-version"], "4");
const body = JSON.parse(init.body);
Expand Down
Loading
Loading