import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; type Env = Record; export type StoredSessionLike = { host: string; organization?: { id?: string; name?: string; slug?: string }; agent?: { id?: string; name?: string; kind?: string }; sessionScope?: string; sessionScopeLabel?: string; savedAt?: number; sessionExpiresAt?: number; }; export type ConfigWithSessions = { currentHost?: string; currentSessionKey?: string; currentHosts?: Record; currentSessionKeys?: Record; sessions?: Record; }; export type StoredSessionEntry = { key: string; host: string; session: TSession; }; export type SessionScope = { key: string; label: string; }; const SESSION_KEY_OPTION_NAMES = ["--session-key", "--session"]; const EXPLICIT_SESSION_ENV_NAMES = ["DENCH_SESSION_KEY", "DENCH_AGENT_SESSION"]; const AGENT_SESSION_ENV_NAMES = [ "CURSOR_AGENT_ID", "CURSOR_SESSION_ID", "CURSOR_WORKSPACE_ID", "CLAUDE_CODE_SESSION_ID", "CLAUDECODE_SESSION_ID", "CODEX_SESSION_ID", "OPENAI_CODEX_SESSION_ID", "AGENT_SESSION_ID", "TERM_SESSION_ID", "TMUX_PANE", "STY", "VSCODE_PID", "VSCODE_IPC_HOOK_CLI", "VSCODE_GIT_IPC_HANDLE", ]; const AGENT_PROCESS_PATTERNS = [ "cursor", "claude", "codex", "openclaw", "hermes", "opencode", ]; function optionFromArgs(args: string[], name: string) { const index = args.indexOf(name); if (index === -1) return undefined; return args[index + 1]; } function firstOptionFromArgs(args: string[], names: string[]) { for (const name of names) { const value = optionFromArgs(args, name); if (value?.trim()) return value.trim(); } return undefined; } function firstPositionalArg(args: string[]) { for (let i = 0; i < args.length; i++) { if (args[i].startsWith("--")) { if (args[i + 1] && !args[i + 1].startsWith("--")) i++; continue; } return args[i]; } return undefined; } function firstEnvValue(env: Env, names: string[]) { for (const name of names) { const value = env[name]?.trim(); if (value) return { name, value }; } return undefined; } function validateStableSessionKeyInput(value: string, source: string) { if (!/^(auto|explicit):/i.test(value)) return; throw new Error( `${source} must be a stable human-readable id, not internal session scope "${value}". Run dench use ${value} to select that existing session, or choose a stable id like billing-repo-agent before dench login.`, ); } export function explicitSessionKeyInput({ args, env = process.env, }: { args: string[]; env?: Env; }) { const optionNames = firstPositionalArg(args) === "logout" ? SESSION_KEY_OPTION_NAMES.filter((name) => name !== "--session") : SESSION_KEY_OPTION_NAMES; const fromOption = firstOptionFromArgs(args, optionNames); if (fromOption) { validateStableSessionKeyInput(fromOption, "--session-key"); return { source: "--session-key", value: fromOption }; } const fromEnv = firstEnvValue(env, EXPLICIT_SESSION_ENV_NAMES); if (fromEnv) { validateStableSessionKeyInput(fromEnv.value, fromEnv.name); return { source: fromEnv.name, value: fromEnv.value }; } return undefined; } function hashValue(value: string) { return createHash("sha256").update(value).digest("hex").slice(0, 24); } export function normalizeHostValue(host: string) { const withProtocol = /^https?:\/\//.test(host) ? host : `https://${host}`; return new URL(withProtocol).origin; } function processAncestrySignature() { if (process.platform === "win32") return undefined; let pid = process.ppid; const parts: string[] = []; for (let depth = 0; pid > 1 && depth < 8; depth++) { const result = spawnSync( "ps", ["-p", String(pid), "-o", "ppid=", "-o", "command="], { encoding: "utf8", timeout: 200, windowsHide: true, }, ); if (result.status !== 0 || !result.stdout.trim()) break; const line = result.stdout.trim(); const match = line.match(/^(\d+)\s+(.+)$/); if (!match) break; const command = match[2].toLowerCase(); const agentName = AGENT_PROCESS_PATTERNS.find((name) => command.includes(name), ); if (agentName) { parts.push(`${agentName}:${pid}`); } pid = Number(match[1]); if (!Number.isFinite(pid)) break; } return parts.length ? parts.join("|") : undefined; } export function resolveSessionScope({ args, env = process.env, cwd = process.cwd(), ancestrySignature, }: { args: string[]; env?: Env; cwd?: string; ancestrySignature?: string | null; }): SessionScope { const resolvedAncestrySignature = ancestrySignature === undefined ? processAncestrySignature() : ancestrySignature; const explicit = explicitSessionKeyInput({ args, env })?.value; if (explicit) { return { key: `explicit:${hashValue(explicit)}`, label: "explicit", }; } const contextParts = AGENT_SESSION_ENV_NAMES.flatMap((name) => { const value = env[name]?.trim(); return value ? [`env:${name}:${value}`] : []; }); if (resolvedAncestrySignature) { contextParts.push(`process:${resolvedAncestrySignature}`); } contextParts.push(`cwd:${cwd}`); return { key: `auto:${hashValue(contextParts.join("\0"))}`, label: contextParts.length > 1 ? "local-agent-context" : "cwd", }; } /** * Whether an injected agent-session token should stand in for a saved * `dench signin` session. * * `DENCH_AGENT_SESSION_TOKEN` names which identity to act as, so it outranks * whatever was last saved on the host. Only sandboxes and Dench Code set it — * nobody exports it by hand — and without this a host with any saved session * silently ignores it, or fails as `session_ambiguous` when several are saved. * * `DENCH_API_KEY` deliberately does not qualify. People export that one * manually for unrelated reasons, and quietly swapping their identity would be * a surprise. Sandboxes set both and have no saved session anyway, so they * keep landing on the API key, which is what they want: the key has no TTL * while the agent-session token expires. */ export function usesInjectedAgentSession(env: Env = process.env) { if (env.DENCH_API_KEY?.trim()) return false; return Boolean(env.DENCH_AGENT_SESSION_TOKEN?.trim()); } export function sessionConfigKey(host: string, scope: SessionScope) { return `${normalizeHostValue(host)}#${scope.key}`; } function isExplicitScope(scope: SessionScope) { return scope.label === "explicit" || scope.key.startsWith("explicit:"); } function sessionFromSelectedKey( config: ConfigWithSessions, selectedKey: string | undefined, normalizedHost: string, ) { if (!selectedKey) return undefined; const selected = config.sessions?.[selectedKey]; if (selected && normalizeHostValue(selected.host) === normalizedHost) { return { key: selectedKey, session: selected }; } return undefined; } export function withCurrentSessionSelection( config: ConfigWithSessions, { host, scope, sessionKey, }: { host: string; scope: SessionScope; sessionKey: string }, ): ConfigWithSessions { const normalizedHost = normalizeHostValue(host); return { ...config, currentHost: normalizedHost, currentSessionKey: sessionKey, currentHosts: { ...(config.currentHosts ?? {}), [scope.key]: normalizedHost, }, currentSessionKeys: { ...(config.currentSessionKeys ?? {}), [scope.key]: sessionKey, }, }; } export function withSavedSession( config: ConfigWithSessions, session: TSession, scope: SessionScope, ) { const host = normalizeHostValue(session.host); const key = sessionConfigKey(host, scope); const nextConfig = withCurrentSessionSelection( { ...config, sessions: { ...(config.sessions ?? {}), [key]: { ...session, host, sessionScope: scope.key, sessionScopeLabel: scope.label, }, }, }, { host, scope, sessionKey: key }, ); return { key, config: nextConfig }; } export function listStoredSessionEntries( config: ConfigWithSessions, host?: string, ): Array> { const normalizedHost = host ? normalizeHostValue(host) : undefined; return Object.entries(config.sessions ?? {}) .map(([key, session]) => ({ key, host: normalizeHostValue(session.host), session, })) .filter((entry) => !normalizedHost || entry.host === normalizedHost) .sort((a, b) => { if (a.host !== b.host) return a.host.localeCompare(b.host); const aSaved = a.session.savedAt ?? 0; const bSaved = b.session.savedAt ?? 0; if (aSaved !== bSaved) return bSaved - aSaved; return a.key.localeCompare(b.key); }); } function matchesSelector( entry: StoredSessionEntry, selector: string, ) { const normalizedSelector = selector.trim().toLowerCase(); if (!normalizedSelector) return false; const candidates = [ entry.key, entry.session.sessionScope, entry.session.organization?.id, entry.session.organization?.name, entry.session.organization?.slug, entry.session.agent?.id, entry.session.agent?.name, ]; return candidates.some( (candidate) => candidate?.toLowerCase() === normalizedSelector, ); } export function findStoredSessionEntries( config: ConfigWithSessions, selector: string, host?: string, ) { return listStoredSessionEntries(config, host).filter((entry) => matchesSelector(entry, selector), ); } export function resolveConfigHost( config: ConfigWithSessions | undefined, scope: SessionScope, ) { return config?.currentHosts?.[scope.key] ?? config?.currentHost; } export function selectStoredSession( config: ConfigWithSessions, host: string, scope: SessionScope, ): | { status: "found"; session: TSession } | { status: "missing" } | { status: "ambiguous"; count: number } { const selected = selectStoredSessionEntry(config, host, scope); if (selected.status === "found") { return { status: "found", session: selected.session }; } return selected; } export function selectStoredSessionEntry( config: ConfigWithSessions, host: string, scope: SessionScope, ): | { status: "found"; key: string; session: TSession } | { status: "missing" } | { status: "ambiguous"; count: number } { const normalizedHost = normalizeHostValue(host); const scopedSelected = sessionFromSelectedKey( config, config.currentSessionKeys?.[scope.key], normalizedHost, ); if (scopedSelected) { return { status: "found", key: scopedSelected.key, session: scopedSelected.session, }; } if (!isExplicitScope(scope)) { const defaultSelected = sessionFromSelectedKey( config, config.currentSessionKey, normalizedHost, ); if (defaultSelected) { return { status: "found", key: defaultSelected.key, session: defaultSelected.session, }; } } const exactKey = sessionConfigKey(normalizedHost, scope); const exact = config.sessions?.[exactKey]; if (exact) return { status: "found", key: exactKey, session: exact }; const hostSessions = Object.entries(config.sessions ?? {}).filter( ([, session]) => normalizeHostValue(session.host) === normalizedHost, ); const scopedHostSessions = hostSessions.filter( ([key]) => key !== normalizedHost, ); const legacy = config.sessions?.[normalizedHost]; if (legacy && scopedHostSessions.length === 0) { return { status: "found", key: normalizedHost, session: legacy }; } if (hostSessions.length > 1) { return { status: "ambiguous", count: hostSessions.length }; } return { status: "missing" }; } export function removeStoredSession( config: ConfigWithSessions, host: string, scope: SessionScope, ): | { status: "removed"; config: ConfigWithSessions; removedKey: string; session: TSession; } | { status: "missing"; config: ConfigWithSessions } | { status: "ambiguous"; count: number; config: ConfigWithSessions; } { const selected = selectStoredSessionEntry(config, host, scope); if (selected.status !== "found") { return { ...selected, config }; } const normalizedHost = normalizeHostValue(host); const sessions = { ...(config.sessions ?? {}) }; delete sessions[selected.key]; const currentHosts = { ...(config.currentHosts ?? {}) }; const currentSessionKeys = { ...(config.currentSessionKeys ?? {}) }; if ( currentHosts[scope.key] && normalizeHostValue(currentHosts[scope.key]) === normalizedHost ) { delete currentHosts[scope.key]; } for (const [scopeKey, sessionKey] of Object.entries(currentSessionKeys)) { if (sessionKey === selected.key) { delete currentSessionKeys[scopeKey]; } } const nextConfig: ConfigWithSessions = { ...config }; if (nextConfig.currentSessionKey === selected.key) { delete nextConfig.currentSessionKey; } if (Object.keys(sessions).length > 0) { nextConfig.sessions = sessions; } else { delete nextConfig.sessions; } if (Object.keys(currentHosts).length > 0) { nextConfig.currentHosts = currentHosts; } else { delete nextConfig.currentHosts; } if (Object.keys(currentSessionKeys).length > 0) { nextConfig.currentSessionKeys = currentSessionKeys; } else { delete nextConfig.currentSessionKeys; } const hasRemainingSessionForHost = Object.values(sessions).some( (session) => normalizeHostValue(session.host) === normalizedHost, ); if ( nextConfig.currentHost && normalizeHostValue(nextConfig.currentHost) === normalizedHost && !hasRemainingSessionForHost ) { delete nextConfig.currentHost; } return { status: "removed", config: nextConfig, removedKey: selected.key, session: selected.session, }; } export function removeAllStoredSessions( config: ConfigWithSessions, ) { const nextConfig: ConfigWithSessions = { ...config }; delete nextConfig.currentHost; delete nextConfig.currentSessionKey; delete nextConfig.currentHosts; delete nextConfig.currentSessionKeys; delete nextConfig.sessions; return nextConfig; }