@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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Endpoint-key authentication — the claude-code harness's ONLY auth mode.
3
+ * There is no OAuth anywhere in this package: a delegation round reaches its
4
+ * endpoint with `ANTHROPIC_BASE_URL` plus `ANTHROPIC_API_KEY` (x-api-key) or
5
+ * `ANTHROPIC_AUTH_TOKEN` (Authorization: Bearer), exactly the environment a
6
+ * shell would export for a proxy relay, and `claude -p` needs nothing else
7
+ * (verified against claude 2.1.296: a fresh scoped home with only these
8
+ * variables runs a round and scaffolds `.claude.json` itself — no onboarding
9
+ * or key-approval gate in headless mode).
10
+ *
11
+ * Two layers, merged per field, file over config:
12
+ *
13
+ * - `<scopedHome>/endpoint-auth.json` (mode 0600) — the per-scope credential.
14
+ * Its name deliberately matches the eval contract hash's credential denylist
15
+ * (`auth`), so the key material never enters an evaluation's condition
16
+ * fingerprint; the file's mtime serves as the credential stamp, which makes
17
+ * "edit the file" the recovery gesture for a `rejected` grade.
18
+ * - The plugin config's `baseUrl` / `apiKey` / `authToken` — the deployment
19
+ * default every scope without a file shares. Read PER ROUND from the
20
+ * apply-time snapshot, so a profile edit applies to the next round (and a
21
+ * host restart clears the in-memory `rejected` mark).
22
+ *
23
+ * The env layer this module emits rides the family's existing plumbing:
24
+ * `delegationEnv` on the host, and `containerExecSpawn`'s name-only `-e`
25
+ * forwarding into a container (the value travels in the docker client's
26
+ * environment, never on an argv). A containerized round therefore
27
+ * authenticates with the same credential as the host — nothing is authorized
28
+ * inside the unit, which is the point.
29
+ * @module @khorsheed/dsh-local-agent-claude-code/endpoint-auth
30
+ */
31
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
32
+ import { join } from 'node:path';
33
+ /**
34
+ * The scoped-home credential file's name. Chosen to match the eval contract
35
+ * hash's credential-shaped denylist (`/token|key|credential|secret|password|auth|\.env/i`),
36
+ * so the file is excluded from condition fingerprints by name.
37
+ */
38
+ export const ENDPOINT_AUTH_FILE = 'endpoint-auth.json';
39
+ /** Trim and normalize one credential field; blank reads as absent. */
40
+ function field(value) {
41
+ if (typeof value !== 'string')
42
+ return undefined;
43
+ const trimmed = value.trim();
44
+ return trimmed === '' ? undefined : trimmed;
45
+ }
46
+ /** Whether a resolved endpoint carries a usable credential. */
47
+ export function hasEndpointCredential(auth) {
48
+ return auth !== undefined && (field(auth.apiKey) !== undefined || field(auth.authToken) !== undefined);
49
+ }
50
+ /**
51
+ * Read the scope's endpoint-auth.json. Absent or unreadable yields undefined
52
+ * (the caller falls back to the plugin config); malformed JSON or a non-object
53
+ * payload also yields undefined — a torn file must not fail a status read.
54
+ * Non-string fields are dropped, never carried through.
55
+ * @param homeDir - the `claude-code` harness's scoped home.
56
+ * @returns the file's endpoint credential, or undefined.
57
+ */
58
+ export async function readEndpointAuthFile(homeDir) {
59
+ let text;
60
+ try {
61
+ text = await readFile(join(homeDir, ENDPOINT_AUTH_FILE), 'utf8');
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ try {
67
+ const parsed = JSON.parse(text);
68
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
69
+ return undefined;
70
+ const baseUrl = field(parsed['baseUrl']);
71
+ const apiKey = field(parsed['apiKey']);
72
+ const authToken = field(parsed['authToken']);
73
+ return {
74
+ ...baseUrl === undefined ? {} : { baseUrl },
75
+ ...apiKey === undefined ? {} : { apiKey },
76
+ ...authToken === undefined ? {} : { authToken },
77
+ };
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ }
83
+ /**
84
+ * Resolve one scope's effective endpoint credential: the scope's
85
+ * endpoint-auth.json merged FIELD BY FIELD over the plugin config's
86
+ * `baseUrl` / `apiKey` / `authToken` (a file that names only a key rotates
87
+ * the key while keeping the deployment's relay URL). The result may carry no
88
+ * credential at all — {@link hasEndpointCredential} is the judgment, kept
89
+ * separate so the caller can still route/log the resolved baseUrl.
90
+ * @param homeDir - the scope's scoped home.
91
+ * @param config - the plugin config's deployment-level endpoint triple.
92
+ * @returns the merged endpoint auth.
93
+ */
94
+ export async function resolveEndpointAuth(homeDir, config) {
95
+ const file = await readEndpointAuthFile(homeDir);
96
+ const configBaseUrl = field(config.baseUrl);
97
+ const configApiKey = field(config.apiKey);
98
+ const configAuthToken = field(config.authToken);
99
+ return {
100
+ ...configBaseUrl === undefined ? {} : { baseUrl: configBaseUrl },
101
+ ...configApiKey === undefined ? {} : { apiKey: configApiKey },
102
+ ...configAuthToken === undefined ? {} : { authToken: configAuthToken },
103
+ ...file,
104
+ };
105
+ }
106
+ /**
107
+ * The spawn-env layer for a resolved endpoint: one entry per defined field,
108
+ * nothing else. Undefined yields an empty layer — the spread then adds no
109
+ * keys at all, byte-identical to the pre-endpoint env.
110
+ * @param auth - the resolved endpoint auth, or undefined.
111
+ * @returns the explicit env entries for the delegation spawn.
112
+ */
113
+ export function endpointAuthEnv(auth) {
114
+ if (auth === undefined)
115
+ return {};
116
+ const baseUrl = field(auth.baseUrl);
117
+ const apiKey = field(auth.apiKey);
118
+ const authToken = field(auth.authToken);
119
+ return {
120
+ ...baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: baseUrl },
121
+ ...apiKey === undefined ? {} : { ANTHROPIC_API_KEY: apiKey },
122
+ ...authToken === undefined ? {} : { ANTHROPIC_AUTH_TOKEN: authToken },
123
+ };
124
+ }
125
+ /**
126
+ * The credential marker's modification stamp (epoch ms): the endpoint file's
127
+ * mtime, or undefined when the scope has no file (a config-sourced credential
128
+ * has no marker — rotating it pairs with the profile edit and instance
129
+ * restart that clear the in-memory grade marks). A fresh file write is a
130
+ * fresh stamp, which is how an edited-in key recovers a `rejected` grade.
131
+ * @param homeDir - the scope's scoped home.
132
+ * @returns the stamp, or undefined.
133
+ */
134
+ export async function endpointAuthStamp(homeDir) {
135
+ try {
136
+ return (await stat(join(homeDir, ENDPOINT_AUTH_FILE))).mtimeMs;
137
+ }
138
+ catch {
139
+ return undefined;
140
+ }
141
+ }
142
+ /** Whether the scope carries its own endpoint file at all. */
143
+ export async function endpointAuthFileExists(homeDir) {
144
+ return (await endpointAuthStamp(homeDir)) !== undefined;
145
+ }
146
+ /**
147
+ * Write the scope's endpoint-auth.json (mode 0600, mkdir -p first), the
148
+ * per-scope provisioning path an evaluation's `conditions provision` (or a
149
+ * person) uses to give one scope its own credential. Whole-file write: the
150
+ * payload is exactly what {@link readEndpointAuthFile} parses back.
151
+ * @param homeDir - the scope's scoped home.
152
+ * @param auth - the endpoint credential to persist.
153
+ */
154
+ export async function writeEndpointAuth(homeDir, auth) {
155
+ await mkdir(homeDir, { recursive: true });
156
+ const baseUrl = field(auth.baseUrl);
157
+ const apiKey = field(auth.apiKey);
158
+ const authToken = field(auth.authToken);
159
+ const payload = {
160
+ ...baseUrl === undefined ? {} : { baseUrl },
161
+ ...apiKey === undefined ? {} : { apiKey },
162
+ ...authToken === undefined ? {} : { authToken },
163
+ };
164
+ await writeFile(join(homeDir, ENDPOINT_AUTH_FILE), JSON.stringify(payload, undefined, 2) + '\n', { mode: 0o600 });
165
+ }
166
+ /**
167
+ * Clear the scope's OWN endpoint credential (the endpoint-auth.json file).
168
+ * A deployment-level credential from the plugin config is untouched — the
169
+ * core's logout reply then names the profile row as the place to edit.
170
+ * @param homeDir - the scope's scoped home.
171
+ */
172
+ export async function clearEndpointAuth(homeDir) {
173
+ await rm(join(homeDir, ENDPOINT_AUTH_FILE), { force: true });
174
+ }
175
+ /** The official endpoint used when no baseUrl is configured anywhere. */
176
+ export const OFFICIAL_BASE_URL = 'https://api.anthropic.com';
177
+ /** How many served model ids the check reply lists before truncating. */
178
+ const CHECK_MODEL_LIST_LIMIT = 8;
179
+ /** Bound on the error-body excerpt a failed check reports. */
180
+ const CHECK_ERROR_BODY_LIMIT = 200;
181
+ /** The effort levels one served-model entry advertises, oldest-first. */
182
+ function servedEfforts(effort) {
183
+ if (typeof effort !== 'object' || effort === null)
184
+ return undefined;
185
+ const levels = ['low', 'medium', 'high', 'xhigh', 'max']
186
+ .filter(level => effort[level]?.supported === true);
187
+ return levels.length === 0 ? undefined : levels;
188
+ }
189
+ /**
190
+ * The effort block's home in a served-model entry. Anthropic's current
191
+ * /v1/models shape nests it under `capabilities.effort` (verified against the
192
+ * openproxy relay 2026-10-22: `capabilities.effort.<level>.supported`); the
193
+ * flat top-level `effort` the parser was written against still wins when
194
+ * present, so either shape answers.
195
+ */
196
+ function servedEffortsOf(record) {
197
+ const direct = servedEfforts(record['effort']);
198
+ if (direct !== undefined)
199
+ return direct;
200
+ const capabilities = record['capabilities'];
201
+ if (typeof capabilities !== 'object' || capabilities === null)
202
+ return undefined;
203
+ return servedEfforts(capabilities['effort']);
204
+ }
205
+ /**
206
+ * `GET <baseUrl>/v1/models` with the resolved credential's auth header(s) —
207
+ * the token-free endpoint probe shared by the `/claude-code check` verdict
208
+ * and the model catalog. Anthropic's own API serves this route with
209
+ * `x-api-key`; relays (openproxy verified) proxy it and answer the same
210
+ * shape. Never throws: every failure mode folds into the result.
211
+ * @param endpoint - the resolved endpoint auth (caller checks the credential).
212
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
213
+ * @param signal - abort for the whole probe (timeout bundled by the caller).
214
+ * @returns the folded result.
215
+ */
216
+ export async function fetchEndpointModels(endpoint, fetchImpl, signal) {
217
+ const headers = { 'anthropic-version': '2023-06-01' };
218
+ if (endpoint.apiKey !== undefined)
219
+ headers['x-api-key'] = endpoint.apiKey;
220
+ if (endpoint.authToken !== undefined)
221
+ headers['authorization'] = `Bearer ${endpoint.authToken}`;
222
+ const fetcher = fetchImpl ?? globalThis.fetch;
223
+ const url = `${endpoint.baseUrl.replace(/\/+$/, '')}/v1/models`;
224
+ let response;
225
+ try {
226
+ response = await fetcher(url, { headers, signal: signal ?? AbortSignal.timeout(8_000) });
227
+ }
228
+ catch (error) {
229
+ return { kind: 'unreachable', message: error instanceof Error ? error.message : String(error) };
230
+ }
231
+ if (!response.ok) {
232
+ return { kind: 'http', status: response.status, body: (await response.text().catch(() => '')).slice(0, CHECK_ERROR_BODY_LIMIT) };
233
+ }
234
+ let data;
235
+ try {
236
+ data = JSON.parse(await response.text()).data;
237
+ }
238
+ catch {
239
+ return { kind: 'malformed' };
240
+ }
241
+ if (!Array.isArray(data))
242
+ return { kind: 'malformed' };
243
+ const models = [];
244
+ for (const entry of data) {
245
+ if (typeof entry !== 'object' || entry === null)
246
+ continue;
247
+ const record = entry;
248
+ if (typeof record.id !== 'string' || record.id === '')
249
+ continue;
250
+ const efforts = servedEffortsOf(record);
251
+ models.push({
252
+ id: record.id,
253
+ ...typeof record.display_name === 'string' && record.display_name !== '' ? { displayName: record.display_name } : {},
254
+ ...efforts === undefined ? {} : { efforts },
255
+ });
256
+ }
257
+ return { kind: 'models', models };
258
+ }
259
+ /**
260
+ * Probe the resolved endpoint with a cheap, token-free round-trip: `GET
261
+ * <baseUrl>/v1/models` with the resolved credential's auth header(s). A 200
262
+ * proves both reachability and credential validity — the exact evidence a
263
+ * "verify" button needs — and the served model list rides the reply so the
264
+ * user sees what the endpoint offers (any id can be typed into the model
265
+ * field by hand). One transport-level retry rides along: a local proxy
266
+ * restart (a Surge/clash reload) stalls the first attempt into the timeout
267
+ * and answers the retry immediately. Never throws: every failure mode folds
268
+ * into the reply text, since the reply IS the diagnostic.
269
+ * @param endpoint - the resolved endpoint auth (scope file over settings).
270
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
271
+ * @param timeoutMs - one attempt's deadline (two attempts at most).
272
+ * @returns the human-readable verdict for the `/claude-code check` reply.
273
+ */
274
+ export async function checkClaudeEndpoint(endpoint, fetchImpl, timeoutMs = 20_000) {
275
+ if (!hasEndpointCredential(endpoint)) {
276
+ return 'No endpoint credential configured — fill API Key or Auth Token on the settings card (or the profile row) first.';
277
+ }
278
+ const baseUrl = (endpoint.baseUrl ?? OFFICIAL_BASE_URL).replace(/\/+$/, '');
279
+ let result = await fetchEndpointModels({ ...endpoint, baseUrl }, fetchImpl, AbortSignal.timeout(timeoutMs));
280
+ if (result.kind === 'unreachable') {
281
+ result = await fetchEndpointModels({ ...endpoint, baseUrl }, fetchImpl, AbortSignal.timeout(timeoutMs));
282
+ }
283
+ if (result.kind === 'unreachable')
284
+ return `Endpoint unreachable (${baseUrl}): ${result.message}`;
285
+ if (result.kind === 'http') {
286
+ return `Endpoint answered HTTP ${String(result.status)} (${baseUrl})${result.body === '' ? '' : `: ${result.body}`}`;
287
+ }
288
+ if (result.kind === 'malformed') {
289
+ return `Endpoint reachable (${baseUrl}) but the model list did not parse — the credential worked, the reply was not /v1/models-shaped.`;
290
+ }
291
+ if (result.models.length === 0)
292
+ return `Endpoint reachable (${baseUrl}); it serves no models.`;
293
+ const ids = result.models.map(model => model.id);
294
+ const listed = ids.slice(0, CHECK_MODEL_LIST_LIMIT).join(', ');
295
+ const more = ids.length > CHECK_MODEL_LIST_LIMIT ? `, … +${String(ids.length - CHECK_MODEL_LIST_LIMIT)} more` : '';
296
+ return `Endpoint reachable (${baseUrl}): ${String(ids.length)} model(s) served — ${listed}${more}`;
297
+ }
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Claude Code harness instantiation of the local-agent family. Registers the
3
3
  * `claude-code` harness into the core registry: scoped home under the shared
4
- * homes root (`CLAUDE_CONFIG_DIR`), login as a manual handoff (claude ≥2.1
5
- * prints no OAuth URL off a TTY, so `/login` replies with the exact terminal
6
- * command and the registry watches for the credential), and project-file
7
- * session records. Delegation mounts through the `claude-local` provider
8
- * (one-shot `claude -p --output-format json` under the scoped home); the
9
- * bundle patch's `tool-subagent-claude-code-local` row puts the tool on the
10
- * profile root, so every agent preset can delegate.
4
+ * homes root (`CLAUDE_CONFIG_DIR`), endpoint-key authentication (the scope's
5
+ * `endpoint-auth.json` over the plugin config's `baseUrl`/`apiKey`/
6
+ * `authToken` triple — no OAuth, no browser flow, nothing to authorize inside
7
+ * a container), and project-file session records. Delegation mounts through
8
+ * the `claude-local` provider (one-shot `claude -p` under the scoped home);
9
+ * the bundle patch's `tool-subagent-claude-code-local` row puts the tool on
10
+ * the profile root, so every agent preset can delegate.
11
11
  *
12
12
  * @module @khorsheed/dsh-local-agent-claude-code
13
13
  */
@@ -17,7 +17,7 @@ import z from '@deepseek-ai/schemastery';
17
17
  export declare const name = "local-agent-claude-code";
18
18
  /** Services required before the harness can register. */
19
19
  export declare const inject: string[];
20
- /** Plugin config: the permission mode fresh delegations default to, plus the live driver. */
20
+ /** Plugin config: the permission mode fresh delegations default to, the endpoint credential, plus the live driver. */
21
21
  export interface Config {
22
22
  /**
23
23
  * `claude -p` permission handling; `skip` passes
@@ -36,17 +36,33 @@ export interface Config {
36
36
  */
37
37
  model?: string;
38
38
  /**
39
- * `ANTHROPIC_BASE_URL` for the child CLI; absent inherits the host
40
- * process environment (a user-level proxy like `https://proxy.example.com/anthropic`
41
- * is typically exported there). Set when delegations must route through a
42
- * different endpoint than the login flow used.
39
+ * The deployment-level endpoint: `ANTHROPIC_BASE_URL` for every delegation
40
+ * (e.g. a relay like `https://openproxy.to/claude`, or absent for the
41
+ * official API). A scope's own `endpoint-auth.json` overrides it field by
42
+ * field — the settings card's credential form edits THAT file (never the
43
+ * profile). See endpoint-auth.ts for the whole auth model — there is no
44
+ * OAuth path in this package.
43
45
  */
44
46
  baseUrl?: string;
45
47
  /**
46
- * HTTP proxy for the child CLI's own traffic (model calls AND OAuth
47
- * refresh), provisioned into the scoped `settings.json` env block. Needed
48
- * when the host process environment carries no proxy (a supervisor-spawned
49
- * instance does not inherit the user's shell exports).
48
+ * The deployment-level credential, sent as `ANTHROPIC_API_KEY` (the
49
+ * `x-api-key` header). Keep it in the LOCAL profile row only — never commit
50
+ * it (the repo hygiene gate blocks credential-shaped strings, but the
51
+ * profile is not the repo's business).
52
+ */
53
+ apiKey?: string;
54
+ /**
55
+ * The deployment-level credential, sent as `ANTHROPIC_AUTH_TOKEN` (the
56
+ * `Authorization: Bearer` header) — some relays expect Bearer instead of
57
+ * `x-api-key`. When both keys are set the CLI's own precedence applies.
58
+ */
59
+ authToken?: string;
60
+ /**
61
+ * HTTP proxy for the child CLI's own traffic, provisioned into the scoped
62
+ * `settings.json` env block. Needed when the host process environment
63
+ * carries no proxy (a supervisor-spawned instance does not inherit the
64
+ * user's shell exports). Distinct from {@link baseUrl}: this is a local
65
+ * traffic forwarder, not the model endpoint.
50
66
  */
51
67
  proxyUrl?: string;
52
68
  /**