out = new java.util.ArrayList<>(a.size() + b.size());
+ out.addAll(a);
+ out.addAll(b);
+ return out;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentials.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentials.java
new file mode 100644
index 00000000..d06d7839
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentials.java
@@ -0,0 +1,62 @@
+package vip.mate.llm.anthropic.oauth;
+
+/**
+ * RFC-062: a Claude Code OAuth credential bundle, parsed from either the macOS
+ * Keychain or {@code ~/.claude/.credentials.json}.
+ *
+ * The shape mirrors the {@code claudeAiOauth} object that Claude Code
+ * persists. Fields are nullable when the source doesn't carry them
+ * (e.g. managed keys with no expiry, or older Claude Code versions that
+ * skipped the refresh token).
+ *
+ * @param accessToken the Bearer token to send to Anthropic API
+ * @param refreshToken token used to obtain a fresh access token; nullable
+ * @param expiresAtMs epoch millis when the access token expires; 0 means "no expiry"
+ * @param source where this credential was read from — used for diagnostics
+ * and for routing writes back to the same storage
+ */
+public record ClaudeCodeCredentials(
+ String accessToken,
+ String refreshToken,
+ long expiresAtMs,
+ Source source
+) {
+
+ /**
+ * Storage location the credential was read from. Determines write-back
+ * destination and influences refresh behaviour.
+ */
+ public enum Source {
+ /** macOS Keychain entry "Claude Code-credentials" (Claude Code >= 2.1.114). */
+ MACOS_KEYCHAIN,
+ /** {@code ~/.claude/.credentials.json} JSON file (all platforms, legacy). */
+ CREDENTIALS_FILE,
+ /** Result of a successful refresh — to be written back to whatever the
+ * original source was. */
+ REFRESH_RESPONSE
+ }
+
+ /**
+ * Returns true if the access token is non-blank and not within
+ * {@code bufferMs} of expiring.
+ *
+ * @param bufferMs safety margin (e.g. 60_000 to refresh 1 minute before expiry).
+ * 0 means "still valid even if it expires this instant".
+ */
+ public boolean isValid(long bufferMs) {
+ if (accessToken == null || accessToken.isBlank()) {
+ return false;
+ }
+ if (expiresAtMs == 0L) {
+ // No expiry recorded (managed keys / older Claude Code formats) —
+ // assume valid as long as token is present.
+ return true;
+ }
+ return System.currentTimeMillis() < (expiresAtMs - bufferMs);
+ }
+
+ /** True if a refresh is possible (refresh token is present). */
+ public boolean canRefresh() {
+ return refreshToken != null && !refreshToken.isBlank();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsReader.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsReader.java
new file mode 100644
index 00000000..150ff3c6
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsReader.java
@@ -0,0 +1,172 @@
+package vip.mate.llm.anthropic.oauth;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * RFC-062: read Claude Code OAuth credentials from local storage.
+ *
+ *
Two sources, in priority order:
+ *
+ * - macOS Keychain entry {@code Claude Code-credentials} (Claude Code
+ * >= 2.1.114 stores here). Read via the {@code security} CLI tool.
+ * - JSON file at {@code ~/.claude/.credentials.json} (legacy + Linux/Win).
+ *
+ *
+ * Both sources contain the same JSON shape:
+ *
+ * {
+ * "claudeAiOauth": {
+ * "accessToken": "...",
+ * "refreshToken": "...",
+ * "expiresAt": 1234567890123,
+ * "scopes": ["user:inference", ...]
+ * }
+ * }
+ *
+ *
+ * Reference: hermes-agent {@code anthropic_adapter._read_claude_code_credentials_from_keychain}
+ * (line 470) and {@code read_claude_code_credentials} (line 530).
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ClaudeCodeCredentialsReader {
+
+ /** macOS Keychain service name written by Claude Code. */
+ static final String KEYCHAIN_SERVICE_NAME = "Claude Code-credentials";
+
+ /** Hermes also queries {@code ~/.claude.json primaryApiKey} but that's a
+ * managed key, not OAuth — intentionally not read here. */
+ static final Path JSON_CREDENTIALS_PATH =
+ Paths.get(System.getProperty("user.home"), ".claude", ".credentials.json");
+
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Read whichever source exists, preferring Keychain on macOS.
+ * @return Optional credentials, never throws on the happy "not found" path.
+ */
+ public Optional read() {
+ // macOS Keychain has priority on Darwin (and is the only valid source for
+ // Claude Code >= 2.1.114 — they migrated off the JSON file)
+ if (isMacOs()) {
+ Optional kc = readFromKeychain();
+ if (kc.isPresent()) return kc;
+ }
+ return readFromJsonFile();
+ }
+
+ /**
+ * Specifically read from the macOS Keychain. Used for diagnostics; production
+ * code should use {@link #read()} which dispatches.
+ * @return empty when not on macOS, when {@code security} command isn't
+ * available, or when no entry exists.
+ */
+ public Optional readFromKeychain() {
+ if (!isMacOs()) return Optional.empty();
+
+ Process process = null;
+ try {
+ ProcessBuilder pb = new ProcessBuilder(
+ "/usr/bin/security",
+ "find-generic-password",
+ "-s", KEYCHAIN_SERVICE_NAME,
+ "-w"); // -w prints just the password (no metadata)
+ pb.redirectErrorStream(false);
+ process = pb.start();
+
+ if (!process.waitFor(5, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ log.debug("[ClaudeCodeReader] keychain read timed out");
+ return Optional.empty();
+ }
+ if (process.exitValue() != 0) {
+ // Exit 44 = "item not found"; anything else also means "no creds for us"
+ log.debug("[ClaudeCodeReader] keychain returned exit {}", process.exitValue());
+ return Optional.empty();
+ }
+ String raw = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
+ return parseCredentials(raw, ClaudeCodeCredentials.Source.MACOS_KEYCHAIN);
+ } catch (Exception e) {
+ log.debug("[ClaudeCodeReader] keychain read failed: {}", e.getMessage());
+ return Optional.empty();
+ } finally {
+ if (process != null && process.isAlive()) {
+ process.destroyForcibly();
+ }
+ }
+ }
+
+ /**
+ * Specifically read from {@code ~/.claude/.credentials.json}.
+ * @return empty when the file is absent / unreadable / malformed.
+ */
+ public Optional readFromJsonFile() {
+ return readFromJsonFile(JSON_CREDENTIALS_PATH);
+ }
+
+ /** Test seam: read from a custom path. Package-private. */
+ Optional readFromJsonFile(Path path) {
+ if (path == null || !Files.isReadable(path)) {
+ return Optional.empty();
+ }
+ try {
+ String raw = Files.readString(path, StandardCharsets.UTF_8);
+ return parseCredentials(raw, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
+ } catch (IOException e) {
+ log.debug("[ClaudeCodeReader] credentials file read failed: {}", e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Parse the canonical Claude Code JSON envelope.
+ * Package-private for unit testing.
+ */
+ Optional parseCredentials(String raw, ClaudeCodeCredentials.Source source) {
+ if (raw == null || raw.isBlank()) {
+ return Optional.empty();
+ }
+ try {
+ JsonNode root = objectMapper.readTree(raw);
+ JsonNode oauth = root.path("claudeAiOauth");
+ if (oauth.isMissingNode() || !oauth.isObject()) {
+ log.debug("[ClaudeCodeReader] payload missing claudeAiOauth object (source={})", source);
+ return Optional.empty();
+ }
+ String accessToken = oauth.path("accessToken").asText("");
+ if (accessToken.isBlank()) {
+ log.debug("[ClaudeCodeReader] claudeAiOauth.accessToken blank (source={})", source);
+ return Optional.empty();
+ }
+ String refreshToken = oauth.path("refreshToken").asText("");
+ long expiresAt = oauth.path("expiresAt").asLong(0L);
+ return Optional.of(new ClaudeCodeCredentials(
+ accessToken,
+ refreshToken.isBlank() ? null : refreshToken,
+ expiresAt,
+ source));
+ } catch (Exception e) {
+ log.debug("[ClaudeCodeReader] JSON parse failed (source={}): {}", source, e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ /** Package-private for tests to override. */
+ boolean isMacOs() {
+ return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac");
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsWriter.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsWriter.java
new file mode 100644
index 00000000..14ca09f7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsWriter.java
@@ -0,0 +1,269 @@
+package vip.mate.llm.anthropic.oauth;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.EnumSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * RFC-062: persist refreshed Claude Code OAuth credentials back to whichever
+ * source we read them from.
+ *
+ * Two write targets, mirroring {@link ClaudeCodeCredentialsReader}:
+ *
+ * - macOS Keychain entry {@code Claude Code-credentials} via the
+ * {@code security add-generic-password -U} CLI.
+ * - {@code ~/.claude/.credentials.json} JSON file — written atomically
+ * (temp file + rename) with {@code 0600} permissions on POSIX.
+ *
+ *
+ * Concurrent-write defence
+ * Claude Code itself may rewrite the credentials file while MateClaw is doing
+ * a refresh. The flow is:
+ *
+ * - Re-read the file just before write.
+ * - If the on-disk {@code accessToken} is newer than the one we are about
+ * to write (different from what we started the refresh with), bail out
+ * — the running Claude Code process beat us to it.
+ * - Otherwise merge our refreshed fields into the existing JSON so we
+ * preserve {@code scopes} (Claude Code >= 2.1.81 requires
+ * {@code user:inference}) and any future fields we don't know about.
+ *
+ *
+ * Reference: hermes-agent
+ * {@code anthropic_adapter._write_claude_code_credentials} (line 684-727)
+ * and {@code _write_claude_code_credentials_to_keychain} (line 730+).
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ClaudeCodeCredentialsWriter {
+
+ /** POSIX permissions for the credentials file: owner read+write only. */
+ private static final Set CREDENTIALS_PERMS =
+ EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
+
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Write the refreshed credentials back to whichever source the originals
+ * came from. Failures are logged but never thrown — a write failure
+ * shouldn't break the in-memory token that's already valid.
+ *
+ * @param previousAccessToken the access token that triggered the refresh.
+ * Used to detect concurrent writes by Claude
+ * Code itself; pass {@code null} to skip the
+ * check (e.g. on first-time write).
+ * @param refreshed the fresh credential bundle from
+ * {@link ClaudeCodeTokenRefresher}.
+ * @return {@code true} if the write completed; {@code false} if skipped
+ * (concurrent change detected) or failed.
+ */
+ public boolean write(String previousAccessToken, ClaudeCodeCredentials refreshed) {
+ if (refreshed == null || refreshed.accessToken() == null || refreshed.accessToken().isBlank()) {
+ log.warn("[ClaudeCodeWriter] refusing to write blank credentials");
+ return false;
+ }
+ ClaudeCodeCredentials.Source target = refreshed.source();
+ if (target == ClaudeCodeCredentials.Source.REFRESH_RESPONSE) {
+ // Caller forgot to pin the destination — fall back to the JSON file.
+ log.debug("[ClaudeCodeWriter] source=REFRESH_RESPONSE not addressable; defaulting to JSON file");
+ target = ClaudeCodeCredentials.Source.CREDENTIALS_FILE;
+ }
+ return switch (target) {
+ case MACOS_KEYCHAIN -> writeKeychain(previousAccessToken, refreshed);
+ case CREDENTIALS_FILE -> writeJsonFile(previousAccessToken, refreshed);
+ case REFRESH_RESPONSE -> false; // already coerced above; defensive
+ };
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* JSON file write */
+ /* ------------------------------------------------------------------ */
+
+ boolean writeJsonFile(String previousAccessToken, ClaudeCodeCredentials refreshed) {
+ return writeJsonFile(ClaudeCodeCredentialsReader.JSON_CREDENTIALS_PATH, previousAccessToken, refreshed);
+ }
+
+ /** Test seam: write to a custom path. Package-private. */
+ boolean writeJsonFile(Path path, String previousAccessToken, ClaudeCodeCredentials refreshed) {
+ try {
+ Path parent = path.getParent();
+ if (parent != null && !Files.exists(parent)) {
+ Files.createDirectories(parent);
+ }
+
+ // Read existing file (if any) so we preserve scopes + unknown fields
+ // and so we can detect a concurrent write by Claude Code.
+ ObjectNode root;
+ ObjectNode oauth;
+ if (Files.isReadable(path)) {
+ String existing = Files.readString(path, StandardCharsets.UTF_8);
+ JsonNode parsed = existing.isBlank() ? null : objectMapper.readTree(existing);
+ if (parsed instanceof ObjectNode obj) {
+ root = obj;
+ JsonNode oauthNode = obj.path("claudeAiOauth");
+ if (oauthNode instanceof ObjectNode oauthObj) {
+ oauth = oauthObj;
+ // Concurrent-write guard — only when caller pinned the prior token.
+ if (previousAccessToken != null && !previousAccessToken.isBlank()) {
+ String diskAccessToken = oauthObj.path("accessToken").asText("");
+ if (!diskAccessToken.isBlank()
+ && !diskAccessToken.equals(previousAccessToken)
+ && !diskAccessToken.equals(refreshed.accessToken())) {
+ log.info("[ClaudeCodeWriter] on-disk access token changed since refresh started — "
+ + "skipping write to avoid clobbering Claude Code's update");
+ return false;
+ }
+ }
+ } else {
+ oauth = objectMapper.createObjectNode();
+ root.set("claudeAiOauth", oauth);
+ }
+ } else {
+ root = objectMapper.createObjectNode();
+ oauth = objectMapper.createObjectNode();
+ root.set("claudeAiOauth", oauth);
+ }
+ } else {
+ root = objectMapper.createObjectNode();
+ oauth = objectMapper.createObjectNode();
+ root.set("claudeAiOauth", oauth);
+ }
+
+ oauth.put("accessToken", refreshed.accessToken());
+ if (refreshed.refreshToken() != null && !refreshed.refreshToken().isBlank()) {
+ oauth.put("refreshToken", refreshed.refreshToken());
+ }
+ if (refreshed.expiresAtMs() > 0L) {
+ oauth.put("expiresAt", refreshed.expiresAtMs());
+ }
+ // If scopes are missing on disk (e.g. corrupted file), default to
+ // the inference scope Claude Code 2.1.81+ expects.
+ if (!oauth.has("scopes") || !oauth.path("scopes").isArray()) {
+ oauth.putArray("scopes").add("user:inference");
+ }
+
+ byte[] payload = objectMapper.writerWithDefaultPrettyPrinter()
+ .writeValueAsBytes(root);
+
+ // Atomic write: tmp file in same directory + rename.
+ Path tmp = Files.createTempFile(
+ parent != null ? parent : path.toAbsolutePath().getParent(),
+ ".credentials-",
+ ".tmp");
+ try {
+ Files.write(tmp, payload);
+ applyOwnerOnlyPerms(tmp);
+ try {
+ Files.move(tmp, path,
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING);
+ } catch (AtomicMoveNotSupportedException e) {
+ // Some filesystems (e.g. cross-FS on Windows) don't support
+ // atomic move; fall back to plain replace.
+ Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING);
+ }
+ } finally {
+ Files.deleteIfExists(tmp);
+ }
+ applyOwnerOnlyPerms(path);
+ log.info("[ClaudeCodeWriter] wrote credentials to {}", path);
+ return true;
+ } catch (IOException e) {
+ log.warn("[ClaudeCodeWriter] failed to write credentials file {}: {}", path, e.getMessage());
+ return false;
+ }
+ }
+
+ private static void applyOwnerOnlyPerms(Path path) {
+ if (!FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
+ // Windows / non-POSIX — rely on filesystem ACLs.
+ return;
+ }
+ try {
+ Files.setPosixFilePermissions(path, PosixFilePermissions.asFileAttribute(CREDENTIALS_PERMS).value());
+ } catch (IOException | UnsupportedOperationException e) {
+ log.debug("[ClaudeCodeWriter] could not chmod 600 on {}: {}", path, e.getMessage());
+ }
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Keychain write */
+ /* ------------------------------------------------------------------ */
+
+ boolean writeKeychain(String previousAccessToken, ClaudeCodeCredentials refreshed) {
+ if (!isMacOs()) {
+ log.debug("[ClaudeCodeWriter] skipping keychain write — not on macOS");
+ return false;
+ }
+ Process process = null;
+ try {
+ // Build the same JSON envelope Claude Code persists. Preserve scopes
+ // by reading the existing keychain entry first.
+ ObjectNode root = objectMapper.createObjectNode();
+ ObjectNode oauth = root.putObject("claudeAiOauth");
+ oauth.put("accessToken", refreshed.accessToken());
+ if (refreshed.refreshToken() != null && !refreshed.refreshToken().isBlank()) {
+ oauth.put("refreshToken", refreshed.refreshToken());
+ }
+ if (refreshed.expiresAtMs() > 0L) {
+ oauth.put("expiresAt", refreshed.expiresAtMs());
+ }
+ oauth.putArray("scopes").add("user:inference");
+
+ String payload = objectMapper.writeValueAsString(root);
+
+ // Use -U to update the existing entry in place (or create if absent).
+ ProcessBuilder pb = new ProcessBuilder(
+ "/usr/bin/security",
+ "add-generic-password",
+ "-U",
+ "-s", ClaudeCodeCredentialsReader.KEYCHAIN_SERVICE_NAME,
+ "-a", System.getProperty("user.name", "claude"),
+ "-w", payload);
+ pb.redirectErrorStream(true);
+ process = pb.start();
+ if (!process.waitFor(5, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ log.warn("[ClaudeCodeWriter] keychain write timed out");
+ return false;
+ }
+ if (process.exitValue() != 0) {
+ String err = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
+ log.warn("[ClaudeCodeWriter] keychain write exit={} ({})", process.exitValue(), err);
+ return false;
+ }
+ log.info("[ClaudeCodeWriter] wrote credentials to macOS Keychain");
+ return true;
+ } catch (Exception e) {
+ log.warn("[ClaudeCodeWriter] keychain write failed: {}", e.getMessage());
+ return false;
+ } finally {
+ if (process != null && process.isAlive()) {
+ process.destroyForcibly();
+ }
+ }
+ }
+
+ /** Package-private for tests to override. */
+ boolean isMacOs() {
+ return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac");
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java
new file mode 100644
index 00000000..a0747d6b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java
@@ -0,0 +1,130 @@
+package vip.mate.llm.anthropic.oauth;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import vip.mate.exception.MateClawException;
+
+import java.util.Optional;
+
+/**
+ * RFC-062: top-level orchestrator for Claude Code OAuth.
+ *
+ * Combines {@link ClaudeCodeCredentialsReader},
+ * {@link ClaudeCodeTokenRefresher}, and {@link ClaudeCodeCredentialsWriter}
+ * to expose a single {@link #getValidToken()} entry point that
+ * {@code AgentClaudeCodeChatModelBuilder} (PR-2) will call on every request.
+ *
+ *
Behavior
+ *
+ * - Read whichever local source exists (Keychain on macOS, JSON file
+ * elsewhere).
+ * - If the access token is still valid (with a 1-minute safety buffer),
+ * return it directly — no network call.
+ * - Otherwise refresh via Anthropic's token endpoints and persist the
+ * fresh credential back to the same source.
+ * - If no refresh token is available (managed key / corrupted file),
+ * raise {@code err.anthropic.token_expired_no_refresh} so the UI can
+ * prompt re-login.
+ *
+ *
+ * This service does NOT handle the OAuth login flow itself — that is
+ * RFC-062 PR-4. Until then, MateClaw piggybacks on whatever credentials the
+ * user already has on disk from their installed Claude Code client.
+ *
+ *
Reference: hermes-agent {@code anthropic_adapter._get_claude_code_token}
+ * + {@code _ensure_claude_code_token_fresh} (lines 540-605).
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ClaudeCodeOAuthService {
+
+ /** Refresh 1 minute before the access token actually expires. */
+ static final long REFRESH_BUFFER_MS = 60_000L;
+
+ private final ClaudeCodeCredentialsReader reader;
+ private final ClaudeCodeTokenRefresher refresher;
+ private final ClaudeCodeCredentialsWriter writer;
+
+ /**
+ * Returns a valid (un-expired) access token, refreshing if necessary.
+ *
+ * @throws MateClawException with key {@code err.anthropic.no_claude_code}
+ * if no local credentials are present;
+ * {@code err.anthropic.token_expired_no_refresh} if the token is
+ * expired and cannot be refreshed.
+ */
+ public String getValidToken() {
+ ClaudeCodeCredentials creds = reader.read()
+ .orElseThrow(() -> new MateClawException("err.anthropic.no_claude_code",
+ "Claude Code 凭据未找到。请安装 Claude Code 客户端并用 Pro/Max 账号登录。"));
+
+ if (creds.isValid(REFRESH_BUFFER_MS)) {
+ return creds.accessToken();
+ }
+
+ if (!creds.canRefresh()) {
+ throw new MateClawException("err.anthropic.token_expired_no_refresh",
+ "Claude Code token 已过期且无法刷新。请打开 Claude Code 客户端重新登录后再试。");
+ }
+
+ log.info("[ClaudeCodeOAuth] access_token expired or near expiry — refreshing (source={})",
+ creds.source());
+ ClaudeCodeCredentials refreshed = refresher.refresh(creds.refreshToken());
+
+ // Pin destination to the original source so the writer knows where to persist.
+ ClaudeCodeCredentials persistable = new ClaudeCodeCredentials(
+ refreshed.accessToken(),
+ refreshed.refreshToken(),
+ refreshed.expiresAtMs(),
+ creds.source());
+ boolean written = writer.write(creds.accessToken(), persistable);
+ if (!written) {
+ log.warn("[ClaudeCodeOAuth] token refresh succeeded but persistence failed; "
+ + "using in-memory token for this request");
+ }
+ return refreshed.accessToken();
+ }
+
+ /**
+ * Quick check used by the UI / status endpoints — does NOT trigger a
+ * refresh.
+ *
+ * @return true if a non-blank access token exists on disk and isn't yet
+ * past expiry.
+ */
+ public boolean isLoggedIn() {
+ return reader.read().map(c -> c.isValid(0L)).orElse(false);
+ }
+
+ /**
+ * Returns metadata for the {@code /api/v1/llm/anthropic/oauth/status}
+ * endpoint (PR-3) without exposing the token itself.
+ */
+ public OAuthStatus getStatus() {
+ Optional opt = reader.read();
+ if (opt.isEmpty()) {
+ return new OAuthStatus(false, false, 0L, null);
+ }
+ ClaudeCodeCredentials c = opt.get();
+ boolean expired = c.expiresAtMs() > 0L && System.currentTimeMillis() >= c.expiresAtMs();
+ return new OAuthStatus(true, expired, c.expiresAtMs(), c.source());
+ }
+
+ /**
+ * Plain DTO surfaced to the management UI.
+ *
+ * @param connected true when local credentials exist
+ * @param expired true when {@link ClaudeCodeCredentials#expiresAtMs()}
+ * is set and already past
+ * @param expiresAtMs raw expiry timestamp (0 means "no expiry recorded")
+ * @param source where the credentials came from; null when not connected
+ */
+ public record OAuthStatus(
+ boolean connected,
+ boolean expired,
+ long expiresAtMs,
+ ClaudeCodeCredentials.Source source
+ ) {}
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java
new file mode 100644
index 00000000..504a3a9b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java
@@ -0,0 +1,146 @@
+package vip.mate.llm.anthropic.oauth;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+import vip.mate.exception.MateClawException;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * RFC-062: refresh Claude Code OAuth access tokens via Anthropic's public
+ * token endpoint chain.
+ *
+ * Anthropic exposes the same refresh endpoint at two hostnames:
+ *
+ * - {@code https://platform.claude.com/v1/oauth/token} — primary, used by
+ * Claude Code >= 2.1.114.
+ * - {@code https://console.anthropic.com/v1/oauth/token} — legacy alias,
+ * still active. Used as a fallback for transient platform.claude.com
+ * outages.
+ *
+ *
+ * The refresh request is a vanilla OAuth 2.0 refresh-token grant with the
+ * public Claude Code {@code client_id}. We must spoof the Claude Code
+ * {@code User-Agent} — Anthropic's edge filters drop unrecognised UAs.
+ *
+ *
Reference: hermes-agent {@code anthropic_adapter._refresh_claude_code_token}
+ * (line 605+).
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ClaudeCodeTokenRefresher {
+
+ /** Public Claude Code OAuth client_id. Same value hermes-agent and OpenCode use. */
+ static final String CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
+
+ /** Endpoints tried in order until one succeeds. */
+ static final List ENDPOINTS = List.of(
+ "https://platform.claude.com/v1/oauth/token",
+ "https://console.anthropic.com/v1/oauth/token");
+
+ private final ObjectMapper objectMapper;
+ private final ClaudeCodeVersionDetector versionDetector;
+ private final RestClient restClient = RestClient.create();
+
+ /**
+ * Exchange a refresh_token for a fresh access_token.
+ *
+ * @return credentials carrying {@link ClaudeCodeCredentials.Source#REFRESH_RESPONSE}.
+ * The caller is responsible for combining this with the original
+ * source so the writer knows where to persist.
+ * @throws MateClawException when all endpoints fail.
+ */
+ public ClaudeCodeCredentials refresh(String refreshToken) {
+ if (refreshToken == null || refreshToken.isBlank()) {
+ throw new MateClawException("err.anthropic.token_expired_no_refresh",
+ "Claude Code refresh_token 缺失,无法刷新");
+ }
+
+ Map params = new LinkedHashMap<>();
+ params.put("grant_type", "refresh_token");
+ params.put("refresh_token", refreshToken);
+ params.put("client_id", CLIENT_ID);
+ String body = formEncode(params);
+
+ Exception lastException = null;
+ for (String endpoint : ENDPOINTS) {
+ try {
+ String response = restClient.post()
+ .uri(endpoint)
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
+ .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .header(HttpHeaders.USER_AGENT,
+ "claude-cli/" + versionDetector.get() + " (external, cli)")
+ .body(body)
+ .retrieve()
+ .body(String.class);
+ return parseTokenResponse(response, refreshToken);
+ } catch (Exception e) {
+ lastException = e;
+ log.debug("[ClaudeCodeRefresher] {} failed: {}", endpoint, e.getMessage());
+ }
+ }
+ String detail = lastException != null ? lastException.getMessage() : "unknown";
+ throw new MateClawException("err.anthropic.refresh_failed",
+ "Claude Code token 刷新失败: " + detail);
+ }
+
+ /** Package-private for unit testing. */
+ ClaudeCodeCredentials parseTokenResponse(String body, String fallbackRefreshToken) {
+ try {
+ JsonNode json = objectMapper.readTree(body);
+ String accessToken = json.path("access_token").asText("");
+ if (accessToken.isBlank()) {
+ throw new MateClawException("err.anthropic.refresh_failed",
+ "Refresh response missing access_token");
+ }
+ String refreshToken = json.has("refresh_token") && !json.path("refresh_token").asText("").isBlank()
+ ? json.path("refresh_token").asText()
+ : fallbackRefreshToken;
+ // Some endpoints return expires_in (seconds); some return expires_at (ms).
+ long expiresAtMs;
+ if (json.has("expires_at")) {
+ expiresAtMs = json.path("expires_at").asLong(0L);
+ } else {
+ long expiresInSec = json.path("expires_in").asLong(0L);
+ expiresAtMs = expiresInSec > 0
+ ? System.currentTimeMillis() + (expiresInSec * 1000L)
+ : 0L;
+ }
+ return new ClaudeCodeCredentials(
+ accessToken,
+ refreshToken,
+ expiresAtMs,
+ ClaudeCodeCredentials.Source.REFRESH_RESPONSE);
+ } catch (MateClawException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new MateClawException("err.anthropic.refresh_failed",
+ "Refresh response parse failed: " + e.getMessage());
+ }
+ }
+
+ private static String formEncode(Map params) {
+ StringBuilder sb = new StringBuilder();
+ boolean first = true;
+ for (Map.Entry e : params.entrySet()) {
+ if (!first) sb.append('&');
+ first = false;
+ sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8));
+ sb.append('=');
+ sb.append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
+ }
+ return sb.toString();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeVersionDetector.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeVersionDetector.java
new file mode 100644
index 00000000..389801d0
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeVersionDetector.java
@@ -0,0 +1,113 @@
+package vip.mate.llm.anthropic.oauth;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * RFC-062: detect the locally-installed Claude Code version.
+ *
+ * Anthropic's OAuth infrastructure validates the User-Agent version on
+ * Bearer-auth requests. A UA that drifts too far behind the actual Claude Code
+ * release returns 400 / 5xx. Detecting dynamically (via {@code claude --version}
+ * or {@code claude-code --version}) keeps users who upgrade Claude Code
+ * automatically aligned; the static fallback covers headless/server boxes
+ * where Claude Code isn't installed locally (the OAuth flow may still work via
+ * a manually-imported credentials file).
+ *
+ *
Reference: hermes-agent {@code anthropic_adapter._detect_claude_code_version}
+ * (line 239) + {@code _CLAUDE_CODE_VERSION_FALLBACK} (line 235).
+ *
+ *
Result is cached for the JVM lifetime (Anthropic's UA validation tolerates
+ * a stable version per process). Restart MateClaw to pick up a Claude Code
+ * upgrade.
+ */
+@Slf4j
+@Component
+public class ClaudeCodeVersionDetector {
+
+ /**
+ * Static fallback version. Update this when bumping the floor at which
+ * Anthropic accepts spoofed Claude Code traffic (track Anthropic's
+ * announcements + hermes-agent's same constant for cadence).
+ */
+ static final String FALLBACK_VERSION = "2.1.74";
+
+ /** Match leading semver-like number from a {@code --version} stdout. */
+ private static final Pattern VERSION_PATTERN = Pattern.compile("^(\\d+\\.\\d+(?:\\.\\d+)?)");
+
+ private final AtomicReference cache = new AtomicReference<>();
+
+ /**
+ * Returns the detected (or fallback) Claude Code version. Cached after
+ * first call.
+ */
+ public String get() {
+ String cached = cache.get();
+ if (cached != null) return cached;
+
+ String detected = detect();
+ cache.compareAndSet(null, detected);
+ return cache.get();
+ }
+
+ /** Force a re-detection. Useful for testing. */
+ public void invalidate() {
+ cache.set(null);
+ }
+
+ private String detect() {
+ for (String cmd : new String[]{"claude", "claude-code"}) {
+ String version = runVersionCommand(cmd);
+ if (version != null) {
+ log.info("[ClaudeCodeVersion] detected {} (from {} --version)", version, cmd);
+ return version;
+ }
+ }
+ log.debug("[ClaudeCodeVersion] no Claude Code binary on PATH; using fallback {}", FALLBACK_VERSION);
+ return FALLBACK_VERSION;
+ }
+
+ /** Returns the version string from {@code --version}, or {@code null} on any failure. */
+ private String runVersionCommand(String cmd) {
+ Process process = null;
+ try {
+ ProcessBuilder pb = new ProcessBuilder(cmd, "--version");
+ pb.redirectErrorStream(true);
+ process = pb.start();
+ // Bound the wait — a hung claude binary should not block startup.
+ if (!process.waitFor(5, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ log.debug("[ClaudeCodeVersion] {} --version timed out after 5s", cmd);
+ return null;
+ }
+ if (process.exitValue() != 0) return null;
+ try (BufferedReader r = new BufferedReader(
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
+ String line = r.readLine();
+ return parseVersion(line);
+ }
+ } catch (Exception e) {
+ log.debug("[ClaudeCodeVersion] {} --version failed: {}", cmd, e.getMessage());
+ return null;
+ } finally {
+ if (process != null && process.isAlive()) {
+ process.destroyForcibly();
+ }
+ }
+ }
+
+ /** Extract the leading {@code N.N[.N]} from a {@code --version} line. Package-private for tests. */
+ static String parseVersion(String line) {
+ if (line == null) return null;
+ Matcher m = VERSION_PATTERN.matcher(line.trim());
+ return m.find() ? m.group(1) : null;
+ }
+}