@axtary/cli 0.1.0 → 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.
Files changed (48) hide show
  1. package/README.md +122 -12
  2. package/dist/bin.js +10 -0
  3. package/dist/bin.js.map +1 -1
  4. package/dist/claude-code-hook.d.ts +20 -0
  5. package/dist/claude-code-hook.d.ts.map +1 -1
  6. package/dist/claude-code-hook.js +114 -34
  7. package/dist/claude-code-hook.js.map +1 -1
  8. package/dist/codex-hook.d.ts +48 -0
  9. package/dist/codex-hook.d.ts.map +1 -0
  10. package/dist/codex-hook.js +259 -0
  11. package/dist/codex-hook.js.map +1 -0
  12. package/dist/credentials.d.ts +101 -0
  13. package/dist/credentials.d.ts.map +1 -0
  14. package/dist/credentials.js +277 -0
  15. package/dist/credentials.js.map +1 -0
  16. package/dist/cursor-hook.d.ts +51 -0
  17. package/dist/cursor-hook.d.ts.map +1 -0
  18. package/dist/cursor-hook.js +214 -0
  19. package/dist/cursor-hook.js.map +1 -0
  20. package/dist/hook-install.d.ts +21 -0
  21. package/dist/hook-install.d.ts.map +1 -0
  22. package/dist/hook-install.js +179 -0
  23. package/dist/hook-install.js.map +1 -0
  24. package/dist/index.d.ts +768 -7
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6132 -1050
  27. package/dist/index.js.map +1 -1
  28. package/dist/mcp-fs-normalize.d.ts +18 -0
  29. package/dist/mcp-fs-normalize.d.ts.map +1 -0
  30. package/dist/mcp-fs-normalize.js +39 -0
  31. package/dist/mcp-fs-normalize.js.map +1 -0
  32. package/dist/mcp-oauth.d.ts +67 -0
  33. package/dist/mcp-oauth.d.ts.map +1 -0
  34. package/dist/mcp-oauth.js +242 -0
  35. package/dist/mcp-oauth.js.map +1 -0
  36. package/dist/oauth.d.ts +90 -0
  37. package/dist/oauth.d.ts.map +1 -0
  38. package/dist/oauth.js +339 -0
  39. package/dist/oauth.js.map +1 -0
  40. package/dist/render.d.ts +103 -0
  41. package/dist/render.d.ts.map +1 -0
  42. package/dist/render.js +172 -0
  43. package/dist/render.js.map +1 -0
  44. package/dist/shell-file-normalize.d.ts +21 -0
  45. package/dist/shell-file-normalize.d.ts.map +1 -0
  46. package/dist/shell-file-normalize.js +269 -0
  47. package/dist/shell-file-normalize.js.map +1 -0
  48. package/package.json +14 -9
@@ -0,0 +1,259 @@
1
+ import { parseNormalizedAction, } from "@axtary/actionpass";
2
+ import { authorizeRank, DENY_RANK, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_PROXY_URL, errorReason, postAuthorize, workspaceRelativePath, } from "./claude-code-hook.js";
3
+ import { classifyMcpFilesystemTool } from "./mcp-fs-normalize.js";
4
+ import { classifyShellFileOps, } from "./shell-file-normalize.js";
5
+ export { classifyShellCommand, classifyShellFileOps } from "./shell-file-normalize.js";
6
+ // Codex CLI runtime-hook adapter (mega-plan M8.4). Codex calls a configured
7
+ // `PreToolUse` hook before it runs a tool; the hook reads a JSON payload on
8
+ // stdin and returns a decision. Codex's hook contract is Claude-Code-shaped —
9
+ // it emits the same `hookSpecificOutput.permissionDecision` object — so the
10
+ // output side mirrors the Claude hook exactly. Verified against the official
11
+ // Codex hooks docs (2026-06-26).
12
+ //
13
+ // What differs from Claude/Cursor: Codex's reliable hook surface is the SHELL
14
+ // (`Bash`) tool, whose `tool_input.command` is a shell command string — not a
15
+ // file path. So the core work here is mapping a file-touching shell command
16
+ // (and, best-effort, `apply_patch` edits and MCP filesystem tools) onto the
17
+ // same `github.contents.read/write` actions, so the existing secret/path policy
18
+ // applies. Codex docs note hooks fire reliably for shell, less so for
19
+ // apply_patch / most MCP tools — hence those are best-effort.
20
+ //
21
+ // Fail-closed: a step-up renders as a BLOCK on Codex (its PreToolUse contract
22
+ // documents only allow/deny; we do not rely on an unverified "ask"), and any
23
+ // error (bad payload / unreachable proxy) is an explicit deny.
24
+ export const CODEX_HOOK_SCHEMA_VERSION = "axtary.codex_hook.v0";
25
+ export function parseCodexHookPayload(payloadText) {
26
+ let raw;
27
+ try {
28
+ raw = JSON.parse(payloadText);
29
+ }
30
+ catch {
31
+ throw new Error("codex_hook_payload_invalid_json");
32
+ }
33
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
34
+ throw new Error("codex_hook_payload_not_object");
35
+ }
36
+ const record = raw;
37
+ const toolName = record.tool_name;
38
+ if (typeof toolName !== "string" || toolName.length === 0) {
39
+ throw new Error("codex_hook_tool_name_required");
40
+ }
41
+ const toolInput = record.tool_input &&
42
+ typeof record.tool_input === "object" &&
43
+ !Array.isArray(record.tool_input)
44
+ ? record.tool_input
45
+ : {};
46
+ return {
47
+ hookEventName: typeof record.hook_event_name === "string" ? record.hook_event_name : null,
48
+ sessionId: typeof record.session_id === "string" ? record.session_id : null,
49
+ cwd: typeof record.cwd === "string" ? record.cwd : null,
50
+ toolName,
51
+ toolInput,
52
+ };
53
+ }
54
+ const SHELL_TOOLS = new Set(["Bash", "shell", "local_shell"]);
55
+ // Codex apply_patch envelope headers name the files touched.
56
+ const APPLY_PATCH_FILE_RE = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+?)\s*$/gm;
57
+ function applyPatchPaths(command) {
58
+ const paths = [];
59
+ let match;
60
+ APPLY_PATCH_FILE_RE.lastIndex = 0;
61
+ while ((match = APPLY_PATCH_FILE_RE.exec(command))) {
62
+ paths.push(match[1]);
63
+ }
64
+ return paths;
65
+ }
66
+ function buildContentAction(op, payload, options, sourceTool) {
67
+ const path = workspaceRelativePath(op.path, payload.cwd);
68
+ const repoResource = options.repoResource ?? "repo:local/workspace";
69
+ const taskId = payload.sessionId
70
+ ? `codex:${payload.sessionId.slice(0, 12)}`
71
+ : "codex:session";
72
+ const base = {
73
+ actor: {
74
+ agentId: "agent:codex",
75
+ humanOwner: options.humanOwner ?? "user:local-developer",
76
+ runtime: "codex",
77
+ },
78
+ intent: {
79
+ taskId,
80
+ declaredGoal: `Codex ${payload.toolName} tool call`,
81
+ },
82
+ };
83
+ if (op.kind === "read") {
84
+ return parseNormalizedAction({
85
+ ...base,
86
+ capability: {
87
+ tool: "github.contents.read",
88
+ resource: repoResource,
89
+ payload: { path, source: "codex-hook", codexTool: sourceTool },
90
+ },
91
+ });
92
+ }
93
+ return parseNormalizedAction({
94
+ ...base,
95
+ capability: {
96
+ tool: "github.contents.write",
97
+ resource: repoResource,
98
+ payload: {
99
+ path,
100
+ branch: options.branch ?? "workspace",
101
+ source: "codex-hook",
102
+ codexTool: sourceTool,
103
+ },
104
+ },
105
+ });
106
+ }
107
+ export function normalizeCodexToolCall(payload, options = {}) {
108
+ // Shell (the reliable Codex surface): map every file op a command performs,
109
+ // so `cp secret dest` governs the source read AND the destination write.
110
+ if (SHELL_TOOLS.has(payload.toolName)) {
111
+ const command = typeof payload.toolInput.command === "string"
112
+ ? payload.toolInput.command
113
+ : "";
114
+ const ops = command ? classifyShellFileOps(command) : [];
115
+ if (ops.length === 0) {
116
+ return { mapped: false, reason: `codex_shell_no_file_op:${payload.toolName}` };
117
+ }
118
+ return mappedActions(ops.map((op) => buildContentAction(op, payload, options, payload.toolName)));
119
+ }
120
+ // apply_patch (best-effort): the envelope names every file it edits; govern
121
+ // all of them (the run loop denies if any is denied).
122
+ if (payload.toolName === "apply_patch") {
123
+ const command = typeof payload.toolInput.command === "string"
124
+ ? payload.toolInput.command
125
+ : "";
126
+ const paths = command ? applyPatchPaths(command) : [];
127
+ if (paths.length === 0) {
128
+ return { mapped: false, reason: "codex_apply_patch_no_files" };
129
+ }
130
+ return mappedActions(paths.map((path) => buildContentAction({ kind: "write", path }, payload, options, payload.toolName)));
131
+ }
132
+ // MCP filesystem tools (best-effort; Codex MCP hooks fire less reliably).
133
+ const classified = classifyMcpFilesystemTool(payload.toolName);
134
+ if (classified) {
135
+ const rawPath = payload.toolInput[classified.pathField];
136
+ if (typeof rawPath === "string" && rawPath.length > 0) {
137
+ return mappedActions([
138
+ buildContentAction({ kind: classified.kind, path: rawPath }, payload, options, payload.toolName),
139
+ ]);
140
+ }
141
+ }
142
+ return { mapped: false, reason: `codex_tool_unmapped:${payload.toolName}` };
143
+ }
144
+ function mappedActions(actions) {
145
+ return { mapped: true, action: actions[0], actions };
146
+ }
147
+ export async function runCodexHook(input) {
148
+ let payload;
149
+ try {
150
+ payload = parseCodexHookPayload(input.payloadText);
151
+ }
152
+ catch (error) {
153
+ return decisionResult(null, null, "deny", [
154
+ `Axtary blocked this tool call: hook payload could not be parsed (${errorReason(error)}). Protected actions fail closed.`,
155
+ ]);
156
+ }
157
+ const normalization = normalizeCodexToolCall(payload, {
158
+ repoResource: input.repoResource,
159
+ humanOwner: input.humanOwner,
160
+ branch: input.branch,
161
+ });
162
+ if (!normalization.mapped) {
163
+ return {
164
+ schemaVersion: CODEX_HOOK_SCHEMA_VERSION,
165
+ status: "no_opinion",
166
+ tool: payload.toolName,
167
+ normalizedTool: null,
168
+ reason: normalization.reason,
169
+ output: null,
170
+ exitCode: 0,
171
+ };
172
+ }
173
+ const proxyUrl = (input.proxyUrl ?? DEFAULT_PROXY_URL).replace(/\/$/, "");
174
+ const fetchImpl = input.fetch ?? globalThis.fetch;
175
+ const timeoutMs = input.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
176
+ // Authorize every governed op; the most restrictive decision wins
177
+ // (deny > step_up > allow) and a hard deny short-circuits. Fail-closed.
178
+ let best = null;
179
+ for (const action of normalization.actions) {
180
+ let response;
181
+ try {
182
+ response = await postAuthorize(fetchImpl, `${proxyUrl}/authorize`, action, timeoutMs);
183
+ }
184
+ catch (error) {
185
+ return decisionResult(payload.toolName, action.capability.tool, "deny", [
186
+ `Axtary blocked ${action.capability.tool}: local proxy unreachable at ${proxyUrl} (${errorReason(error)}).`,
187
+ "Start it with: axtary proxy --config axtary.yml",
188
+ ]);
189
+ }
190
+ const rank = authorizeRank(response.status);
191
+ if (!best || rank > best.rank) {
192
+ best = { action, response, rank };
193
+ }
194
+ if (rank === DENY_RANK)
195
+ break;
196
+ }
197
+ const response = best.response;
198
+ const normalizedTool = best.action.capability.tool;
199
+ const reasons = response.decision?.reasons ?? [];
200
+ const hints = response.hints ?? [];
201
+ const summary = response.explanation?.summary ?? reasons.join(", ");
202
+ const ledgerLine = typeof response.ledger?.lineNumber === "number"
203
+ ? ` Ledger line ${response.ledger.lineNumber}.`
204
+ : "";
205
+ if (response.status === "authorized") {
206
+ // Live-verified against Codex CLI v0.142.3: emitting
207
+ // `permissionDecision:"allow"` errors the hook ("unsupported
208
+ // permissionDecision:allow") and Codex then fails open. For an allow we emit
209
+ // NO decision so Codex proceeds normally; the decision is already recorded by
210
+ // the /authorize call above (only `deny` is honored on PreToolUse).
211
+ return {
212
+ schemaVersion: CODEX_HOOK_SCHEMA_VERSION,
213
+ status: "allow",
214
+ tool: payload.toolName,
215
+ normalizedTool,
216
+ reason: `Axtary authorized ${normalizedTool}: ${summary}${ledgerLine}`,
217
+ output: null,
218
+ exitCode: 0,
219
+ };
220
+ }
221
+ if (response.status === "step_up") {
222
+ // Codex PreToolUse honors only `deny`; we block step-up (fail closed) rather
223
+ // than rely on an unverified interactive "ask", and point to the
224
+ // payload-bound approval path.
225
+ return decisionResult(payload.toolName, normalizedTool, "step_up", [
226
+ `Axtary blocked ${normalizedTool} pending human step-up approval: ${summary}`,
227
+ ...hints,
228
+ "Approve the exact payload via the Axtary dashboard / proxy approval path before re-running.",
229
+ ledgerLine.trim(),
230
+ ]);
231
+ }
232
+ return decisionResult(payload.toolName, normalizedTool, "deny", [
233
+ `Axtary blocked ${normalizedTool}: ${summary}`,
234
+ ...hints,
235
+ ledgerLine.trim(),
236
+ ]);
237
+ }
238
+ // Codex PreToolUse honors only `permissionDecision:"deny"` (allow/ask are
239
+ // rejected as unsupported, v0.142.3), so every blocking decision — an explicit
240
+ // deny, a fail-closed step-up, or an error — renders as a deny.
241
+ function decisionResult(tool, normalizedTool, status, reasonParts) {
242
+ const reason = reasonParts.filter((part) => part.length > 0).join(" ");
243
+ return {
244
+ schemaVersion: CODEX_HOOK_SCHEMA_VERSION,
245
+ status,
246
+ tool,
247
+ normalizedTool,
248
+ reason,
249
+ output: JSON.stringify({
250
+ hookSpecificOutput: {
251
+ hookEventName: "PreToolUse",
252
+ permissionDecision: "deny",
253
+ permissionDecisionReason: reason,
254
+ },
255
+ }),
256
+ exitCode: 0,
257
+ };
258
+ }
259
+ //# sourceMappingURL=codex-hook.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codex-hook.js","sourceRoot":"","sources":["../src/codex-hook.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,101 @@
1
+ import { spawnSync } from "node:child_process";
2
+ export declare const CREDENTIALS_SCHEMA_VERSION = "axtary.credentials.v0";
3
+ export type CredentialSecretBackendName = "keychain" | "file";
4
+ /** Non-secret record of a connected provider. Safe to print / show in a UI. */
5
+ export type CredentialMetadata = {
6
+ provider: string;
7
+ type: "oauth" | "token";
8
+ source: "oauth" | "manual";
9
+ scopes: string[];
10
+ obtainedAt: string;
11
+ expiresAt: string | null;
12
+ /** A non-secret label like a Slack team id or workspace name. */
13
+ account: string | null;
14
+ /** Non-secret provider routing data, for example Jira cloudId and siteUrl. */
15
+ context?: Record<string, string>;
16
+ secretBackend: CredentialSecretBackendName;
17
+ };
18
+ /** Full credential including the secret material. Never logged or serialized to status output. */
19
+ export type StoredCredential = CredentialMetadata & {
20
+ accessToken: string;
21
+ refreshToken: string | null;
22
+ };
23
+ export type CredentialSecret = {
24
+ accessToken: string;
25
+ refreshToken: string | null;
26
+ };
27
+ /** Pluggable secret storage so the keychain can be swapped for a file (or an in-memory fake in tests). */
28
+ export type CredentialSecretBackend = {
29
+ readonly name: CredentialSecretBackendName;
30
+ get(provider: string): Promise<CredentialSecret | null>;
31
+ set(provider: string, secret: CredentialSecret): Promise<void>;
32
+ delete(provider: string): Promise<void>;
33
+ };
34
+ /** True when the macOS `security` keychain CLI is usable. */
35
+ export declare function macKeychainAvailable(platform?: NodeJS.Platform, run?: typeof spawnSync): boolean;
36
+ /** macOS keychain-backed secret store (generic passwords under a per-provider service). */
37
+ export declare function createMacKeychainSecretBackend(run?: typeof spawnSync): CredentialSecretBackend;
38
+ export type CredentialStoreOptions = {
39
+ baseDir?: string;
40
+ secretBackend?: CredentialSecretBackend;
41
+ platform?: NodeJS.Platform;
42
+ run?: typeof spawnSync;
43
+ };
44
+ export declare class CredentialStore {
45
+ private readonly baseDir;
46
+ private readonly filePath;
47
+ private readonly secretBackend;
48
+ constructor(options?: CredentialStoreOptions);
49
+ /** The backend chosen for new writes (so callers can report where secrets land). */
50
+ secretBackendName(): CredentialSecretBackendName;
51
+ get(provider: string): Promise<StoredCredential | null>;
52
+ set(input: {
53
+ provider: string;
54
+ type: CredentialMetadata["type"];
55
+ source: CredentialMetadata["source"];
56
+ accessToken: string;
57
+ refreshToken?: string | null;
58
+ scopes?: string[];
59
+ expiresAt?: string | null;
60
+ account?: string | null;
61
+ context?: Record<string, string>;
62
+ }): Promise<CredentialMetadata>;
63
+ /**
64
+ * Write the secret to the preferred backend, then read it back and confirm it
65
+ * round-trips. If the keychain write did not actually persist a readable
66
+ * secret — e.g. macOS `security add-generic-password -w` reads the password
67
+ * from the controlling TTY rather than our piped stdin, storing garbage — fall
68
+ * back to the portable 0600 file backend instead of silently storing an
69
+ * unreadable credential. The secret value is never placed on a process argv.
70
+ */
71
+ private writeSecretVerified;
72
+ delete(provider: string): Promise<boolean>;
73
+ /** Metadata only — never returns secret material. */
74
+ list(): Promise<CredentialMetadata[]>;
75
+ private fileSecretBackend;
76
+ private read;
77
+ private write;
78
+ }
79
+ /** True once an OAuth credential has lapsed (with a small safety skew). */
80
+ export declare function credentialExpired(credential: Pick<StoredCredential, "expiresAt">, now?: number, skewMs?: number): boolean;
81
+ /**
82
+ * Resolve a provider token with the broker taking precedence over a raw env
83
+ * var. A connected, non-expired stored credential wins; otherwise we fall back
84
+ * to `env[tokenEnv]` (which itself may be populated by a secret manager — see
85
+ * https://docs.axtary.com/credentials). Returns the token and where it came from.
86
+ */
87
+ export declare function resolveProviderToken(input: {
88
+ provider: string;
89
+ tokenEnv: string;
90
+ env: Record<string, string | undefined>;
91
+ store: CredentialStore | null;
92
+ now?: number;
93
+ }): Promise<{
94
+ token: string | undefined;
95
+ source: "broker" | "env" | "missing";
96
+ }>;
97
+ export declare function loadCredentialStore(options?: CredentialStoreOptions): CredentialStore;
98
+ export declare function credentialsBaseDir(): string;
99
+ export declare function credentialsLocation(baseDir?: string): string;
100
+ export declare function removeCredentialsFile(baseDir: string): Promise<void>;
101
+ //# sourceMappingURL=credentials.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,277 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ // Axtary local credential broker.
6
+ //
7
+ // PRD §10.1: the credential broker runs locally so provider secrets stay on the
8
+ // customer's machine and never reach the hosted control plane. This module is
9
+ // that broker's storage layer: it keeps a 0600 metadata file (which providers
10
+ // are connected, scopes, expiry — never the secret) and stores the actual access
11
+ // tokens in the OS keychain when available, falling back to the same 0600 file.
12
+ //
13
+ // The metadata/secret split means `axtary connections` (status) is secret-safe
14
+ // by construction: it only ever reads metadata, never a token.
15
+ export const CREDENTIALS_SCHEMA_VERSION = "axtary.credentials.v0";
16
+ const KEYCHAIN_SERVICE_PREFIX = "axtary:credential:";
17
+ const KEYCHAIN_ACCOUNT = "axtary";
18
+ function defaultBaseDir() {
19
+ return process.env.AXTARY_HOME ?? join(homedir(), ".axtary");
20
+ }
21
+ function credentialsFilePath(baseDir) {
22
+ return join(baseDir, "credentials.json");
23
+ }
24
+ /** True when the macOS `security` keychain CLI is usable. */
25
+ export function macKeychainAvailable(platform = process.platform, run = spawnSync) {
26
+ if (platform !== "darwin")
27
+ return false;
28
+ const probe = run("security", ["help"], { stdio: "ignore" });
29
+ return probe.status === 0 || probe.status === 1; // `security help` exits non-zero but exists
30
+ }
31
+ /** macOS keychain-backed secret store (generic passwords under a per-provider service). */
32
+ export function createMacKeychainSecretBackend(run = spawnSync) {
33
+ const service = (provider) => `${KEYCHAIN_SERVICE_PREFIX}${provider}`;
34
+ return {
35
+ name: "keychain",
36
+ async get(provider) {
37
+ const result = run("security", ["find-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", service(provider), "-w"], { encoding: "utf8" });
38
+ if (result.status !== 0 || typeof result.stdout !== "string")
39
+ return null;
40
+ try {
41
+ return JSON.parse(result.stdout.trim());
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ },
47
+ async set(provider, secret) {
48
+ // Pass the secret on stdin, never on argv. `-w` with no value makes
49
+ // `security` read the password from the prompt (here, stdin), which
50
+ // prompts twice ("password" + "retype"), so the payload is written twice.
51
+ // This keeps the token off the `security` process command line, where
52
+ // `ps` would otherwise expose it to any same-user process. `-U` updates an
53
+ // existing item.
54
+ const payload = JSON.stringify(secret);
55
+ const result = run("security", [
56
+ "add-generic-password",
57
+ "-U",
58
+ "-a",
59
+ KEYCHAIN_ACCOUNT,
60
+ "-s",
61
+ service(provider),
62
+ "-w",
63
+ ], { input: `${payload}\n${payload}\n`, stdio: ["pipe", "ignore", "ignore"] });
64
+ if (result.status !== 0) {
65
+ throw new Error("credential_keychain_write_failed");
66
+ }
67
+ },
68
+ async delete(provider) {
69
+ run("security", ["delete-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", service(provider)], { stdio: "ignore" });
70
+ },
71
+ };
72
+ }
73
+ /**
74
+ * Resolve the secret backend: explicit override wins; otherwise keychain on
75
+ * macOS when available, else the 0600 file. `AXTARY_CREDENTIAL_BACKEND=file`
76
+ * forces the portable file backend (useful in CI / containers).
77
+ */
78
+ function resolveSecretBackend(options, fileBackend) {
79
+ if (options.secretBackend)
80
+ return options.secretBackend;
81
+ if (process.env.AXTARY_CREDENTIAL_BACKEND === "file")
82
+ return fileBackend;
83
+ if (macKeychainAvailable(options.platform, options.run)) {
84
+ return createMacKeychainSecretBackend(options.run);
85
+ }
86
+ return fileBackend;
87
+ }
88
+ export class CredentialStore {
89
+ baseDir;
90
+ filePath;
91
+ secretBackend;
92
+ constructor(options = {}) {
93
+ this.baseDir = options.baseDir ?? defaultBaseDir();
94
+ this.filePath = credentialsFilePath(this.baseDir);
95
+ this.secretBackend = resolveSecretBackend(options, this.fileSecretBackend());
96
+ }
97
+ /** The backend chosen for new writes (so callers can report where secrets land). */
98
+ secretBackendName() {
99
+ return this.secretBackend.name;
100
+ }
101
+ async get(provider) {
102
+ const file = await this.read();
103
+ const metadata = file.credentials[provider];
104
+ if (!metadata)
105
+ return null;
106
+ const backend = metadata.secretBackend === this.secretBackend.name
107
+ ? this.secretBackend
108
+ : metadata.secretBackend === "file"
109
+ ? this.fileSecretBackend()
110
+ : createMacKeychainSecretBackend();
111
+ const secret = await backend.get(provider);
112
+ if (!secret)
113
+ return null;
114
+ return { ...metadata, accessToken: secret.accessToken, refreshToken: secret.refreshToken };
115
+ }
116
+ async set(input) {
117
+ const usedBackend = await this.writeSecretVerified(input.provider, {
118
+ accessToken: input.accessToken,
119
+ refreshToken: input.refreshToken ?? null,
120
+ });
121
+ const metadata = {
122
+ provider: input.provider,
123
+ type: input.type,
124
+ source: input.source,
125
+ scopes: input.scopes ?? [],
126
+ obtainedAt: new Date().toISOString(),
127
+ expiresAt: input.expiresAt ?? null,
128
+ account: input.account ?? null,
129
+ context: input.context,
130
+ secretBackend: usedBackend.name,
131
+ };
132
+ const file = await this.read();
133
+ file.credentials[input.provider] = metadata;
134
+ if (usedBackend.name !== "file" && file.secrets) {
135
+ delete file.secrets[input.provider];
136
+ }
137
+ await this.write(file);
138
+ return metadata;
139
+ }
140
+ /**
141
+ * Write the secret to the preferred backend, then read it back and confirm it
142
+ * round-trips. If the keychain write did not actually persist a readable
143
+ * secret — e.g. macOS `security add-generic-password -w` reads the password
144
+ * from the controlling TTY rather than our piped stdin, storing garbage — fall
145
+ * back to the portable 0600 file backend instead of silently storing an
146
+ * unreadable credential. The secret value is never placed on a process argv.
147
+ */
148
+ async writeSecretVerified(provider, secret) {
149
+ const primary = this.secretBackend;
150
+ try {
151
+ await primary.set(provider, secret);
152
+ const readBack = await primary.get(provider);
153
+ if (readBack &&
154
+ readBack.accessToken === secret.accessToken &&
155
+ (readBack.refreshToken ?? null) === (secret.refreshToken ?? null)) {
156
+ return primary;
157
+ }
158
+ }
159
+ catch {
160
+ // Fall through to the file backend below.
161
+ }
162
+ if (primary.name === "file") {
163
+ throw new Error("credential_secret_write_failed");
164
+ }
165
+ // The preferred backend did not round-trip. Remove any partial item it left,
166
+ // then store the secret in the verifiable 0600 file backend.
167
+ try {
168
+ await primary.delete(provider);
169
+ }
170
+ catch {
171
+ // best-effort cleanup
172
+ }
173
+ const fileBackend = this.fileSecretBackend();
174
+ await fileBackend.set(provider, secret);
175
+ return fileBackend;
176
+ }
177
+ async delete(provider) {
178
+ const file = await this.read();
179
+ const metadata = file.credentials[provider];
180
+ if (!metadata)
181
+ return false;
182
+ const backend = metadata.secretBackend === "file" ? this.fileSecretBackend() : createMacKeychainSecretBackend();
183
+ await backend.delete(provider);
184
+ delete file.credentials[provider];
185
+ if (file.secrets)
186
+ delete file.secrets[provider];
187
+ await this.write(file);
188
+ return true;
189
+ }
190
+ /** Metadata only — never returns secret material. */
191
+ async list() {
192
+ const file = await this.read();
193
+ return Object.values(file.credentials).sort((a, b) => a.provider.localeCompare(b.provider));
194
+ }
195
+ fileSecretBackend() {
196
+ return {
197
+ name: "file",
198
+ get: async (provider) => {
199
+ const file = await this.read();
200
+ return file.secrets?.[provider] ?? null;
201
+ },
202
+ set: async (provider, secret) => {
203
+ const file = await this.read();
204
+ file.secrets = file.secrets ?? {};
205
+ file.secrets[provider] = secret;
206
+ await this.write(file);
207
+ },
208
+ delete: async (provider) => {
209
+ const file = await this.read();
210
+ if (file.secrets) {
211
+ delete file.secrets[provider];
212
+ await this.write(file);
213
+ }
214
+ },
215
+ };
216
+ }
217
+ async read() {
218
+ try {
219
+ const raw = await readFile(this.filePath, "utf8");
220
+ const parsed = JSON.parse(raw);
221
+ return {
222
+ version: parsed.version ?? CREDENTIALS_SCHEMA_VERSION,
223
+ credentials: parsed.credentials ?? {},
224
+ secrets: parsed.secrets,
225
+ };
226
+ }
227
+ catch (error) {
228
+ if (error.code === "ENOENT") {
229
+ return { version: CREDENTIALS_SCHEMA_VERSION, credentials: {} };
230
+ }
231
+ throw error;
232
+ }
233
+ }
234
+ async write(file) {
235
+ await mkdir(this.baseDir, { recursive: true, mode: 0o700 });
236
+ await writeFile(this.filePath, `${JSON.stringify({ ...file, version: CREDENTIALS_SCHEMA_VERSION }, null, 2)}\n`, { mode: 0o600 });
237
+ }
238
+ }
239
+ /** True once an OAuth credential has lapsed (with a small safety skew). */
240
+ export function credentialExpired(credential, now = Date.now(), skewMs = 30_000) {
241
+ if (!credential.expiresAt)
242
+ return false;
243
+ return new Date(credential.expiresAt).getTime() - skewMs <= now;
244
+ }
245
+ /**
246
+ * Resolve a provider token with the broker taking precedence over a raw env
247
+ * var. A connected, non-expired stored credential wins; otherwise we fall back
248
+ * to `env[tokenEnv]` (which itself may be populated by a secret manager — see
249
+ * https://docs.axtary.com/credentials). Returns the token and where it came from.
250
+ */
251
+ export async function resolveProviderToken(input) {
252
+ if (input.store) {
253
+ const credential = await input.store.get(input.provider);
254
+ if (credential && !credentialExpired(credential, input.now)) {
255
+ return { token: credential.accessToken, source: "broker" };
256
+ }
257
+ }
258
+ const envToken = input.env[input.tokenEnv];
259
+ if (envToken)
260
+ return { token: envToken, source: "env" };
261
+ return { token: undefined, source: "missing" };
262
+ }
263
+ export function loadCredentialStore(options = {}) {
264
+ return new CredentialStore(options);
265
+ }
266
+ export function credentialsBaseDir() {
267
+ return defaultBaseDir();
268
+ }
269
+ export function credentialsLocation(baseDir = defaultBaseDir()) {
270
+ return credentialsFilePath(baseDir);
271
+ }
272
+ // Re-exported for callers that need to clean up a base dir in tests.
273
+ export async function removeCredentialsFile(baseDir) {
274
+ await rm(credentialsFilePath(baseDir), { force: true });
275
+ await rm(dirname(credentialsFilePath(baseDir)), { force: true, recursive: true });
276
+ }
277
+ //# sourceMappingURL=credentials.js.map