create-claude-workspace 1.1.35 → 1.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -9,6 +9,7 @@ import { createLogger } from './lib/logger.mjs';
|
|
|
9
9
|
import { emptyCheckpoint, readCheckpoint, writeCheckpoint } from './lib/state.mjs';
|
|
10
10
|
import { runClaude, currentChild } from './lib/claude-runner.mjs';
|
|
11
11
|
import { sleep, formatDuration, acquireLock, releaseLock, readMemory, isProjectComplete, getCurrentTask, getCurrentPhase, checkClaudeInstalled, checkAuth, checkGitIdentity, checkFilesystemWritable, gitFetchAndPull, gitCheckState, notify, printSummary, promptUser, } from './lib/utils.mjs';
|
|
12
|
+
import { isTokenExpiringSoon, refreshOAuthToken } from './lib/oauth-refresh.mjs';
|
|
12
13
|
// ─── Args ───
|
|
13
14
|
function parseArgs(argv) {
|
|
14
15
|
const opts = { ...DEFAULTS };
|
|
@@ -291,18 +292,34 @@ async function main() {
|
|
|
291
292
|
if (timeSinceLastAuth > opts.processTimeout * 2) {
|
|
292
293
|
log.warn('Large time gap detected (system sleep?). Re-checking auth...');
|
|
293
294
|
if (!checkAuth()) {
|
|
294
|
-
log.
|
|
295
|
-
|
|
296
|
-
|
|
295
|
+
log.warn('Auth expired after sleep. Attempting OAuth refresh...');
|
|
296
|
+
const refreshed = await refreshOAuthToken(log);
|
|
297
|
+
if (!refreshed || !checkAuth()) {
|
|
298
|
+
log.error('Auth expired after sleep and refresh failed. Run `claude /login`.');
|
|
299
|
+
notify(opts.notifyCommand, 'stopped', 'Auth expired after system sleep (refresh failed)', i);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
log.info('OAuth token refreshed after sleep. Continuing.');
|
|
297
303
|
}
|
|
298
304
|
checkpoint.lastAuthCheckTime = Date.now();
|
|
299
305
|
}
|
|
300
|
-
//
|
|
301
|
-
if (
|
|
306
|
+
// Proactive token refresh — refresh before expiry to prevent mid-iteration failures
|
|
307
|
+
if (isTokenExpiringSoon()) {
|
|
308
|
+
log.info('OAuth token expiring soon. Refreshing proactively...');
|
|
309
|
+
await refreshOAuthToken(log);
|
|
310
|
+
}
|
|
311
|
+
// Periodic auth re-check (every 10 min)
|
|
312
|
+
if (timeSinceLastAuth > 10 * 60_000) {
|
|
302
313
|
if (!checkAuth()) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
314
|
+
// Try OAuth refresh before giving up
|
|
315
|
+
log.warn('Auth check failed. Attempting OAuth token refresh...');
|
|
316
|
+
const refreshed = await refreshOAuthToken(log);
|
|
317
|
+
if (!refreshed || !checkAuth()) {
|
|
318
|
+
log.error('Auth expired and refresh failed. Run `claude /login` or check ANTHROPIC_API_KEY.');
|
|
319
|
+
notify(opts.notifyCommand, 'stopped', 'Auth expired (refresh failed)', i);
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
log.info('OAuth token refreshed successfully. Continuing.');
|
|
306
323
|
}
|
|
307
324
|
checkpoint.lastAuthCheckTime = Date.now();
|
|
308
325
|
}
|
|
@@ -333,14 +350,37 @@ async function main() {
|
|
|
333
350
|
stats.processTimeouts++;
|
|
334
351
|
if (category === 'activity_timeout')
|
|
335
352
|
stats.activityTimeouts++;
|
|
353
|
+
// Immediate auth check after any error — catches expired OAuth tokens that
|
|
354
|
+
// weren't classified as auth_expired (e.g., process_timeout with auth errors in stderr)
|
|
355
|
+
if (category !== 'auth_expired' && !checkAuth()) {
|
|
356
|
+
log.warn('Auth check failed after error. Attempting OAuth refresh...');
|
|
357
|
+
const refreshed = await refreshOAuthToken(log);
|
|
358
|
+
if (!refreshed || !checkAuth()) {
|
|
359
|
+
log.error('Auth expired and refresh failed. Run `claude /login` or check ANTHROPIC_API_KEY.');
|
|
360
|
+
notify(opts.notifyCommand, 'stopped', 'Auth expired (refresh failed after process error)', i);
|
|
361
|
+
writeCheckpoint(opts.projectDir, checkpoint, log);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
log.info('OAuth token refreshed after error. Continuing.');
|
|
365
|
+
}
|
|
336
366
|
}
|
|
337
367
|
// Get response action
|
|
338
368
|
const action = getErrorAction(category, {
|
|
339
369
|
consecutiveFailures: checkpoint.consecutiveFailures,
|
|
340
370
|
authRetries: checkpoint.authRetries,
|
|
341
371
|
});
|
|
342
|
-
// Handle stop
|
|
372
|
+
// Handle stop — try OAuth refresh for auth errors before giving up
|
|
343
373
|
if (action.type === 'stop') {
|
|
374
|
+
if (category === 'auth_expired') {
|
|
375
|
+
log.warn('Auth expired. Attempting OAuth token refresh...');
|
|
376
|
+
const refreshed = await refreshOAuthToken(log);
|
|
377
|
+
if (refreshed && checkAuth()) {
|
|
378
|
+
log.info('OAuth token refreshed. Retrying iteration.');
|
|
379
|
+
checkpoint.lastAuthCheckTime = Date.now();
|
|
380
|
+
i--; // Retry this iteration
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
344
384
|
log.error(action.reason);
|
|
345
385
|
notify(opts.notifyCommand, 'stopped', action.reason, i);
|
|
346
386
|
writeCheckpoint(opts.projectDir, checkpoint, log);
|
|
@@ -352,9 +352,9 @@ export function runClaude(opts, log, runOpts = {}) {
|
|
|
352
352
|
// Kill on auth error burst (e.g. expired token causing infinite retry loop)
|
|
353
353
|
if (AUTH_ERROR_RE.test(lower)) {
|
|
354
354
|
authErrorCount++;
|
|
355
|
-
if (authErrorCount >=
|
|
355
|
+
if (authErrorCount >= 3 && !killed) {
|
|
356
356
|
log.warn(`Auth error burst detected (${authErrorCount} errors) — killing process.`);
|
|
357
|
-
killChild('
|
|
357
|
+
killChild('process_timeout');
|
|
358
358
|
return;
|
|
359
359
|
}
|
|
360
360
|
}
|
|
@@ -15,14 +15,20 @@ export function classifyError(signals) {
|
|
|
15
15
|
if (signals.code === 0 && signals.hasResult)
|
|
16
16
|
return 'none';
|
|
17
17
|
// Flag-based (set by stream events, not just stderr)
|
|
18
|
+
// Auth errors take priority over everything — prevents 30-min timeout loops on expired tokens
|
|
18
19
|
if (signals.isAuthError)
|
|
19
20
|
return 'auth_expired';
|
|
20
21
|
if (signals.isAuthServerError)
|
|
21
22
|
return 'auth_server_error';
|
|
22
23
|
if (signals.isRateLimit)
|
|
23
24
|
return 'rate_limited';
|
|
24
|
-
if (signals.timedOut)
|
|
25
|
+
if (signals.timedOut) {
|
|
26
|
+
// Double-check stderr for auth errors (may not have been caught by stream events)
|
|
27
|
+
if (/authentication_error|failed to authenticate|api error: 401|oauth token has expired/i.test(signals.stderr)) {
|
|
28
|
+
return 'auth_expired';
|
|
29
|
+
}
|
|
25
30
|
return 'process_timeout';
|
|
31
|
+
}
|
|
26
32
|
// Activity timeout after result = success (process just hung after finishing)
|
|
27
33
|
if (signals.activityTimedOut)
|
|
28
34
|
return signals.hasResult ? 'none' : 'activity_timeout';
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ─── OAuth token refresh for Claude Code ───
|
|
2
|
+
// Cross-platform: reads/writes ~/.claude/.credentials.json (Windows/Linux)
|
|
3
|
+
// or macOS Keychain via `security` CLI.
|
|
4
|
+
// Refresh endpoint: https://console.anthropic.com/v1/oauth/token
|
|
5
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
const TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
|
|
10
|
+
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
11
|
+
const CREDENTIALS_FILE = '.claude/.credentials.json';
|
|
12
|
+
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
13
|
+
// Buffer: refresh if token expires within this many ms
|
|
14
|
+
const REFRESH_BUFFER_MS = 30 * 60_000; // 30 minutes
|
|
15
|
+
// ─── Credential storage (cross-platform) ───
|
|
16
|
+
function credentialsPath() {
|
|
17
|
+
return resolve(homedir(), CREDENTIALS_FILE);
|
|
18
|
+
}
|
|
19
|
+
function readCredentialsFile() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = readFileSync(credentialsPath(), 'utf-8');
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
if (parsed?.claudeAiOauth?.refreshToken)
|
|
24
|
+
return parsed;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function writeCredentialsFile(creds) {
|
|
32
|
+
writeFileSync(credentialsPath(), JSON.stringify(creds));
|
|
33
|
+
}
|
|
34
|
+
function readKeychainCredentials() {
|
|
35
|
+
try {
|
|
36
|
+
// Try common account names
|
|
37
|
+
const accounts = ['claude', 'Claude Code', 'default'];
|
|
38
|
+
for (const account of accounts) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${account}" -w`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
if (parsed?.claudeAiOauth?.refreshToken)
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
catch { /* try next account */ }
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function writeKeychainCredentials(creds, account) {
|
|
54
|
+
const data = JSON.stringify(creds);
|
|
55
|
+
try {
|
|
56
|
+
execSync(`security delete-generic-password -s "${KEYCHAIN_SERVICE}" -a "${account}"`, { stdio: 'ignore', timeout: 5000 });
|
|
57
|
+
}
|
|
58
|
+
catch { /* may not exist */ }
|
|
59
|
+
execSync(`security add-generic-password -s "${KEYCHAIN_SERVICE}" -a "${account}" -w '${data.replace(/'/g, "'\\''")}' -U`, { stdio: 'ignore', timeout: 5000 });
|
|
60
|
+
}
|
|
61
|
+
function readCredentials() {
|
|
62
|
+
// File-based (Windows/Linux) takes priority — always present
|
|
63
|
+
const fileCreds = readCredentialsFile();
|
|
64
|
+
if (fileCreds)
|
|
65
|
+
return fileCreds;
|
|
66
|
+
// macOS Keychain fallback
|
|
67
|
+
if (process.platform === 'darwin')
|
|
68
|
+
return readKeychainCredentials();
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function writeCredentials(creds) {
|
|
72
|
+
// Always write to file
|
|
73
|
+
writeCredentialsFile(creds);
|
|
74
|
+
// Also update Keychain on macOS
|
|
75
|
+
if (process.platform === 'darwin') {
|
|
76
|
+
try {
|
|
77
|
+
writeKeychainCredentials(creds, 'claude');
|
|
78
|
+
}
|
|
79
|
+
catch { /* non-fatal */ }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// ─── Token refresh ───
|
|
83
|
+
async function postRefresh(refreshToken) {
|
|
84
|
+
const body = JSON.stringify({
|
|
85
|
+
grant_type: 'refresh_token',
|
|
86
|
+
refresh_token: refreshToken,
|
|
87
|
+
client_id: CLIENT_ID,
|
|
88
|
+
});
|
|
89
|
+
const response = await fetch(TOKEN_URL, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body,
|
|
93
|
+
signal: AbortSignal.timeout(30_000),
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const text = await response.text().catch(() => '');
|
|
97
|
+
throw new Error(`OAuth refresh failed: ${response.status} ${text.slice(0, 200)}`);
|
|
98
|
+
}
|
|
99
|
+
return await response.json();
|
|
100
|
+
}
|
|
101
|
+
// ─── Public API ───
|
|
102
|
+
/** Check if the current OAuth token is expired or about to expire. */
|
|
103
|
+
export function isTokenExpiringSoon() {
|
|
104
|
+
const creds = readCredentials();
|
|
105
|
+
if (!creds)
|
|
106
|
+
return false; // No OAuth credentials (API key user) — not our problem
|
|
107
|
+
return Date.now() > (creds.claudeAiOauth.expiresAt - REFRESH_BUFFER_MS);
|
|
108
|
+
}
|
|
109
|
+
/** Attempt to refresh the OAuth token. Returns true on success. */
|
|
110
|
+
export async function refreshOAuthToken(log) {
|
|
111
|
+
const creds = readCredentials();
|
|
112
|
+
if (!creds) {
|
|
113
|
+
log.warn('No OAuth credentials found — cannot refresh.');
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const oauth = creds.claudeAiOauth;
|
|
117
|
+
if (!oauth.refreshToken) {
|
|
118
|
+
log.warn('No refresh token available.');
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const remaining = oauth.expiresAt - Date.now();
|
|
122
|
+
log.info(`Token expires in ${Math.round(remaining / 60_000)}m. Attempting refresh...`);
|
|
123
|
+
try {
|
|
124
|
+
const result = await postRefresh(oauth.refreshToken);
|
|
125
|
+
if (!result.access_token || !result.refresh_token) {
|
|
126
|
+
log.error('Refresh response missing tokens.');
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
// Update credentials
|
|
130
|
+
const updated = {
|
|
131
|
+
claudeAiOauth: {
|
|
132
|
+
...oauth,
|
|
133
|
+
accessToken: result.access_token,
|
|
134
|
+
refreshToken: result.refresh_token,
|
|
135
|
+
expiresAt: Date.now() + (result.expires_in * 1000),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
writeCredentials(updated);
|
|
139
|
+
log.info(`OAuth token refreshed. New expiry: ${new Date(updated.claudeAiOauth.expiresAt).toISOString()}`);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
log.error(`OAuth refresh failed: ${err.message}`);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|