Skip to content

Commit 72ad963

Browse files
committed
feat(cloud-agent): user-authored-prs
1 parent 0e0896f commit 72ad963

11 files changed

Lines changed: 267 additions & 4 deletions

File tree

apps/code/src/main/services/git/schemas.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,14 @@ export const ghStatusOutput = z.object({
213213

214214
export type GhStatusOutput = z.infer<typeof ghStatusOutput>;
215215

216+
export const ghAuthTokenOutput = z.object({
217+
success: z.boolean(),
218+
token: z.string().nullable(),
219+
error: z.string().nullable(),
220+
});
221+
222+
export type GhAuthTokenOutput = z.infer<typeof ghAuthTokenOutput>;
223+
216224
// Pull request status
217225
export const prStatusInput = directoryPathInput;
218226
export const prStatusOutput = z.object({

apps/code/src/main/services/git/service.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,61 @@ describe("GitService.getPrChangedFiles", () => {
127127
).rejects.toThrow("Failed to fetch PR files");
128128
});
129129
});
130+
131+
describe("GitService.getGhAuthToken", () => {
132+
let service: GitService;
133+
134+
beforeEach(() => {
135+
vi.clearAllMocks();
136+
service = new GitService({} as LlmGatewayService);
137+
});
138+
139+
it("returns the authenticated GitHub CLI token", async () => {
140+
mockExecGh.mockResolvedValue({
141+
exitCode: 0,
142+
stdout: "ghu_test_token\n",
143+
stderr: "",
144+
});
145+
146+
const result = await service.getGhAuthToken();
147+
148+
expect(mockExecGh).toHaveBeenCalledWith(["auth", "token"]);
149+
expect(result).toEqual({
150+
success: true,
151+
token: "ghu_test_token",
152+
error: null,
153+
});
154+
});
155+
156+
it("returns the gh error when auth token lookup fails", async () => {
157+
mockExecGh.mockResolvedValue({
158+
exitCode: 1,
159+
stdout: "",
160+
stderr: "authentication required",
161+
});
162+
163+
const result = await service.getGhAuthToken();
164+
165+
expect(result).toEqual({
166+
success: false,
167+
token: null,
168+
error: "authentication required",
169+
});
170+
});
171+
172+
it("returns error when stdout is empty", async () => {
173+
mockExecGh.mockResolvedValue({
174+
exitCode: 0,
175+
stdout: "",
176+
stderr: "",
177+
});
178+
179+
const result = await service.getGhAuthToken();
180+
181+
expect(result).toEqual({
182+
success: false,
183+
token: null,
184+
error: "GitHub auth token is empty",
185+
});
186+
});
187+
});

apps/code/src/main/services/git/service.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import type {
4444
DiscardFileChangesOutput,
4545
GetCommitConventionsOutput,
4646
GetPrTemplateOutput,
47+
GhAuthTokenOutput,
4748
GhStatusOutput,
4849
GitCommitInfo,
4950
GitFileStatus,
@@ -646,6 +647,33 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
646647
};
647648
}
648649

650+
public async getGhAuthToken(): Promise<GhAuthTokenOutput> {
651+
const result = await execGh(["auth", "token"]);
652+
if (result.exitCode !== 0) {
653+
return {
654+
success: false,
655+
token: null,
656+
error:
657+
result.stderr || result.error || "Failed to read GitHub auth token",
658+
};
659+
}
660+
661+
const token = result.stdout.trim();
662+
if (!token) {
663+
return {
664+
success: false,
665+
token: null,
666+
error: "GitHub auth token is empty",
667+
};
668+
}
669+
670+
return {
671+
success: true,
672+
token,
673+
error: null,
674+
};
675+
}
676+
649677
public async getPrStatus(directoryPath: string): Promise<PrStatusOutput> {
650678
const base: PrStatusOutput = {
651679
hasRemote: false,

apps/code/src/main/trpc/routers/git.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
getPrChangedFilesOutput,
4545
getPrTemplateInput,
4646
getPrTemplateOutput,
47+
ghAuthTokenOutput,
4748
ghStatusOutput,
4849
openPrInput,
4950
openPrOutput,
@@ -234,6 +235,10 @@ export const gitRouter = router({
234235
.output(ghStatusOutput)
235236
.query(() => getService().getGhStatus()),
236237

238+
getGhAuthToken: publicProcedure
239+
.output(ghAuthTokenOutput)
240+
.query(() => getService().getGhAuthToken()),
241+
237242
getPrStatus: publicProcedure
238243
.input(prStatusInput)
239244
.output(prStatusOutput)

apps/code/src/renderer/api/posthogClient.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
Task,
1111
TaskRun,
1212
} from "@shared/types";
13+
import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud";
1314
import type { StoredLogEntry } from "@shared/types/session-events";
1415
import { logger } from "@utils/logger";
1516
import { buildApiFetcher } from "./fetcher";
@@ -559,6 +560,12 @@ export class PostHogAPIClient {
559560
branch?: string | null,
560561
resumeOptions?: { resumeFromRunId: string; pendingUserMessage: string },
561562
sandboxEnvironmentId?: string,
563+
runOptions?: {
564+
prAuthorshipMode?: PrAuthorshipMode;
565+
runSource?: CloudRunSource;
566+
signalReportId?: string;
567+
githubUserToken?: string;
568+
},
562569
): Promise<Task> {
563570
const teamId = await this.getTeamId();
564571
const body: Record<string, unknown> = { mode: "interactive" };
@@ -572,6 +579,18 @@ export class PostHogAPIClient {
572579
if (sandboxEnvironmentId) {
573580
body.sandbox_environment_id = sandboxEnvironmentId;
574581
}
582+
if (runOptions?.prAuthorshipMode) {
583+
body.pr_authorship_mode = runOptions.prAuthorshipMode;
584+
}
585+
if (runOptions?.runSource) {
586+
body.run_source = runOptions.runSource;
587+
}
588+
if (runOptions?.signalReportId) {
589+
body.signal_report_id = runOptions.signalReportId;
590+
}
591+
if (runOptions?.githubUserToken) {
592+
body.github_user_token = runOptions.githubUserToken;
593+
}
575594

576595
const data = await this.api.post(
577596
`/api/projects/{project_id}/tasks/{id}/run/`,

apps/code/src/renderer/features/inbox/stores/inboxCloudTaskStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export const useInboxCloudTaskStore = create<InboxCloudTaskStore>()(
6161
workspaceMode: "cloud",
6262
githubIntegrationId: params.githubIntegrationId,
6363
repository: selectedRepo,
64+
cloudPrAuthorshipMode: "bot",
65+
cloudRunSource: "signal_report",
66+
signalReportId: params.reportId,
6467
});
6568

6669
if (result.success) {

apps/code/src/renderer/features/sessions/service/service.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { taskViewedApi } from "@features/sidebar/hooks/useTaskViewed";
2929
import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models";
3030
import { getIsOnline } from "@renderer/stores/connectivityStore";
3131
import { trpcClient } from "@renderer/trpc/client";
32+
import { getGhUserTokenOrThrow } from "@renderer/utils/github";
3233
import { toast } from "@renderer/utils/toast";
3334
import { getCloudUrlFromRegion } from "@shared/constants/oauth";
3435
import {
@@ -39,6 +40,7 @@ import {
3940
type Task,
4041
} from "@shared/types";
4142
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
43+
import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud";
4244
import type { AcpMessage, StoredLogEntry } from "@shared/types/session-events";
4345
import { isJsonRpcRequest } from "@shared/types/session-events";
4446
import { buildPermissionToolMetadata, track } from "@utils/analytics";
@@ -1364,6 +1366,35 @@ export class SessionService {
13641366
throw new Error("Authentication required for cloud commands");
13651367
}
13661368

1369+
const [previousRun, task] = await Promise.all([
1370+
client.getTaskRun(session.taskId, session.taskRunId),
1371+
client.getTask(session.taskId),
1372+
]);
1373+
const hasGitHubRepo = !!task.repository && !!task.github_integration;
1374+
const previousState = previousRun.state as Record<string, unknown>;
1375+
const previousOutput = (previousRun.output ?? {}) as Record<
1376+
string,
1377+
unknown
1378+
>;
1379+
// Prefer the actual working branch the agent last pushed to (synced by
1380+
// agent-server after each turn), then the run-level branch field, then
1381+
// the original base branch from state. This preserves unmerged work when
1382+
// the snapshot has expired and the sandbox is rebuilt from scratch.
1383+
const previousBaseBranch =
1384+
(typeof previousOutput.head_branch === "string"
1385+
? previousOutput.head_branch
1386+
: null) ??
1387+
previousRun.branch ??
1388+
(typeof previousState.pr_base_branch === "string"
1389+
? previousState.pr_base_branch
1390+
: null) ??
1391+
session.cloudBranch;
1392+
const prAuthorshipMode = this.getCloudPrAuthorshipMode(previousState);
1393+
const githubUserToken =
1394+
prAuthorshipMode === "user" && hasGitHubRepo
1395+
? await getGhUserTokenOrThrow()
1396+
: undefined;
1397+
13671398
log.info("Creating resume run for terminal cloud task", {
13681399
taskId: session.taskId,
13691400
previousRunId: session.taskRunId,
@@ -1375,11 +1406,21 @@ export class SessionService {
13751406
// The agent will load conversation history and restore the sandbox snapshot.
13761407
const updatedTask = await client.runTaskInCloud(
13771408
session.taskId,
1378-
session.cloudBranch,
1409+
previousBaseBranch,
13791410
{
13801411
resumeFromRunId: session.taskRunId,
13811412
pendingUserMessage: promptText,
13821413
},
1414+
undefined,
1415+
{
1416+
prAuthorshipMode,
1417+
runSource: this.getCloudRunSource(previousState),
1418+
signalReportId:
1419+
typeof previousState.signal_report_id === "string"
1420+
? previousState.signal_report_id
1421+
: undefined,
1422+
githubUserToken,
1423+
},
13831424
);
13841425
const newRun = updatedTask.latest_run;
13851426
if (!newRun?.id) {
@@ -2102,6 +2143,20 @@ export class SessionService {
21022143
}
21032144
}
21042145

2146+
private getCloudPrAuthorshipMode(
2147+
state: Record<string, unknown>,
2148+
): PrAuthorshipMode {
2149+
const explicitMode = state.pr_authorship_mode;
2150+
if (explicitMode === "user" || explicitMode === "bot") {
2151+
return explicitMode;
2152+
}
2153+
return state.run_source === "signal_report" ? "bot" : "user";
2154+
}
2155+
2156+
private getCloudRunSource(state: Record<string, unknown>): CloudRunSource {
2157+
return state.run_source === "signal_report" ? "signal_report" : "manual";
2158+
}
2159+
21052160
/**
21062161
* Filter out session/prompt events that should be skipped during resume.
21072162
* When resuming a cloud run, the initial session/prompt from the new run's

apps/code/src/renderer/sagas/task/task-creation.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { trpcClient } from "@renderer/trpc";
1717
import { generateTitle } from "@renderer/utils/generateTitle";
1818
import { getTaskRepository } from "@renderer/utils/repository";
1919
import type { ExecutionMode, Task } from "@shared/types";
20+
import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud";
21+
import { getGhUserTokenOrThrow } from "@utils/github";
2022
import { logger } from "@utils/logger";
2123
import { queryClient } from "@utils/queryClient";
2224

@@ -72,6 +74,8 @@ export interface TaskCreationInput {
7274
reasoningLevel?: string;
7375
environmentId?: string;
7476
sandboxEnvironmentId?: string;
77+
cloudPrAuthorshipMode?: PrAuthorshipMode;
78+
cloudRunSource?: CloudRunSource;
7579
signalReportId?: string;
7680
}
7781

@@ -256,13 +260,29 @@ export class TaskCreationSaga extends Saga<
256260
if (workspaceMode === "cloud" && !task.latest_run) {
257261
await this.step({
258262
name: "cloud_run",
259-
execute: () =>
260-
this.deps.posthogClient.runTaskInCloud(
263+
execute: async () => {
264+
const hasGitHubRepo = !!task.repository && !!task.github_integration;
265+
const prAuthorshipMode =
266+
input.cloudPrAuthorshipMode ?? (hasGitHubRepo ? "user" : "bot");
267+
let githubUserToken: string | undefined;
268+
269+
if (prAuthorshipMode === "user" && hasGitHubRepo) {
270+
githubUserToken = await getGhUserTokenOrThrow();
271+
}
272+
273+
return this.deps.posthogClient.runTaskInCloud(
261274
task.id,
262275
branch,
263276
undefined,
264277
input.sandboxEnvironmentId,
265-
),
278+
{
279+
prAuthorshipMode,
280+
runSource: input.cloudRunSource ?? "manual",
281+
signalReportId: input.signalReportId,
282+
githubUserToken,
283+
},
284+
);
285+
},
266286
rollback: async () => {
267287
log.info("Rolling back: cloud run (no-op)", { taskId: task.id });
268288
},
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { trpcClient } from "@renderer/trpc";
2+
3+
export async function getGhUserTokenOrThrow(): Promise<string> {
4+
const tokenResult = await trpcClient.git.getGhAuthToken.query();
5+
if (!tokenResult.success || !tokenResult.token) {
6+
throw new Error(
7+
tokenResult.error ||
8+
"Authenticate GitHub CLI with `gh auth login` before starting a cloud task.",
9+
);
10+
}
11+
return tokenResult.token;
12+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type PrAuthorshipMode = "user" | "bot";
2+
export type CloudRunSource = "manual" | "signal_report";

0 commit comments

Comments
 (0)