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
2 changes: 1 addition & 1 deletion apps/claude-sdk-cli/src/createAppTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function createAppTools({ fs, tsServer, toolsConfig, rulesProvider, objec
// A real process-lifetime singleton, not just per-call: createAppTools is only ever invoked once
// (see AppToolsService's factory registration in container.ts — core-di-lite memoizes by the
// registration itself), so this is constructed exactly once for the process's lifetime.
const azSessionCache = new AzSessionCache(clock, logger);
const azSessionCache = new AzSessionCache(fs, clock, logger);

tools.push(...createAdoPrTools(azDeps, getAzAccounts, azSessionCache));
tools.push(...createAzTools(azDeps, getAzAccounts, azSessionCache));
Expand Down
19 changes: 19 additions & 0 deletions apps/claude-sdk-cli/test/MemoryFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,25 @@ export class MemoryFileSystem extends IFileSystem {
// Directories are implicit \u2014 nothing to remove when empty
}

public async deleteDirectoryRecursive(path: string): Promise<void> {
const prefix = path.endsWith('/') ? path : `${path}/`;
for (const p of [...this.files.keys()]) {
if (p.startsWith(prefix)) {
this.files.delete(p);
}
}
}

public async mkdir(): Promise<void> {
// Directories are implicit — nothing to create
}

#mkdtempCounter = 0;

public async mkdtemp(prefix: string): Promise<string> {
return `${prefix}${this.#mkdtempCounter++}`;
}

public async stat(path: string): Promise<StatResult> {
const content = this.files.get(path);
if (content === undefined) {
Expand Down
1 change: 1 addition & 0 deletions packages/claude-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add the conversation-history model: store types, read/write interfaces, and near-duplicate detection (shingle, minhash, LSH) for the sweep
- Add the Memory tool: a persistent, shared, relevance-searchable memory Claude reads and writes across sessions
- Condition attached images: resize to a 2000px PNG long edge via sips, downscaling only, and log each outcome to the debug log; a missing or failing sips passes the image through unchanged
- IFileSystem gained mkdir, mkdtemp, and deleteDirectoryRecursive, and writeFile now accepts an optional mode
- Parse mouse-wheel events from stdin into scroll_up/scroll_down key actions; add enableMouse/disableMouse escape sequences
- Support binary file reads through encoding parameter on IFileSystem.readFile

Expand Down
1 change: 1 addition & 0 deletions packages/claude-core/changes.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@
{"description":"Fix version metadata","category":"fixed"}
{"description":"Depend on @shellicar/core-di instead of @shellicar/core-di-lite","category":"changed"}
{"description":"Config merge now recurses arbitrarily deep instead of stopping after one nested level, so a local override several levels down (e.g. one entry of a nested record) no longer silently replaces its whole containing object and drops its siblings","category":"fixed"}
{"description":"IFileSystem gained mkdir, mkdtemp, and deleteDirectoryRecursive, and writeFile now accepts an optional mode","category":"added"}
10 changes: 9 additions & 1 deletion packages/claude-core/src/fs/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ export abstract class IFileSystem {
public abstract homedir(): string;
public abstract exists(path: string): Promise<boolean>;
public abstract readFile(path: string, encoding?: BufferEncoding): Promise<string>;
public abstract writeFile(path: string, content: string): Promise<void>;
public abstract writeFile(path: string, content: string, options?: { mode?: number }): Promise<void>;
public abstract deleteFile(path: string): Promise<void>;
public abstract deleteDirectory(path: string): Promise<void>;
/** Recursive, force delete — for internal housekeeping (e.g. a session cache's own temp dirs),
* never exposed by a tool. `deleteDirectory` above stays non-recursive; that is the tool-facing
* safety contract. */
public abstract deleteDirectoryRecursive(path: string): Promise<void>;
/** Ensures a directory exists, creating any missing parents — no content, unlike `writeFile`. */
public abstract mkdir(path: string): Promise<void>;
/** Creates a fresh, uniquely-named directory inside the OS temp directory, named with `prefix`, and returns its path. */
public abstract mkdtemp(prefix: string): Promise<string>;
public abstract rename(oldPath: string, newPath: string): Promise<void>;
public async find(path: string, options?: FindOptions): Promise<FileRecord[]> {
const re = options?.pattern ? new RegExp(options.pattern) : undefined;
Expand Down
12 changes: 12 additions & 0 deletions packages/claude-core/test/MemoryFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ export class MemoryFileSystem extends IFileSystem {
throw new Error('MemoryFileSystem: deleteDirectory() not supported');
}

public deleteDirectoryRecursive(): Promise<void> {
throw new Error('MemoryFileSystem: deleteDirectoryRecursive() not supported');
}

public mkdir(): Promise<void> {
throw new Error('MemoryFileSystem: mkdir() not supported');
}

public mkdtemp(): Promise<string> {
throw new Error('MemoryFileSystem: mkdtemp() not supported');
}

public appendFile(): Promise<void> {
throw new Error('MemoryFileSystem: appendFile() not supported');
}
Expand Down
1 change: 1 addition & 0 deletions packages/claude-sdk-tools/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- A failed tsserver request now throws instead of returning an empty result that was indistinguishable from a clean file
- An interactive az identity no longer gets a silent, unattended background relogin; the browser/MFA prompt only ever appears attached to a real caller's call
- An interactive az identity now reuses an existing signed-in session found on disk after a CLI restart instead of always prompting for a fresh sign-in
- AzCli, EscalatedAzCli, and every AzureDevOps.PullRequest.* tool now honor cancellation — an in-progress az login or command can be aborted instead of blocking until the process crashes or restarts
- AzureDevOps.PullRequest.* tools accept an account field, matching AzCli/EscalatedAzCli
- Binary files are blocked from text reads when the format is recognised; unrecognised formats are still treated as text
Expand Down
1 change: 1 addition & 0 deletions packages/claude-sdk-tools/changes.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,4 @@
{"description":"AzCli, EscalatedAzCli, and every AzureDevOps.PullRequest.* tool now honor cancellation — an in-progress az login or command can be aborted instead of blocking until the process crashes or restarts","category":"fixed"}
{"description":"An interactive az identity no longer gets a silent, unattended background relogin; the browser/MFA prompt only ever appears attached to a real caller's call","category":"fixed"}
{"description":"The az session's own login and command env now strips the same ambient Azure credential vars ExecV3 strips, so the CLI's own environment can no longer steer a login it believes it fully controls","category":"security"}
{"description":"An interactive az identity now reuses an existing signed-in session found on disk after a CLI restart instead of always prompting for a fresh sign-in","category":"fixed"}
67 changes: 58 additions & 9 deletions packages/claude-sdk-tools/src/Az/AzSessionCache.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { rmSync } from 'node:fs';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Clock } from '@js-joda/core';
import { Instant } from '@js-joda/core';
import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces';
import type { ILogger } from '@shellicar/claude-core/logging/ILogger';
import type { IExecutor } from '@shellicar/exec-core';
import { ensureAzExtensionDir, ensureAzInteractiveSessionDir, type RunResult, removeConfigDir, runOnce, stripAmbientAzureEnv } from '../az-shared';
Expand Down Expand Up @@ -47,12 +46,17 @@ export class AzSessionCache {
readonly #entries = new Map<string, Entry>();
readonly #allConfigDirs = new Set<string>();
readonly #onExit: () => void;
readonly #fs: IFileSystem;
readonly #clock: Clock;
readonly #logger?: ILogger;

public constructor(clock: Clock, logger?: ILogger) {
public constructor(fs: IFileSystem, clock: Clock, logger?: ILogger) {
this.#fs = fs;
this.#clock = clock;
this.#logger = logger;
// A synchronous, direct node:fs call is a deliberate exception to the IFileSystem abstraction
// used everywhere else in this class: this runs on the 'exit' event, where async work cannot
// complete, so there is no IFileSystem call this could route through.
this.#onExit = () => {
for (const dir of this.#allConfigDirs) {
try {
Expand Down Expand Up @@ -151,7 +155,7 @@ export class AzSessionCache {
// path every login for this account/identity reuses, so discarding it here would destroy the
// persistence the whole interactive path exists for.
if (deps.getIdentity(account, identity).type === 'cert') {
await removeConfigDir(result.configDir);
await removeConfigDir(this.#fs, result.configDir);
this.#allConfigDirs.delete(result.configDir);
}
return;
Expand All @@ -163,21 +167,28 @@ export class AzSessionCache {
async #doLogin(deps: AzDeps, identity: 'reader' | 'holder', account: string, cwd: string, key: string, signal?: AbortSignal): Promise<Session | { loginFailed: RunResult }> {
const identityConfig = deps.getIdentity(account, identity);
const tenantId = deps.getTenantId(account);
const extensionDir = await ensureAzExtensionDir();
const extensionDir = await ensureAzExtensionDir(this.#fs);

// Cert-SP gets a fresh throwaway dir per login (cheap, silent relogin — no reason to keep it
// around, and #onExit sweeps it). Interactive gets a stable dir reused across restarts, so its
// MSAL token cache survives a CLI restart without forcing MFA again — never swept on exit.
const configDir = identityConfig.type === 'cert' ? await mkdtemp(join(tmpdir(), 'az-')) : await ensureAzInteractiveSessionDir(account, identity);
const configDir = identityConfig.type === 'cert' ? await this.#fs.mkdtemp('az-') : await ensureAzInteractiveSessionDir(this.#fs, account, identity);
if (identityConfig.type === 'cert') {
this.#allConfigDirs.add(configDir);
}
const env = { ...stripAmbientAzureEnv(process.env), AZURE_CONFIG_DIR: configDir, AZURE_EXTENSION_DIR: extensionDir };

if (identityConfig.type === 'interactive') {
const reused = await this.#tryReuseInteractiveSession(deps.executor, cwd, env, tenantId, key);
if (reused != null) {
return reused;
}
}

const loginArgs = ['login', '--tenant', tenantId];
if (identityConfig.type === 'cert') {
const certPath = join(configDir, 'cert.pem');
await writeFile(certPath, deps.getCert(account, identity), { mode: 0o600 });
await this.#fs.writeFile(certPath, deps.getCert(account, identity), { mode: 0o600 });
loginArgs.push('--service-principal', '-u', identityConfig.clientId, '--certificate', certPath);
if (identityConfig.subscriptionIds.length === 0) {
loginArgs.push('--allow-no-subscriptions');
Expand All @@ -200,13 +211,12 @@ export class AzSessionCache {
}
if (login.exitCode !== 0) {
if (identityConfig.type === 'cert') {
await removeConfigDir(configDir);
await removeConfigDir(this.#fs, configDir);
this.#allConfigDirs.delete(configDir);
}
this.#logger?.warn('az_login_failed', { key, exitCode: login.exitCode, durationMs: this.#clock.millis() - loginStartedAt });
return { loginFailed: login };
}

const loginAt = this.#clock.millis();
const lifetimeMs = await this.#tokenLifetimeMs(deps.executor, cwd, env);
const refreshAt = loginAt + lifetimeMs * REFRESH_FRACTION;
Expand All @@ -222,6 +232,45 @@ export class AzSessionCache {
return { configDir, extensionDir, refreshAt, hardExpireAt };
}

/** Cold-start reuse check for an interactive identity only: the session dir from
* `ensureAzInteractiveSessionDir` is stable across CLI restarts, so a prior process may already
* have left a good login there. Queries the local account cache for an entry in the configured
* tenant — no network call, no token minted — and if one exists, skips `az login` entirely and
* reuses the existing session, computing `refreshAt`/`hardExpireAt` from the token's real
* lifetime exactly as a fresh login would. Returns null when nothing reusable is found, so the
* caller falls through to the normal `az login` flow unchanged. */
async #tryReuseInteractiveSession(executor: IExecutor, cwd: string, env: NodeJS.ProcessEnv, tenantId: string, key: string): Promise<Session | null> {
const probe = await runOnce(executor, 'az', ['account', 'list', '--output', 'json'], cwd, env);
const hasTenant = probe.exitCode === 0 && this.#accountListHasTenant(probe.stdout, tenantId);
if (!hasTenant) {
this.#logger?.debug('az_session_reuse_miss', { key });
return null;
}
const loginAt = this.#clock.millis();
const lifetimeMs = await this.#tokenLifetimeMs(executor, cwd, env);
const refreshAt = loginAt + lifetimeMs * REFRESH_FRACTION;
const hardExpireAt = loginAt + lifetimeMs * HARD_EXPIRE_FRACTION;
this.#logger?.info('az_session_reused_from_disk', {
key,
tokenLifetimeMs: lifetimeMs,
tokenLifetimeMinutes: Math.round(lifetimeMs / 60_000),
refreshAt: Instant.ofEpochMilli(refreshAt).toString(),
hardExpireAt: Instant.ofEpochMilli(hardExpireAt).toString(),
});
return { configDir: env.AZURE_CONFIG_DIR as string, extensionDir: env.AZURE_EXTENSION_DIR as string, refreshAt, hardExpireAt };
}

/** Parses `az account list`'s own JSON rather than filtering it server-side with a `--query` string
* built from `tenantId` — a tenant id containing `'` would otherwise break the JMESPath expression. */
#accountListHasTenant(stdout: string, tenantId: string): boolean {
try {
const accounts = JSON.parse(stdout) as Array<{ tenantId?: string }>;
return accounts.some((account) => account.tenantId === tenantId);
} catch {
return false;
}
}

/** Reads the token's real lifetime so refresh/expiry are bounded by fact, not an assumption. Falls
* back to a conservative default if `az` can't report an expiry (older CLI, transient failure) —
* the fallback only ever makes the cache refresh sooner than a longer-lived real token needed. */
Expand Down
14 changes: 7 additions & 7 deletions packages/claude-sdk-tools/src/az-shared.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdir, rm } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces';
import type { IExecutor } from '@shellicar/exec-core';

/** Azure CLI installs extensions (e.g. `azure-devops`) under AZURE_EXTENSION_DIR. Each escalated
Expand All @@ -11,8 +11,8 @@ import type { IExecutor } from '@shellicar/exec-core';
* once ever instead of once per call, which was the real cost behind every call being slow. */
const AZ_EXTENSION_DIR = join(homedir(), '.claude', 'az-extensions');

export async function ensureAzExtensionDir(): Promise<string> {
await mkdir(AZ_EXTENSION_DIR, { recursive: true });
export async function ensureAzExtensionDir(fs: IFileSystem): Promise<string> {
await fs.mkdir(AZ_EXTENSION_DIR);
return AZ_EXTENSION_DIR;
}

Expand All @@ -38,9 +38,9 @@ function platformDataDir(appName: string): string {
* without forcing a fresh interactive sign-in (MFA/CA prompt) every time. Cert-SP identities never
* use this: their relogin is silent and cheap, so they get a fresh throwaway dir per login instead
* (see `AzSessionCache`). */
export async function ensureAzInteractiveSessionDir(account: string, identity: 'reader' | 'holder'): Promise<string> {
export async function ensureAzInteractiveSessionDir(fs: IFileSystem, account: string, identity: 'reader' | 'holder'): Promise<string> {
const dir = join(platformDataDir('claude-sdk-cli'), 'az-sessions', `${account}-${identity}`);
await mkdir(dir, { recursive: true });
await fs.mkdir(dir);
return dir;
}

Expand Down Expand Up @@ -81,10 +81,10 @@ const REMOVE_RETRY_BASE_MS = 100;
* for a moment after the foreground command exits, racing a plain rm with ENOTEMPTY. Retry a few
* times with a short linear backoff before giving up — this is a timing issue on our side, not a
* reason to leave the temp dir behind. */
export async function removeConfigDir(dir: string): Promise<void> {
export async function removeConfigDir(fs: IFileSystem, dir: string): Promise<void> {
for (let attempt = 1; attempt <= REMOVE_ATTEMPTS; attempt++) {
try {
await rm(dir, { recursive: true, force: true });
await fs.deleteDirectoryRecursive(dir);
return;
} catch (err) {
if (attempt === REMOVE_ATTEMPTS) {
Expand Down
27 changes: 22 additions & 5 deletions packages/claude-sdk-tools/src/fs/NodeFileSystem.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createWriteStream, existsSync } from 'node:fs';
import { appendFile, readdir as fsReaddir, readlink as fsReadlink, realpath as fsRealpath, rename as fsRename, stat as fsStat, mkdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
import { homedir as osHomedir } from 'node:os';
import { dirname } from 'node:path';
import { appendFile, chmod, mkdtemp as fsMkdtemp, readdir as fsReaddir, readlink as fsReadlink, realpath as fsRealpath, rename as fsRename, stat as fsStat, mkdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
import { homedir as osHomedir, tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import type { Writable } from 'node:stream';
import { IFileSystem } from '@shellicar/claude-core/fs/interfaces';
import type { IFileEntry, StatResult } from '@shellicar/claude-core/fs/types';
Expand Down Expand Up @@ -34,9 +34,14 @@ export class NodeFileSystem extends IFileSystem {
return readFile(path, encoding);
}

public async writeFile(path: string, content: string): Promise<void> {
public async writeFile(path: string, content: string, options?: { mode?: number }): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content, 'utf-8');
await writeFile(path, content, { encoding: 'utf-8', mode: options?.mode });
// writeFile's mode option only applies when creating a new file, so an explicit chmod is needed
// to also lock down a file that already existed before this write with looser permissions.
if (options?.mode != null) {
await chmod(path, options.mode);
}
}

public async deleteFile(path: string): Promise<void> {
Expand All @@ -47,6 +52,18 @@ export class NodeFileSystem extends IFileSystem {
await rmdir(path);
}

public async deleteDirectoryRecursive(path: string): Promise<void> {
await rm(path, { recursive: true, force: true });
}

public async mkdir(path: string): Promise<void> {
await mkdir(path, { recursive: true });
}

public async mkdtemp(prefix: string): Promise<string> {
return fsMkdtemp(join(tmpdir(), prefix));
}

public async appendFile(path: string, content: string): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await appendFile(path, content, 'utf-8');
Expand Down
Loading
Loading