Fix Foundry UI bugs: org names, sessions, and repo selection (#250)

* Fix Foundry auth: migrate to Better Auth adapter, fix access token retrieval

- Remove @ts-nocheck from better-auth.ts, auth-user/index.ts, app-shell.ts
  and fix all type errors
- Fix getAccessTokenForSession: read GitHub token directly from account
  record instead of calling Better Auth's internal /get-access-token
  endpoint which returns 403 on server-side calls
- Re-implement workspaceAuth helper functions (workspaceAuthColumn,
  normalizeAuthValue, workspaceAuthClause, workspaceAuthWhere) that were
  accidentally deleted
- Remove all retry logic (withRetries, isRetryableAppActorError)
- Implement CORS origin allowlist from configured environment
- Document cachedAppWorkspace singleton pattern
- Add inline org sync fallback in buildAppSnapshot for post-OAuth flow
- Add no-retry rule to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add Foundry dev panel from fix-git-data branch

Port the dev panel component that was left out when PR #243 was replaced
by PR #247. Adapted to remove runtime/mock-debug references that don't
exist on the current branch.

- Toggle with Shift+D, persists visibility to localStorage
- Shows context, session, GitHub sync status sections
- Dev-only (import.meta.env.DEV)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add full Docker image defaults, fix actor deadlocks, and improve dev experience

- Add Dockerfile.full and --all flag to install-agent CLI for pre-built images
- Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full
- Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example
- Expand Docker docs with full runnable Dockerfile
- Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning)
- Audit and convert 12 task actions from wait:true to wait:false
- Add bun --hot for dev backend hot reload
- Remove --force from pnpm install in dev Dockerfile for faster startup
- Add env_file support to compose.dev.yaml for automatic credential loading
- Add mock frontend compose config and dev panel
- Update CLAUDE.md with wait:true policy and dev environment setup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* WIP: async action fixes and interest manager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix Foundry UI bugs: org names, hanging sessions, and wrong repo creation

- Fix org display name using GitHub description instead of name field
- Fix createWorkbenchSession hanging when sandbox is provisioning
- Fix auto-session creation retry storm on errors
- Fix task creation using wrong repo due to React state race conditions
- Remove Bun hot-reload from backend Dockerfile (causes port drift)
- Add GitHub sync/install status to dev panel

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-13 20:48:22 -07:00 committed by GitHub
parent 58c54156f1
commit d8b8b49f37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 9252 additions and 1933 deletions

View file

@ -262,11 +262,11 @@ export class GitHubAppClient {
}
async listOrganizations(accessToken: string): Promise<GitHubOrgIdentity[]> {
const organizations = await this.paginate<{ id: number; login: string; description?: string | null }>("/user/orgs?per_page=100", accessToken);
const organizations = await this.paginate<{ id: number; login: string; name?: string | null }>("/user/orgs?per_page=100", accessToken);
return organizations.map((organization) => ({
id: String(organization.id),
login: organization.login,
name: organization.description?.trim() || organization.login,
name: organization.name?.trim() || organization.login,
}));
}

View file

@ -0,0 +1,533 @@
import { betterAuth } from "better-auth";
import { createAdapterFactory } from "better-auth/adapters";
import { APP_SHELL_WORKSPACE_ID } from "../actors/workspace/app-shell.js";
import { authUserKey, workspaceKey } from "../actors/keys.js";
import { logger } from "../logging.js";
const AUTH_BASE_PATH = "/v1/auth";
const SESSION_COOKIE = "better-auth.session_token";
let betterAuthService: BetterAuthService | null = null;
function requireEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function stripTrailingSlash(value: string): string {
return value.replace(/\/$/, "");
}
function buildCookieHeaders(sessionToken: string): Headers {
return new Headers({
cookie: `${SESSION_COOKIE}=${encodeURIComponent(sessionToken)}`,
});
}
async function readJsonSafe(response: Response): Promise<any> {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function callAuthEndpoint(auth: any, url: string, init?: RequestInit): Promise<Response> {
return await auth.handler(new Request(url, init));
}
function resolveRouteUserId(workspace: any, resolved: any): string | null {
if (!resolved) {
return null;
}
if (typeof resolved === "string") {
return resolved;
}
if (typeof resolved.userId === "string" && resolved.userId.length > 0) {
return resolved.userId;
}
if (typeof resolved.id === "string" && resolved.id.length > 0) {
return resolved.id;
}
return null;
}
export interface BetterAuthService {
auth: any;
resolveSession(headers: Headers): Promise<{ session: any; user: any } | null>;
signOut(headers: Headers): Promise<Response>;
getAuthState(sessionId: string): Promise<any | null>;
upsertUserProfile(userId: string, patch: Record<string, unknown>): Promise<any>;
setActiveOrganization(sessionId: string, activeOrganizationId: string | null): Promise<any>;
getAccessTokenForSession(sessionId: string): Promise<{ accessToken: string; scopes: string[] } | null>;
}
export function initBetterAuthService(actorClient: any, options: { apiUrl: string; appUrl: string }): BetterAuthService {
if (betterAuthService) {
return betterAuthService;
}
// getOrCreate is intentional here: the adapter runs during Better Auth callbacks
// which can fire before any explicit create path. The app workspace and auth user
// actors must exist by the time the adapter needs them.
const appWorkspace = () =>
actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
createWithInput: APP_SHELL_WORKSPACE_ID,
});
// getOrCreate is intentional: Better Auth creates user records during OAuth
// callbacks, so the auth-user actor must be lazily provisioned on first access.
const getAuthUser = async (userId: string) =>
await actorClient.authUser.getOrCreate(authUserKey(userId), {
createWithInput: { userId },
});
const adapter = createAdapterFactory({
config: {
adapterId: "rivetkit-actor",
adapterName: "RivetKit Actor Adapter",
supportsBooleans: false,
supportsDates: false,
supportsJSON: false,
},
adapter: ({ transformInput, transformOutput, transformWhereClause }) => {
const resolveUserIdForQuery = async (model: string, where?: any[], data?: Record<string, unknown>): Promise<string | null> => {
const clauses = where ?? [];
const direct = (field: string) => clauses.find((entry) => entry.field === field)?.value;
if (model === "user") {
const fromId = direct("id") ?? data?.id;
if (typeof fromId === "string" && fromId.length > 0) {
return fromId;
}
const email = direct("email");
if (typeof email === "string" && email.length > 0) {
const workspace = await appWorkspace();
const resolved = await workspace.authFindEmailIndex({ email: email.toLowerCase() });
return resolveRouteUserId(workspace, resolved);
}
return null;
}
if (model === "session") {
const fromUserId = direct("userId") ?? data?.userId;
if (typeof fromUserId === "string" && fromUserId.length > 0) {
return fromUserId;
}
const sessionId = direct("id") ?? data?.id;
const sessionToken = direct("token") ?? data?.token;
if (typeof sessionId === "string" || typeof sessionToken === "string") {
const workspace = await appWorkspace();
const resolved = await workspace.authFindSessionIndex({
...(typeof sessionId === "string" ? { sessionId } : {}),
...(typeof sessionToken === "string" ? { sessionToken } : {}),
});
return resolveRouteUserId(workspace, resolved);
}
return null;
}
if (model === "account") {
const fromUserId = direct("userId") ?? data?.userId;
if (typeof fromUserId === "string" && fromUserId.length > 0) {
return fromUserId;
}
const accountRecordId = direct("id") ?? data?.id;
const providerId = direct("providerId") ?? data?.providerId;
const accountId = direct("accountId") ?? data?.accountId;
const workspace = await appWorkspace();
if (typeof accountRecordId === "string" && accountRecordId.length > 0) {
const resolved = await workspace.authFindAccountIndex({ id: accountRecordId });
return resolveRouteUserId(workspace, resolved);
}
if (typeof providerId === "string" && providerId.length > 0 && typeof accountId === "string" && accountId.length > 0) {
const resolved = await workspace.authFindAccountIndex({ providerId, accountId });
return resolveRouteUserId(workspace, resolved);
}
return null;
}
return null;
};
const ensureWorkspaceVerification = async (method: string, payload: Record<string, unknown>) => {
const workspace = await appWorkspace();
return await workspace[method](payload);
};
return {
options: {
useDatabaseGeneratedIds: false,
},
create: async ({ model, data }) => {
const transformed = await transformInput(data, model, "create", true);
if (model === "verification") {
return await ensureWorkspaceVerification("authCreateVerification", { data: transformed });
}
const userId = await resolveUserIdForQuery(model, undefined, transformed);
if (!userId) {
throw new Error(`Unable to resolve auth actor for create(${model})`);
}
const userActor = await getAuthUser(userId);
const created = await userActor.createAuthRecord({ model, data: transformed });
const workspace = await appWorkspace();
if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) {
await workspace.authUpsertEmailIndex({
email: transformed.email.toLowerCase(),
userId,
});
}
if (model === "session") {
await workspace.authUpsertSessionIndex({
sessionId: String(created.id),
sessionToken: String(created.token),
userId,
});
}
if (model === "account") {
await workspace.authUpsertAccountIndex({
id: String(created.id),
providerId: String(created.providerId),
accountId: String(created.accountId),
userId,
});
}
return (await transformOutput(created, model)) as any;
},
findOne: async ({ model, where, join }) => {
const transformedWhere = transformWhereClause({ model, where, action: "findOne" });
if (model === "verification") {
return await ensureWorkspaceVerification("authFindOneVerification", { where: transformedWhere, join });
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
if (!userId) {
return null;
}
const userActor = await getAuthUser(userId);
const found = await userActor.findOneAuthRecord({ model, where: transformedWhere, join });
return found ? ((await transformOutput(found, model, undefined, join)) as any) : null;
},
findMany: async ({ model, where, limit, sortBy, offset, join }) => {
const transformedWhere = transformWhereClause({ model, where, action: "findMany" });
if (model === "verification") {
return await ensureWorkspaceVerification("authFindManyVerification", {
where: transformedWhere,
limit,
sortBy,
offset,
join,
});
}
if (model === "session") {
const tokenClause = transformedWhere?.find((entry: any) => entry.field === "token" && entry.operator === "in");
if (tokenClause && Array.isArray(tokenClause.value)) {
const workspace = await appWorkspace();
const resolved = await Promise.all(
(tokenClause.value as string[]).map(async (sessionToken: string) => ({
sessionToken,
route: await workspace.authFindSessionIndex({ sessionToken }),
})),
);
const byUser = new Map<string, string[]>();
for (const item of resolved) {
if (!item.route?.userId) {
continue;
}
const tokens = byUser.get(item.route.userId) ?? [];
tokens.push(item.sessionToken);
byUser.set(item.route.userId, tokens);
}
const rows = [];
for (const [userId, tokens] of byUser) {
const userActor = await getAuthUser(userId);
const scopedWhere = transformedWhere.map((entry: any) =>
entry.field === "token" && entry.operator === "in" ? { ...entry, value: tokens } : entry,
);
const found = await userActor.findManyAuthRecords({ model, where: scopedWhere, limit, sortBy, offset, join });
rows.push(...found);
}
return await Promise.all(rows.map(async (row: any) => await transformOutput(row, model, undefined, join)));
}
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
if (!userId) {
return [];
}
const userActor = await getAuthUser(userId);
const found = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit, sortBy, offset, join });
return await Promise.all(found.map(async (row: any) => await transformOutput(row, model, undefined, join)));
},
update: async ({ model, where, update }) => {
const transformedWhere = transformWhereClause({ model, where, action: "update" });
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
if (model === "verification") {
return await ensureWorkspaceVerification("authUpdateVerification", { where: transformedWhere, update: transformedUpdate });
}
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
if (!userId) {
return null;
}
const userActor = await getAuthUser(userId);
const before =
model === "user"
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
: model === "account"
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
: model === "session"
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
: null;
const updated = await userActor.updateAuthRecord({ model, where: transformedWhere, update: transformedUpdate });
const workspace = await appWorkspace();
if (model === "user" && updated) {
if (before?.email && before.email !== updated.email) {
await workspace.authDeleteEmailIndex({ email: before.email.toLowerCase() });
}
if (updated.email) {
await workspace.authUpsertEmailIndex({ email: updated.email.toLowerCase(), userId });
}
}
if (model === "session" && updated) {
await workspace.authUpsertSessionIndex({
sessionId: String(updated.id),
sessionToken: String(updated.token),
userId,
});
}
if (model === "account" && updated) {
await workspace.authUpsertAccountIndex({
id: String(updated.id),
providerId: String(updated.providerId),
accountId: String(updated.accountId),
userId,
});
}
return updated ? ((await transformOutput(updated, model)) as any) : null;
},
updateMany: async ({ model, where, update }) => {
const transformedWhere = transformWhereClause({ model, where, action: "updateMany" });
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
if (model === "verification") {
return await ensureWorkspaceVerification("authUpdateManyVerification", { where: transformedWhere, update: transformedUpdate });
}
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
if (!userId) {
return 0;
}
const userActor = await getAuthUser(userId);
return await userActor.updateManyAuthRecords({ model, where: transformedWhere, update: transformedUpdate });
},
delete: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "delete" });
if (model === "verification") {
await ensureWorkspaceVerification("authDeleteVerification", { where: transformedWhere });
return;
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
if (!userId) {
return;
}
const userActor = await getAuthUser(userId);
const workspace = await appWorkspace();
const before = await userActor.findOneAuthRecord({ model, where: transformedWhere });
await userActor.deleteAuthRecord({ model, where: transformedWhere });
if (model === "session" && before) {
await workspace.authDeleteSessionIndex({
sessionId: before.id,
sessionToken: before.token,
});
}
if (model === "account" && before) {
await workspace.authDeleteAccountIndex({
id: before.id,
providerId: before.providerId,
accountId: before.accountId,
});
}
if (model === "user" && before?.email) {
await workspace.authDeleteEmailIndex({ email: before.email.toLowerCase() });
}
},
deleteMany: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "deleteMany" });
if (model === "verification") {
return await ensureWorkspaceVerification("authDeleteManyVerification", { where: transformedWhere });
}
if (model === "session") {
const userId = await resolveUserIdForQuery(model, transformedWhere);
if (!userId) {
return 0;
}
const userActor = await getAuthUser(userId);
const workspace = await appWorkspace();
const sessions = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit: 5000 });
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
for (const session of sessions) {
await workspace.authDeleteSessionIndex({
sessionId: session.id,
sessionToken: session.token,
});
}
return deleted;
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
if (!userId) {
return 0;
}
const userActor = await getAuthUser(userId);
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
return deleted;
},
count: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "count" });
if (model === "verification") {
return await ensureWorkspaceVerification("authCountVerification", { where: transformedWhere });
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
if (!userId) {
return 0;
}
const userActor = await getAuthUser(userId);
return await userActor.countAuthRecords({ model, where: transformedWhere });
},
};
},
});
const auth = betterAuth({
baseURL: stripTrailingSlash(process.env.BETTER_AUTH_URL ?? options.apiUrl),
basePath: AUTH_BASE_PATH,
secret: requireEnv("BETTER_AUTH_SECRET"),
database: adapter,
trustedOrigins: [stripTrailingSlash(options.appUrl), stripTrailingSlash(options.apiUrl)],
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60,
strategy: "compact",
},
},
socialProviders: {
github: {
clientId: requireEnv("GITHUB_CLIENT_ID"),
clientSecret: requireEnv("GITHUB_CLIENT_SECRET"),
scope: ["read:org", "repo"],
redirectURI: process.env.GITHUB_REDIRECT_URI || undefined,
},
},
});
betterAuthService = {
auth,
async resolveSession(headers: Headers) {
return (await auth.api.getSession({ headers })) ?? null;
},
async signOut(headers: Headers) {
return await callAuthEndpoint(auth, `${stripTrailingSlash(process.env.BETTER_AUTH_URL ?? options.apiUrl)}${AUTH_BASE_PATH}/sign-out`, {
method: "POST",
headers,
});
},
async getAuthState(sessionId: string) {
const workspace = await appWorkspace();
const route = await workspace.authFindSessionIndex({ sessionId });
if (!route?.userId) {
return null;
}
const userActor = await getAuthUser(route.userId);
return await userActor.getAppAuthState({ sessionId });
},
async upsertUserProfile(userId: string, patch: Record<string, unknown>) {
const userActor = await getAuthUser(userId);
return await userActor.upsertUserProfile({ userId, patch });
},
async setActiveOrganization(sessionId: string, activeOrganizationId: string | null) {
const authState = await this.getAuthState(sessionId);
if (!authState?.user?.id) {
throw new Error(`Unknown auth session ${sessionId}`);
}
const userActor = await getAuthUser(authState.user.id);
return await userActor.upsertSessionState({ sessionId, activeOrganizationId });
},
async getAccessTokenForSession(sessionId: string) {
// Read the GitHub access token directly from the account record stored in the
// auth user actor. Better Auth's internal /get-access-token endpoint requires
// session middleware resolution which fails for server-side internal calls (403),
// so we bypass it and read the stored token from our adapter layer directly.
const authState = await this.getAuthState(sessionId);
if (!authState?.user?.id || !authState?.accounts) {
return null;
}
const githubAccount = authState.accounts.find((account: any) => account.providerId === "github");
if (!githubAccount?.accessToken) {
logger.warn({ sessionId, userId: authState.user.id }, "get_access_token_no_github_account");
return null;
}
return {
accessToken: githubAccount.accessToken,
scopes: githubAccount.scope ? githubAccount.scope.split(/[, ]+/) : [],
};
},
};
return betterAuthService;
}
export function getBetterAuthService(): BetterAuthService {
if (!betterAuthService) {
throw new Error("BetterAuth service is not initialized");
}
return betterAuthService;
}