@provos/ironcurtain 0.5.0 → 0.6.0
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.
- package/README.md +66 -66
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/types.d.ts +6 -0
- package/dist/docker/adapters/claude-code.js +44 -19
- package/dist/docker/adapters/claude-code.js.map +1 -1
- package/dist/docker/agent-adapter.d.ts +6 -0
- package/dist/docker/agent-adapter.js +6 -1
- package/dist/docker/agent-adapter.js.map +1 -1
- package/dist/docker/docker-agent-session.d.ts +1 -1
- package/dist/docker/docker-agent-session.js +3 -2
- package/dist/docker/docker-agent-session.js.map +1 -1
- package/dist/docker/docker-infrastructure.js +10 -3
- package/dist/docker/docker-infrastructure.js.map +1 -1
- package/dist/docker/mitm-proxy.d.ts +5 -2
- package/dist/docker/mitm-proxy.js +169 -46
- package/dist/docker/mitm-proxy.js.map +1 -1
- package/dist/docker/oauth-credentials.d.ts +15 -2
- package/dist/docker/oauth-credentials.js +92 -8
- package/dist/docker/oauth-credentials.js.map +1 -1
- package/dist/docker/oauth-token-manager.d.ts +80 -0
- package/dist/docker/oauth-token-manager.js +133 -0
- package/dist/docker/oauth-token-manager.js.map +1 -0
- package/dist/docker/orientation.d.ts +1 -1
- package/dist/docker/orientation.js +2 -1
- package/dist/docker/orientation.js.map +1 -1
- package/dist/docker/pty-session.d.ts +2 -0
- package/dist/docker/pty-session.js +105 -9
- package/dist/docker/pty-session.js.map +1 -1
- package/dist/escalation/escalation-watcher.js +2 -0
- package/dist/escalation/escalation-watcher.js.map +1 -1
- package/dist/escalation/listener-command.js +9 -58
- package/dist/escalation/listener-command.js.map +1 -1
- package/dist/escalation/listener-lock.d.ts +20 -0
- package/dist/escalation/listener-lock.js +69 -0
- package/dist/escalation/listener-lock.js.map +1 -0
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/mux/mux-app.d.ts +24 -0
- package/dist/mux/mux-app.js +445 -0
- package/dist/mux/mux-app.js.map +1 -0
- package/dist/mux/mux-command.d.ts +7 -0
- package/dist/mux/mux-command.js +93 -0
- package/dist/mux/mux-command.js.map +1 -0
- package/dist/mux/mux-escalation-manager.d.ts +48 -0
- package/dist/mux/mux-escalation-manager.js +162 -0
- package/dist/mux/mux-escalation-manager.js.map +1 -0
- package/dist/mux/mux-input-handler.d.ts +49 -0
- package/dist/mux/mux-input-handler.js +457 -0
- package/dist/mux/mux-input-handler.js.map +1 -0
- package/dist/mux/mux-renderer.d.ts +86 -0
- package/dist/mux/mux-renderer.js +758 -0
- package/dist/mux/mux-renderer.js.map +1 -0
- package/dist/mux/mux-splash.d.ts +17 -0
- package/dist/mux/mux-splash.js +322 -0
- package/dist/mux/mux-splash.js.map +1 -0
- package/dist/mux/pty-bridge.d.ts +79 -0
- package/dist/mux/pty-bridge.js +151 -0
- package/dist/mux/pty-bridge.js.map +1 -0
- package/dist/mux/trusted-input.d.ts +27 -0
- package/dist/mux/trusted-input.js +27 -0
- package/dist/mux/trusted-input.js.map +1 -0
- package/dist/mux/types.d.ts +92 -0
- package/dist/mux/types.js +45 -0
- package/dist/mux/types.js.map +1 -0
- package/dist/sandbox/index.js +6 -0
- package/dist/sandbox/index.js.map +1 -1
- package/dist/session/agent-session.js +3 -0
- package/dist/session/agent-session.js.map +1 -1
- package/dist/session/index.js +14 -7
- package/dist/session/index.js.map +1 -1
- package/dist/session/types.d.ts +6 -0
- package/dist/session/workspace-validation.d.ts +17 -0
- package/dist/session/workspace-validation.js +65 -0
- package/dist/session/workspace-validation.js.map +1 -0
- package/dist/trusted-process/auto-approver.d.ts +13 -4
- package/dist/trusted-process/auto-approver.js +12 -5
- package/dist/trusted-process/auto-approver.js.map +1 -1
- package/dist/trusted-process/escalation.js +5 -0
- package/dist/trusted-process/escalation.js.map +1 -1
- package/dist/trusted-process/index.js +23 -0
- package/dist/trusted-process/index.js.map +1 -1
- package/dist/trusted-process/mcp-proxy-server.d.ts +0 -2
- package/dist/trusted-process/mcp-proxy-server.js +82 -26
- package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
- package/dist/trusted-process/path-utils.d.ts +15 -0
- package/dist/trusted-process/path-utils.js +38 -0
- package/dist/trusted-process/path-utils.js.map +1 -1
- package/package.json +6 -1
|
@@ -11,13 +11,19 @@
|
|
|
11
11
|
* 2. macOS Keychain (if credentials file missing)
|
|
12
12
|
* 3. Fall back to API key from config
|
|
13
13
|
*/
|
|
14
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
15
15
|
import { homedir, platform, userInfo } from 'node:os';
|
|
16
16
|
import { resolve } from 'node:path';
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import * as logger from '../logger.js';
|
|
19
19
|
/** Minimum remaining token lifetime (5 minutes) to consider a token usable. */
|
|
20
20
|
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
21
|
+
function isNonEmptyString(value) {
|
|
22
|
+
return typeof value === 'string' && value.length > 0;
|
|
23
|
+
}
|
|
24
|
+
function isPositiveFiniteNumber(value) {
|
|
25
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
26
|
+
}
|
|
21
27
|
/** Service names used by Claude Code in macOS Keychain. */
|
|
22
28
|
const KEYCHAIN_SERVICE_NAMES = ['Claude Code-credentials', 'Claude Code'];
|
|
23
29
|
/**
|
|
@@ -71,11 +77,11 @@ export function parseCredentialsJson(json) {
|
|
|
71
77
|
if (typeof oauth !== 'object' || oauth === null)
|
|
72
78
|
return null;
|
|
73
79
|
const creds = oauth;
|
|
74
|
-
if (
|
|
80
|
+
if (!isNonEmptyString(creds.accessToken))
|
|
75
81
|
return null;
|
|
76
|
-
if (
|
|
82
|
+
if (!isNonEmptyString(creds.refreshToken))
|
|
77
83
|
return null;
|
|
78
|
-
if (
|
|
84
|
+
if (!isPositiveFiniteNumber(creds.expiresAt))
|
|
79
85
|
return null;
|
|
80
86
|
return {
|
|
81
87
|
accessToken: creds.accessToken,
|
|
@@ -119,6 +125,86 @@ export function extractFromKeychain() {
|
|
|
119
125
|
}
|
|
120
126
|
return null;
|
|
121
127
|
}
|
|
128
|
+
/** Claude Code's public OAuth client ID. */
|
|
129
|
+
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
130
|
+
/** Anthropic's OAuth token endpoint. */
|
|
131
|
+
const OAUTH_TOKEN_URL = 'https://console.anthropic.com/api/oauth/token';
|
|
132
|
+
/**
|
|
133
|
+
* Refreshes an OAuth access token using a refresh token grant.
|
|
134
|
+
*
|
|
135
|
+
* Returns new credentials on success, or null on failure (expired refresh
|
|
136
|
+
* token, network error, etc.).
|
|
137
|
+
*/
|
|
138
|
+
export async function refreshOAuthToken(refreshToken) {
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch(OAUTH_TOKEN_URL, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
grant_type: 'refresh_token',
|
|
145
|
+
refresh_token: refreshToken,
|
|
146
|
+
client_id: OAUTH_CLIENT_ID,
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
logger.warn(`OAuth token refresh failed: HTTP ${response.status}`);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const data = (await response.json());
|
|
154
|
+
return parseTokenResponse(data);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
logger.warn(`OAuth token refresh error: ${err instanceof Error ? err.message : String(err)}`);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validates and extracts credentials from an OAuth token endpoint response.
|
|
163
|
+
* Returns null if any required field is missing or invalid.
|
|
164
|
+
*/
|
|
165
|
+
function parseTokenResponse(data) {
|
|
166
|
+
const { access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn } = data;
|
|
167
|
+
if (!isNonEmptyString(accessToken))
|
|
168
|
+
return null;
|
|
169
|
+
if (!isNonEmptyString(refreshToken))
|
|
170
|
+
return null;
|
|
171
|
+
if (!isPositiveFiniteNumber(expiresIn))
|
|
172
|
+
return null;
|
|
173
|
+
return {
|
|
174
|
+
accessToken,
|
|
175
|
+
refreshToken,
|
|
176
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Writes updated OAuth credentials back to ~/.claude/.credentials.json,
|
|
181
|
+
* preserving other fields in the file (e.g., scopes, subscriptionType).
|
|
182
|
+
*/
|
|
183
|
+
export function saveOAuthCredentials(credentials, filePath) {
|
|
184
|
+
const credPath = filePath ?? getCredentialsFilePath();
|
|
185
|
+
let existing = {};
|
|
186
|
+
try {
|
|
187
|
+
if (existsSync(credPath)) {
|
|
188
|
+
existing = JSON.parse(readFileSync(credPath, 'utf-8'));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// Start fresh if file is unreadable
|
|
193
|
+
}
|
|
194
|
+
const existingOauth = typeof existing.claudeAiOauth === 'object' && existing.claudeAiOauth !== null
|
|
195
|
+
? existing.claudeAiOauth
|
|
196
|
+
: {};
|
|
197
|
+
existing.claudeAiOauth = {
|
|
198
|
+
...existingOauth,
|
|
199
|
+
accessToken: credentials.accessToken,
|
|
200
|
+
refreshToken: credentials.refreshToken,
|
|
201
|
+
expiresAt: credentials.expiresAt,
|
|
202
|
+
};
|
|
203
|
+
writeFileSync(credPath, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 });
|
|
204
|
+
// chmod after write — writeFileSync's mode only applies when creating new files;
|
|
205
|
+
// existing files with broader permissions need an explicit chmod.
|
|
206
|
+
chmodSync(credPath, 0o600);
|
|
207
|
+
}
|
|
122
208
|
const defaultSources = {
|
|
123
209
|
loadFromFile: loadOAuthCredentials,
|
|
124
210
|
loadFromKeychain: extractFromKeychain,
|
|
@@ -132,8 +218,6 @@ const defaultSources = {
|
|
|
132
218
|
* 3. OAuth credentials from macOS Keychain (if credentials file missing)
|
|
133
219
|
* 4. API key from config
|
|
134
220
|
* 5. None
|
|
135
|
-
*
|
|
136
|
-
* @param sources - Injectable credential loaders (for testing)
|
|
137
221
|
*/
|
|
138
222
|
export function detectAuthMethod(config, sources) {
|
|
139
223
|
const { loadFromFile, loadFromKeychain } = sources ?? defaultSources;
|
|
@@ -147,7 +231,7 @@ export function detectAuthMethod(config, sources) {
|
|
|
147
231
|
if (fileCreds) {
|
|
148
232
|
if (!isTokenExpired(fileCreds)) {
|
|
149
233
|
logger.info('Detected OAuth credentials from ~/.claude/.credentials.json');
|
|
150
|
-
return { kind: 'oauth', credentials: fileCreds };
|
|
234
|
+
return { kind: 'oauth', credentials: fileCreds, source: 'file' };
|
|
151
235
|
}
|
|
152
236
|
logger.warn('OAuth token from credentials file is expired');
|
|
153
237
|
}
|
|
@@ -156,7 +240,7 @@ export function detectAuthMethod(config, sources) {
|
|
|
156
240
|
const keychainCreds = loadFromKeychain();
|
|
157
241
|
if (keychainCreds) {
|
|
158
242
|
if (!isTokenExpired(keychainCreds)) {
|
|
159
|
-
return { kind: 'oauth', credentials: keychainCreds };
|
|
243
|
+
return { kind: 'oauth', credentials: keychainCreds, source: 'keychain' };
|
|
160
244
|
}
|
|
161
245
|
logger.warn('OAuth token from macOS Keychain is expired');
|
|
162
246
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth-credentials.js","sourceRoot":"","sources":["../../src/docker/oauth-credentials.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"oauth-credentials.js","sourceRoot":"","sources":["../../src/docker/oauth-credentials.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth token lifecycle manager for Docker agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Handles proactive refresh (before token expiry) and reactive refresh
|
|
5
|
+
* (on upstream 401). Coordinates with the host Claude Code process that
|
|
6
|
+
* may also refresh tokens concurrently — re-reads the credentials file
|
|
7
|
+
* before attempting a refresh to avoid using a stale refresh token.
|
|
8
|
+
*
|
|
9
|
+
* On macOS where credentials come from the Keychain, the manager operates
|
|
10
|
+
* in read-only mode: it re-reads credentials from file and Keychain but
|
|
11
|
+
* never performs its own refresh grant. This prevents invalidating the
|
|
12
|
+
* host Claude Code's refresh token, which lives in the Keychain.
|
|
13
|
+
*
|
|
14
|
+
* A single in-flight refresh promise is shared across concurrent callers
|
|
15
|
+
* to prevent duplicate refresh requests.
|
|
16
|
+
*/
|
|
17
|
+
import type { OAuthCredentials } from './oauth-credentials.js';
|
|
18
|
+
/** Injectable dependencies for testability. */
|
|
19
|
+
export interface TokenManagerDeps {
|
|
20
|
+
loadCredentials: (filePath: string) => OAuthCredentials | null;
|
|
21
|
+
loadFromKeychain: () => OAuthCredentials | null;
|
|
22
|
+
refreshToken: (refreshToken: string) => Promise<OAuthCredentials | null>;
|
|
23
|
+
saveCredentials: (credentials: OAuthCredentials, filePath?: string) => void;
|
|
24
|
+
credentialsFilePath: string;
|
|
25
|
+
/** @deprecated No longer used internally. Kept for backward compatibility with tests. */
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}
|
|
28
|
+
export interface OAuthTokenManagerOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Whether the manager may perform its own refresh_token grant.
|
|
31
|
+
*
|
|
32
|
+
* When false (Keychain-sourced credentials on macOS), the manager only
|
|
33
|
+
* re-reads credentials from the file and Keychain — it never POSTs to
|
|
34
|
+
* the token endpoint itself, because doing so would rotate the refresh
|
|
35
|
+
* token and break host Claude Code's copy in the Keychain.
|
|
36
|
+
*
|
|
37
|
+
* Defaults to true (file-sourced credentials on Linux/WSL).
|
|
38
|
+
*/
|
|
39
|
+
canRefresh?: boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare class OAuthTokenManager {
|
|
42
|
+
private credentials;
|
|
43
|
+
private inflightRefresh;
|
|
44
|
+
private readonly deps;
|
|
45
|
+
private readonly canRefresh;
|
|
46
|
+
constructor(initialCredentials: OAuthCredentials, options?: OAuthTokenManagerOptions, deps?: Partial<TokenManagerDeps>);
|
|
47
|
+
/**
|
|
48
|
+
* Returns the current access token. This is always the latest known-good
|
|
49
|
+
* token, whether from the initial credentials, a file re-read, or a refresh.
|
|
50
|
+
*/
|
|
51
|
+
get accessToken(): string;
|
|
52
|
+
/**
|
|
53
|
+
* Returns a valid access token, refreshing proactively if near expiry.
|
|
54
|
+
*
|
|
55
|
+
* Called before every upstream request to ensure the token is fresh.
|
|
56
|
+
* If the token has more than 5 minutes of validity, returns immediately.
|
|
57
|
+
*/
|
|
58
|
+
getValidAccessToken(): Promise<string>;
|
|
59
|
+
/**
|
|
60
|
+
* Called on upstream 401 — forces a refresh attempt.
|
|
61
|
+
*
|
|
62
|
+
* Returns the new access token on success, or null if refresh is
|
|
63
|
+
* unrecoverable (both our refresh and file re-read failed).
|
|
64
|
+
*/
|
|
65
|
+
handleAuthFailure(): Promise<string | null>;
|
|
66
|
+
/**
|
|
67
|
+
* Core refresh logic with deduplication. Concurrent callers share a single
|
|
68
|
+
* in-flight refresh promise.
|
|
69
|
+
*
|
|
70
|
+
* File-sourced credentials (canRefresh=true):
|
|
71
|
+
* Re-read file -> if still expired, POST refresh grant -> on failure,
|
|
72
|
+
* re-read file once more (host process may have won the race).
|
|
73
|
+
*
|
|
74
|
+
* Keychain-sourced credentials (canRefresh=false):
|
|
75
|
+
* Re-read file and Keychain only -- never POST a refresh grant, because
|
|
76
|
+
* doing so would rotate the refresh token and break host Claude Code.
|
|
77
|
+
*/
|
|
78
|
+
private doRefresh;
|
|
79
|
+
private executeRefresh;
|
|
80
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth token lifecycle manager for Docker agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Handles proactive refresh (before token expiry) and reactive refresh
|
|
5
|
+
* (on upstream 401). Coordinates with the host Claude Code process that
|
|
6
|
+
* may also refresh tokens concurrently — re-reads the credentials file
|
|
7
|
+
* before attempting a refresh to avoid using a stale refresh token.
|
|
8
|
+
*
|
|
9
|
+
* On macOS where credentials come from the Keychain, the manager operates
|
|
10
|
+
* in read-only mode: it re-reads credentials from file and Keychain but
|
|
11
|
+
* never performs its own refresh grant. This prevents invalidating the
|
|
12
|
+
* host Claude Code's refresh token, which lives in the Keychain.
|
|
13
|
+
*
|
|
14
|
+
* A single in-flight refresh promise is shared across concurrent callers
|
|
15
|
+
* to prevent duplicate refresh requests.
|
|
16
|
+
*/
|
|
17
|
+
import { isTokenExpired, loadCredentialsFromFile, refreshOAuthToken, saveOAuthCredentials, getCredentialsFilePath, extractFromKeychain, } from './oauth-credentials.js';
|
|
18
|
+
import * as logger from '../logger.js';
|
|
19
|
+
const defaultDeps = {
|
|
20
|
+
loadCredentials: loadCredentialsFromFile,
|
|
21
|
+
loadFromKeychain: extractFromKeychain,
|
|
22
|
+
refreshToken: refreshOAuthToken,
|
|
23
|
+
saveCredentials: saveOAuthCredentials,
|
|
24
|
+
credentialsFilePath: getCredentialsFilePath(),
|
|
25
|
+
};
|
|
26
|
+
export class OAuthTokenManager {
|
|
27
|
+
credentials;
|
|
28
|
+
inflightRefresh = null;
|
|
29
|
+
deps;
|
|
30
|
+
canRefresh;
|
|
31
|
+
constructor(initialCredentials, options, deps) {
|
|
32
|
+
this.credentials = initialCredentials;
|
|
33
|
+
this.canRefresh = options?.canRefresh ?? true;
|
|
34
|
+
this.deps = { ...defaultDeps, ...deps };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Returns the current access token. This is always the latest known-good
|
|
38
|
+
* token, whether from the initial credentials, a file re-read, or a refresh.
|
|
39
|
+
*/
|
|
40
|
+
get accessToken() {
|
|
41
|
+
return this.credentials.accessToken;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Returns a valid access token, refreshing proactively if near expiry.
|
|
45
|
+
*
|
|
46
|
+
* Called before every upstream request to ensure the token is fresh.
|
|
47
|
+
* If the token has more than 5 minutes of validity, returns immediately.
|
|
48
|
+
*/
|
|
49
|
+
async getValidAccessToken() {
|
|
50
|
+
if (!isTokenExpired(this.credentials)) {
|
|
51
|
+
return this.credentials.accessToken;
|
|
52
|
+
}
|
|
53
|
+
logger.info('[oauth-token-manager] Token near expiry, attempting proactive refresh');
|
|
54
|
+
const newToken = await this.doRefresh();
|
|
55
|
+
return newToken ?? this.credentials.accessToken;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Called on upstream 401 — forces a refresh attempt.
|
|
59
|
+
*
|
|
60
|
+
* Returns the new access token on success, or null if refresh is
|
|
61
|
+
* unrecoverable (both our refresh and file re-read failed).
|
|
62
|
+
*/
|
|
63
|
+
async handleAuthFailure() {
|
|
64
|
+
logger.info('[oauth-token-manager] Handling auth failure (401), attempting refresh');
|
|
65
|
+
return this.doRefresh();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Core refresh logic with deduplication. Concurrent callers share a single
|
|
69
|
+
* in-flight refresh promise.
|
|
70
|
+
*
|
|
71
|
+
* File-sourced credentials (canRefresh=true):
|
|
72
|
+
* Re-read file -> if still expired, POST refresh grant -> on failure,
|
|
73
|
+
* re-read file once more (host process may have won the race).
|
|
74
|
+
*
|
|
75
|
+
* Keychain-sourced credentials (canRefresh=false):
|
|
76
|
+
* Re-read file and Keychain only -- never POST a refresh grant, because
|
|
77
|
+
* doing so would rotate the refresh token and break host Claude Code.
|
|
78
|
+
*/
|
|
79
|
+
doRefresh() {
|
|
80
|
+
if (this.inflightRefresh) {
|
|
81
|
+
return this.inflightRefresh;
|
|
82
|
+
}
|
|
83
|
+
this.inflightRefresh = this.executeRefresh().finally(() => {
|
|
84
|
+
this.inflightRefresh = null;
|
|
85
|
+
});
|
|
86
|
+
return this.inflightRefresh;
|
|
87
|
+
}
|
|
88
|
+
async executeRefresh() {
|
|
89
|
+
// Re-read credentials file -- another process may have refreshed already
|
|
90
|
+
const fileCreds = this.deps.loadCredentials(this.deps.credentialsFilePath);
|
|
91
|
+
if (fileCreds && !isTokenExpired(fileCreds)) {
|
|
92
|
+
logger.info('[oauth-token-manager] Found valid token in credentials file (refreshed by another process)');
|
|
93
|
+
this.credentials = fileCreds;
|
|
94
|
+
return this.credentials.accessToken;
|
|
95
|
+
}
|
|
96
|
+
// In read-only mode, also try the Keychain but never POST a refresh grant
|
|
97
|
+
if (!this.canRefresh) {
|
|
98
|
+
const keychainCreds = this.deps.loadFromKeychain();
|
|
99
|
+
if (keychainCreds && !isTokenExpired(keychainCreds)) {
|
|
100
|
+
logger.info('[oauth-token-manager] Found valid token in Keychain (refreshed by host process)');
|
|
101
|
+
this.credentials = keychainCreds;
|
|
102
|
+
return this.credentials.accessToken;
|
|
103
|
+
}
|
|
104
|
+
logger.warn('[oauth-token-manager] Token expired and cannot self-refresh (Keychain mode) — waiting for host Claude Code to refresh');
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
// Attempt refresh ourselves, preferring the file's refresh token if available
|
|
108
|
+
const refreshToken = fileCreds?.refreshToken ?? this.credentials.refreshToken;
|
|
109
|
+
const newCreds = await this.deps.refreshToken(refreshToken);
|
|
110
|
+
if (newCreds) {
|
|
111
|
+
logger.info('[oauth-token-manager] Token refresh successful');
|
|
112
|
+
try {
|
|
113
|
+
this.deps.saveCredentials(newCreds, this.deps.credentialsFilePath);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
logger.warn(`[oauth-token-manager] Failed to save refreshed credentials: ${err instanceof Error ? err.message : String(err)}`);
|
|
117
|
+
}
|
|
118
|
+
this.credentials = newCreds;
|
|
119
|
+
return this.credentials.accessToken;
|
|
120
|
+
}
|
|
121
|
+
// Refresh failed -- re-read file one more time (host may have won the race)
|
|
122
|
+
logger.info('[oauth-token-manager] Refresh failed, re-reading credentials file as fallback');
|
|
123
|
+
const fallbackCreds = this.deps.loadCredentials(this.deps.credentialsFilePath);
|
|
124
|
+
if (fallbackCreds && !isTokenExpired(fallbackCreds)) {
|
|
125
|
+
logger.info('[oauth-token-manager] Found valid token in credentials file on fallback re-read');
|
|
126
|
+
this.credentials = fallbackCreds;
|
|
127
|
+
return this.credentials.accessToken;
|
|
128
|
+
}
|
|
129
|
+
logger.warn('[oauth-token-manager] Token refresh failed and no valid credentials available');
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=oauth-token-manager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth-token-manager.js","sourceRoot":"","sources":["../../src/docker/oauth-token-manager.ts"],"names":[],"mappings":"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"}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* agent container. Written to the orientation directory which is
|
|
6
6
|
* bind-mounted read-only into the container.
|
|
7
7
|
*/
|
|
8
|
-
import type
|
|
8
|
+
import { type AgentAdapter } from './agent-adapter.js';
|
|
9
9
|
import type { IronCurtainConfig } from '../config/types.js';
|
|
10
10
|
import type { ServerListing } from '../session/prompts.js';
|
|
11
11
|
/**
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { resolve, dirname } from 'node:path';
|
|
10
|
+
import { CONTAINER_WORKSPACE_DIR, } from './agent-adapter.js';
|
|
10
11
|
/**
|
|
11
12
|
* Extracts allowed domains from MCP server configurations.
|
|
12
13
|
* Used to inform the agent about which domains it can access via MCP tools.
|
|
@@ -47,7 +48,7 @@ export function prepareSession(adapter, serverListings, sessionDir, config, host
|
|
|
47
48
|
const orientationDir = resolve(sessionDir, 'orientation');
|
|
48
49
|
mkdirSync(orientationDir, { recursive: true });
|
|
49
50
|
const context = {
|
|
50
|
-
workspaceDir:
|
|
51
|
+
workspaceDir: CONTAINER_WORKSPACE_DIR,
|
|
51
52
|
hostSandboxDir,
|
|
52
53
|
serverListings,
|
|
53
54
|
allowedDomains: extractAllowedDomains(config),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"orientation.js","sourceRoot":"","sources":["../../src/docker/orientation.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"orientation.js","sourceRoot":"","sources":["../../src/docker/orientation.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EACL,uBAAuB,GAIxB,MAAM,oBAAoB,CAAC;AAI5B;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAAyB;IAC7D,MAAM,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;IAClC,KAAK,MAAM,YAAY,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;QAC5D,MAAM,OAAO,GAAG,YAAY,CAAC,OAAO,CAAC;QACrC,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,KAAK,QAAQ;YAAE,SAAS;QACtD,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;QAChC,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,KAAK,QAAQ;YAAE,SAAS;QACtD,KAAK,MAAM,MAAM,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;YAC5C,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACtB,CAAC;IACH,CAAC;IACD,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC;AACtB,CAAC;AAED;;;GAGG;AACH,SAAS,gBAAgB,CAAC,OAAe,EAAE,KAAwB;IACjE,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,UAAU,GAAG,OAAO,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAC/C,SAAS,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACpD,aAAa,CAAC,UAAU,EAAE,IAAI,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IAC/D,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAC5B,OAAqB,EACrB,cAA+B,EAC/B,UAAkB,EAClB,MAAyB,EACzB,cAAsB,EACtB,YAAqB;IAErB,MAAM,cAAc,GAAG,OAAO,CAAC,UAAU,EAAE,aAAa,CAAC,CAAC;IAC1D,SAAS,CAAC,cAAc,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAE/C,MAAM,OAAO,GAAuB;QAClC,YAAY,EAAE,uBAAuB;QACrC,cAAc;QACd,cAAc;QACd,cAAc,EAAE,qBAAqB,CAAC,MAAM,CAAC;QAC7C,WAAW,EAAE,YAAY,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM;KAC9C,CAAC;IAEF,qFAAqF;IACrF,MAAM,OAAO,GAAG,YAAY,IAAI,6BAA6B,CAAC;IAC9D,gBAAgB,CAAC,cAAc,EAAE,OAAO,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC,CAAC;IACrE,gBAAgB,CAAC,cAAc,EAAE,OAAO,CAAC,wBAAwB,CAAC,OAAO,CAAC,CAAC,CAAC;IAE5E,MAAM,YAAY,GAAG,OAAO,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;IACxD,OAAO,EAAE,YAAY,EAAE,CAAC;AAC1B,CAAC"}
|
|
@@ -19,6 +19,8 @@ export interface PtySessionOptions {
|
|
|
19
19
|
readonly mode: SessionMode & {
|
|
20
20
|
kind: 'docker';
|
|
21
21
|
};
|
|
22
|
+
/** Validated workspace path. When provided, replaces the session sandbox. */
|
|
23
|
+
readonly workspacePath?: string;
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* Runs a PTY session: starts proxies, launches container with PTY-enabled
|
|
@@ -19,9 +19,10 @@ import { resolve } from 'node:path';
|
|
|
19
19
|
import chalk from 'chalk';
|
|
20
20
|
import ora from 'ora';
|
|
21
21
|
import { createSessionId } from '../session/types.js';
|
|
22
|
+
import { CONTAINER_WORKSPACE_DIR } from './agent-adapter.js';
|
|
22
23
|
import { PTY_SOCK_NAME, DEFAULT_PTY_PORT } from './pty-types.js';
|
|
23
24
|
import { createEscalationWatcher, atomicWriteJsonSync } from '../escalation/escalation-watcher.js';
|
|
24
|
-
import { getSessionDir, getSessionSandboxDir, getSessionEscalationDir, getSessionAuditLogPath, getSessionLogPath, getSessionLlmLogPath, getPtyRegistryDir, } from '../config/paths.js';
|
|
25
|
+
import { getSessionDir, getSessionSandboxDir, getSessionEscalationDir, getSessionAuditLogPath, getSessionLogPath, getSessionLlmLogPath, getSessionAutoApproveLlmLogPath, getPtyRegistryDir, } from '../config/paths.js';
|
|
25
26
|
import * as logger from '../logger.js';
|
|
26
27
|
/** Maximum time to wait for the PTY socket to appear (ms). */
|
|
27
28
|
const PTY_READINESS_TIMEOUT_MS = 30_000;
|
|
@@ -35,11 +36,14 @@ export async function runPtySession(options) {
|
|
|
35
36
|
const { prepareDockerInfrastructure } = await import('./docker-infrastructure.js');
|
|
36
37
|
const sessionId = createSessionId();
|
|
37
38
|
const sessionDir = getSessionDir(sessionId);
|
|
38
|
-
const sandboxDir = getSessionSandboxDir(sessionId);
|
|
39
|
+
const sandboxDir = options.workspacePath ?? getSessionSandboxDir(sessionId);
|
|
39
40
|
const escalationDir = getSessionEscalationDir(sessionId);
|
|
40
41
|
const auditLogPath = getSessionAuditLogPath(sessionId);
|
|
41
42
|
const socketsDir = resolve(sessionDir, 'sockets');
|
|
42
|
-
|
|
43
|
+
// Only create the sandbox directory when not using an external workspace
|
|
44
|
+
if (!options.workspacePath) {
|
|
45
|
+
mkdirSync(sandboxDir, { recursive: true, mode: 0o700 });
|
|
46
|
+
}
|
|
43
47
|
mkdirSync(escalationDir, { recursive: true, mode: 0o700 });
|
|
44
48
|
mkdirSync(socketsDir, { recursive: true, mode: 0o700 });
|
|
45
49
|
// Set up session logging
|
|
@@ -47,10 +51,11 @@ export async function runPtySession(options) {
|
|
|
47
51
|
const llmLogPath = getSessionLlmLogPath(sessionId);
|
|
48
52
|
logger.setup({ logFilePath: sessionLogPath });
|
|
49
53
|
logger.info(`PTY session ${sessionId} starting`);
|
|
50
|
-
logger.info(
|
|
54
|
+
logger.info(`${options.workspacePath ? 'Workspace' : 'Sandbox'}: ${sandboxDir}`);
|
|
51
55
|
logger.info(`Escalation dir: ${escalationDir}`);
|
|
52
56
|
logger.info(`LLM log: ${llmLogPath}`);
|
|
53
57
|
// Patch config for this session
|
|
58
|
+
const autoApproveLlmLogPath = getSessionAutoApproveLlmLogPath(sessionId);
|
|
54
59
|
const sessionConfig = {
|
|
55
60
|
...options.config,
|
|
56
61
|
allowedDirectory: sandboxDir,
|
|
@@ -58,6 +63,8 @@ export async function runPtySession(options) {
|
|
|
58
63
|
escalationDir,
|
|
59
64
|
sessionLogPath,
|
|
60
65
|
llmLogPath,
|
|
66
|
+
autoApproveLlmLogPath,
|
|
67
|
+
isPtySession: true,
|
|
61
68
|
mcpServers: JSON.parse(JSON.stringify(options.config.mcpServers)),
|
|
62
69
|
};
|
|
63
70
|
const initSpinner = ora({
|
|
@@ -164,7 +171,7 @@ export async function runPtySession(options) {
|
|
|
164
171
|
const sidecarIp = await docker.getContainerIp(sidecarContainerId, INTERNAL_NETWORK_NAME);
|
|
165
172
|
extraHosts = [`host.docker.internal:${sidecarIp}`];
|
|
166
173
|
mounts = [
|
|
167
|
-
{ source: sandboxDir, target:
|
|
174
|
+
{ source: sandboxDir, target: CONTAINER_WORKSPACE_DIR, readonly: false },
|
|
168
175
|
{ source: orientationDir, target: '/etc/ironcurtain', readonly: true },
|
|
169
176
|
];
|
|
170
177
|
}
|
|
@@ -177,11 +184,18 @@ export async function runPtySession(options) {
|
|
|
177
184
|
};
|
|
178
185
|
network = 'none';
|
|
179
186
|
mounts = [
|
|
180
|
-
{ source: sandboxDir, target:
|
|
187
|
+
{ source: sandboxDir, target: CONTAINER_WORKSPACE_DIR, readonly: false },
|
|
181
188
|
{ source: socketsDir, target: '/run/ironcurtain', readonly: false },
|
|
182
189
|
{ source: orientationDir, target: '/etc/ironcurtain', readonly: true },
|
|
183
190
|
];
|
|
184
191
|
}
|
|
192
|
+
// Pass initial terminal size so start-claude.sh can set PTY dimensions
|
|
193
|
+
// before Claude starts, eliminating the resize race condition.
|
|
194
|
+
const { columns, rows } = process.stdout;
|
|
195
|
+
if (columns)
|
|
196
|
+
env.IRONCURTAIN_INITIAL_COLS = String(columns);
|
|
197
|
+
if (rows)
|
|
198
|
+
env.IRONCURTAIN_INITIAL_ROWS = String(rows);
|
|
185
199
|
// Create and start container with PTY command and TTY
|
|
186
200
|
containerId = await docker.create({
|
|
187
201
|
image,
|
|
@@ -302,18 +316,37 @@ function attachPty(options) {
|
|
|
302
316
|
stdin.setRawMode(true);
|
|
303
317
|
}
|
|
304
318
|
stdin.resume();
|
|
305
|
-
// SIGWINCH: forward terminal resize to container PTY
|
|
319
|
+
// SIGWINCH: forward terminal resize to container PTY.
|
|
320
|
+
// verifyAbort cancels the background verify loop on first user resize
|
|
321
|
+
// so it doesn't fight with the new size.
|
|
322
|
+
const verifyAbort = new AbortController();
|
|
323
|
+
let isFirstResize = true;
|
|
306
324
|
const onResize = () => {
|
|
307
325
|
const { columns, rows } = stdout;
|
|
308
326
|
if (columns && rows) {
|
|
309
|
-
|
|
310
|
-
|
|
327
|
+
if (!isFirstResize) {
|
|
328
|
+
verifyAbort.abort();
|
|
329
|
+
}
|
|
330
|
+
isFirstResize = false;
|
|
331
|
+
execFile('docker', ['exec', options.containerId, '/etc/ironcurtain/resize-pty.sh', String(columns), String(rows)], { timeout: 5000 }, (err, _stdout, stderr) => {
|
|
332
|
+
if (err) {
|
|
333
|
+
logger.warn(`resize-pty.sh failed: ${err.message}`);
|
|
334
|
+
if (stderr) {
|
|
335
|
+
logger.warn(`resize-pty.sh stderr: ${stderr.trim()}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
311
338
|
});
|
|
312
339
|
}
|
|
313
340
|
};
|
|
314
341
|
stdout.on('resize', onResize);
|
|
315
342
|
// Send initial size
|
|
316
343
|
onResize();
|
|
344
|
+
// Background verify+retry to ensure the initial resize took effect.
|
|
345
|
+
// Fire-and-forget -- does not block the PTY proxy.
|
|
346
|
+
// Canceled via verifyAbort when the user resizes the terminal.
|
|
347
|
+
if (stdout.columns && stdout.rows) {
|
|
348
|
+
void verifyInitialPtySize(options.containerId, stdout.columns, stdout.rows, verifyAbort.signal);
|
|
349
|
+
}
|
|
317
350
|
// Host -> Container
|
|
318
351
|
// Ctrl-\ (0x1c) is intercepted as an emergency exit since raw mode
|
|
319
352
|
// disables normal signal generation for SIGQUIT.
|
|
@@ -337,6 +370,7 @@ function attachPty(options) {
|
|
|
337
370
|
stdin.removeListener('data', onData);
|
|
338
371
|
conn.unpipe(stdout);
|
|
339
372
|
stdin.pause();
|
|
373
|
+
verifyAbort.abort();
|
|
340
374
|
options.signal?.removeEventListener('abort', onAbort);
|
|
341
375
|
}
|
|
342
376
|
function onAbort() {
|
|
@@ -363,6 +397,68 @@ function attachPty(options) {
|
|
|
363
397
|
});
|
|
364
398
|
});
|
|
365
399
|
}
|
|
400
|
+
// --- PTY size verification ---
|
|
401
|
+
/** Maximum retries for initial PTY size verification. */
|
|
402
|
+
const PTY_SIZE_VERIFY_RETRIES = 5;
|
|
403
|
+
/** Interval between PTY size verification attempts (ms). */
|
|
404
|
+
const PTY_SIZE_VERIFY_INTERVAL_MS = 1_000;
|
|
405
|
+
/** Initial delay before first verification attempt (ms). */
|
|
406
|
+
const PTY_SIZE_VERIFY_INITIAL_DELAY_MS = 500;
|
|
407
|
+
/**
|
|
408
|
+
* Runs check-pty-size.sh and returns { rows, cols } or null on failure.
|
|
409
|
+
*/
|
|
410
|
+
function checkPtySize(containerId) {
|
|
411
|
+
return new Promise((resolve) => {
|
|
412
|
+
execFile('docker', ['exec', containerId, '/etc/ironcurtain/check-pty-size.sh'], { timeout: 5000 }, (err, stdout) => {
|
|
413
|
+
if (err) {
|
|
414
|
+
resolve(null);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const parts = stdout.trim().split(/\s+/);
|
|
418
|
+
if (parts.length >= 2) {
|
|
419
|
+
const rows = parseInt(parts[0], 10);
|
|
420
|
+
const cols = parseInt(parts[1], 10);
|
|
421
|
+
if (!isNaN(rows) && !isNaN(cols) && rows > 0 && cols > 0) {
|
|
422
|
+
resolve({ rows, cols });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
resolve(null);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Background verify+retry loop for initial PTY resize.
|
|
432
|
+
* Non-blocking (fire-and-forget). Aborted when a user resize occurs so it
|
|
433
|
+
* does not fight with legitimate SIGWINCH-driven resizes.
|
|
434
|
+
*/
|
|
435
|
+
async function verifyInitialPtySize(containerId, expectedCols, expectedRows, signal) {
|
|
436
|
+
await new Promise((r) => setTimeout(r, PTY_SIZE_VERIFY_INITIAL_DELAY_MS));
|
|
437
|
+
for (let attempt = 0; attempt < PTY_SIZE_VERIFY_RETRIES; attempt++) {
|
|
438
|
+
if (signal?.aborted)
|
|
439
|
+
return;
|
|
440
|
+
const size = await checkPtySize(containerId);
|
|
441
|
+
if (size && size.cols === expectedCols && size.rows === expectedRows) {
|
|
442
|
+
return; // PTY size matches
|
|
443
|
+
}
|
|
444
|
+
if (signal?.aborted)
|
|
445
|
+
return;
|
|
446
|
+
// Mismatch or check failed -- try resizing
|
|
447
|
+
await new Promise((resolve) => {
|
|
448
|
+
execFile('docker', ['exec', containerId, '/etc/ironcurtain/resize-pty.sh', String(expectedCols), String(expectedRows)], { timeout: 5000 }, () => resolve());
|
|
449
|
+
});
|
|
450
|
+
// Wait before rechecking
|
|
451
|
+
await new Promise((r) => setTimeout(r, PTY_SIZE_VERIFY_INTERVAL_MS));
|
|
452
|
+
}
|
|
453
|
+
if (signal?.aborted)
|
|
454
|
+
return;
|
|
455
|
+
// Final check
|
|
456
|
+
const finalSize = await checkPtySize(containerId);
|
|
457
|
+
if (!finalSize || finalSize.cols !== expectedCols || finalSize.rows !== expectedRows) {
|
|
458
|
+
logger.warn(`PTY size verification failed after ${PTY_SIZE_VERIFY_RETRIES} retries ` +
|
|
459
|
+
`(expected ${expectedCols}x${expectedRows}, got ${finalSize ? `${finalSize.cols}x${finalSize.rows}` : 'unknown'})`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
366
462
|
// --- Readiness polling ---
|
|
367
463
|
/**
|
|
368
464
|
* Waits for the PTY socket/port to become connectable.
|