@khorsheed/dsh-local-agent-claude-code 0.2.0-rc.1 → 0.2.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.
@@ -97,6 +97,42 @@ export class ClaudeScopedModelMemory {
97
97
  }
98
98
  /** How long one transcript read-back answer is reused (repeated card opens). */
99
99
  export const OBSERVED_MODEL_CACHE_TTL_MS = 60_000;
100
+ /**
101
+ * Scope the served directory to the surface asking for it (the same helper
102
+ * lives in each provider's broker — fold into the family core when the
103
+ * directory faces next evolve):
104
+ *
105
+ * - memberless (the card's add-model dialog, the invite picker): the
106
+ * endpoint's served catalog only — the configuration/history extras the
107
+ * broker's info extension adds for STATUS display must never become add
108
+ * candidates;
109
+ * - a member whose harness has a curated list: the list IS the vocabulary,
110
+ * in the list's own order (the same rule `choices` already follows) — a
111
+ * curated value the endpoint no longer serves stays as a bare row (the
112
+ * card shows it the same way), and the member's current effective model is
113
+ * kept visible as a bare entry when it sits outside the list, so a pre-list
114
+ * binding never renders blank;
115
+ * - a member without a curated list: the legacy extended vocabulary.
116
+ */
117
+ function scopeServedDirectory(directory, opts) {
118
+ if (opts.member && opts.curated.length === 0)
119
+ return directory;
120
+ const served = directory.entries.filter(entry => entry.source === 'native');
121
+ if (!opts.member)
122
+ return { ...directory, entries: served };
123
+ const byValue = new Map(served.flatMap(entry => entry.resolvedModel === undefined
124
+ ? [[entry.value, entry]]
125
+ : [[entry.value, entry], [entry.resolvedModel, entry]]));
126
+ // Curated ORDER leads (the card's first row is the default); a curated value
127
+ // the endpoint no longer serves stays listed as a bare row, exactly like the
128
+ // card shows it.
129
+ const entries = opts.curated.map(value => byValue.get(value) ?? { value, label: value, source: 'configuration' });
130
+ const current = opts.current?.trim();
131
+ if (current !== undefined && current !== '' && !entries.some(entry => entry.value === current || entry.resolvedModel === current)) {
132
+ entries.push({ value: current, label: current, source: 'configuration' });
133
+ }
134
+ return { ...directory, entries };
135
+ }
100
136
  export class ClaudeModelBroker {
101
137
  deps;
102
138
  constructor(deps) {
@@ -111,7 +147,7 @@ export class ClaudeModelBroker {
111
147
  if (directory.status === 'loading')
112
148
  directory = await context.directory(true);
113
149
  const model = selection.model.mode === 'value' ? selection.model.value
114
- : (selection.model.mode === 'inherit' ? binding.model : undefined) ?? this.deps.settingsModel()
150
+ : (selection.model.mode === 'inherit' ? binding.model : undefined) ?? this.deps.settingsModel(binding.scope)
115
151
  ?? await readClaudeConfiguredModel(context.home) ?? directory.defaultModel;
116
152
  const entry = directory.entries.find(entry => entry.value === model || entry.resolvedModel === model);
117
153
  const effort = (selection.effort.mode === 'value' ? selection.effort.value : selection.effort.mode === 'inherit' ? binding.effort : undefined)
@@ -136,8 +172,14 @@ export class ClaudeModelBroker {
136
172
  async modelDirectory(childSessionId, refresh = false) {
137
173
  if (refresh)
138
174
  await this.deps.directory?.(childSessionId, true);
139
- const info = await this.modelInfo(childSessionId, childSessionId === undefined ? undefined : this.deps.localAgent.getDelegation(childSessionId)?.model);
140
- return info.directory ?? { entries: [], complete: false, customInput: true, status: 'unsupported', refreshing: false, revision: 0 };
175
+ const record = childSessionId === undefined ? undefined : this.deps.localAgent.getDelegation(childSessionId);
176
+ const info = await this.modelInfo(childSessionId, record?.model);
177
+ const directory = info.directory ?? { entries: [], complete: false, customInput: true, status: 'unsupported', refreshing: false, revision: 0 };
178
+ return scopeServedDirectory(directory, {
179
+ member: childSessionId !== undefined,
180
+ curated: this.deps.modelList?.(record?.scope) ?? [],
181
+ current: info.effective,
182
+ });
141
183
  }
142
184
  async *followModelDirectory(childSessionId, signal) {
143
185
  if (this.deps.followDirectory === undefined) {
@@ -179,9 +221,10 @@ export class ClaudeModelBroker {
179
221
  * would a round run with" read.
180
222
  */
181
223
  async modelInfo(childSessionId, delegationModel) {
224
+ const record = childSessionId === undefined ? undefined : this.deps.localAgent.getDelegation(childSessionId);
182
225
  const override = childSessionId === undefined ? undefined : this.deps.overrides.get(childSessionId);
183
226
  const delegation = delegationModel?.trim() === '' ? undefined : delegationModel?.trim();
184
- const settings = this.deps.settingsModel();
227
+ const settings = this.deps.settingsModel(record?.scope);
185
228
  const cliDefault = await this.deps.cliDefault(childSessionId);
186
229
  const native = await this.deps.directory?.(childSessionId);
187
230
  const [effective, source] = override !== undefined
@@ -193,9 +236,14 @@ export class ClaudeModelBroker {
193
236
  : cliDefault !== undefined
194
237
  ? [cliDefault, 'cli-config']
195
238
  : [native?.defaultModel, 'cli-builtin'];
196
- // Native candidates plus labelled configuration and historical suggestions.
197
- const choices = [...new Set([settings, cliDefault, ...native?.entries.filter(entry => !entry.hidden).map(entry => entry.value) ?? [], ...this.deps.recentModels()]
198
- .filter((value) => value !== undefined))];
239
+ // The curated list is the whole vocabulary when non-empty — nothing from
240
+ // outside the endpoint's served catalog sneaks in. Empty falls back to
241
+ // the pre-list chain (native candidates plus configuration and history).
242
+ const curated = this.deps.modelList?.(record?.scope) ?? [];
243
+ const choices = curated.length > 0
244
+ ? [...curated]
245
+ : [...new Set([settings, cliDefault, ...native?.entries.filter(entry => !entry.hidden).map(entry => entry.value) ?? [], ...this.deps.recentModels()]
246
+ .filter((value) => value !== undefined))];
199
247
  const lastObserved = await this.lastObserved(childSessionId);
200
248
  const active = childSessionId !== undefined && this.deps.localAgent.isDelegationActive(childSessionId);
201
249
  return {
@@ -207,7 +255,11 @@ export class ClaudeModelBroker {
207
255
  ...cliDefault === undefined ? {} : { cliDefault },
208
256
  ...lastObserved === undefined ? {} : { lastObserved },
209
257
  choices,
210
- ...native === undefined ? {} : { directory: extendModelDirectory(native, [settings, cliDefault], [...this.deps.recentModels(), lastObserved]) },
258
+ // The picker's extended vocabulary follows the same rule as choices:
259
+ // a curated list is complete on its own (no history entries sneak in);
260
+ // settings/cliDefault still extend so the member's CURRENT bound model
261
+ // stays selectable even when it is not in the list.
262
+ ...native === undefined ? {} : { directory: extendModelDirectory(native, [settings, cliDefault], curated.length > 0 ? [] : [...this.deps.recentModels(), lastObserved]) },
211
263
  live: this.deps.live(),
212
264
  switchable: !active,
213
265
  ...active
@@ -238,8 +290,9 @@ export class ClaudeModelBroker {
238
290
  else
239
291
  this.deps.overrides.set(childSessionId, next);
240
292
  // What the next spawn would bind, in the round resolvers' own order.
241
- const delegation = this.deps.localAgent.getDelegation(childSessionId)?.model?.trim();
242
- const spawnModel = next ?? (delegation === undefined || delegation === '' ? undefined : delegation) ?? this.deps.settingsModel();
293
+ const record = this.deps.localAgent.getDelegation(childSessionId);
294
+ const delegation = record?.model?.trim();
295
+ const spawnModel = next ?? (delegation === undefined || delegation === '' ? undefined : delegation) ?? this.deps.settingsModel(record?.scope);
243
296
  const host = this.deps.liveSwitch.hostingDriver(childSessionId);
244
297
  if (host !== undefined && host.runtimeModel(childSessionId) !== spawnModel) {
245
298
  await host.retireRuntime(childSessionId);
@@ -1,16 +1,45 @@
1
- import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess';
2
1
  import type { LocalAgentModelDirectory, LocalAgentModelDirectoryData } from '@khorsheed/dsh-local-agent/types';
2
+ import type { ClaudeEndpointAuth, EndpointCheckFetch } from './endpoint-auth.ts';
3
3
  type JsonObject = Record<string, unknown>;
4
- /** Initialize's native picker vocabulary, not an account entitlement claim. */
4
+ /**
5
+ * Fold the CLI's own `initialize` model vocabulary. Consumed by the LIVE
6
+ * DRIVER's runtime init handling; the catalog no longer reads this — a
7
+ * local list cannot prove anything about the configured endpoint, and
8
+ * showing it made an invalid credential look valid.
9
+ */
5
10
  export declare function claudeDirectory(result: JsonObject): LocalAgentModelDirectoryData;
6
- /** Scoped native control probe. Never syncs credentials, writes settings or submits a user turn. */
11
+ /**
12
+ * The model directory, sourced from the configured ENDPOINT (`GET
13
+ * <baseUrl>/v1/models` with the resolved credential) — never from the local
14
+ * CLI: what the picker lists is exactly what the endpoint serves, so a
15
+ * credential that does not work can never hide behind a locally-known list.
16
+ * The answers are deliberate:
17
+ *
18
+ * - no credential resolved → empty with a "save a key first" reason;
19
+ * - 401/403 → empty with the rejection (definitive — cached as data);
20
+ * - another HTTP status → empty with it (the relay serves no model route);
21
+ * - a transport failure THROWS, so the cache keeps the last good list and
22
+ * retries on schedule instead of blanking a momentarily-down endpoint.
23
+ *
24
+ * Effort levels ride the served entries' `effort` capability when the
25
+ * endpoint advertises them. Entries carry `source: 'native'` — they are the
26
+ * harness's native vocabulary, just read from the endpoint in force.
27
+ */
7
28
  export declare class ClaudeModelCatalog {
8
29
  private readonly deps;
9
30
  private readonly cache;
10
31
  constructor(deps: {
11
- spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle;
12
- baseUrl?: string;
13
- cli?: string;
32
+ /** Per-probe endpoint credential resolver (the scope's file over the settings layer). */
33
+ endpoint?: (homeDir: string) => Promise<ClaudeEndpointAuth>;
34
+ /**
35
+ * The cache key's endpoint component: the settings layer's baseUrl plus a
36
+ * short credential digest, resolved per read so a card write (either
37
+ * field) re-keys the cache. A scope FILE's contribution needs no place
38
+ * here — the file itself is in the key's fingerprinted file list.
39
+ */
40
+ endpointLabel?: () => string | undefined;
41
+ /** The HTTP fetch (injected in tests). */
42
+ fetchImpl?: EndpointCheckFetch;
14
43
  timeoutMs?: number;
15
44
  ttlMs?: number;
16
45
  warn?: (message: string) => void;
@@ -1,8 +1,12 @@
1
- import { dirname, join } from 'node:path';
2
- import { StringDecoder } from 'node:string_decoder';
3
- import { ModelDirectoryCache, delegationEnv, modelDirectoryContextKey } from '@khorsheed/dsh-local-agent';
4
- import { ClaudeControlRequests } from './control-requests.js';
5
- /** Initialize's native picker vocabulary, not an account entitlement claim. */
1
+ import { join } from 'node:path';
2
+ import { ModelDirectoryCache, modelDirectoryContextKey } from '@khorsheed/dsh-local-agent';
3
+ import { fetchEndpointModels, hasEndpointCredential, OFFICIAL_BASE_URL } from './endpoint-auth.js';
4
+ /**
5
+ * Fold the CLI's own `initialize` model vocabulary. Consumed by the LIVE
6
+ * DRIVER's runtime init handling; the catalog no longer reads this — a
7
+ * local list cannot prove anything about the configured endpoint, and
8
+ * showing it made an invalid credential look valid.
9
+ */
6
10
  export function claudeDirectory(result) {
7
11
  const models = result['models'];
8
12
  if (models === undefined)
@@ -28,7 +32,27 @@ export function claudeDirectory(result) {
28
32
  }
29
33
  return { entries: [...entries.values()], complete: true, customInput: true, ...entries.has('default') ? { defaultModel: 'default' } : {} };
30
34
  }
31
- /** Scoped native control probe. Never syncs credentials, writes settings or submits a user turn. */
35
+ /** One directory answer that names why it is empty. */
36
+ function unsupported(reason) {
37
+ return { entries: [], complete: false, customInput: true, unsupported: true, reason };
38
+ }
39
+ /**
40
+ * The model directory, sourced from the configured ENDPOINT (`GET
41
+ * <baseUrl>/v1/models` with the resolved credential) — never from the local
42
+ * CLI: what the picker lists is exactly what the endpoint serves, so a
43
+ * credential that does not work can never hide behind a locally-known list.
44
+ * The answers are deliberate:
45
+ *
46
+ * - no credential resolved → empty with a "save a key first" reason;
47
+ * - 401/403 → empty with the rejection (definitive — cached as data);
48
+ * - another HTTP status → empty with it (the relay serves no model route);
49
+ * - a transport failure THROWS, so the cache keeps the last good list and
50
+ * retries on schedule instead of blanking a momentarily-down endpoint.
51
+ *
52
+ * Effort levels ride the served entries' `effort` capability when the
53
+ * endpoint advertises them. Entries carry `source: 'native'` — they are the
54
+ * harness's native vocabulary, just read from the endpoint in force.
55
+ */
32
56
  export class ClaudeModelCatalog {
33
57
  deps;
34
58
  cache;
@@ -44,75 +68,41 @@ export class ClaudeModelCatalog {
44
68
  });
45
69
  }
46
70
  key(homeDir, cwd = homeDir) {
47
- const files = [join(homeDir, 'settings.json'), join(homeDir, '.credentials.json'), join(homeDir, '.claude.json')];
48
- for (let directory = cwd;; directory = dirname(directory)) {
49
- files.push(join(directory, '.claude', 'settings.json'), join(directory, '.claude', 'settings.local.json'));
50
- if (dirname(directory) === directory)
51
- break;
52
- }
53
- return modelDirectoryContextKey({ provider: 'claude-code', homeDir, cwd, cli: [this.deps.cli ?? 'claude'], files, ...this.deps.baseUrl === undefined ? {} : { endpoint: this.deps.baseUrl } });
71
+ const files = [join(homeDir, 'settings.json'), join(homeDir, 'endpoint-auth.json')];
72
+ const endpointLabel = this.deps.endpointLabel?.();
73
+ return modelDirectoryContextKey({ provider: 'claude-code', homeDir, cwd, cli: [], files, ...endpointLabel === undefined ? {} : { endpoint: endpointLabel } });
54
74
  }
55
75
  read(homeDir, cwd) { return this.cache.read(this.key(homeDir, cwd)); }
56
76
  refresh(homeDir, cwd) { return this.cache.refresh(this.key(homeDir, cwd)); }
57
77
  follow(homeDir, cwd, signal) { return this.cache.follow(() => this.key(homeDir, cwd), signal); }
58
78
  invalidate(homeDir, cwd) { this.cache.invalidate(this.key(homeDir, cwd)); }
59
79
  dispose() { this.cache.dispose(); }
60
- async probe(homeDir, cwd, signal) {
61
- const child = this.deps.spawn({
62
- argv: [this.deps.cli ?? 'claude', '-p', '--verbose', '--input-format', 'stream-json', '--output-format', 'stream-json',
63
- '--no-session-persistence', '--settings', '{"disableAllHooks":true}', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--'],
64
- cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, graceMs: 2_000,
65
- env: delegationEnv({ CLAUDE_CONFIG_DIR: homeDir, ...this.deps.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: this.deps.baseUrl } }),
66
- });
67
- const controls = new ClaudeControlRequests(frame => {
68
- if (child.stdin === undefined)
69
- throw new Error('Claude discovery stdin is unavailable');
70
- child.stdin.write(JSON.stringify(frame) + '\n');
71
- }, this.deps.timeoutMs ?? 5_000);
72
- const decoder = new StringDecoder('utf8');
73
- let buffer = '';
74
- child.stdout?.on('data', (chunk) => {
75
- buffer += decoder.write(chunk);
76
- if (buffer.length > 1_048_576) {
77
- controls.close();
78
- child.terminate();
79
- return;
80
- }
81
- let index;
82
- while ((index = buffer.indexOf('\n')) >= 0) {
83
- const line = buffer.slice(0, index);
84
- buffer = buffer.slice(index + 1);
85
- let event;
86
- try {
87
- event = JSON.parse(line);
88
- }
89
- catch {
90
- continue;
91
- }
92
- if (controls.accept(event))
93
- continue;
94
- if (event['type'] === 'control_request' && typeof event['request_id'] === 'string') {
95
- child.stdin?.write(JSON.stringify({ type: 'control_response', response: { subtype: 'error', request_id: event['request_id'], error: 'Model discovery does not execute tools' } }) + '\n');
96
- }
97
- }
98
- });
99
- child.stderr?.on('data', () => { });
100
- void child.done.then(() => controls.close(), () => controls.close());
101
- let cleanupTimer;
102
- try {
103
- return claudeDirectory(await controls.request({ subtype: 'initialize', hooks: {} }, signal));
80
+ async probe(homeDir, _cwd, signal) {
81
+ const endpoint = await this.deps.endpoint?.(homeDir);
82
+ if (!hasEndpointCredential(endpoint)) {
83
+ return unsupported('No endpoint credential configured — the model list loads from the endpoint once an API Key or Auth Token is saved');
104
84
  }
105
- finally {
106
- controls.close();
107
- child.stdin?.end();
108
- await Promise.race([child.done.catch(() => { }), new Promise(resolve => {
109
- cleanupTimer = setTimeout(resolve, 1_000);
110
- cleanupTimer.unref();
111
- })]);
112
- if (cleanupTimer !== undefined)
113
- clearTimeout(cleanupTimer);
114
- child.terminate();
115
- await child.waitForExit().catch(() => { });
85
+ const result = await fetchEndpointModels({ ...endpoint, baseUrl: endpoint.baseUrl ?? OFFICIAL_BASE_URL }, this.deps.fetchImpl, AbortSignal.any([signal, AbortSignal.timeout(this.deps.timeoutMs ?? 20_000)]));
86
+ if (result.kind === 'unreachable') {
87
+ // Not a verdict: the cache keeps the last good list and retries.
88
+ throw new Error(`endpoint unreachable: ${result.message}`);
89
+ }
90
+ if (result.kind === 'malformed')
91
+ return unsupported('The endpoint answered but the model list did not parse (not /v1/models-shaped)');
92
+ if (result.kind === 'http') {
93
+ if (result.status === 401 || result.status === 403) {
94
+ return unsupported(`The endpoint rejected the credential (HTTP ${String(result.status)}) — the list loads once a valid key is saved`);
95
+ }
96
+ return unsupported(`The endpoint does not serve a model list (HTTP ${String(result.status)})`);
116
97
  }
98
+ const entries = result.models.map(model => ({
99
+ value: model.id,
100
+ label: model.displayName ?? model.id,
101
+ source: 'native',
102
+ ...model.efforts === undefined ? {} : { reasoning: { options: model.efforts.map(effort => ({ value: effort, label: effort })) } },
103
+ }));
104
+ if (entries.length === 0)
105
+ return unsupported('The endpoint serves no models');
106
+ return { entries, complete: true, customInput: true };
117
107
  }
118
108
  }
@@ -1,29 +1,47 @@
1
1
  /**
2
- * Claude Code scoped-home provisioning. Unlike codex (whose credential store
3
- * must be pinned to a file) and kimi (which needs a provider config),
4
- * claude's `CLAUDE_CONFIG_DIR` scoped home needs nothing beyond the
5
- * directory itself: login via `claude auth login` writes `.claude.json`
6
- * (with the `oauthAccount` record) into it, and the real credential lands in
7
- * the macOS keychain under a hashed entry or — on Linux, despite upstream
8
- * bug #47661 — in the default `~/.claude/.credentials.json` while the config
9
- * stays scoped. Sign-out removes the scoped config so `status` reports not
10
- * authenticated; the keychain entry on macOS persists until the CLI removes
11
- * it, which the framework's file-based logout cannot reach.
2
+ * Claude Code scoped-home provisioning. The harness authenticates by
3
+ * endpoint key (see endpoint-auth.ts), so a scoped home needs nothing beyond
4
+ * the directory itself: `claude -p` scaffolds `.claude.json` on its first
5
+ * run, and the endpoint credential arrives per round through the spawn env
6
+ * (resolved from the scope's endpoint-auth.json over the plugin config).
7
+ * When `proxyUrl` is configured, the scoped `settings.json` gains an env
8
+ * block pointing the CLI's own traffic at it — claude reads proxy settings
9
+ * from this file, so the child works even when the host process environment
10
+ * carries no proxy (a supervisor-spawned instance's env is not the user's
11
+ * shell env).
12
12
  * @module @khorsheed/dsh-local-agent-claude-code/provision
13
13
  */
14
14
  /**
15
15
  * Provision the scoped home directory. Claude creates it lazily on first
16
16
  * use, but creating it eagerly keeps the harness's homeDir contract uniform
17
- * with the other harnesses. When `proxyUrl` is configured, the scoped
18
- * `settings.json` gains an env block pointing the CLI's own traffic at it —
19
- * claude reads proxy settings from this file, so the child works even when
20
- * the host process environment carries no proxy (a supervisor-spawned
21
- * instance's env is not the user's shell env). An existing settings file is
22
- * merged, not overwritten; only the two proxy keys are managed.
17
+ * with the other harnesses. Two maintenance duties run here:
18
+ *
19
+ * - Legacy OAuth sweep: the endpoint-key era never reads `.credentials.json`
20
+ * or the `.claude.json` `oauthAccount` marker — both are deleted so a
21
+ * pre-migration scoped home carries no dead grant (measured: claude
22
+ * 2.1.296 prefers the env credential over a stored one anyway; the sweep
23
+ * is hygiene, not priority repair). Other `.claude.json` keys survive.
24
+ * - When `proxyUrl` is configured, the scoped `settings.json` gains an env
25
+ * block pointing the CLI's own traffic at it — claude reads proxy settings
26
+ * from this file, so the child works even when the host process environment
27
+ * carries no proxy (a supervisor-spawned instance's env is not the user's
28
+ * shell env). An existing settings file is merged, not overwritten; only
29
+ * the two proxy keys are managed.
23
30
  * @param homeDir - the `claude-code` harness's scoped home.
24
31
  * @param proxyUrl - optional HTTP proxy URL for the child CLI's traffic.
25
32
  */
26
33
  export declare function provisionClaudeHome(homeDir: string, proxyUrl?: string): Promise<void>;
34
+ /**
35
+ * Remove the OAuth era's artifacts from the scoped home: the
36
+ * `.credentials.json` grant file outright, and the `oauthAccount` marker key
37
+ * from `.claude.json` (the file itself carries live CLI state and stays).
38
+ * Idempotent and best-effort per file — a missing or malformed file is
39
+ * simply not a sweep target. The macOS keychain entry is deliberately left
40
+ * alone: the CLI never consults it once an env credential rides the spawn,
41
+ * and keychain writes are outside the harness's file-based contract.
42
+ * @param homeDir - the `claude-code` harness's scoped home.
43
+ */
44
+ export declare function sweepLegacyOauth(homeDir: string): Promise<void>;
27
45
  /**
28
46
  * Read the scoped settings' configured model: the `model` key of
29
47
  * `<homeDir>/settings.json` — the model a delegation round pins when the
@@ -49,15 +67,4 @@ export declare function readClaudeConfiguredEffort(homeDir: string): Promise<str
49
67
  * @param model - the model to scratch in, or undefined to remove the key.
50
68
  */
51
69
  export declare function writeClaudeScopedModel(homeDir: string, model: string | undefined): Promise<void>;
52
- /**
53
- * Sign out of the scoped account: remove the scoped config file, so
54
- * `/<name> status` reports not authenticated and the next login authorizes
55
- * a fresh account. The macOS keychain entry (a hashed
56
- * `Claude Code-credentials-<sha256(path)[:8]>` record keyed to the scoped
57
- * home path) is NOT removed — the CLI's `claude auth logout` owns that, and
58
- * the harness logout contract is file-based; the stale entry is harmless
59
- * because a fresh login rewrites the same hashed slot.
60
- * @param homeDir - the `claude-code` harness's scoped home.
61
- */
62
- export declare function claudeLogout(homeDir: string): Promise<void>;
63
70
  //# sourceMappingURL=provision.d.ts.map
@@ -1,36 +1,42 @@
1
1
  /**
2
- * Claude Code scoped-home provisioning. Unlike codex (whose credential store
3
- * must be pinned to a file) and kimi (which needs a provider config),
4
- * claude's `CLAUDE_CONFIG_DIR` scoped home needs nothing beyond the
5
- * directory itself: login via `claude auth login` writes `.claude.json`
6
- * (with the `oauthAccount` record) into it, and the real credential lands in
7
- * the macOS keychain under a hashed entry or — on Linux, despite upstream
8
- * bug #47661 — in the default `~/.claude/.credentials.json` while the config
9
- * stays scoped. Sign-out removes the scoped config so `status` reports not
10
- * authenticated; the keychain entry on macOS persists until the CLI removes
11
- * it, which the framework's file-based logout cannot reach.
2
+ * Claude Code scoped-home provisioning. The harness authenticates by
3
+ * endpoint key (see endpoint-auth.ts), so a scoped home needs nothing beyond
4
+ * the directory itself: `claude -p` scaffolds `.claude.json` on its first
5
+ * run, and the endpoint credential arrives per round through the spawn env
6
+ * (resolved from the scope's endpoint-auth.json over the plugin config).
7
+ * When `proxyUrl` is configured, the scoped `settings.json` gains an env
8
+ * block pointing the CLI's own traffic at it — claude reads proxy settings
9
+ * from this file, so the child works even when the host process environment
10
+ * carries no proxy (a supervisor-spawned instance's env is not the user's
11
+ * shell env).
12
12
  * @module @khorsheed/dsh-local-agent-claude-code/provision
13
13
  */
14
14
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
15
15
  import { join } from 'node:path';
16
- /** The scoped config file holding the oauthAccount auth marker. */
17
- const SCOPED_CONFIG = '.claude.json';
18
16
  /** The scoped settings file carrying the CLI's env block. */
19
17
  const SCOPED_SETTINGS = 'settings.json';
20
18
  /**
21
19
  * Provision the scoped home directory. Claude creates it lazily on first
22
20
  * use, but creating it eagerly keeps the harness's homeDir contract uniform
23
- * with the other harnesses. When `proxyUrl` is configured, the scoped
24
- * `settings.json` gains an env block pointing the CLI's own traffic at it —
25
- * claude reads proxy settings from this file, so the child works even when
26
- * the host process environment carries no proxy (a supervisor-spawned
27
- * instance's env is not the user's shell env). An existing settings file is
28
- * merged, not overwritten; only the two proxy keys are managed.
21
+ * with the other harnesses. Two maintenance duties run here:
22
+ *
23
+ * - Legacy OAuth sweep: the endpoint-key era never reads `.credentials.json`
24
+ * or the `.claude.json` `oauthAccount` marker — both are deleted so a
25
+ * pre-migration scoped home carries no dead grant (measured: claude
26
+ * 2.1.296 prefers the env credential over a stored one anyway; the sweep
27
+ * is hygiene, not priority repair). Other `.claude.json` keys survive.
28
+ * - When `proxyUrl` is configured, the scoped `settings.json` gains an env
29
+ * block pointing the CLI's own traffic at it — claude reads proxy settings
30
+ * from this file, so the child works even when the host process environment
31
+ * carries no proxy (a supervisor-spawned instance's env is not the user's
32
+ * shell env). An existing settings file is merged, not overwritten; only
33
+ * the two proxy keys are managed.
29
34
  * @param homeDir - the `claude-code` harness's scoped home.
30
35
  * @param proxyUrl - optional HTTP proxy URL for the child CLI's traffic.
31
36
  */
32
37
  export async function provisionClaudeHome(homeDir, proxyUrl) {
33
38
  await mkdir(homeDir, { recursive: true });
39
+ await sweepLegacyOauth(homeDir);
34
40
  if (proxyUrl === undefined)
35
41
  return;
36
42
  const path = join(homeDir, SCOPED_SETTINGS);
@@ -49,6 +55,31 @@ export async function provisionClaudeHome(homeDir, proxyUrl) {
49
55
  settings.env = env;
50
56
  await writeFile(path, JSON.stringify(settings, undefined, 2) + '\n');
51
57
  }
58
+ /**
59
+ * Remove the OAuth era's artifacts from the scoped home: the
60
+ * `.credentials.json` grant file outright, and the `oauthAccount` marker key
61
+ * from `.claude.json` (the file itself carries live CLI state and stays).
62
+ * Idempotent and best-effort per file — a missing or malformed file is
63
+ * simply not a sweep target. The macOS keychain entry is deliberately left
64
+ * alone: the CLI never consults it once an env credential rides the spawn,
65
+ * and keychain writes are outside the harness's file-based contract.
66
+ * @param homeDir - the `claude-code` harness's scoped home.
67
+ */
68
+ export async function sweepLegacyOauth(homeDir) {
69
+ await rm(join(homeDir, '.credentials.json'), { force: true });
70
+ const configPath = join(homeDir, '.claude.json');
71
+ let config;
72
+ try {
73
+ config = JSON.parse(await readFile(configPath, 'utf8'));
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ if (typeof config !== 'object' || config === null || !('oauthAccount' in config))
79
+ return;
80
+ delete config['oauthAccount'];
81
+ await writeFile(configPath, JSON.stringify(config, undefined, 2) + '\n');
82
+ }
52
83
  /**
53
84
  * Read the scoped settings' configured model: the `model` key of
54
85
  * `<homeDir>/settings.json` — the model a delegation round pins when the
@@ -117,16 +148,3 @@ export async function writeClaudeScopedModel(homeDir, model) {
117
148
  }
118
149
  await writeFile(path, JSON.stringify(settings, undefined, 2) + '\n');
119
150
  }
120
- /**
121
- * Sign out of the scoped account: remove the scoped config file, so
122
- * `/<name> status` reports not authenticated and the next login authorizes
123
- * a fresh account. The macOS keychain entry (a hashed
124
- * `Claude Code-credentials-<sha256(path)[:8]>` record keyed to the scoped
125
- * home path) is NOT removed — the CLI's `claude auth logout` owns that, and
126
- * the harness logout contract is file-based; the stale entry is harmless
127
- * because a fresh login rewrites the same hashed slot.
128
- * @param homeDir - the `claude-code` harness's scoped home.
129
- */
130
- export async function claudeLogout(homeDir) {
131
- await rm(join(homeDir, SCOPED_CONFIG), { force: true });
132
- }
@@ -11,71 +11,13 @@
11
11
  * timestamp the listing needs; the rest of the file (conversation) is not
12
12
  * parsed. Files are append-only with torn-tail tolerance, and contents are
13
13
  * always untrusted input.
14
+ *
15
+ * Credentials are NOT this module's business: the harness authenticates by
16
+ * endpoint key only (see endpoint-auth.ts), which needs no keychain read,
17
+ * no `.credentials.json` reconcile, and no OAuth marker probe.
14
18
  * @module @khorsheed/dsh-local-agent-claude-code/records
15
19
  */
16
- import { execFile } from 'node:child_process';
17
20
  import type { LocalAgentSessionRecord } from '@khorsheed/dsh-local-agent';
18
- /** Test-swappable process exec (the keychain read goes through `security`). */
19
- export declare const internals: {
20
- exec: typeof execFile.__promisify__;
21
- };
22
- /**
23
- * The scoped `.claude.json`'s modification stamp (epoch ms), undefined when
24
- * absent. A completed login rewrites this file, so the stamp distinguishes a
25
- * fresh login from a leftover marker (a revoked token's record lingers).
26
- * @param homeDir - the `claude-code` harness's scoped home.
27
- * @returns the marker's mtime, or undefined when no scoped config exists.
28
- */
29
- export declare function claudeCredentialStamp(homeDir: string): Promise<number | undefined>;
30
- /**
31
- * Reconcile the keychain credential with `<homeDir>/.credentials.json`,
32
- * NEWER WINS. Claude 2.1.236 on macOS WRITES the scoped login to the hashed
33
- * keychain entry but READS the credentials file at runtime (the same
34
- * write/read split as the Linux #47661 bug) — a login that lands only in the
35
- * keychain still answers "Not logged in", so the file has to be kept current.
36
- *
37
- * The file is not this process's private mirror, though: a containerized
38
- * round bind-mounts this very directory read-write (the unit has no keychain,
39
- * so the CLI inside it reads and writes the file), and the pre-spawn sync runs
40
- * against the HOST path moments before the unit reads it through the mount.
41
- * An unconditional mirror therefore makes this function the one participant
42
- * that can move the credential chain BACKWARD: it hands the next run a refresh
43
- * token the other writer already spent, the endpoint rejects it, and the CLI
44
- * answers by CLEARING the file — which logs the instance out, because that
45
- * file is the instance's credential. So the keychain blob is written only when
46
- * it is not older than the file's. Undefined expiry on either side keeps the
47
- * pre-newer-wins behavior (the keychain writes), and a file that is not USABLE
48
- * never wins.
49
- *
50
- * Only a USABLE blob (non-empty token, see `readKeychainCredential`) is
51
- * mirrored; with none in the keychain, a file holding an empty-token shell —
52
- * debris from an earlier first-match read, or a cleared credential — is
53
- * REMOVED, so the runtime says "Not logged in" instead of the misleading
54
- * "OAuth session expired". Idempotent and content-compare before write.
55
- * @param homeDir - the `claude-code` harness's scoped home.
56
- * @param log - warn channel for the two decisions that used to be silent.
57
- * @returns true when a usable credential is in the file after the call.
58
- */
59
- export declare function syncClaudeCredentialFile(homeDir: string, log?: (message: string) => void): Promise<boolean>;
60
- /**
61
- * Whether the scoped home holds usable Claude Code credentials: the scoped
62
- * `.claude.json` carries an `oauthAccount` record once a device-code login
63
- * has completed, AND the keychain credential is not past its stored expiry
64
- * (when the expiry is readable — a server-side REVOCATION is not visible
65
- * locally; providers report those through `localAgent.reportAuthFailure`).
66
- * The real credential lives in the macOS keychain under a hashed entry
67
- * (`Claude Code-credentials-<sha256(path)[:8]>`, hashed from the config-dir
68
- * path) or in the default `~/.claude/.credentials.json` on Linux (upstream
69
- * bug #47661: `CLAUDE_CONFIG_DIR` does not isolate the credentials file
70
- * there — it writes the scoped dir but READS the default home, so Linux
71
- * credentials may exist even when the scoped home has none). Spawning the
72
- * CLI to probe auth is avoided; the platform differences are documented in
73
- * the README.
74
- * @param homeDir - the `claude-code` harness's scoped home.
75
- * @param log - warn channel handed to the credential reconcile below.
76
- * @returns true when a completed login is recorded and not locally expired.
77
- */
78
- export declare function claudeAuthenticated(homeDir: string, log?: (message: string) => void): Promise<boolean>;
79
21
  /**
80
22
  * Parse the head of a session file for the listing fields. The first `user`
81
23
  * event carries `sessionId`, `cwd`, and `timestamp`; its `cwd` is the only