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
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,16 @@ extension ConfigurationManager {
}

/// Whether any OpenAI authentication material is available — either an API
/// key (via `getOpenAIAPIKey()`) or a non-expired OAuth access token (via
/// `peekaboo config login openai`). Use this for agent-availability gates;
/// key or a usable/refreshable OAuth session created by
/// `peekaboo config login openai`. Use this for agent-availability gates;
/// `getOpenAIAPIKey()` alone deliberately ignores OAuth tokens so they are
/// not misclassified as `x-api-key` material.
/// not misclassified as API-key material.
public func hasOpenAIAuth() -> Bool {
if self.getOpenAIAPIKey()?.isEmpty == false {
return true
}
return self.validOAuthAccessToken(prefix: "OPENAI") != nil
return self.validOAuthAccessToken(prefix: "OPENAI") != nil ||
self.hasOAuthRefreshToken(prefix: "OPENAI")
}

/// OpenAI credential for APIs that accept Bearer tokens but still require
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,41 @@ extension ConfigurationManager {
func validOAuthAccessToken(prefix: String) -> String? {
self.withStateLock {
self.loadCredentials()
guard let token = self.credentials["\(prefix)_ACCESS_TOKEN"] else { return nil }
guard let expiryString = self.credentials["\(prefix)_ACCESS_EXPIRES"],
let expiryInt = Int(expiryString) else { return token }
let expiryDate = Date(timeIntervalSince1970: TimeInterval(expiryInt))
if expiryDate > Date() {
return token
let tokenKey = "\(prefix)_ACCESS_TOKEN"
let expiryKey = "\(prefix)_ACCESS_EXPIRES"

if let environmentToken = self.environmentValue(for: tokenKey),
self.isOAuthAccessTokenValid(
environmentToken,
expiry: self.environmentValue(for: expiryKey),
)
{
return environmentToken
}
if let storedToken = self.credentials[tokenKey],
self.isOAuthAccessTokenValid(storedToken, expiry: self.credentials[expiryKey])
{
return storedToken
}
return nil
}
}

private func isOAuthAccessTokenValid(_ token: String, expiry: String?) -> Bool {
guard !token.isEmpty else { return false }
guard let expiry, let expiryInterval = TimeInterval(expiry) else { return true }
return Date(timeIntervalSince1970: expiryInterval) > Date()
}

func hasOAuthRefreshToken(prefix: String) -> Bool {
self.withStateLock {
self.loadCredentials()
let key = "\(prefix)_REFRESH_TOKEN"
let token = self.environmentValue(for: key) ?? self.credentials[key]
return token?.isEmpty == false
}
}

/// Read a credential by key (loads from disk if needed)
public func credentialValue(for key: String) -> String? {
self.withStateLock {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,36 @@ struct ConfigurationAccessorsOAuthTests {
}
}

@Test
func `OpenAI availability accepts an expired access token with a refresh token`() throws {
try withIsolatedConfigurationEnvironment { _ in
self.unsetAllOpenAIEnv()
self.manager.resetForTesting()
try self.manager.saveCredentials([
"OPENAI_ACCESS_TOKEN": "expired-openai-oauth-access-token",
"OPENAI_REFRESH_TOKEN": "openai-oauth-refresh-token",
"OPENAI_ACCESS_EXPIRES": String(Int(Date().addingTimeInterval(-3600).timeIntervalSince1970)),
])

#expect(self.manager.hasOpenAIAuth())
}
}

@Test
func `OAuth access and expiry stay paired by source`() throws {
try withIsolatedConfigurationEnvironment { _ in
self.unsetAllOpenAIEnv()
self.manager.resetForTesting()
try self.manager.saveCredentials([
"OPENAI_ACCESS_EXPIRES": String(Int(Date().addingTimeInterval(-3600).timeIntervalSince1970)),
])
setenv("OPENAI_ACCESS_TOKEN", "valid-environment-access-token", 1)

#expect(self.manager.getOpenAITranscriptionCredential() == "valid-environment-access-token")
#expect(self.manager.hasOpenAIAuth())
}
}

@Test
func `getOpenAITranscriptionCredential returns OAuth access token when no API key is stored`() throws {
try withIsolatedConfigurationEnvironment { _ in
Expand Down
14 changes: 9 additions & 5 deletions docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Peekaboo supports OAuth for two providers:

These flows avoid storing API keys and instead keep refresh/access tokens in `~/.peekaboo/credentials` (chmod 600).

> Peekaboo shares the same credential layout as Tachikoma. Hosts can swap the profile directory (`TachikomaConfiguration.profileDirectoryName`) but **never copy environment keys into the file**; only explicit `config add`/`config login` writes.
> Peekaboo shares the same credential layout as Tachikoma. Hosts can swap the profile directory (`TachikomaConfiguration.profileDirectoryName`). Credentials supplied through the environment are never copied into the file. Refreshed environment OAuth tokens remain in memory for the current process; use `peekaboo config login` when the rotated refresh chain must persist across launches.

## What happens during login
1. Generate PKCE values and open the provider’s authorize URL in the browser (also printed for headless use).
Expand All @@ -24,23 +24,27 @@ These flows avoid storing API keys and instead keep refresh/access tokens in `~/
4. No API key is written for OAuth flows.

## How requests are sent
- Providers resolve OAuth tokens and API keys through the shared Tachikoma credential manager. If the access token is expired, Peekaboo refreshes once per request and updates the credentials file.

- Providers resolve OAuth tokens and API keys through the shared Tachikoma credential manager. If a stored access token is expired, Peekaboo refreshes it and atomically updates the credentials file. Environment-supplied OAuth sessions refresh only in memory.
- OpenAI OAuth requests use the ChatGPT Codex Responses backend and include the ChatGPT account identifier from the access token. Text and image inputs are supported, including `see --analyze`, `image --analyze`, and agent vision calls.
- Anthropic requests include the beta header used for Claude Max: `anthropic-beta: oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14`.
- If OAuth tokens are absent but an API key exists, the provider falls back to the API-key path.
- OpenAI/Codex OAuth tokens may still be rejected by OpenAI API endpoints if the issued token lacks platform API scopes such as model or Responses access. In that case, use `peekaboo config add openai <api-key>` / `OPENAI_API_KEY` for `see --analyze`, `image --analyze`, and agent runs until the OAuth client is granted the required scopes.
- An explicit OpenAI API key remains higher priority and uses the public OpenAI API rather than the Codex OAuth transport.

## Validating connectivity

- `peekaboo config show --timeout 30` pings each configured provider and reports status (`ready (validated)`, `stored (validation failed: <reason>)`, `missing`).
- `peekaboo config add <provider> <secret>` validates immediately; failures are stored but warned.

## Revoking access

- **OpenAI/Codex**: revoke from your OpenAI account security page; then delete the stored tokens (`peekaboo config edit` or remove the keys from `~/.peekaboo/credentials`).
- **Anthropic**: revoke from your Claude account; remove the stored tokens the same way.

## Headless / CI

- If the browser cannot open, the CLI still prints the authorize URL; paste the resulting code back. Access/refresh storage and refresh logic are identical.

## Troubleshooting

- If validation fails after login, run `peekaboo config show --timeout 10 --verbose` to see the provider error.
- OpenAI errors mentioning missing scopes are server-side OAuth scope failures, not local credential loading failures. Configure an API key for API-backed OpenAI features.
- Stale access tokens are refreshed automatically; if refresh fails, rerun `peekaboo config login <provider>`.
Loading