/** * The daemon's connection readiness for one space (A2b.1). It answers whether this machine's daemon * could register with the space as its own service, and if not, exactly why. It never registers, * persists no bearer and starts nothing. * * Every input is stock: the mesh registry entry (`findMesh`), the machine's stock IdP session * (`loadIdpSession` and `fetchIdpJwt`), and the registered auth provider's `serviceEndpointInfo`, * which reads the space's stock service authority route (GET only, no redirects, closed parser). * A 404 there means the * space does not host service endpoints (stock `serviceEndpoints` is off by default); the daemon * reports that, and never falls back to a Manager view, `manager.vitals` or static credentials. * * The daemon's identity is its own, separate from the stock Manager identity: one record per * (machine home, space) under `/daemon//service-identity.json`, created * once through stock `claimIdentityRecord` (exclusive create, one winner). It pins the space's * DATA account on first sight; a later different account is a mismatch, never a silent repin. It * holds no credential, owner, hostname or hardware id. */ import { join } from "node:path"; import { base64url, decodeJwt, decodeProtectedHeader } from "jose"; import { assertLifecycleToken, isAccountNkey, mintLifecycleUid, resolveAuthProvider } from "@cotal-ai/core"; import { claimIdentityRecord, codeSpan, findMesh, homeCotalDir, spaceSegment, userAuthStateDir, workspaceSecretStore } from "@cotal-ai/workspace"; import { fetchIdpJwt, IdpSessionRejectedError, IdpUnreachableError, loadIdpSession } from "@cotal-ai/auth"; import type { ConnectCheckResult } from "./protocol.js"; import { daemonCommandCopy } from "./guidance.js"; /** The service name the daemon proposes for itself. Single-label names are reserved for reference * endpoints and a third-party name must be reverse-DNS, so this is the reverse-DNS of cotal.ai. * Binding it still needs the space operator's `name-bind`; the daemon never claims it. */ export const DAEMON_ENDPOINT = "ai.cotal.machine"; /** Shared diagnosis when the live authority no longer matches the daemon's pinned account. */ export const ACCOUNT_MISMATCH_MESSAGE = "the space now answers with a different account than this daemon pinned; nothing was changed. Confirm the space was not replaced before removing the daemon's identity for it by hand"; /** Fixed clock diagnosis for a provider proof already expired on this machine. */ const IDP_CREDENTIAL_EXPIRED_MESSAGE = "the sign-in provider returned a credential that is expired according to this machine's clock; check this machine's and the authority's clocks, then connect again; nothing is connected"; const KIND = "cotal-daemon-service-identity"; export interface DaemonServiceIdentity { readonly v: 1; readonly kind: typeof KIND; readonly space: string; readonly endpoint: typeof DAEMON_ENDPOINT; readonly instanceId: string; readonly accountPublicKey: string; } /** Where this machine's daemon keeps its identity for `space`. */ export function daemonIdentityFile(space: string, home: string = homeCotalDir()): string { return join(home, "daemon", spaceSegment(space), "service-identity.json"); } function parseIdentity(space: string) { return (raw: unknown): DaemonServiceIdentity | undefined => { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; const o = raw as Record; const keys = Object.keys(o).sort().join(","); if (keys !== "accountPublicKey,endpoint,instanceId,kind,space,v") return undefined; if (o.v !== 1 || o.kind !== KIND || o.space !== space || o.endpoint !== DAEMON_ENDPOINT || !isAccountNkey(o.accountPublicKey)) return undefined; if (typeof o.instanceId !== "string") return undefined; try { assertLifecycleToken(o.instanceId, "instanceId"); } catch { return undefined; } return o as unknown as DaemonServiceIdentity; }; } /** The fixed sentence per failure. Nothing from the remote side is quoted. */ const SENTENCE = { "not-registered": "this space is not registered on this machine; add it with `cotal meshes add`", "not-user-auth": "this space does not use user sign-in, so the daemon cannot connect to it as its own service", "not-signed-in": "this machine is not signed in to the space's provider", "unsupported-by-space": "the space's auth service does not host service endpoints, so the daemon cannot register with it", unreachable: "the space's auth service could not be reached", refused: "the space's auth service gave an answer the daemon cannot use", } as const; /** Check readiness for `space`. `home` is the machine home where the stock session and the daemon's * own state live. */ export async function connectCheck(space: string, home: string = homeCotalDir(), signal?: AbortSignal): Promise { signal?.throwIfAborted(); const entry = findMesh(space); if (!entry) return { outcome: "not-registered", space, message: SENTENCE["not-registered"] }; const ua = entry.mode === "user" ? entry.userAuth : undefined; if (!ua) return { outcome: "not-user-auth", space, message: SENTENCE["not-user-auth"] }; let session; try { session = loadIdpSession(home, ua.idp.url); } catch { session = undefined; } if (!session) return { outcome: "not-signed-in", space, message: SENTENCE["not-signed-in"], remedy: `run ${codeSpan(daemonCommandCopy("login", home, [["--idp", ua.idp.url]]))}` }; // The cached handle's presence and advisory expiry do not prove the IdP still accepts it. // Reuse the stock bounded JWT cache, so readiness and registration agree on revocation. try { const jwt = await fetchIdpJwt(ua.idp.url, session.token, signal); const header = decodeProtectedHeader(jwt), claims = decodeJwt(jwt); const signature = jwt.split(".")[2] ?? ""; const now = Date.now() / 1000; // This is a usability check only. The space still verifies the signature and pinned claims. if (typeof header.alg !== "string" || !header.alg || header.alg === "none" || !/^[A-Za-z0-9_-]+$/.test(signature) || base64url.decode(signature).length === 0 || typeof claims.sub !== "string" || !claims.sub || typeof claims.exp !== "number" || !Number.isFinite(claims.exp) || (claims.nbf !== undefined && (typeof claims.nbf !== "number" || !Number.isFinite(claims.nbf) || claims.nbf > now))) return { outcome: "refused", space, message: "the space's sign-in provider gave an answer the daemon cannot use" }; if (claims.exp <= now) return { outcome: "credential-expired", space, message: IDP_CREDENTIAL_EXPIRED_MESSAGE }; } catch (e) { if (e instanceof IdpSessionRejectedError) return { outcome: "not-signed-in", space, message: SENTENCE["not-signed-in"], remedy: `run ${codeSpan(daemonCommandCopy("login", home, [["--idp", ua.idp.url]]))}` }; if (e instanceof IdpUnreachableError) return { outcome: "unreachable", space, message: "the space's sign-in provider could not be reached" }; return { outcome: "refused", space, message: "the space's sign-in provider gave an answer the daemon cannot use" }; } let accountPublicKey: string; try { const provider = resolveAuthProvider(); if (!provider.serviceEndpointInfo) return { outcome: "unsupported-by-space", space, message: SENTENCE["unsupported-by-space"] }; const info = await provider.serviceEndpointInfo({ store: workspaceSecretStore(entry.root), dir: userAuthStateDir(entry.root, space, { readOnly: true }), space, signal }); accountPublicKey = info.accountPublicKey; } catch (e) { const status = (e as { httpStatus?: number }).httpStatus; if (status === 404) return { outcome: "unsupported-by-space", space, message: SENTENCE["unsupported-by-space"] }; if (status === undefined && (e as { outcome?: string }).outcome === "not-executed" && /did not return a response/.test((e as Error).message)) return { outcome: "unreachable", space, message: SENTENCE.unreachable }; return { outcome: "refused", space, message: SENTENCE.refused }; } signal?.throwIfAborted(); let identity: DaemonServiceIdentity; try { identity = claimIdentityRecord(daemonIdentityFile(space, home), "daemon service identity", parseIdentity(space), () => ({ v: 1, kind: KIND, space, endpoint: DAEMON_ENDPOINT, instanceId: mintLifecycleUid(), accountPublicKey, })); } catch { return { outcome: "local-state-error", space, message: "the daemon could not read or create its local service identity; inspect service-identity.json and its permissions, and restore the original record if damaged" }; } if (identity.accountPublicKey !== accountPublicKey) return { outcome: "account-mismatch", space, pinnedAccountPublicKey: identity.accountPublicKey, observedAccountPublicKey: accountPublicKey, message: ACCOUNT_MISMATCH_MESSAGE }; return { outcome: "ready-to-register", space, endpoint: DAEMON_ENDPOINT, instanceId: identity.instanceId, accountPublicKey, message: "this daemon can ask the space to register it; the space owner must still approve it, and nothing has been registered" }; }