@zhijiewang/openharness 2.13.0 → 2.14.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/dist/commands/session.js +39 -12
- package/dist/harness/config.d.ts +4 -0
- package/dist/harness/session.d.ts +4 -0
- package/dist/harness/session.js +2 -0
- package/dist/main.js +48 -24
- package/dist/mcp/oauth-keychain.d.ts +16 -0
- package/dist/mcp/oauth-keychain.js +70 -0
- package/dist/mcp/oauth-storage-fs.d.ts +23 -0
- package/dist/mcp/oauth-storage-fs.js +58 -0
- package/dist/mcp/oauth-storage.d.ts +24 -19
- package/dist/mcp/oauth-storage.js +46 -49
- package/package.json +5 -2
package/dist/commands/session.js
CHANGED
|
@@ -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", (
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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,
|
|
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${
|
|
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
|
-
|
|
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
|
});
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/harness/session.js
CHANGED
|
@@ -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
|
@@ -276,38 +276,62 @@ program
|
|
|
276
276
|
: opts.permissionMode !== "ask"
|
|
277
277
|
? opts.permissionMode
|
|
278
278
|
: (savedConfig?.permissionMode ?? "ask");
|
|
279
|
-
// Auto-detect provider or
|
|
279
|
+
// Auto-detect provider or launch the setup wizard
|
|
280
280
|
let provider;
|
|
281
281
|
let resolvedModel;
|
|
282
|
-
|
|
282
|
+
const tryCreateProvider = async () => {
|
|
283
283
|
const { createProvider } = await import("./providers/index.js");
|
|
284
284
|
const overrides = {};
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
285
|
+
const fresh = readOhConfig();
|
|
286
|
+
if (fresh?.apiKey)
|
|
287
|
+
overrides.apiKey = fresh.apiKey;
|
|
288
|
+
if (fresh?.baseUrl)
|
|
289
|
+
overrides.baseUrl = fresh.baseUrl;
|
|
290
|
+
const targetModel = fresh?.model ?? effectiveModel;
|
|
291
|
+
return createProvider(targetModel, Object.keys(overrides).length ? overrides : undefined);
|
|
292
|
+
};
|
|
293
|
+
try {
|
|
294
|
+
const result = await tryCreateProvider();
|
|
290
295
|
provider = result.provider;
|
|
291
296
|
resolvedModel = result.model;
|
|
292
297
|
}
|
|
293
298
|
catch (_err) {
|
|
294
|
-
// First-run
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
299
|
+
// First-run: launch the interactive wizard in TTY mode; fall back to
|
|
300
|
+
// static help text for non-TTY (CI, piped stdin, etc.).
|
|
301
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
302
|
+
const { default: InitWizard } = await import("./components/InitWizard.js");
|
|
303
|
+
const { waitUntilExit } = render(_jsx(InitWizard, { onDone: () => { } }));
|
|
304
|
+
await waitUntilExit();
|
|
305
|
+
try {
|
|
306
|
+
const result = await tryCreateProvider();
|
|
307
|
+
provider = result.provider;
|
|
308
|
+
resolvedModel = result.model;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
console.log();
|
|
312
|
+
console.log(" Setup incomplete. Run 'oh init' to try again, or set a provider via --model.");
|
|
313
|
+
console.log();
|
|
314
|
+
process.exit(0);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
console.log();
|
|
319
|
+
console.log(" Welcome to OpenHarness!");
|
|
320
|
+
console.log();
|
|
321
|
+
console.log(" To get started, choose a provider:");
|
|
322
|
+
console.log();
|
|
323
|
+
console.log(" Local (free, no API key):");
|
|
324
|
+
console.log(" npx openharness --model ollama/llama3");
|
|
325
|
+
console.log(" npx openharness --model ollama/qwen2.5:7b-instruct");
|
|
326
|
+
console.log();
|
|
327
|
+
console.log(" Cloud (needs API key in env var):");
|
|
328
|
+
console.log(" OPENAI_API_KEY=sk-... npx openharness --model gpt-4o");
|
|
329
|
+
console.log(" ANTHROPIC_API_KEY=sk-ant-... npx openharness --model claude-sonnet-4-6");
|
|
330
|
+
console.log();
|
|
331
|
+
console.log(" Make sure Ollama is running: ollama serve");
|
|
332
|
+
console.log();
|
|
333
|
+
process.exit(0);
|
|
334
|
+
}
|
|
311
335
|
}
|
|
312
336
|
const mcpTools = await loadMcpTools();
|
|
313
337
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
await
|
|
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
|
-
/**
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Delete credentials from BOTH keychain and filesystem. Idempotent.
|
|
49
|
+
*/
|
|
47
50
|
export async function deleteCredentials(storageDir, name) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
3
|
+
"version": "2.14.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
|
}
|