@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.
- package/CHANGELOG.md +27 -0
- package/README.en.md +24 -106
- package/README.i18n.yaml +2 -2
- package/README.md +24 -106
- package/lib/client.js +842 -315
- package/lib/index.js +1707 -1392
- package/lib/types/claude-cli-provider.d.ts +86 -6
- package/lib/types/claude-cli-provider.js +391 -146
- package/lib/types/client/SettingsCard.d.ts +75 -24
- package/lib/types/client/SettingsCard.js +106 -128
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/index.d.ts +12 -25
- package/lib/types/client/index.js +135 -50
- package/lib/types/client/locales.d.ts +36 -24
- package/lib/types/client/locales.js +40 -28
- package/lib/types/endpoint-auth.d.ts +181 -0
- package/lib/types/endpoint-auth.js +297 -0
- package/lib/types/index.d.ts +32 -16
- package/lib/types/index.js +128 -62
- package/lib/types/live-driver.d.ts +9 -6
- package/lib/types/live-driver.js +277 -119
- package/lib/types/live-switch.d.ts +4 -1
- package/lib/types/model-broker.d.ts +4 -2
- package/lib/types/model-broker.js +63 -10
- package/lib/types/model-catalog.d.ts +35 -6
- package/lib/types/model-catalog.js +58 -68
- package/lib/types/provision.d.ts +34 -27
- package/lib/types/provision.js +49 -31
- package/lib/types/records.d.ts +4 -62
- package/lib/types/records.js +5 -382
- package/locale/en.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +20 -21
- package/lib/invariant.js +0 -31
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -33
|
@@ -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
|
|
140
|
-
|
|
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
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
|
242
|
-
const
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
/**
|
|
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, '.
|
|
48
|
-
|
|
49
|
-
|
|
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,
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
}
|
package/lib/types/provision.d.ts
CHANGED
|
@@ -1,29 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Claude Code scoped-home provisioning.
|
|
3
|
-
*
|
|
4
|
-
* claude
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
package/lib/types/provision.js
CHANGED
|
@@ -1,36 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Claude Code scoped-home provisioning.
|
|
3
|
-
*
|
|
4
|
-
* claude
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
}
|
package/lib/types/records.d.ts
CHANGED
|
@@ -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
|