From 4ca6591045313a8c38b8b3c035bfcedf5f8aae91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 22 Dec 2025 21:02:48 +0000 Subject: [PATCH] refactor: move OAuth storage and drop legacy sessions --- .../Clawdis/AnthropicAuthControls.swift | 20 +-- .../Sources/Clawdis/AnthropicOAuth.swift | 90 +++++++++---- .../Sources/Clawdis/ConfigSettings.swift | 2 +- apps/macos/Sources/Clawdis/Onboarding.swift | 23 ++-- .../Sources/Clawdis/SessionActions.swift | 2 - .../AnthropicAuthResolverTests.swift | 3 +- ...sts.swift => ClawdisOAuthStoreTests.swift} | 20 +-- docs/agent.md | 16 ++- docs/mac/bun.md | 8 +- docs/onboarding.md | 46 +++---- docs/session.md | 1 + src/agents/pi-embedded-runner.ts | 126 ++++++++++++++++-- src/gateway/server.ts | 31 +++-- 13 files changed, 265 insertions(+), 123 deletions(-) rename apps/macos/Tests/ClawdisIPCTests/{PiOAuthStoreTests.swift => ClawdisOAuthStoreTests.swift} (76%) diff --git a/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift b/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift index e6ee42049..2b688fcc2 100644 --- a/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift +++ b/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift @@ -6,7 +6,7 @@ import SwiftUI struct AnthropicAuthControls: View { let connectionMode: AppState.ConnectionMode - @State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus() + @State private var oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus() @State private var pkce: AnthropicOAuth.PKCE? @State private var code: String = "" @State private var busy = false @@ -20,7 +20,7 @@ struct AnthropicAuthControls: View { var body: some View { VStack(alignment: .leading, spacing: 10) { if self.connectionMode != .local { - Text("Gateway isn’t running locally; OAuth must be created on the gateway host where Pi runs.") + Text("Gateway isn’t running locally; OAuth must be created on the gateway host.") .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -35,10 +35,10 @@ struct AnthropicAuthControls: View { .foregroundStyle(.secondary) Spacer() Button("Reveal") { - NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()]) + NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()]) } .buttonStyle(.bordered) - .disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path)) + .disabled(!FileManager.default.fileExists(atPath: ClawdisOAuthStore.oauthURL().path)) Button("Refresh") { self.refresh() @@ -46,7 +46,7 @@ struct AnthropicAuthControls: View { .buttonStyle(.bordered) } - Text(PiOAuthStore.oauthURL().path) + Text(ClawdisOAuthStore.oauthURL().path) .font(.caption.monospaced()) .foregroundStyle(.secondary) .lineLimit(1) @@ -123,7 +123,11 @@ struct AnthropicAuthControls: View { } private func refresh() { - self.oauthStatus = PiOAuthStore.anthropicOAuthStatus() + let imported = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded() + self.oauthStatus = ClawdisOAuthStore.anthropicOAuthStatus() + if imported != nil { + self.statusText = "Imported existing OAuth credentials." + } } private func startOAuth() { @@ -161,11 +165,11 @@ struct AnthropicAuthControls: View { code: parsed.code, state: parsed.state, verifier: pkce.verifier) - try PiOAuthStore.saveAnthropicOAuth(creds) + try ClawdisOAuthStore.saveAnthropicOAuth(creds) self.refresh() self.pkce = nil self.code = "" - self.statusText = "Connected. Pi can now use Claude via OAuth." + self.statusText = "Connected. Clawdis can now use Claude via OAuth." } catch { self.statusText = "OAuth failed: \(error.localizedDescription)" } diff --git a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift index 52ba5ca5d..0c238bb7b 100644 --- a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift +++ b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift @@ -18,7 +18,7 @@ enum AnthropicAuthMode: Equatable { var shortLabel: String { switch self { - case .oauthFile: "OAuth (Pi token file)" + case .oauthFile: "OAuth (Clawdis token file)" case .oauthEnv: "OAuth (env var)" case .apiKeyEnv: "API key (env var)" case .missing: "Missing credentials" @@ -36,7 +36,8 @@ enum AnthropicAuthMode: Equatable { enum AnthropicAuthResolver { static func resolve( environment: [String: String] = ProcessInfo.processInfo.environment, - oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode + oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus() + ) -> AnthropicAuthMode { if oauthStatus.isConnected { return .oauthFile } @@ -92,7 +93,7 @@ enum AnthropicOAuth { URLQueryItem(name: "scope", value: self.scopes), URLQueryItem(name: "code_challenge", value: pkce.challenge), URLQueryItem(name: "code_challenge_method", value: "S256"), - // Match Pi: state is the verifier. + // Match legacy flow: state is the verifier. URLQueryItem(name: "state", value: pkce.verifier), ] return components.url! @@ -140,7 +141,7 @@ enum AnthropicOAuth { ]) } - // Match Pi: expiresAt = now + expires_in - 5 minutes. + // Match legacy flow: expiresAt = now + expires_in - 5 minutes. let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) + Int64(expiresIn * 1000) - Int64(5 * 60 * 1000) @@ -150,10 +151,11 @@ enum AnthropicOAuth { } } -enum PiOAuthStore { +enum ClawdisOAuthStore { static let oauthFilename = "oauth.json" private static let providerKey = "anthropic" - private static let piAgentDirEnv = "PI_CODING_AGENT_DIR" + private static let clawdisOAuthDirEnv = "CLAWDIS_OAUTH_DIR" + private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR" enum AnthropicOAuthStatus: Equatable { case missingFile @@ -170,18 +172,18 @@ enum PiOAuthStore { var shortDescription: String { switch self { - case .missingFile: "Pi OAuth token file not found" - case .unreadableFile: "Pi OAuth token file not readable" - case .invalidJSON: "Pi OAuth token file invalid" - case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file" + case .missingFile: "Clawdis OAuth token file not found" + case .unreadableFile: "Clawdis OAuth token file not readable" + case .invalidJSON: "Clawdis OAuth token file invalid" + case .missingProviderEntry: "No Anthropic entry in Clawdis OAuth token file" case .missingTokens: "Anthropic entry missing tokens" - case .connected: "Pi OAuth credentials found" + case .connected: "Clawdis OAuth credentials found" } } } static func oauthDir() -> URL { - if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]? + if let override = ProcessInfo.processInfo.environment[self.clawdisOAuthDirEnv]? .trimmingCharacters(in: .whitespacesAndNewlines), !override.isEmpty { @@ -190,14 +192,58 @@ enum PiOAuthStore { } return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".pi", isDirectory: true) - .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent(".clawdis", isDirectory: true) + .appendingPathComponent("credentials", isDirectory: true) } static func oauthURL() -> URL { self.oauthDir().appendingPathComponent(self.oauthFilename) } + static func legacyOAuthURLs() -> [URL] { + var urls: [URL] = [] + let env = ProcessInfo.processInfo.environment + if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + let expanded = NSString(string: override).expandingTildeInPath + urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename)) + } + + let home = FileManager.default.homeDirectoryForCurrentUser + urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)")) + urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)")) + urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)")) + urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)")) + + var seen = Set() + return urls.filter { url in + let path = url.standardizedFileURL.path + if seen.contains(path) { return false } + seen.insert(path) + return true + } + } + + static func importLegacyAnthropicOAuthIfNeeded() -> URL? { + let dest = self.oauthURL() + guard !FileManager.default.fileExists(atPath: dest.path) else { return nil } + + for url in self.legacyOAuthURLs() { + guard FileManager.default.fileExists(atPath: url.path) else { continue } + guard self.anthropicOAuthStatus(at: url).isConnected else { continue } + guard let storage = self.loadStorage(at: url) else { continue } + do { + try self.saveStorage(storage) + return url + } catch { + continue + } + } + + return nil + } + static func anthropicOAuthStatus() -> AnthropicOAuthStatus { self.anthropicOAuthStatus(at: self.oauthURL()) } @@ -240,17 +286,15 @@ enum PiOAuthStore { return nil } + private static func loadStorage(at url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url) else { return nil } + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } + return json as? [String: Any] + } + static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws { let url = self.oauthURL() - let existing: [String: Any] = if FileManager.default.fileExists(atPath: url.path), - let data = try? Data(contentsOf: url), - let json = try? JSONSerialization.jsonObject(with: data, options: []), - let dict = json as? [String: Any] - { - dict - } else { - [:] - } + let existing: [String: Any] = self.loadStorage(at: url) ?? [:] var updated = existing updated[self.providerKey] = [ diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index 1548e9e27..fdc92d745 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -152,7 +152,7 @@ struct ConfigSettings: View { } private var anthropicAuthHelpText: String { - "Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " + + "Determined from Clawdis OAuth token file (~/.clawdis/credentials/oauth.json) " + "or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)." } diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index cdae91af4..2044f2713 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -62,7 +62,7 @@ struct OnboardingView: View { @State private var anthropicAuthStatus: String? @State private var anthropicAuthBusy = false @State private var anthropicAuthConnected = false - @State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile + @State private var anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile @State private var anthropicAuthAutoDetectClipboard = true @State private var anthropicAuthAutoConnectClipboard = true @State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount @@ -530,7 +530,7 @@ struct OnboardingView: View { .multilineTextAlignment(.center) .frame(maxWidth: 540) .fixedSize(horizontal: false, vertical: true) - Text("Pi supports any model — we strongly recommend Opus 4.5 for the best experience.") + Text("Clawdis supports any model — we strongly recommend Opus 4.5 for the best experience.") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -555,14 +555,14 @@ struct OnboardingView: View { } Text( - "This lets Pi use Claude immediately. Credentials are stored at " + - "`~/.pi/agent/oauth.json` (owner-only). You can redo this anytime.") + "This lets Clawdis use Claude immediately. Credentials are stored at " + + "`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.") .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 12) { - Text(PiOAuthStore.oauthURL().path) + Text(ClawdisOAuthStore.oauthURL().path) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -571,7 +571,7 @@ struct OnboardingView: View { Spacer() Button("Reveal") { - NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()]) + NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()]) } .buttonStyle(.bordered) @@ -683,9 +683,9 @@ struct OnboardingView: View { code: parsed.code, state: parsed.state, verifier: pkce.verifier) - try PiOAuthStore.saveAnthropicOAuth(creds) + try ClawdisOAuthStore.saveAnthropicOAuth(creds) self.refreshAnthropicOAuthStatus() - self.anthropicAuthStatus = "Connected. Pi can now use Claude." + self.anthropicAuthStatus = "Connected. Clawdis can now use Claude." } catch { self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)" } @@ -717,7 +717,8 @@ struct OnboardingView: View { } private func refreshAnthropicOAuthStatus() { - let status = PiOAuthStore.anthropicOAuthStatus() + _ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded() + let status = ClawdisOAuthStore.anthropicOAuthStatus() self.anthropicAuthDetectedStatus = status self.anthropicAuthConnected = status.isConnected } @@ -947,8 +948,8 @@ struct OnboardingView: View { self.featureRow( title: "Remote gateway checklist", subtitle: """ - On your gateway host: install/update the `clawdis` package and make sure Pi has credentials - (typically `~/.pi/agent/oauth.json`). Then connect again if needed. + On your gateway host: install/update the `clawdis` package and make sure credentials exist + (typically `~/.clawdis/credentials/oauth.json`). Then connect again if needed. """, systemImage: "network") Divider() diff --git a/apps/macos/Sources/Clawdis/SessionActions.swift b/apps/macos/Sources/Clawdis/SessionActions.swift index d3d7694c2..177f53134 100644 --- a/apps/macos/Sources/Clawdis/SessionActions.swift +++ b/apps/macos/Sources/Clawdis/SessionActions.swift @@ -83,8 +83,6 @@ enum SessionActions { } let home = FileManager.default.homeDirectoryForCurrentUser urls.append(home.appendingPathComponent(".clawdis/sessions/\(sessionId).jsonl")) - urls.append(home.appendingPathComponent(".pi/agent/sessions/\(sessionId).jsonl")) - urls.append(home.appendingPathComponent(".tau/agent/sessions/clawdis/\(sessionId).jsonl")) return urls }() diff --git a/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift index 20a4eeebc..941443b05 100644 --- a/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift @@ -6,7 +6,7 @@ import Testing struct AnthropicAuthResolverTests { @Test func prefersOAuthFileOverEnv() throws { - let key = "PI_CODING_AGENT_DIR" + let key = "CLAWDIS_OAUTH_DIR" let previous = ProcessInfo.processInfo.environment[key] defer { if let previous { @@ -61,4 +61,3 @@ struct AnthropicAuthResolverTests { #expect(mode == .missing) } } - diff --git a/apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/ClawdisOAuthStoreTests.swift similarity index 76% rename from apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift rename to apps/macos/Tests/ClawdisIPCTests/ClawdisOAuthStoreTests.swift index 278d7aa7f..1f616c0e3 100644 --- a/apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/ClawdisOAuthStoreTests.swift @@ -3,18 +3,18 @@ import Testing @testable import Clawdis @Suite -struct PiOAuthStoreTests { +struct ClawdisOAuthStoreTests { @Test func returnsMissingWhenFileAbsent() { let url = FileManager.default.temporaryDirectory .appendingPathComponent("clawdis-oauth-\(UUID().uuidString)") .appendingPathComponent("oauth.json") - #expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile) + #expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingFile) } @Test - func usesEnvOverrideForPiAgentDir() throws { - let key = "PI_CODING_AGENT_DIR" + func usesEnvOverrideForClawdisOAuthDir() throws { + let key = "CLAWDIS_OAUTH_DIR" let previous = ProcessInfo.processInfo.environment[key] defer { if let previous { @@ -25,10 +25,10 @@ struct PiOAuthStoreTests { } let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("clawdis-pi-agent-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true) setenv(key, dir.path, 1) - #expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL) + #expect(ClawdisOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL) } @Test @@ -42,7 +42,7 @@ struct PiOAuthStoreTests { ], ]) - #expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected) + #expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected) } @Test @@ -55,7 +55,7 @@ struct PiOAuthStoreTests { ], ]) - #expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected) + #expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected) } @Test @@ -68,7 +68,7 @@ struct PiOAuthStoreTests { ], ]) - #expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry) + #expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry) } @Test @@ -81,7 +81,7 @@ struct PiOAuthStoreTests { ], ]) - #expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens) + #expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens) } private func writeOAuthFile(_ json: [String: Any]) throws -> URL { diff --git a/docs/agent.md b/docs/agent.md index 65b0ad5a0..9292ea16b 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -1,12 +1,12 @@ --- -summary: "Agent runtime (embedded Pi), workspace contract, and session bootstrap" +summary: "Agent runtime (embedded p-mono), workspace contract, and session bootstrap" read_when: - Changing agent runtime, workspace bootstrap, or session behavior --- # Agent Runtime 🤖 -CLAWDIS runs a single agent runtime: **Pi (embedded, in-process)**. +CLAWDIS runs a single embedded agent runtime derived from **p-mono** (internal name: **p**). ## Workspace (required) @@ -30,7 +30,7 @@ If a file is missing, CLAWDIS injects a single “missing file” marker line (a ## Built-in tools (internal) -Pi’s embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; it’s guidance for how *you* want them used. +p’s embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; it’s guidance for how *you* want them used. ## Skills @@ -41,11 +41,12 @@ Clawdis loads skills from three locations (workspace wins on name conflict): Skills can be gated by config/env (see `skills.*` in `docs/configuration.md`). -## SDK integration +## p-mono integration -The embedded agent uses the `@mariozechner/pi-coding-agent` SDK for sessions and discovery. -- Hooks, custom tools, and slash commands are discovered via the SDK (from `~/.pi/agent` and `/.pi` settings). -- Bootstrap files are injected as SDK project context (see “Project Context” in the system prompt). +Clawdis reuses pieces of the p-mono codebase (models/tools), but **session management, discovery, and tool wiring are Clawdis-owned**. + +- No p-coding agent runtime. +- No `~/.pi/agent` or `/.pi` settings are consulted. ## Peter @ steipete (only) @@ -65,6 +66,7 @@ Session transcripts are stored as JSONL at: - `~/.clawdis/sessions/.jsonl` The session ID is stable and chosen by CLAWDIS. +Legacy Pi/Tau session folders are **not** read. ## Steering while streaming diff --git a/docs/mac/bun.md b/docs/mac/bun.md index 1f6aea41d..f8b6fb0f0 100644 --- a/docs/mac/bun.md +++ b/docs/mac/bun.md @@ -20,13 +20,13 @@ App bundle layout: - `clawdis …` (CLI) - `clawdis gateway-daemon …` (LaunchAgent daemon) - `Clawdis.app/Contents/Resources/Relay/package.json` - - tiny “Pi compatibility” file (see below) + - tiny “p runtime compatibility” file (see below) - `Clawdis.app/Contents/Resources/Relay/theme/` - - Pi TUI theme payload (optional, but strongly recommended) + - p TUI theme payload (optional, but strongly recommended) Why the sidecar files matter: -- `@mariozechner/pi-coding-agent` detects “bun binary mode” and then looks for `package.json` + `theme/` **next to `process.execPath`** (i.e. next to `clawdis`). -- So even if bun can embed assets, Pi currently expects filesystem paths. Keep the sidecar files. +- The embedded p runtime detects “bun binary mode” and then looks for `package.json` + `theme/` **next to `process.execPath`** (i.e. next to `clawdis`). +- So even if bun can embed assets, the runtime expects filesystem paths. Keep the sidecar files. ## Build pipeline diff --git a/docs/onboarding.md b/docs/onboarding.md index 953c9ccdb..f60768a49 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -2,12 +2,12 @@ summary: "Planned first-run onboarding flow for Clawdis (local vs remote, Anthropic OAuth, workspace bootstrap ritual)" read_when: - Designing the macOS onboarding assistant - - Implementing Pi authentication or identity setup + - Implementing Anthropic auth or identity setup --- # Onboarding (macOS app) -This doc describes the intended **first-run onboarding** for Clawdis. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for Pi, and then let the **agent bootstrap itself** via a first-run ritual in the workspace. +This doc describes the intended **first-run onboarding** for Clawdis. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for the embedded agent runtime, and then let the **agent bootstrap itself** via a first-run ritual in the workspace. ## Page order (high level) @@ -19,58 +19,42 @@ This doc describes the intended **first-run onboarding** for Clawdis. The goal i First question: where does the **Gateway** run? -- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write Pi’s token store locally. +- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdis token store locally. - **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**. Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user). ## 2) Local-only: Connect Claude (Anthropic OAuth) -This is the “bind Pi to Clawdis” step. It is explicitly the **Anthropic (Claude Pro/Max) OAuth flow**, not a generic “login”. +This is the “bind Clawdis to Anthropic” step. It is explicitly the **Anthropic (Claude Pro/Max) OAuth flow**, not a generic “login”. ### Recommended: OAuth The macOS app should: - Start the Anthropic OAuth (PKCE) flow in the user’s browser. - Ask the user to paste the `code#state` value. -- Exchange it for tokens and write Pi-compatible credentials to: - - `~/.pi/agent/oauth.json` (file mode `0600`, directory mode `0700`) +- Exchange it for tokens and write credentials to: + - `~/.clawdis/credentials/oauth.json` (file mode `0600`, directory mode `0700`) -Why this location matters: it makes Pi work immediately (Clawdis doesn’t need a terminal and doesn’t need to re-implement Pi’s auth plumbing later). +Why this location matters: it’s the Clawdis-owned OAuth store. +On first run, Clawdis can import existing OAuth tokens from legacy p/Claude locations if present. ### Alternative: API key (instructions only) Offer an “API key” option, but for now it is **instructions only**: - Get an Anthropic API key. -- Provide it to Pi (or to Clawdis’s Pi invocation) via your preferred mechanism. +- Provide it to Clawdis via your preferred mechanism (env/config). Note: environment variables are often confusing when the Gateway is launched by a GUI app (launchd environment != your shell). ### Provider/model safety rule -Clawdis should **always pass** `--provider` and `--model` when invoking Pi (don’t rely on Pi defaults). +Clawdis should **always pass** `--provider` and `--model` when invoking the embedded agent (don’t rely on defaults). -Until that is hard-coded, the equivalent configuration is: +Example (CLI): -```json5 -{ - inbound: { - reply: { - mode: "command", - command: [ - "pi", - "--mode", - "rpc", - "--provider", - "anthropic", - "--model", - "claude-opus-4-5", - "{{BodyStripped}}" - ], - agent: { kind: "pi", format: "json" } - } - } -} +```bash +clawdis agent --mode rpc --provider anthropic --model claude-opus-4-5 "" ``` If the user skips auth, onboarding should be clear: the agent likely won’t respond until auth is configured. @@ -136,10 +120,10 @@ Daily memory lives under `memory/` in the workspace: ## Remote mode note (why OAuth is hidden) -If the Gateway runs on another machine, the Anthropic OAuth credentials must be created/stored on that host (where Pi runs). +If the Gateway runs on another machine, the Anthropic OAuth credentials must be created/stored on that host (where the agent runtime runs). For now, remote onboarding should: - explain why OAuth isn’t shown -- point the user at the credential location (`~/.pi/agent/oauth.json`) and the workspace location on the gateway host +- point the user at the credential location (`~/.clawdis/credentials/oauth.json`) and the workspace location on the gateway host - mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files) diff --git a/docs/session.md b/docs/session.md index 480a7f798..72585ef5a 100644 --- a/docs/session.md +++ b/docs/session.md @@ -18,6 +18,7 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli - Store file: `~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`). - Transcripts: `~/.clawdis/sessions/.jsonl` (one file per session id). - The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand. +- Clawdis does **not** read legacy Pi/Tau session folders. ## Mapping transports → session keys - Direct chats (WhatsApp, Telegram, desktop Web Chat) all collapse to the **primary key** so they share context. diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index ebae7b70a..9b284fe36 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -1,9 +1,16 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { AppMessage, ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai"; +import { + setOAuthStorage, + type Api, + type AssistantMessage, + type Model, + type OAuthStorage, +} from "@mariozechner/pi-ai"; import { buildSystemPrompt, createAgentSession, @@ -18,7 +25,7 @@ import { formatToolAggregate } from "../auto-reply/tool-meta.js"; import type { ClawdisConfig } from "../config/config.js"; import { splitMediaFromOutput } from "../media/parse.js"; import { enqueueCommand } from "../process/command-queue.js"; -import { resolveUserPath } from "../utils.js"; +import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { buildBootstrapContextFiles, @@ -75,6 +82,109 @@ type EmbeddedPiQueueHandle = { const ACTIVE_EMBEDDED_RUNS = new Map(); +const OAUTH_FILENAME = "oauth.json"; +const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials"); +const DEFAULT_AGENT_DIR = path.join(CONFIG_DIR, "agent"); +let oauthStorageConfigured = false; +let cachedDefaultApiKey: ReturnType | null = null; + +function resolveClawdisOAuthPath(): string { + const overrideDir = + process.env.CLAWDIS_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR; + return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME); +} + +function resolveAgentDir(): string { + const override = + process.env.CLAWDIS_AGENT_DIR?.trim() || + process.env.PI_CODING_AGENT_DIR?.trim() || + DEFAULT_AGENT_DIR; + return resolveUserPath(override); +} + +function loadOAuthStorageAt(pathname: string): OAuthStorage | null { + if (!fsSync.existsSync(pathname)) return null; + try { + const content = fsSync.readFileSync(pathname, "utf8"); + const json = JSON.parse(content) as OAuthStorage; + if (!json || typeof json !== "object") return null; + return json; + } catch { + return null; + } +} + +function hasAnthropicOAuth(storage: OAuthStorage): boolean { + const entry = storage.anthropic as + | { + refresh?: string; + refresh_token?: string; + refreshToken?: string; + access?: string; + access_token?: string; + accessToken?: string; + } + | undefined; + if (!entry) return false; + const refresh = + entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? ""; + const access = entry.access ?? entry.access_token ?? entry.accessToken ?? ""; + return Boolean(refresh.trim() && access.trim()); +} + +function saveOAuthStorageAt(pathname: string, storage: OAuthStorage): void { + const dir = path.dirname(pathname); + fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fsSync.writeFileSync( + pathname, + `${JSON.stringify(storage, null, 2)}\n`, + "utf8", + ); + fsSync.chmodSync(pathname, 0o600); +} + +function legacyOAuthPaths(): string[] { + const paths: string[] = []; + const piOverride = process.env.PI_CODING_AGENT_DIR?.trim(); + if (piOverride) { + paths.push(path.join(resolveUserPath(piOverride), OAUTH_FILENAME)); + } + paths.push(path.join(os.homedir(), ".pi", "agent", OAUTH_FILENAME)); + paths.push(path.join(os.homedir(), ".claude", OAUTH_FILENAME)); + paths.push(path.join(os.homedir(), ".config", "claude", OAUTH_FILENAME)); + paths.push(path.join(os.homedir(), ".config", "anthropic", OAUTH_FILENAME)); + return Array.from(new Set(paths)); +} + +function importLegacyOAuthIfNeeded(destPath: string): void { + if (fsSync.existsSync(destPath)) return; + for (const legacyPath of legacyOAuthPaths()) { + const storage = loadOAuthStorageAt(legacyPath); + if (!storage || !hasAnthropicOAuth(storage)) continue; + saveOAuthStorageAt(destPath, storage); + return; + } +} + +function ensureOAuthStorage(): void { + if (oauthStorageConfigured) return; + oauthStorageConfigured = true; + const oauthPath = resolveClawdisOAuthPath(); + importLegacyOAuthIfNeeded(oauthPath); + setOAuthStorage({ + load: () => loadOAuthStorageAt(oauthPath) ?? {}, + save: (storage) => saveOAuthStorageAt(oauthPath, storage), + }); +} + +function getDefaultApiKey() { + if (!cachedDefaultApiKey) { + ensureOAuthStorage(); + cachedDefaultApiKey = defaultGetApiKey(); + } + return cachedDefaultApiKey; +} + export function queueEmbeddedPiMessage( sessionId: string, text: string, @@ -106,14 +216,13 @@ function resolveModel( return { model }; } -const defaultApiKey = defaultGetApiKey(); - async function getApiKeyForModel(model: Model): Promise { + ensureOAuthStorage(); if (model.provider === "anthropic") { const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN; if (oauthEnv?.trim()) return oauthEnv.trim(); } - const key = await defaultApiKey(model); + const key = await getDefaultApiKey()(model); if (key) return key; throw new Error(`No API key found for provider "${model.provider}"`); } @@ -175,9 +284,10 @@ export async function runEmbeddedPiAgent(params: { const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; - const agentDir = - process.env.PI_CODING_AGENT_DIR ?? - path.join(os.homedir(), ".pi", "agent"); + const agentDir = resolveAgentDir(); + if (!process.env.PI_CODING_AGENT_DIR) { + process.env.PI_CODING_AGENT_DIR = agentDir; + } const { model, error } = resolveModel(provider, modelId, agentDir); if (!model) { throw new Error(error ?? `Unknown model: ${provider}/${modelId}`); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index b4bc7f32d..c23624a3d 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -720,8 +720,6 @@ function readSessionMessages( const parsed = JSON.parse(line); if (parsed?.message) { messages.push(parsed.message); - } else if (parsed?.role && parsed?.content) { - messages.push(parsed); } } catch { // ignore bad lines @@ -742,19 +740,6 @@ function resolveSessionTranscriptCandidates( candidates.push( path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`), ); - candidates.push( - path.join(os.homedir(), ".pi", "agent", "sessions", `${sessionId}.jsonl`), - ); - candidates.push( - path.join( - os.homedir(), - ".tau", - "agent", - "sessions", - "clawdis", - `${sessionId}.jsonl`, - ), - ); return candidates; } @@ -1516,7 +1501,21 @@ export async function startGatewayServer( ); return; } - logTelegram.info("starting provider"); + let telegramBotLabel = ""; + try { + const probe = await probeTelegram( + telegramToken.trim(), + 2500, + cfg.telegram?.proxy, + ); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) telegramBotLabel = ` (@${username})`; + } catch (err) { + if (isVerbose()) { + logTelegram.debug(`bot probe failed: ${String(err)}`); + } + } + logTelegram.info(`starting provider${telegramBotLabel}`); telegramAbort = new AbortController(); telegramRuntime = { ...telegramRuntime,