Skip to content
Open
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
120 changes: 62 additions & 58 deletions src/products/breeze/engine/daemon/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* The daemon needs a richer identity than the one-shot commands:
* - `host` — GitHub host (default `github.com`)
* - `login` — the authenticated user's GH login
* - `gitProtocol` — `https` | `ssh` (from `gh auth status --json hosts`)
* - `gitProtocol` — `https` | `ssh` (from `gh auth status --active`)
* - `scopes` — OAuth scope list; used by `hasRequiredScope` to warn if
* the token lacks `repo`/`notifications`
* - `lockKey(profile)` — `host__login__profile`, used by the broker
Expand All @@ -16,7 +16,7 @@
* Caching: delegates to `runtime/identity-cache.ts`'s 24h-TTL JSON file at
* `~/.breeze/identity.json`. The core cache only stores `{login, host,
* fetched_at_ms}` because one-shot callers don't need scopes. The
* daemon fetches the richer payload via `gh auth status --json hosts`
* daemon fetches the richer payload via `gh auth status --active`
* and keeps it in memory for the lifetime of the daemon process.
*/

Expand Down Expand Up @@ -47,72 +47,85 @@ export interface ResolveDaemonIdentityDeps {
host?: string;
}

interface AuthStatusHostEntry {
active?: boolean;
user?: string;
login?: string;
gitProtocol?: string;
git_protocol?: string;
scopes?: string | string[];
}

interface AuthStatusPayload {
hosts?: Record<string, AuthStatusHostEntry | AuthStatusHostEntry[]>;
}

/**
* Parse the scope field as `gh` returns it. Shape varies:
* - Comma-separated string (`"repo,workflow"`) — older output
* - Array of strings (`["repo", "workflow"]`) — newer output
* - Shell-quoted string from `gh auth status`
* (`"'repo', 'workflow'"`)
*/
function parseScopes(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw
.filter((s): s is string => typeof s === "string")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
if (typeof raw === "string") {
return raw
.split(",")
.map((s) => s.trim())
.map((s) => s.trim().replace(/^['"]+|['"]+$/gu, ""))
.filter((s) => s.length > 0)
.filter((s) => s !== "(none)" && s.toLowerCase() !== "none");
}
if (Array.isArray(raw)) {
return raw
.filter((s): s is string => typeof s === "string")
.map((s) => s.trim().replace(/^['"]+|['"]+$/gu, ""))
.filter((s) => s.length > 0);
}
return [];
}

/**
* Pick the active account from a `gh auth status --json hosts` payload.
* Handles both shapes seen in the wild (single object vs array per host).
* Parse the active account from `gh auth status --active --hostname <host>`.
*/
export function pickActiveIdentityFromAuthStatus(
payload: AuthStatusPayload,
export function pickIdentityFromAuthStatusText(
statusText: string,
targetHost: string,
): DaemonIdentity | null {
const hosts = payload.hosts ?? {};
for (const [host, bucket] of Object.entries(hosts)) {
if (host !== targetHost) continue;
const candidates: AuthStatusHostEntry[] = Array.isArray(bucket)
? bucket
: [bucket];
const active = candidates.find((c) => c?.active === true) ?? candidates[0];
if (!active) continue;
const login = active.user ?? active.login;
if (typeof login !== "string" || login.length === 0) continue;
const gitProtocol = active.gitProtocol ?? active.git_protocol ?? "https";
return {
host,
login,
gitProtocol,
scopes: parseScopes(active.scopes),
};
let login: string | null = null;
let gitProtocol = "https";
let scopes: string[] = [];

for (const line of statusText.split(/\r?\n/u)) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;

if (!login) {
const marker = `Logged in to ${targetHost} account `;
const start = trimmed.indexOf(marker);
if (start >= 0) {
const rest = trimmed.slice(start + marker.length).trim();
const parsedLogin = rest.match(/^([^\s(]+)/u)?.[1];
if (parsedLogin) {
login = parsedLogin;
continue;
}
}
}

const protocolMatch = trimmed.match(
/^-\s*Git operations protocol:\s*(\S+)/u,
);
if (protocolMatch) {
gitProtocol = protocolMatch[1];
continue;
}

const scopesMatch = trimmed.match(/^-\s*Token scopes:\s*(.+)$/u);
if (scopesMatch) {
scopes = parseScopes(scopesMatch[1]);
}
}
return null;

if (!login) return null;

return {
host: targetHost,
login,
gitProtocol,
scopes,
};
}

/**
* Resolve the active gh identity for the daemon. Uses
* `gh auth status --json hosts` with a jq-free JSON parse in Node.
* `gh auth status --active --hostname <host>` to stay compatible with
* older gh releases that don't support `gh auth status --json`.
*/
export function resolveDaemonIdentity(
deps: ResolveDaemonIdentityDeps = {},
Expand All @@ -128,8 +141,6 @@ export function resolveDaemonIdentity(
"--active",
"--hostname",
host,
"--json",
"hosts",
]);
} catch (err) {
if (err instanceof GhExecError) {
Expand All @@ -140,18 +151,11 @@ export function resolveDaemonIdentity(
throw err;
}

let parsed: AuthStatusPayload;
try {
parsed = JSON.parse(stdout) as AuthStatusPayload;
} catch (err) {
const identity = pickIdentityFromAuthStatusText(stdout, host);
if (!identity) {
throw new Error(
`gh auth status returned non-JSON output: ${err instanceof Error ? err.message : String(err)}`,
`could not parse an active gh identity from \`gh auth status\` for host \`${host}\``,
);
}

const identity = pickActiveIdentityFromAuthStatus(parsed, host);
if (!identity) {
throw new Error(`no active gh identity found for host \`${host}\``);
}
return identity;
}
114 changes: 58 additions & 56 deletions tests/breeze/breeze-daemon-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,24 @@ import { GhClient } from "../../src/products/breeze/engine/runtime/gh.js";
import {
identityHasRequiredScope,
identityLockKey,
pickActiveIdentityFromAuthStatus,
pickIdentityFromAuthStatusText,
resolveDaemonIdentity,
} from "../../src/products/breeze/engine/daemon/identity.js";

function makeGhReturning(stdout: string, status = 0): GhClient {
function makeGhReturning(
stdout: string,
status = 0,
stderr = "",
): { gh: GhClient; spawn: ReturnType<typeof vi.fn> } {
const spawn = vi.fn().mockReturnValue({
pid: 1,
status,
signal: null,
stdout: Buffer.from(stdout),
stderr: Buffer.alloc(0),
stderr: Buffer.from(stderr),
output: [],
});
return new GhClient({ spawn });
return { gh: new GhClient({ spawn }), spawn };
}

describe("identityLockKey", () => {
Expand Down Expand Up @@ -76,78 +80,76 @@ describe("identityHasRequiredScope", () => {
});
});

describe("pickActiveIdentityFromAuthStatus", () => {
it("picks active=true when multiple entries per host", () => {
const payload = {
hosts: {
"github.com": [
{
user: "old-login",
active: false,
gitProtocol: "ssh",
scopes: "repo",
},
{
user: "active-login",
active: true,
gitProtocol: "https",
scopes: "repo,workflow",
},
],
},
};
const id = pickActiveIdentityFromAuthStatus(payload, "github.com");
describe("pickIdentityFromAuthStatusText", () => {
it("parses the active account, git protocol, and token scopes", () => {
const statusText = [
"github.com",
" ✓ Logged in to github.com account active-login (keyring)",
" - Active account: true",
" - Git operations protocol: ssh",
" - Token scopes: 'repo', 'workflow'",
"",
].join("\n");

const id = pickIdentityFromAuthStatusText(statusText, "github.com");
expect(id?.login).toBe("active-login");
expect(id?.scopes).toEqual(["repo", "workflow"]);
expect(id?.gitProtocol).toBe("https");
expect(id?.gitProtocol).toBe("ssh");
});

it("accepts scope arrays as well as comma strings", () => {
const payload = {
hosts: {
"github.com": {
user: "x",
active: true,
gitProtocol: "https",
scopes: ["repo", "notifications"],
},
},
};
const id = pickActiveIdentityFromAuthStatus(payload, "github.com");
it("accepts unquoted scope strings as well as quoted ones", () => {
const statusText = [
"github.com",
" ✓ Logged in to github.com account x (keyring)",
" - Active account: true",
" - Git operations protocol: https",
" - Token scopes: repo,notifications",
"",
].join("\n");

const id = pickIdentityFromAuthStatusText(statusText, "github.com");
expect(id?.scopes).toEqual(["repo", "notifications"]);
});

it("returns null when target host is missing", () => {
const payload = {
hosts: { "ghe.other": { user: "x", active: true, scopes: "repo" } },
};
expect(pickActiveIdentityFromAuthStatus(payload, "github.com")).toBeNull();
it("returns null when the login line is missing for the target host", () => {
const statusText = [
"github.com",
" - Active account: true",
" - Token scopes: repo",
"",
].join("\n");
expect(pickIdentityFromAuthStatusText(statusText, "github.com")).toBeNull();
});
});

describe("resolveDaemonIdentity", () => {
it("surfaces gh auth failure with an actionable error", () => {
const gh = makeGhReturning("", 1);
const { gh } = makeGhReturning("", 1, "not logged in");
expect(() => resolveDaemonIdentity({ gh })).toThrow(/gh auth login/u);
});

it("round-trips a realistic payload", () => {
const payload = JSON.stringify({
hosts: {
"github.com": {
user: "bingran-you",
active: true,
gitProtocol: "https",
scopes: "repo,workflow,notifications",
},
},
});
const gh = makeGhReturning(payload, 0);
it("parses a realistic auth status response without using --json", () => {
const statusText = [
"github.com",
" ✓ Logged in to github.com account bingran-you (keyring)",
" - Active account: true",
" - Git operations protocol: https",
" - Token scopes: 'repo', 'workflow', 'notifications'",
"",
].join("\n");
const { gh, spawn } = makeGhReturning(statusText, 0);
const id = resolveDaemonIdentity({ gh });
expect(id.host).toBe("github.com");
expect(id.login).toBe("bingran-you");
expect(id.gitProtocol).toBe("https");
expect(id.scopes).toContain("repo");
expect(identityHasRequiredScope(id)).toBe(true);
expect(spawn.mock.calls[0]?.[1]).toEqual([
"auth",
"status",
"--active",
"--hostname",
"github.com",
]);
});
});
Loading