bin/daemon/connect.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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 `<COTAL_HOME>/daemon/<space segment>/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<string, unknown>;
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<ConnectCheckResult> {
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" };
}