@zhijiewang/openharness 2.13.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,6 +57,8 @@ oh
57
57
 
58
58
  That's it. OpenHarness auto-detects Ollama and starts chatting. No API key needed.
59
59
 
60
+ **Python SDK:** there's also an official Python SDK for driving `oh` from Python programs (notebooks, batch scripts, ML pipelines). Install with `pip install openharness` after the npm install, then `from openharness import query`. See [`python/README.md`](python/README.md).
61
+
60
62
  ```bash
61
63
  oh init # interactive setup wizard (provider + cybergotchi)
62
64
  oh # auto-detect local model
@@ -7,6 +7,33 @@ import { dirname, join, resolve } from "node:path";
7
7
  import { getContextWindow } from "../harness/cost.js";
8
8
  import { createSession, listSessions, loadSession, saveSession } from "../harness/session.js";
9
9
  import { compressMessages } from "../query/index.js";
10
+ function formatMessagesAsMarkdown(messages) {
11
+ const blocks = [];
12
+ for (const m of messages) {
13
+ if (m.role === "user") {
14
+ blocks.push(`## User\n\n${m.content}`);
15
+ }
16
+ else if (m.role === "assistant") {
17
+ const parts = [];
18
+ if (m.content)
19
+ parts.push(m.content);
20
+ if (m.toolCalls?.length) {
21
+ for (const tc of m.toolCalls) {
22
+ parts.push(`**Tool call:** \`${tc.toolName}(${JSON.stringify(tc.arguments)})\``);
23
+ }
24
+ }
25
+ blocks.push(`## Assistant\n\n${parts.join("\n\n")}`);
26
+ }
27
+ else if (m.role === "tool") {
28
+ for (const tr of m.toolResults ?? []) {
29
+ const label = tr.isError ? "Tool error" : "Tool result";
30
+ blocks.push(`**${label}:**\n\n\`\`\`\n${tr.output}\n\`\`\``);
31
+ }
32
+ }
33
+ // system / info messages are skipped — they're OH-internal UX, not conversation
34
+ }
35
+ return blocks.join("\n\n");
36
+ }
10
37
  function setPinned(args, ctx, pinned) {
11
38
  const idx = parseInt(args.trim(), 10);
12
39
  if (Number.isNaN(idx) || idx < 1 || idx > ctx.messages.length) {
@@ -59,20 +86,19 @@ export function registerSessionCommands(register) {
59
86
  compactedMessages: compacted,
60
87
  };
61
88
  });
62
- register("export", "Export conversation to file", (_args, ctx) => {
63
- const lines = ctx.messages
64
- .filter((m) => m.role === "user" || m.role === "assistant")
65
- .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
66
- .join("\n\n");
67
- const filename = `.oh/export-${ctx.sessionId}.md`;
89
+ register("export", "Export conversation to file (args: 'json' for JSON format)", (args, ctx) => {
90
+ const asJson = args.trim().toLowerCase() === "json";
91
+ const ext = asJson ? "json" : "md";
92
+ const filename = `.oh/export-${ctx.sessionId}.${ext}`;
93
+ const body = asJson ? JSON.stringify(ctx.messages, null, 2) : formatMessagesAsMarkdown(ctx.messages);
68
94
  try {
69
95
  mkdirSync(dirname(filename), { recursive: true });
70
96
  const { writeFileSync } = require("node:fs");
71
- writeFileSync(filename, lines);
72
- return { output: `Exported to ${filename}`, handled: true };
97
+ writeFileSync(filename, body);
98
+ return { output: `Exported ${ctx.messages.length} messages to ${filename}`, handled: true };
73
99
  }
74
100
  catch {
75
- return { output: `Export failed. Content:\n\n${lines.slice(0, 500)}`, handled: true };
101
+ return { output: `Export failed. Content:\n\n${body.slice(0, 500)}`, handled: true };
76
102
  }
77
103
  });
78
104
  register("history", "List recent sessions or search across them", (args) => {
@@ -106,7 +132,8 @@ export function registerSessionCommands(register) {
106
132
  const lines = sessions.map((s) => {
107
133
  const date = new Date(s.updatedAt).toLocaleDateString();
108
134
  const cost = s.cost > 0 ? ` $${s.cost.toFixed(4)}` : "";
109
- return ` ${s.id} ${date} ${String(s.messages).padStart(3)} msgs ${(s.model || "?").slice(0, 24)}${cost}`;
135
+ const parent = s.parentSessionId ? ` ⤴ forked from ${s.parentSessionId}` : "";
136
+ return ` ${s.id} ${date} ${String(s.messages).padStart(3)} msgs ${(s.model || "?").slice(0, 24)}${cost}${parent}`;
110
137
  });
111
138
  return { output: `Recent sessions (use /resume <id> to continue):\n${lines.join("\n")}`, handled: true };
112
139
  });
@@ -127,11 +154,11 @@ export function registerSessionCommands(register) {
127
154
  }
128
155
  });
129
156
  register("fork", "Fork current session (create a branch you can resume later)", (_args, ctx) => {
130
- const forked = createSession("", "");
157
+ const forked = createSession(ctx.providerName, ctx.model, { parentSessionId: ctx.sessionId });
131
158
  forked.messages = [...ctx.messages];
132
159
  saveSession(forked);
133
160
  return {
134
- output: `Session forked as ${forked.id}. Resume later with: oh --resume ${forked.id}`,
161
+ output: `Session forked as ${forked.id} (from ${ctx.sessionId}). Resume later with: oh --resume ${forked.id}`,
135
162
  handled: true,
136
163
  };
137
164
  });
@@ -107,6 +107,10 @@ export type OhConfig = {
107
107
  apiKey?: string;
108
108
  baseUrl?: string;
109
109
  }>;
110
+ /** MCP OAuth token storage backend. Default: "auto" — keychain when available, filesystem otherwise. */
111
+ credentials?: {
112
+ storage?: "filesystem" | "auto";
113
+ };
110
114
  /** Auto-commit after each file-modifying tool execution */
111
115
  gitCommitPerTool?: boolean;
112
116
  /** Effort level for LLM reasoning depth */
@@ -13,6 +13,8 @@ export type Session = {
13
13
  gitBranch?: string;
14
14
  workingDir?: string;
15
15
  tools?: string[];
16
+ /** For forked sessions: the session this one was forked from. */
17
+ parentSessionId?: string;
16
18
  /** Hibernate state — saved on exit for wake reconstruction */
17
19
  hibernate?: {
18
20
  summary?: string;
@@ -26,6 +28,7 @@ export declare function createSession(provider: string, model: string, extras?:
26
28
  gitBranch?: string;
27
29
  workingDir?: string;
28
30
  tools?: string[];
31
+ parentSessionId?: string;
29
32
  }): Session;
30
33
  export declare function saveSession(session: Session, dir?: string): string;
31
34
  export declare function loadSession(id: string, dir?: string): Session;
@@ -35,6 +38,7 @@ export declare function listSessions(dir?: string): Array<{
35
38
  messages: number;
36
39
  cost: number;
37
40
  updatedAt: number;
41
+ parentSessionId?: string;
38
42
  }>;
39
43
  /** Returns the ID of the most recently updated session, or null if none exist. */
40
44
  export declare function getLastSessionId(dir?: string): string | null;
@@ -18,6 +18,7 @@ export function createSession(provider, model, extras) {
18
18
  ...(extras?.gitBranch ? { gitBranch: extras.gitBranch } : {}),
19
19
  ...(extras?.workingDir ? { workingDir: extras.workingDir } : {}),
20
20
  ...(extras?.tools ? { tools: extras.tools } : {}),
21
+ ...(extras?.parentSessionId ? { parentSessionId: extras.parentSessionId } : {}),
21
22
  };
22
23
  }
23
24
  let _evicting = false;
@@ -73,6 +74,7 @@ export function listSessions(dir) {
73
74
  messages: data.messages?.length ?? 0,
74
75
  cost: data.totalCost ?? 0,
75
76
  updatedAt: data.updatedAt ?? 0,
77
+ ...(data.parentSessionId ? { parentSessionId: data.parentSessionId } : {}),
76
78
  };
77
79
  }
78
80
  catch {
package/dist/main.js CHANGED
@@ -230,7 +230,21 @@ program
230
230
  console.log(JSON.stringify({ type: "error", message: event.message }));
231
231
  }
232
232
  }
233
+ else if (event.type === "cost_update") {
234
+ if (outputFormat === "stream-json") {
235
+ console.log(JSON.stringify({
236
+ type: "cost_update",
237
+ inputTokens: event.inputTokens,
238
+ outputTokens: event.outputTokens,
239
+ cost: event.cost,
240
+ model: event.model,
241
+ }));
242
+ }
243
+ }
233
244
  else if (event.type === "turn_complete") {
245
+ if (outputFormat === "stream-json") {
246
+ console.log(JSON.stringify({ type: "turn_complete", reason: event.reason }));
247
+ }
234
248
  if (event.reason !== "completed") {
235
249
  process.exitCode = 1;
236
250
  }
@@ -243,6 +257,138 @@ program
243
257
  process.stdout.write("\n");
244
258
  }
245
259
  });
260
+ // ── `oh session`: long-lived stateful session for the Python SDK ──
261
+ program
262
+ .command("session")
263
+ .description("Long-lived session: read JSON prompts from stdin, stream NDJSON events on stdout (for the Python SDK)")
264
+ .option("-m, --model <model>", "Model to use")
265
+ .addOption(new Option("--permission-mode <mode>", "Permission mode")
266
+ .choices(["ask", "trust", "deny", "acceptEdits", "plan", "auto", "bypassPermissions"])
267
+ .default("trust"))
268
+ .option("--allowed-tools <tools>", "Comma-separated allowed tool names")
269
+ .option("--disallowed-tools <tools>", "Comma-separated disallowed tool names")
270
+ .option("--max-turns <n>", "Maximum turns per prompt", "20")
271
+ .option("--system-prompt <prompt>", "Override the system prompt")
272
+ .action(async (opts) => {
273
+ const savedConfig = readOhConfig();
274
+ const permissionMode = (opts.permissionMode ??
275
+ savedConfig?.permissionMode ??
276
+ "trust");
277
+ const { createProvider } = await import("./providers/index.js");
278
+ const effectiveModel = opts.model ?? savedConfig?.model;
279
+ const overrides = {};
280
+ if (savedConfig?.apiKey)
281
+ overrides.apiKey = savedConfig.apiKey;
282
+ if (savedConfig?.baseUrl)
283
+ overrides.baseUrl = savedConfig.baseUrl;
284
+ const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
285
+ const { query } = await import("./query.js");
286
+ const { createAssistantMessage, createToolResultMessage, createUserMessage } = await import("./types/message.js");
287
+ let tools = getAllTools();
288
+ if (opts.allowedTools) {
289
+ const allowed = new Set(opts.allowedTools.split(",").map((s) => s.trim()));
290
+ tools = tools.filter((t) => allowed.has(t.name));
291
+ }
292
+ if (opts.disallowedTools) {
293
+ const disallowed = new Set(opts.disallowedTools.split(",").map((s) => s.trim()));
294
+ tools = tools.filter((t) => !disallowed.has(t.name));
295
+ }
296
+ const systemPrompt = opts.systemPrompt ?? buildSystemPrompt(model);
297
+ const config = {
298
+ provider,
299
+ tools,
300
+ systemPrompt,
301
+ permissionMode,
302
+ maxTurns: parseInt(opts.maxTurns, 10),
303
+ model,
304
+ };
305
+ // Conversation history, shared across all prompts for this process.
306
+ const conversation = [];
307
+ // Announce readiness so the client can send the first prompt.
308
+ console.log(JSON.stringify({ type: "ready" }));
309
+ const readline = await import("node:readline");
310
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
311
+ for await (const rawLine of rl) {
312
+ const line = rawLine.trim();
313
+ if (!line)
314
+ continue;
315
+ let request;
316
+ try {
317
+ request = JSON.parse(line);
318
+ }
319
+ catch {
320
+ console.log(JSON.stringify({ id: "", type: "error", message: "invalid JSON on stdin" }));
321
+ continue;
322
+ }
323
+ if (request.command === "exit")
324
+ break;
325
+ const id = request.id ?? "";
326
+ const prompt = request.prompt;
327
+ if (!id || !prompt) {
328
+ console.log(JSON.stringify({ id, type: "error", message: "missing 'id' or 'prompt' field" }));
329
+ continue;
330
+ }
331
+ // Accumulate this turn's assistant output so we can push a full message at the end.
332
+ let assistantText = "";
333
+ const turnToolCalls = [];
334
+ const callIdToName = {};
335
+ const toolResults = [];
336
+ for await (const event of query(prompt, config, conversation)) {
337
+ if (event.type === "text_delta") {
338
+ assistantText += event.content;
339
+ console.log(JSON.stringify({ id, type: "text", content: event.content }));
340
+ }
341
+ else if (event.type === "tool_call_start") {
342
+ callIdToName[event.callId] = event.toolName;
343
+ console.log(JSON.stringify({ id, type: "tool_start", tool: event.toolName }));
344
+ }
345
+ else if (event.type === "tool_call_complete") {
346
+ turnToolCalls.push({
347
+ id: event.callId,
348
+ toolName: callIdToName[event.callId] ?? event.callId,
349
+ arguments: event.arguments,
350
+ });
351
+ }
352
+ else if (event.type === "tool_call_end") {
353
+ toolResults.push({ callId: event.callId, output: event.output, isError: event.isError });
354
+ console.log(JSON.stringify({
355
+ id,
356
+ type: "tool_end",
357
+ tool: callIdToName[event.callId],
358
+ output: event.output,
359
+ error: event.isError,
360
+ }));
361
+ }
362
+ else if (event.type === "error") {
363
+ console.log(JSON.stringify({ id, type: "error", message: event.message }));
364
+ }
365
+ else if (event.type === "cost_update") {
366
+ console.log(JSON.stringify({
367
+ id,
368
+ type: "cost_update",
369
+ inputTokens: event.inputTokens,
370
+ outputTokens: event.outputTokens,
371
+ cost: event.cost,
372
+ model: event.model,
373
+ }));
374
+ }
375
+ else if (event.type === "turn_complete") {
376
+ console.log(JSON.stringify({ id, type: "turn_complete", reason: event.reason }));
377
+ }
378
+ }
379
+ // Rebuild this turn's contribution to the conversation.
380
+ // The pattern mirrors query()'s internal accumulation at
381
+ // src/query/index.ts:119 (user msg pushed before turn) and 344 (assistant
382
+ // msg with tool calls pushed after each turn) — see the spec for detail.
383
+ conversation.push(createUserMessage(prompt));
384
+ if (assistantText || turnToolCalls.length > 0) {
385
+ conversation.push(createAssistantMessage(assistantText, turnToolCalls.length > 0 ? turnToolCalls : undefined));
386
+ }
387
+ for (const tr of toolResults) {
388
+ conversation.push(createToolResultMessage({ callId: tr.callId, output: tr.output, isError: tr.isError }));
389
+ }
390
+ }
391
+ });
246
392
  // ── Default command: just run `openharness` to start chatting ──
247
393
  program
248
394
  .command("chat", { isDefault: true })
@@ -276,38 +422,62 @@ program
276
422
  : opts.permissionMode !== "ask"
277
423
  ? opts.permissionMode
278
424
  : (savedConfig?.permissionMode ?? "ask");
279
- // Auto-detect provider or prompt for setup
425
+ // Auto-detect provider or launch the setup wizard
280
426
  let provider;
281
427
  let resolvedModel;
282
- try {
428
+ const tryCreateProvider = async () => {
283
429
  const { createProvider } = await import("./providers/index.js");
284
430
  const overrides = {};
285
- if (savedConfig?.apiKey)
286
- overrides.apiKey = savedConfig.apiKey;
287
- if (savedConfig?.baseUrl)
288
- overrides.baseUrl = savedConfig.baseUrl;
289
- const result = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
431
+ const fresh = readOhConfig();
432
+ if (fresh?.apiKey)
433
+ overrides.apiKey = fresh.apiKey;
434
+ if (fresh?.baseUrl)
435
+ overrides.baseUrl = fresh.baseUrl;
436
+ const targetModel = fresh?.model ?? effectiveModel;
437
+ return createProvider(targetModel, Object.keys(overrides).length ? overrides : undefined);
438
+ };
439
+ try {
440
+ const result = await tryCreateProvider();
290
441
  provider = result.provider;
291
442
  resolvedModel = result.model;
292
443
  }
293
444
  catch (_err) {
294
- // First-run experience: guide the user
295
- console.log();
296
- console.log(" Welcome to OpenHarness!");
297
- console.log();
298
- console.log(" To get started, choose a provider:");
299
- console.log();
300
- console.log(" Local (free, no API key):");
301
- console.log(" npx openharness --model ollama/llama3");
302
- console.log(" npx openharness --model ollama/qwen2.5:7b-instruct");
303
- console.log();
304
- console.log(" Cloud (needs API key in env var):");
305
- console.log(" OPENAI_API_KEY=sk-... npx openharness --model gpt-4o");
306
- console.log(" ANTHROPIC_API_KEY=sk-ant-... npx openharness --model claude-sonnet-4-6");
307
- console.log();
308
- console.log(" Make sure Ollama is running: ollama serve");
309
- console.log();
310
- process.exit(0);
445
+ // First-run: launch the interactive wizard in TTY mode; fall back to
446
+ // static help text for non-TTY (CI, piped stdin, etc.).
447
+ if (process.stdout.isTTY && process.stdin.isTTY) {
448
+ const { default: InitWizard } = await import("./components/InitWizard.js");
449
+ const { waitUntilExit } = render(_jsx(InitWizard, { onDone: () => { } }));
450
+ await waitUntilExit();
451
+ try {
452
+ const result = await tryCreateProvider();
453
+ provider = result.provider;
454
+ resolvedModel = result.model;
455
+ }
456
+ catch {
457
+ console.log();
458
+ console.log(" Setup incomplete. Run 'oh init' to try again, or set a provider via --model.");
459
+ console.log();
460
+ process.exit(0);
461
+ }
462
+ }
463
+ else {
464
+ console.log();
465
+ console.log(" Welcome to OpenHarness!");
466
+ console.log();
467
+ console.log(" To get started, choose a provider:");
468
+ console.log();
469
+ console.log(" Local (free, no API key):");
470
+ console.log(" npx openharness --model ollama/llama3");
471
+ console.log(" npx openharness --model ollama/qwen2.5:7b-instruct");
472
+ console.log();
473
+ console.log(" Cloud (needs API key in env var):");
474
+ console.log(" OPENAI_API_KEY=sk-... npx openharness --model gpt-4o");
475
+ console.log(" ANTHROPIC_API_KEY=sk-ant-... npx openharness --model claude-sonnet-4-6");
476
+ console.log();
477
+ console.log(" Make sure Ollama is running: ollama serve");
478
+ console.log();
479
+ process.exit(0);
480
+ }
311
481
  }
312
482
  const mcpTools = await loadMcpTools();
313
483
  const mcpNames = connectedMcpServers();
@@ -0,0 +1,16 @@
1
+ /**
2
+ * OS keychain backend for MCP OAuth tokens.
3
+ *
4
+ * Wraps @napi-rs/keyring (optional dependency). All functions catch every error
5
+ * and return an "unavailable" sentinel so the orchestrator in oauth-storage.ts
6
+ * can fall back to the filesystem store without any user-visible disruption.
7
+ */
8
+ import type { OhCredentials } from "./oauth-storage-fs.js";
9
+ /** Clear the cached module reference. For tests only. */
10
+ export declare function _resetForTesting(): void;
11
+ /** True iff @napi-rs/keyring loaded successfully AND the platform has an Entry class. */
12
+ export declare function keychainAvailable(): boolean;
13
+ export declare function saveCredentialsKeychain(name: string, creds: OhCredentials): boolean;
14
+ export declare function loadCredentialsKeychain(name: string): OhCredentials | undefined;
15
+ export declare function deleteCredentialsKeychain(name: string): boolean;
16
+ //# sourceMappingURL=oauth-keychain.d.ts.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * OS keychain backend for MCP OAuth tokens.
3
+ *
4
+ * Wraps @napi-rs/keyring (optional dependency). All functions catch every error
5
+ * and return an "unavailable" sentinel so the orchestrator in oauth-storage.ts
6
+ * can fall back to the filesystem store without any user-visible disruption.
7
+ */
8
+ import { createRequire } from "node:module";
9
+ const SERVICE = "openharness-mcp";
10
+ const nodeRequire = createRequire(import.meta.url);
11
+ let entryCtorCache;
12
+ function getEntryCtor() {
13
+ if (entryCtorCache !== undefined)
14
+ return entryCtorCache;
15
+ try {
16
+ const mod = nodeRequire("@napi-rs/keyring");
17
+ entryCtorCache = mod.Entry;
18
+ }
19
+ catch {
20
+ entryCtorCache = null;
21
+ }
22
+ return entryCtorCache;
23
+ }
24
+ /** Clear the cached module reference. For tests only. */
25
+ export function _resetForTesting() {
26
+ entryCtorCache = undefined;
27
+ }
28
+ /** True iff @napi-rs/keyring loaded successfully AND the platform has an Entry class. */
29
+ export function keychainAvailable() {
30
+ return getEntryCtor() !== null;
31
+ }
32
+ export function saveCredentialsKeychain(name, creds) {
33
+ const Ctor = getEntryCtor();
34
+ if (!Ctor)
35
+ return false;
36
+ try {
37
+ new Ctor(SERVICE, name).setPassword(JSON.stringify(creds));
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ export function loadCredentialsKeychain(name) {
45
+ const Ctor = getEntryCtor();
46
+ if (!Ctor)
47
+ return undefined;
48
+ try {
49
+ const raw = new Ctor(SERVICE, name).getPassword();
50
+ if (!raw)
51
+ return undefined;
52
+ return JSON.parse(raw);
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ export function deleteCredentialsKeychain(name) {
59
+ const Ctor = getEntryCtor();
60
+ if (!Ctor)
61
+ return false;
62
+ try {
63
+ new Ctor(SERVICE, name).deletePassword();
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ //# sourceMappingURL=oauth-keychain.js.map
@@ -0,0 +1,23 @@
1
+ export type OhCredentials = {
2
+ issuerUrl: string;
3
+ clientInformation: {
4
+ client_id: string;
5
+ client_secret?: string;
6
+ } & Record<string, unknown>;
7
+ tokens: {
8
+ access_token: string;
9
+ refresh_token?: string;
10
+ expires_at?: number;
11
+ token_type?: string;
12
+ scope?: string;
13
+ };
14
+ codeVerifier?: string;
15
+ updatedAt: string;
16
+ };
17
+ /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
18
+ export declare function saveCredentials(storageDir: string, name: string, creds: OhCredentials): Promise<void>;
19
+ /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
20
+ export declare function loadCredentials(storageDir: string, name: string): Promise<OhCredentials | undefined>;
21
+ /** Idempotent delete — ENOENT is swallowed. */
22
+ export declare function deleteCredentials(storageDir: string, name: string): Promise<void>;
23
+ //# sourceMappingURL=oauth-storage-fs.d.ts.map
@@ -0,0 +1,58 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ function pathFor(storageDir, name) {
4
+ return join(storageDir, `${name}.json`);
5
+ }
6
+ /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
7
+ export async function saveCredentials(storageDir, name, creds) {
8
+ const filePath = pathFor(storageDir, name);
9
+ const tmpPath = `${filePath}.tmp`;
10
+ await fs.mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
11
+ const body = JSON.stringify(creds, null, 2);
12
+ await fs.writeFile(tmpPath, body, { mode: 0o600 });
13
+ await fs.rename(tmpPath, filePath);
14
+ }
15
+ /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
16
+ export async function loadCredentials(storageDir, name) {
17
+ const filePath = pathFor(storageDir, name);
18
+ let raw;
19
+ try {
20
+ raw = await fs.readFile(filePath, "utf8");
21
+ }
22
+ catch (err) {
23
+ if (err.code === "ENOENT")
24
+ return undefined;
25
+ throw err;
26
+ }
27
+ try {
28
+ if (process.platform !== "win32") {
29
+ const s = await fs.stat(filePath);
30
+ if ((s.mode & 0o077) !== 0) {
31
+ console.warn(`[mcp] credentials file for '${name}' is world/group-readable; run 'chmod 600 ${filePath}'`);
32
+ }
33
+ }
34
+ }
35
+ catch {
36
+ // stat failure is non-fatal for load
37
+ }
38
+ try {
39
+ return JSON.parse(raw);
40
+ }
41
+ catch {
42
+ console.warn(`[mcp] credentials file for '${name}' is corrupt; ignoring`);
43
+ return undefined;
44
+ }
45
+ }
46
+ /** Idempotent delete — ENOENT is swallowed. */
47
+ export async function deleteCredentials(storageDir, name) {
48
+ const filePath = pathFor(storageDir, name);
49
+ try {
50
+ await fs.unlink(filePath);
51
+ }
52
+ catch (err) {
53
+ if (err.code === "ENOENT")
54
+ return;
55
+ throw err;
56
+ }
57
+ }
58
+ //# sourceMappingURL=oauth-storage-fs.js.map
@@ -1,23 +1,28 @@
1
- export type OhCredentials = {
2
- issuerUrl: string;
3
- clientInformation: {
4
- client_id: string;
5
- client_secret?: string;
6
- } & Record<string, unknown>;
7
- tokens: {
8
- access_token: string;
9
- refresh_token?: string;
10
- expires_at?: number;
11
- token_type?: string;
12
- scope?: string;
13
- };
14
- codeVerifier?: string;
15
- updatedAt: string;
16
- };
17
- /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
1
+ /**
2
+ * OAuth token storage orchestrator.
3
+ *
4
+ * Prefers the OS keychain via `oauth-keychain.ts` when available and not
5
+ * opted out via `credentials.storage: "filesystem"`. Falls back to the
6
+ * filesystem store in `oauth-storage-fs.ts` on any keychain failure.
7
+ *
8
+ * Public API unchanged: callers in oauth.ts and commands/mcp-auth.ts
9
+ * continue to import `saveCredentials` / `loadCredentials` /
10
+ * `deleteCredentials` / `OhCredentials` from this module.
11
+ */
12
+ export type { OhCredentials } from "./oauth-storage-fs.js";
13
+ import type { OhCredentials } from "./oauth-storage-fs.js";
14
+ /**
15
+ * Save credentials. Tries keychain first when available; falls back to
16
+ * filesystem on any keychain failure.
17
+ */
18
18
  export declare function saveCredentials(storageDir: string, name: string, creds: OhCredentials): Promise<void>;
19
- /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
19
+ /**
20
+ * Load credentials. Checks keychain first (when available), then filesystem.
21
+ * If both have entries for the same name, keychain wins.
22
+ */
20
23
  export declare function loadCredentials(storageDir: string, name: string): Promise<OhCredentials | undefined>;
21
- /** Idempotent delete — ENOENT is swallowed. */
24
+ /**
25
+ * Delete credentials from BOTH keychain and filesystem. Idempotent.
26
+ */
22
27
  export declare function deleteCredentials(storageDir: string, name: string): Promise<void>;
23
28
  //# sourceMappingURL=oauth-storage.d.ts.map
@@ -1,58 +1,55 @@
1
- import { promises as fs } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- function pathFor(storageDir, name) {
4
- return join(storageDir, `${name}.json`);
1
+ /**
2
+ * OAuth token storage orchestrator.
3
+ *
4
+ * Prefers the OS keychain via `oauth-keychain.ts` when available and not
5
+ * opted out via `credentials.storage: "filesystem"`. Falls back to the
6
+ * filesystem store in `oauth-storage-fs.ts` on any keychain failure.
7
+ *
8
+ * Public API unchanged: callers in oauth.ts and commands/mcp-auth.ts
9
+ * continue to import `saveCredentials` / `loadCredentials` /
10
+ * `deleteCredentials` / `OhCredentials` from this module.
11
+ */
12
+ import { readOhConfig } from "../harness/config.js";
13
+ import { deleteCredentialsKeychain, keychainAvailable, loadCredentialsKeychain, saveCredentialsKeychain, } from "./oauth-keychain.js";
14
+ import { deleteCredentials as deleteFs, loadCredentials as loadFs, saveCredentials as saveFs, } from "./oauth-storage-fs.js";
15
+ function shouldUseKeychain() {
16
+ // Explicit opt-out via env var (used by the test runner to isolate tests
17
+ // from the real OS keychain). Accepts "disabled", "false", "0", or "off".
18
+ const envOpt = (process.env.OH_KEYCHAIN ?? "").toLowerCase();
19
+ if (envOpt === "disabled" || envOpt === "false" || envOpt === "0" || envOpt === "off")
20
+ return false;
21
+ const cfg = readOhConfig();
22
+ if (cfg?.credentials?.storage === "filesystem")
23
+ return false;
24
+ return keychainAvailable();
5
25
  }
6
- /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
26
+ /**
27
+ * Save credentials. Tries keychain first when available; falls back to
28
+ * filesystem on any keychain failure.
29
+ */
7
30
  export async function saveCredentials(storageDir, name, creds) {
8
- const filePath = pathFor(storageDir, name);
9
- const tmpPath = `${filePath}.tmp`;
10
- await fs.mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
11
- const body = JSON.stringify(creds, null, 2);
12
- await fs.writeFile(tmpPath, body, { mode: 0o600 });
13
- await fs.rename(tmpPath, filePath);
31
+ if (shouldUseKeychain() && saveCredentialsKeychain(name, creds))
32
+ return;
33
+ await saveFs(storageDir, name, creds);
14
34
  }
15
- /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
35
+ /**
36
+ * Load credentials. Checks keychain first (when available), then filesystem.
37
+ * If both have entries for the same name, keychain wins.
38
+ */
16
39
  export async function loadCredentials(storageDir, name) {
17
- const filePath = pathFor(storageDir, name);
18
- let raw;
19
- try {
20
- raw = await fs.readFile(filePath, "utf8");
21
- }
22
- catch (err) {
23
- if (err.code === "ENOENT")
24
- return undefined;
25
- throw err;
26
- }
27
- try {
28
- if (process.platform !== "win32") {
29
- const s = await fs.stat(filePath);
30
- if ((s.mode & 0o077) !== 0) {
31
- console.warn(`[mcp] credentials file for '${name}' is world/group-readable; run 'chmod 600 ${filePath}'`);
32
- }
33
- }
34
- }
35
- catch {
36
- // stat failure is non-fatal for load
37
- }
38
- try {
39
- return JSON.parse(raw);
40
- }
41
- catch {
42
- console.warn(`[mcp] credentials file for '${name}' is corrupt; ignoring`);
43
- return undefined;
40
+ if (shouldUseKeychain()) {
41
+ const fromKc = loadCredentialsKeychain(name);
42
+ if (fromKc)
43
+ return fromKc;
44
44
  }
45
+ return loadFs(storageDir, name);
45
46
  }
46
- /** Idempotent delete — ENOENT is swallowed. */
47
+ /**
48
+ * Delete credentials from BOTH keychain and filesystem. Idempotent.
49
+ */
47
50
  export async function deleteCredentials(storageDir, name) {
48
- const filePath = pathFor(storageDir, name);
49
- try {
50
- await fs.unlink(filePath);
51
- }
52
- catch (err) {
53
- if (err.code === "ENOENT")
54
- return;
55
- throw err;
56
- }
51
+ if (keychainAvailable())
52
+ deleteCredentialsKeychain(name);
53
+ await deleteFs(storageDir, name);
57
54
  }
58
55
  //# sourceMappingURL=oauth-storage.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.13.0",
3
+ "version": "2.15.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,5 +84,8 @@
84
84
  "bugs": {
85
85
  "url": "https://github.com/zhijiewong/openharness/issues"
86
86
  },
87
- "homepage": "https://github.com/zhijiewong/openharness#readme"
87
+ "homepage": "https://github.com/zhijiewong/openharness#readme",
88
+ "optionalDependencies": {
89
+ "@napi-rs/keyring": "^1.2.0"
90
+ }
88
91
  }