Skip to content

Commit 828b11d

Browse files
committed
feat(cloud-agent): user-authored-prs
1 parent aa5a024 commit 828b11d

11 files changed

Lines changed: 268 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
@@ -9,6 +9,7 @@ import type {
99
Task,
1010
TaskRun,
1111
} from "@shared/types";
12+
import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud";
1213
import type { StoredLogEntry } from "@shared/types/session-events";
1314
import { logger } from "@utils/logger";
1415
import { buildApiFetcher } from "./fetcher";
@@ -507,6 +508,12 @@ export class PostHogAPIClient {
507508
branch?: string | null,
508509
resumeOptions?: { resumeFromRunId: string; pendingUserMessage: string },
509510
sandboxEnvironmentId?: string,
511+
runOptions?: {
512+
prAuthorshipMode?: PrAuthorshipMode;
513+
runSource?: CloudRunSource;
514+
signalReportId?: string;
515+
githubUserToken?: string;
516+
},
510517
): Promise<Task> {
511518
const teamId = await this.getTeamId();
512519
const body: Record<string, unknown> = { mode: "interactive" };
@@ -520,6 +527,18 @@ export class PostHogAPIClient {
520527
if (sandboxEnvironmentId) {
521528
body.sandbox_environment_id = sandboxEnvironmentId;
522529
}
530+
if (runOptions?.prAuthorshipMode) {
531+
body.pr_authorship_mode = runOptions.prAuthorshipMode;
532+
}
533+
if (runOptions?.runSource) {
534+
body.run_source = runOptions.runSource;
535+
}
536+
if (runOptions?.signalReportId) {
537+
body.signal_report_id = runOptions.signalReportId;
538+
}
539+
if (runOptions?.githubUserToken) {
540+
body.github_user_token = runOptions.githubUserToken;
541+
}
523542

524543
const data = await this.api.post(
525544
`/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";
@@ -1388,6 +1390,35 @@ export class SessionService {
13881390
throw new Error("Authentication required for cloud commands");
13891391
}
13901392

1393+
const [previousRun, task] = await Promise.all([
1394+
client.getTaskRun(session.taskId, session.taskRunId),
1395+
client.getTask(session.taskId),
1396+
]);
1397+
const hasGitHubRepo = !!task.repository && !!task.github_integration;
1398+
const previousState = previousRun.state as Record<string, unknown>;
1399+
const previousOutput = (previousRun.output ?? {}) as Record<
1400+
string,
1401+
unknown
1402+
>;
1403+
// Prefer the actual working branch the agent last pushed to (synced by
1404+
// agent-server after each turn), then the run-level branch field, then
1405+
// the original base branch from state. This preserves unmerged work when
1406+
// the snapshot has expired and the sandbox is rebuilt from scratch.
1407+
const previousBaseBranch =
1408+
(typeof previousOutput.head_branch === "string"
1409+
? previousOutput.head_branch
1410+
: null) ??
1411+
previousRun.branch ??
1412+
(typeof previousState.pr_base_branch === "string"
1413+
? previousState.pr_base_branch
1414+
: null) ??
1415+
session.cloudBranch;
1416+
const prAuthorshipMode = this.getCloudPrAuthorshipMode(previousState);
1417+
const githubUserToken =
1418+
prAuthorshipMode === "user" && hasGitHubRepo
1419+
? await getGhUserTokenOrThrow()
1420+
: undefined;
1421+
13911422
log.info("Creating resume run for terminal cloud task", {
13921423
taskId: session.taskId,
13931424
previousRunId: session.taskRunId,
@@ -1399,11 +1430,21 @@ export class SessionService {
13991430
// The agent will load conversation history and restore the sandbox snapshot.
14001431
const updatedTask = await client.runTaskInCloud(
14011432
session.taskId,
1402-
session.cloudBranch,
1433+
previousBaseBranch,
14031434
{
14041435
resumeFromRunId: session.taskRunId,
14051436
pendingUserMessage: promptText,
14061437
},
1438+
undefined,
1439+
{
1440+
prAuthorshipMode,
1441+
runSource: this.getCloudRunSource(previousState),
1442+
signalReportId:
1443+
typeof previousState.signal_report_id === "string"
1444+
? previousState.signal_report_id
1445+
: undefined,
1446+
githubUserToken,
1447+
},
14071448
);
14081449
const newRun = updatedTask.latest_run;
14091450
if (!newRun?.id) {
@@ -2126,6 +2167,20 @@ export class SessionService {
21262167
}
21272168
}
21282169

2170+
private getCloudPrAuthorshipMode(
2171+
state: Record<string, unknown>,
2172+
): PrAuthorshipMode {
2173+
const explicitMode = state.pr_authorship_mode;
2174+
if (explicitMode === "user" || explicitMode === "bot") {
2175+
return explicitMode;
2176+
}
2177+
return state.run_source === "signal_report" ? "bot" : "user";
2178+
}
2179+
2180+
private getCloudRunSource(state: Record<string, unknown>): CloudRunSource {
2181+
return state.run_source === "signal_report" ? "signal_report" : "manual";
2182+
}
2183+
21292184
/**
21302185
* Filter out session/prompt events that should be skipped during resume.
21312186
* 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: 24 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,9 @@ export interface TaskCreationInput {
7274
reasoningLevel?: string;
7375
environmentId?: string;
7476
sandboxEnvironmentId?: string;
77+
cloudPrAuthorshipMode?: PrAuthorshipMode;
78+
cloudRunSource?: CloudRunSource;
79+
signalReportId?: string;
7580
}
7681

7782
export interface TaskCreationOutput {
@@ -255,13 +260,29 @@ export class TaskCreationSaga extends Saga<
255260
if (workspaceMode === "cloud" && !task.latest_run) {
256261
await this.step({
257262
name: "cloud_run",
258-
execute: () =>
259-
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(
260274
task.id,
261275
branch,
262276
undefined,
263277
input.sandboxEnvironmentId,
264-
),
278+
{
279+
prAuthorshipMode,
280+
runSource: input.cloudRunSource ?? "manual",
281+
signalReportId: input.signalReportId,
282+
githubUserToken,
283+
},
284+
);
285+
},
265286
rollback: async () => {
266287
log.info("Rolling back: cloud run (no-op)", { taskId: task.id });
267288
},
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)