Skip to content
Merged
1 change: 1 addition & 0 deletions apps/cli/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function environmentWithDaemon(daemon: Partial<DaemonControl>): Environment {
serviceUrl: "https://www.getcrosscode.dev",
createService: unused,
signIn: unused,
refreshSession: unused,
createDaemon: () => ({
start: async () => ({ alreadyRunning: false, pid: 1 }),
stop: async () => ({ wasRunning: false }),
Expand Down
51 changes: 45 additions & 6 deletions apps/cli/src/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { syncDaemonConfigSchema } from "../../../packages/protocol/src/sync.js";
// two sides agree, so asserting it with the CLI's helper alone would prove nothing.
import { readSyncConfig, syncConfigPath } from "../../daemon/src/sync-config.js";
import { installAgentSurface } from "./agent-surface.js";
import { configPath, readConfig } from "./config.js";
import { configPath, readConfig, writeConfig } from "./config.js";
import type { DaemonControl } from "./daemon.js";
import { httpSyncService, type SyncService } from "./service.js";
import { setup, type Environment } from "./setup.js";
Expand Down Expand Up @@ -43,8 +43,8 @@ const LEGAL_DOCUMENTS = [
{ document: "privacy", version: "2026-08-01", url: "/docs/privacy-policy.html" }
];

function stubEnvironment(overrides: { service?: Partial<SyncService> } = {}) {
const calls = { createProject: 0, redeemInvite: 0, signIn: 0, daemonStart: 0 };
function stubEnvironment(overrides: { service?: Partial<SyncService>; refreshSession?: Environment["refreshSession"] } = {}) {
const calls = { createProject: 0, redeemInvite: 0, signIn: 0, daemonStart: 0, refreshSession: 0 };
/**
* The terms half, counted separately so the assertions above keep saying exactly what they
* said: `calls` is the "did start do this twice" ledger.
Expand Down Expand Up @@ -92,6 +92,10 @@ function stubEnvironment(overrides: { service?: Partial<SyncService> } = {}) {
calls.signIn += 1;
return { session: SESSION, githubToken: "gho_test" };
},
refreshSession: async (session) => {
calls.refreshSession += 1;
return overrides.refreshSession ? overrides.refreshSession(session) : session;
},
createDaemon: () => daemon,
// The real installer, not a stub: whether installing twice duplicates anything is the
// question, so it has to be the code that writes the files.
Expand All @@ -116,7 +120,7 @@ describe("crosscode start", () => {
const result = await setup(root, environment);

expect(result).toMatchObject({ repoRoot: root, repo: "acme/app", projectId: "project-1", signedIn: "just-now", project: "created" });
expect(calls).toEqual({ createProject: 1, redeemInvite: 0, signIn: 1, daemonStart: 1 });
expect(calls).toEqual({ createProject: 1, redeemInvite: 0, signIn: 1, daemonStart: 1, refreshSession: 0 });
// What it wrote is the contract's own shape, not a CLI-private one.
expect(syncDaemonConfigSchema.parse(JSON.parse(await readFile(await configPath(root), "utf8")))).toMatchObject({
projectId: "project-1",
Expand All @@ -143,6 +147,41 @@ describe("crosscode start", () => {
expect(await readSyncConfig(root)).toMatchObject({ projectId: "project-1", repo: "acme/app" });
});

/**
* The dead end this closes. Supabase access tokens last an hour and refresh tokens are
* spent on first use, so a checkout comes back to a session that is on disk and useless.
* The daemon died on it saying "run `crosscode start`"; `start` saw a session, said
* "Already signed in for this checkout", and started the same doomed daemon again. There
* was no way out of that loop from inside the CLI.
*/
it("signs in again when the stored session cannot be refreshed", async () => {
const root = await checkout();
const { environment, calls } = stubEnvironment({ refreshSession: async () => undefined });
await setup(root, environment);
await writeConfig(root, { ...(await readSyncConfig(root)), service: { url: environment.serviceUrl, session: { ...SESSION, expiresAt: "2020-01-01T00:00:00.000Z" } } });

const second = await setup(root, environment);

expect(calls.refreshSession).toBe(1);
expect(second.signedIn).toBe("just-now");
expect(calls.signIn).toBe(2);
});

it("reuses a session it can still refresh, without a second sign-in", async () => {
const root = await checkout();
const refreshed = { ...SESSION, accessToken: "refreshed", expiresAt: "2031-01-01T00:00:00.000Z" };
const { environment, calls } = stubEnvironment({ refreshSession: async () => refreshed });
await setup(root, environment);
await writeConfig(root, { ...(await readSyncConfig(root)), service: { url: environment.serviceUrl, session: { ...SESSION, expiresAt: "2020-01-01T00:00:00.000Z" } } });

const second = await setup(root, environment);

expect(second.signedIn).toBe("already");
expect(calls.signIn).toBe(1);
// The refreshed token is what the daemon will be started with, so it has to be stored.
expect((await readSyncConfig(root)).service.session?.accessToken).toBe("refreshed");
});

// The property the invite flow depends on: the person sending the link has already run
// start, and the person receiving it is told to run the same command.
it("is idempotent -- one project, one daemon, no duplicated MCP, skill, or hook install", async () => {
Expand All @@ -152,7 +191,7 @@ describe("crosscode start", () => {
const first = await setup(root, environment);
const second = await setup(root, environment);

expect(calls).toEqual({ createProject: 1, redeemInvite: 0, signIn: 1, daemonStart: 1 });
expect(calls).toEqual({ createProject: 1, redeemInvite: 0, signIn: 1, daemonStart: 1, refreshSession: 0 });
expect(second.projectId).toBe(first.projectId);
expect(second).toMatchObject({ signedIn: "already", project: "existing", daemon: { alreadyRunning: true } });
expect(second.agent).toMatchObject({ mcp: { changed: false }, skill: { changed: false }, hooks: { changed: false } });
Expand Down Expand Up @@ -261,7 +300,7 @@ describe("crosscode join <code>", () => {

const result = await setup(root, environment, { code: "CC-7F3A-9C2E" });

expect(calls).toEqual({ createProject: 0, redeemInvite: 1, signIn: 1, daemonStart: 1 });
expect(calls).toEqual({ createProject: 0, redeemInvite: 1, signIn: 1, daemonStart: 1, refreshSession: 0 });
expect(result).toMatchObject({ projectId: "project-from-invite", repo: "acme/app", project: "joined", daemon: { alreadyRunning: false } });
expect(await readConfig(root)).toMatchObject({ projectId: "project-from-invite", repo: "acme/app", service: { session: SESSION } });
expect(result.agent).toMatchObject({ mcp: { changed: true }, skill: { changed: true }, hooks: { changed: true } });
Expand Down
35 changes: 30 additions & 5 deletions apps/cli/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { localDaemon, type DaemonControl } from "./daemon.js";
import { CliError } from "./errors.js";
import { confirmTerms, type ConfirmKind } from "./notice.js";
import { httpSyncService, type SyncService } from "./service.js";
import { refreshStoredSession, type StoredSession } from "../../daemon/src/supabase-client.js";
import { SERVICE_URL } from "./version.js";

/**
Expand All @@ -27,6 +28,8 @@ export type Environment = {
serviceUrl: string;
createService(accessToken: string): SyncService;
signIn: SignIn;
/** Trades a refresh token for a live one, or resolves undefined if it cannot. */
refreshSession(session: StoredSession): Promise<StoredSession | undefined>;
createDaemon(repoRoot: string): DaemonControl;
installAgentSurface(repoRoot: string): Promise<AgentSurface>;
/**
Expand All @@ -41,6 +44,7 @@ export function defaultEnvironment(): Environment {
serviceUrl: SERVICE_URL,
createService: (accessToken) => httpSyncService(SERVICE_URL, accessToken),
signIn: githubSignIn(),
refreshSession: (session) => refreshStoredSession(session).catch(() => undefined),
createDaemon: (repoRoot) => localDaemon(repoRoot),
installAgentSurface: (repoRoot) => installAgentSurface(repoRoot),
confirmTerms: (options) => confirmTerms(options)
Expand Down Expand Up @@ -92,12 +96,20 @@ export async function setup(directory: string, environment: Environment, options
// so it exists for exactly as long as the handshake that produced it. `join` therefore
// signs in even in a checkout that is already signed in, rather than failing at the
// redeem call with nothing to offer.
const reuseSession = session && !options.code;
if (reuseSession) report("Already signed in for this checkout.");
const signedIn = reuseSession
//
// A stored session is only worth reusing if it still works. Supabase access tokens last
// an hour and refresh tokens are spent on first use, so "there is a session on disk" and
// "this checkout can talk to the service" are different questions. Answering the first
// one left the user with no way out: the daemon died on the dead session telling them to
// run `crosscode start`, and `start` said "Already signed in for this checkout" and
// started the same doomed daemon again.
const usable = session && !options.code ? await usableSession(session, environment) : undefined;
if (usable) report("Already signed in for this checkout.");
else if (session && !options.code) report("This checkout's sign-in has expired; signing in again.");
const signedIn = usable
? undefined
: await environment.signIn({ serviceUrl: environment.serviceUrl, openBrowser: options.openBrowser !== false, report });
const activeSession = signedIn?.session ?? session!;
const activeSession = signedIn?.session ?? usable!;

const service = environment.createService(activeSession.accessToken);
await acceptTerms(service, environment, { assumeYes, noticeShown, report });
Expand All @@ -119,13 +131,26 @@ export async function setup(directory: string, environment: Environment, options
repo,
projectId,
branch: repository.branch,
signedIn: reuseSession ? "already" : "just-now",
signedIn: usable ? "already" : "just-now",
project: origin,
daemon,
agent
};
}

/**
* The stored session if it can still be used, refreshed first if it is close to expiring,
* and undefined if the refresh token is spent or revoked -- in which case the only way
* forward is signing in again, which is what the caller does.
*/
async function usableSession(session: StoredSession, environment: Environment): Promise<StoredSession | undefined> {
if (Date.parse(session.expiresAt) - Date.now() > EXPIRY_MARGIN_MS) return session;
return environment.refreshSession(session);
}

/** Refresh this far ahead of expiry, so setup never hands the daemon a token about to die. */
const EXPIRY_MARGIN_MS = 60_000;

/**
* Records this person's acceptance, but only of something they were actually shown.
*
Expand Down
8 changes: 8 additions & 0 deletions apps/daemon/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { createDaemonTelemetry } from "./observability.js";
import { SessionExpiredError } from "./supabase-client.js";
import { supervise } from "./supervisor.js";
import { SyncDaemon } from "./sync-daemon.js";
import { VERSION } from "./version.js";
Expand Down Expand Up @@ -33,6 +34,13 @@ if (!directory) {
const daemon = await SyncDaemon.start(directory).catch(async (error: unknown) => {
telemetry.capture(error, "startup");
await telemetry.flush();
// An expired sign-in is the one start-up failure with a one-line fix, and a stack trace
// buries it. Everything else still throws, because it is a bug and the trace is the
// useful part.
if (error instanceof SessionExpiredError) {
process.stderr.write(`${error.message}\n`);
process.exit(1);
}
throw error;
});
process.stdout.write(`${JSON.stringify({ ready: true, pid: process.pid, port: daemon.connection.port })}\n`);
Expand Down
21 changes: 21 additions & 0 deletions apps/daemon/src/supabase-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,27 @@ export function getSupabaseClient(): SupabaseClient {
return createClient(url, anonKey, { auth: { persistSession: false, autoRefreshToken: false } });
}

/** Re-signing-in is the only fix, so this is worth telling apart from a network failure. */
export class SessionExpiredError extends Error {}

/**
* Trades a refresh token for a fresh access token. Supabase access tokens last an hour, so
* without this a daemon that has been running since before lunch holds a token the service
* refuses -- which is exactly what happened: `register()` threw "Access token is invalid or
* expired" and took the process down, and the only way back was signing in again.
*/
export async function refreshStoredSession(session: StoredSession): Promise<StoredSession> {
const supabase = getSupabaseClient();
const { data, error } = await supabase.auth.refreshSession({ refresh_token: session.refreshToken });
if (error || !data.session) {
// A refresh token is spent on first use and rotated, so "already used" and "expired"
// both mean the same thing to the person reading this: sign in again. Say that, rather
// than leaving them with Supabase's wording and a stack trace.
throw new SessionExpiredError(`This checkout's Crosscode sign-in is no longer valid (${error?.message ?? "no session returned"}); run \`crosscode start\` to sign in again`);
}
return toStoredSession(data.session);
}

export function toStoredSession(session: Session): StoredSession {
const expiresAtMs = session.expires_at ? session.expires_at * 1_000 : Date.now() + (session.expires_in ?? 3_600) * 1_000;
return { accessToken: session.access_token, refreshToken: session.refresh_token, expiresAt: new Date(expiresAtMs).toISOString() };
Expand Down
98 changes: 98 additions & 0 deletions apps/daemon/src/sync-daemon.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile } from "node:child_process";
import { createServer } from "node:http";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
Expand Down Expand Up @@ -36,6 +37,7 @@ const FAST: SyncDaemonOptions = {
flushMs: 100,
deferralMs: 60,
gitPollMs: 100,
streamPollMs: 100,
// The 10s hot window is real behaviour, not a constant worth waiting out in a test.
engine: { hotWindowMs: 150, maxDeferrals: 3 }
};
Expand Down Expand Up @@ -219,6 +221,102 @@ describe("the daemon around the engine", () => {
await waitFor("bob's cursor to advance", async () => bob.status().cursor > 0);
}, 60_000);

/**
* The production outage this covers: the service runs as a serverless function, which
* cannot answer a WebSocket upgrade, so `/v1/stream` 404s forever. Publishing worked and
* receiving did not -- an uploader, not a sync -- and `crosscode status` said
* `connected: false` with no way for it to ever become true.
*/
it("still receives a peer's edits when the host cannot serve a stream at all", async () => {
const service = await startSyncServiceStub({ streams: false });
services.push(service);
const origin = await seedOrigin({ "a.txt": "0\n", "b.txt": "0\n" });
const roots = await Promise.all(["alice", "bob"].map((name) => checkout(origin, name, service)));
const [alice, bob] = [await start(roots[0]!), await start(roots[1]!)];

// Reachable by the only transport there is, and honest about saying so.
await waitFor("both daemons to report themselves connected", async () => alice.status().connected && bob.status().connected);

// Each direction gets its own file. Bouncing one file back immediately would land the
// reply inside the hot window the first write opened, which is a deferral test, not a
// transport one.
await type(roots[0]!, "a.txt", "arrived without a socket\n");
await waitFor("bob to receive it", () => allEqual(roots, "a.txt", "arrived without a socket\n"));

// And the other way, so it is a two-way sync rather than one lucky direction.
await type(roots[1]!, "b.txt", "and back again\n");
await waitFor("alice to receive it", () => allEqual(roots, "b.txt", "and back again\n"));
}, 60_000);

/**
* `crosscode start` polls for the descriptor every 100ms and reports success the moment
* it appears. Written when the loopback server bound, it appeared before the stream was
* up -- so a daemon that then died on an expired session still had `start` announcing
* "Daemon started; this checkout is syncing" about a process that was already gone.
*/
it("does not advertise itself as ready while start-up is still in flight", async () => {
// Accepts the connection and never answers, so the daemon is unambiguously still
// starting up while the assertions below run.
const hanging = createServer(() => {});
await new Promise<void>((ready) => hanging.listen(0, "127.0.0.1", ready));
const service = await startSyncServiceStub();
services.push(service);
const origin = await seedOrigin({ "a.txt": "0\n" });
const root = await checkout(origin, "alice", service);
await writeSyncConfig(root, {
projectId: "project-1", repo: "acme/app",
service: { url: `http://127.0.0.1:${(hanging.address() as { port: number }).port}` }
});

const starting = start(root).catch(() => undefined);
// Comfortably past the point the loopback server has bound and the old code would have
// published the descriptor, and well inside the request that never returns.
await new Promise((settle) => setTimeout(settle, 2_000));
const descriptor = await read(root, ".git/crosscode/daemon.json");

// Dropping the connection fails the pending request now rather than at its 10s timeout,
// which is 8s this suite does not need to spend.
hanging.closeAllConnections();
await new Promise<void>((closed) => hanging.close(() => closed()));
await starting;

expect(descriptor).toBeNull();
}, 60_000);

/**
* A change built on a blob this checkout has never held makes apply() call catchUp() to
* fill the gap. Called from inside a drain, that re-walked the same page from the same
* cursor -- and the cursor cannot move past a change that has not applied, so it never
* moved. One such change from one peer span the real service at several requests a second
* indefinitely, while `crosscode status` showed `cursor` frozen and no error anywhere.
*/
it("does not spin the service on a change it cannot rebase", async () => {
const service = await startSyncServiceStub();
services.push(service);
const origin = await seedOrigin({ "a.txt": "0\n" });
const root = await checkout(origin, "alice", service);
// A modify whose baseHash names a blob that exists nowhere: the sender's history is one
// this checkout has no way to reach.
service.changes.push({
sequence: 1, projectId: "project-1", branch: "main",
replicaId: "00000000-1111-4222-8333-444455556666",
createdAt: "2026-01-01T00:00:00.000Z",
version: {
path: "a.txt", op: "modify", baseHash: "0".repeat(40),
contentHash: "1".repeat(40), content: "from a history we never had\n", encoding: "utf8"
}
});

const daemon = await start(root);
await waitFor("the cursor to get past it", async () => daemon.status().cursor >= 1);
const afterSettling = service.listCalls;
await new Promise((settle) => setTimeout(settle, 1_000));

// Ten polls a second would be a spin; the configured interval is 100ms in these tests,
// so a healthy daemon asks a handful of times in a second and no more.
expect(service.listCalls - afterSettling).toBeLessThan(20);
}, 60_000);

it("resyncs from full content when the service says the cursor is too old", async () => {
const service = await startSyncServiceStub();
services.push(service);
Expand Down
Loading
Loading