axauth 1.11.1 → 1.12.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.
@@ -9,8 +9,8 @@ import { tmpdir } from "node:os";
9
9
  import path from "node:path";
10
10
  import { buildAgentRuntimeEnvironment, getAgent, getAgentConfigSubdirectory, } from "axshared";
11
11
  import { buildRefreshedCredentials } from "./build-refreshed-credentials.js";
12
- import { spawnAgent } from "./spawn-agent.js";
13
- import { getFileStats, waitForFileUpdate } from "./wait-for-file-update.js";
12
+ import { spawnAgentWithFileMonitor } from "./spawn-agent-with-file-monitor.js";
13
+ import { getFileStats } from "./wait-for-file-update.js";
14
14
  /** Default timeout for refresh operations in milliseconds */
15
15
  const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
16
16
  /**
@@ -95,30 +95,19 @@ async function refreshCredentials(creds, options) {
95
95
  const cliArguments = agent.simplePromptArguments("ping", {
96
96
  provider: options?.provider,
97
97
  });
98
- // 8. Spawn agent with minimal prompt
99
- const { timedOut, exitCode } = await spawnAgent(agent.cli, cliArguments, fullEnvironment, timeout);
98
+ // 8. Spawn agent with file monitoring
99
+ // File monitoring starts before agent spawn to catch async writes that may
100
+ // occur during process execution (e.g., Gemini's async event handlers).
101
+ const { timedOut, exitCode, fileUpdated } = await spawnAgentWithFileMonitor(agent.cli, cliArguments, fullEnvironment, timeout, credentialFilePath, beforeStats);
100
102
  if (timedOut) {
101
103
  return { ok: false, error: "Refresh timed out" };
102
104
  }
103
- if (exitCode !== 0 && exitCode !== null) {
105
+ if (exitCode !== 0 && exitCode !== undefined) {
104
106
  return { ok: false, error: `Agent exited with code ${exitCode}` };
105
107
  }
106
- // 8b. Wait for credential file to be written
107
- // Some agents (like Gemini) use async event handlers that may not complete
108
- // before the process exits. Wait for the file to be created or modified.
109
- //
110
- // If the wait times out or errors, we still proceed to read credentials.
111
- // This is intentional: even if monitoring failed, the agent may have written
112
- // the file by now. Failing here would be worse than attempting to read.
113
- const waitResult = await waitForFileUpdate(credentialFilePath, beforeStats, {
114
- timeout: 5000,
115
- });
116
- if (!waitResult.ok) {
117
- const errorDetail = waitResult.reason === "error" && waitResult.error
118
- ? ` - ${waitResult.error}`
119
- : "";
108
+ if (!fileUpdated) {
120
109
  console.error(`Warning: Credential file "${credentialFilePath}" was not observed ` +
121
- `to be updated (${waitResult.reason}${errorDetail}). Proceeding may read stale credentials.`);
110
+ `to be updated. Proceeding may read stale credentials.`);
122
111
  }
123
112
  // 9. Read refreshed credentials from temp directory
124
113
  const { extractRawCredentialsFromDirectory } = await import("./registry.js");
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Agent subprocess spawning with file monitoring.
3
+ *
4
+ * Starts file monitoring before agent spawn to catch async writes that may
5
+ * occur during process execution (e.g., Gemini's async event handlers).
6
+ */
7
+ import { type SpawnResult } from "./spawn-agent.js";
8
+ import { type FileStats } from "./wait-for-file-update.js";
9
+ /**
10
+ * Spawn agent and monitor a file for updates.
11
+ *
12
+ * File monitoring starts before agent spawn to catch writes that occur during
13
+ * process execution, not just after exit.
14
+ *
15
+ * @returns Object with spawn result and whether file was updated
16
+ */
17
+ declare function spawnAgentWithFileMonitor(cli: string, arguments_: string[], environment: NodeJS.ProcessEnv, timeout: number, filePath: string, beforeStats: FileStats | undefined): Promise<SpawnResult & {
18
+ fileUpdated: boolean;
19
+ }>;
20
+ export { spawnAgentWithFileMonitor };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Agent subprocess spawning with file monitoring.
3
+ *
4
+ * Starts file monitoring before agent spawn to catch async writes that may
5
+ * occur during process execution (e.g., Gemini's async event handlers).
6
+ */
7
+ import { spawnAgent } from "./spawn-agent.js";
8
+ import { getFileStats, waitForFileUpdate, } from "./wait-for-file-update.js";
9
+ /**
10
+ * Spawn agent and monitor a file for updates.
11
+ *
12
+ * File monitoring starts before agent spawn to catch writes that occur during
13
+ * process execution, not just after exit.
14
+ *
15
+ * @returns Object with spawn result and whether file was updated
16
+ */
17
+ async function spawnAgentWithFileMonitor(cli, arguments_, environment, timeout, filePath, beforeStats) {
18
+ const fileMonitorAbort = new AbortController();
19
+ // Start file monitoring BEFORE spawning agent
20
+ const fileMonitorPromise = waitForFileUpdate(filePath, beforeStats, {
21
+ timeout: timeout + 5000,
22
+ signal: fileMonitorAbort.signal,
23
+ });
24
+ try {
25
+ // Spawn agent
26
+ const spawnResult = await spawnAgent(cli, arguments_, environment, timeout);
27
+ // On early exit, abort file monitor
28
+ if (spawnResult.timedOut ||
29
+ (spawnResult.exitCode !== 0 && spawnResult.exitCode !== undefined)) {
30
+ return { ...spawnResult, fileUpdated: false };
31
+ }
32
+ // Agent exited successfully - wait for in-flight writes
33
+ // Use 5s grace period to match previous behavior (some agents like Gemini
34
+ // may write credentials several seconds after process exit)
35
+ const gracePeriodMs = 5000;
36
+ const fileResult = await Promise.race([
37
+ fileMonitorPromise,
38
+ new Promise((resolve) => {
39
+ setTimeout(() => {
40
+ resolve({ ok: false });
41
+ }, gracePeriodMs).unref();
42
+ }),
43
+ ]);
44
+ // Final check in case write completed after race
45
+ const finalStats = getFileStats(filePath);
46
+ const fileUpdated = fileResult.ok ||
47
+ (finalStats !== undefined &&
48
+ (beforeStats === undefined ||
49
+ finalStats.mtimeMs !== beforeStats.mtimeMs ||
50
+ finalStats.size !== beforeStats.size));
51
+ return { ...spawnResult, fileUpdated };
52
+ }
53
+ finally {
54
+ fileMonitorAbort.abort();
55
+ }
56
+ }
57
+ export { spawnAgentWithFileMonitor };
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * Agent subprocess spawning utility.
3
3
  */
4
+ /** Result of spawning an agent process */
5
+ interface SpawnResult {
6
+ timedOut: boolean;
7
+ exitCode: number | undefined;
8
+ }
4
9
  /**
5
10
  * Spawn agent and wait for completion.
6
11
  *
12
+ * Enforces a hard timeout - if the process doesn't exit after SIGTERM,
13
+ * escalates to SIGKILL after a grace period and resolves anyway.
14
+ *
7
15
  * @returns Object with timedOut flag and exit code
8
16
  */
9
- declare function spawnAgent(binary: string, arguments_: string[], environment: NodeJS.ProcessEnv, timeout: number): Promise<{
10
- timedOut: boolean;
11
- exitCode: number | null;
12
- }>;
17
+ declare function spawnAgent(binary: string, arguments_: string[], environment: NodeJS.ProcessEnv, timeout: number): Promise<SpawnResult>;
13
18
  export { spawnAgent };
19
+ export type { SpawnResult };
@@ -2,37 +2,60 @@
2
2
  * Agent subprocess spawning utility.
3
3
  */
4
4
  import { spawn } from "node:child_process";
5
+ /** Grace period before escalating SIGTERM to SIGKILL */
6
+ const SIGKILL_GRACE_MS = 5000;
5
7
  /**
6
8
  * Spawn agent and wait for completion.
7
9
  *
10
+ * Enforces a hard timeout - if the process doesn't exit after SIGTERM,
11
+ * escalates to SIGKILL after a grace period and resolves anyway.
12
+ *
8
13
  * @returns Object with timedOut flag and exit code
9
14
  */
10
15
  async function spawnAgent(binary, arguments_, environment, timeout) {
11
16
  return new Promise((resolve) => {
12
17
  const child = spawn(binary, arguments_, {
13
18
  env: environment,
14
- stdio: ["ignore", "pipe", "pipe"],
19
+ // Ignore stdin/stdout to prevent pipe buffer deadlocks, but inherit
20
+ // stderr so agent error messages are visible for debugging
21
+ stdio: ["ignore", "ignore", "inherit"],
15
22
  });
16
23
  let timedOut = false;
17
24
  let resolved = false;
25
+ let killTimer;
26
+ const cleanup = () => {
27
+ clearTimeout(timer);
28
+ if (killTimer)
29
+ clearTimeout(killTimer);
30
+ };
18
31
  const timer = setTimeout(() => {
19
32
  if (!resolved) {
20
33
  timedOut = true;
21
34
  child.kill("SIGTERM");
35
+ // If process doesn't exit after SIGTERM, escalate to SIGKILL
36
+ // and resolve anyway to prevent indefinite hangs
37
+ killTimer = setTimeout(() => {
38
+ if (!resolved) {
39
+ child.kill("SIGKILL");
40
+ resolved = true;
41
+ cleanup();
42
+ resolve({ timedOut: true, exitCode: undefined });
43
+ }
44
+ }, SIGKILL_GRACE_MS);
22
45
  }
23
46
  }, timeout);
24
47
  child.on("error", () => {
25
48
  if (!resolved) {
26
49
  resolved = true;
27
- clearTimeout(timer);
50
+ cleanup();
28
51
  resolve({ timedOut: false, exitCode: 1 });
29
52
  }
30
53
  });
31
54
  child.on("close", (code) => {
32
55
  if (!resolved) {
33
56
  resolved = true;
34
- clearTimeout(timer);
35
- resolve({ timedOut, exitCode: code });
57
+ cleanup();
58
+ resolve({ timedOut, exitCode: code ?? undefined });
36
59
  }
37
60
  });
38
61
  });
@@ -5,10 +5,14 @@
5
5
  * used by token refresh to wait for credential files to be written.
6
6
  */
7
7
  import { type Stats } from "node:fs";
8
+ /** File stats type for external use */
9
+ type FileStats = Stats;
8
10
  /** Options for waiting on file changes */
9
11
  interface WaitOptions {
10
12
  /** Maximum time to wait in milliseconds (default: 5000) */
11
13
  timeout?: number;
14
+ /** AbortSignal to cancel waiting early */
15
+ signal?: AbortSignal;
12
16
  }
13
17
  /** Result of waiting for file change */
14
18
  type WaitResult = {
@@ -16,7 +20,7 @@ type WaitResult = {
16
20
  reason: "created" | "modified";
17
21
  } | {
18
22
  ok: false;
19
- reason: "timeout" | "error";
23
+ reason: "timeout" | "error" | "aborted";
20
24
  error?: string;
21
25
  };
22
26
  /**
@@ -35,3 +39,4 @@ declare function getFileStats(filePath: string): Stats | undefined;
35
39
  */
36
40
  declare function waitForFileUpdate(filePath: string, beforeStats: Stats | undefined, options?: WaitOptions): Promise<WaitResult>;
37
41
  export { getFileStats, waitForFileUpdate };
42
+ export type { FileStats };
@@ -47,8 +47,13 @@ function checkFileChange(filePath, beforeStats) {
47
47
  */
48
48
  async function waitForFileUpdate(filePath, beforeStats, options) {
49
49
  const timeout = options?.timeout ?? 5000;
50
+ const signal = options?.signal;
50
51
  const parentDirectory = path.dirname(filePath);
51
52
  const fileName = path.basename(filePath);
53
+ // Check if already aborted
54
+ if (signal?.aborted) {
55
+ return { ok: false, reason: "aborted" };
56
+ }
52
57
  // Check immediately (file might already be written)
53
58
  const immediate = checkFileChange(filePath, beforeStats);
54
59
  if (immediate) {
@@ -57,6 +62,7 @@ async function waitForFileUpdate(filePath, beforeStats, options) {
57
62
  return new Promise((resolve) => {
58
63
  let watcher;
59
64
  let resolved = false;
65
+ let abortHandler;
60
66
  const cleanup = (timeoutId, pollId) => {
61
67
  if (resolved)
62
68
  return;
@@ -64,6 +70,9 @@ async function waitForFileUpdate(filePath, beforeStats, options) {
64
70
  watcher?.close();
65
71
  clearTimeout(timeoutId);
66
72
  clearInterval(pollId);
73
+ if (abortHandler && signal) {
74
+ signal.removeEventListener("abort", abortHandler);
75
+ }
67
76
  };
68
77
  // Set timeout
69
78
  const timeoutId = setTimeout(() => {
@@ -79,6 +88,14 @@ async function waitForFileUpdate(filePath, beforeStats, options) {
79
88
  resolve(result);
80
89
  }
81
90
  }, 100);
91
+ // Handle abort signal
92
+ if (signal) {
93
+ abortHandler = () => {
94
+ cleanup(timeoutId, pollIntervalId);
95
+ resolve({ ok: false, reason: "aborted" });
96
+ };
97
+ signal.addEventListener("abort", abortHandler);
98
+ }
82
99
  // Watch parent directory for changes (for faster responsiveness)
83
100
  try {
84
101
  watcher = watch(parentDirectory, (_eventType, changedFile) => {
package/dist/cli.js CHANGED
@@ -28,24 +28,29 @@ Examples:
28
28
  # List authenticated agents
29
29
  axauth list
30
30
 
31
- # Filter to show only authenticated agents
32
- axauth list | tail -n +2 | awk -F'\t' '$2 == "authenticated"'
33
-
34
31
  # Get access token for an agent
35
32
  axauth token --agent claude
36
33
 
37
- # Export credentials for CI/CD
38
- axauth export --agent claude --output creds.json --no-password
34
+ # Export/import credentials (file-based)
35
+ axauth export --agent claude --output creds.json
36
+ axauth install-credentials --agent claude --input creds.json
37
+
38
+ # For vault operations: export raw format (--no-encrypt)
39
+ # Note: install-credentials cannot consume raw exports
40
+ axauth export --agent claude --output creds.json --no-encrypt
39
41
 
40
42
  # Remove credentials (agent will prompt for login)
41
43
  axauth remove-credentials --agent claude
42
44
 
43
- # Install credentials from exported file
44
- axauth install-credentials --agent claude --input creds.json
45
+ CI/CD with axvault (recommended):
46
+ # One-time: push local credentials to vault
47
+ axauth vault push --agent claude --name ci
48
+
49
+ # In CI/CD: fetch and install credentials
50
+ AXVAULT_URL=https://vault.example.com AXVAULT_API_KEY=axv_sk_xxx \
51
+ axauth vault fetch --agent claude --name ci --install
45
52
 
46
- # Fetch credentials from vault (JSON config)
47
- AXVAULT='{"url":"https://vault.example.com","apiKey":"axv_sk_xxx"}' \
48
- axauth vault fetch --agent claude --name ci --install`);
53
+ # See "axauth vault --help" for full workflow documentation`);
49
54
  program
50
55
  .command("list")
51
56
  .description("List agents and their auth status")
@@ -118,7 +123,32 @@ program
118
123
  // Vault subcommand
119
124
  const vault = program
120
125
  .command("vault")
121
- .description("Manage credentials stored in axvault server");
126
+ .description("Manage credentials stored in axvault server")
127
+ .addHelpText("after", String.raw `
128
+ Workflow:
129
+ 1. Push local credentials to vault (one-time setup from authenticated machine):
130
+ axauth vault push --agent claude --name ci
131
+
132
+ 2. Fetch credentials in CI/CD (set AXVAULT_URL and AXVAULT_API_KEY):
133
+ axauth vault fetch --agent claude --name ci --install
134
+
135
+ Environment variables:
136
+ AXVAULT JSON config: {"url":"...","apiKey":"..."}
137
+ AXVAULT_URL Vault server URL
138
+ AXVAULT_API_KEY API key for authentication
139
+
140
+ OpenCode multi-provider support:
141
+ OpenCode stores credentials for multiple providers (anthropic, openai, google).
142
+ Use --provider to push a single provider (fetch uses the stored name):
143
+
144
+ axauth vault push --agent opencode --provider anthropic --name ci-anthropic
145
+ axauth vault fetch --agent opencode --name ci-anthropic --install
146
+
147
+ Quick reference:
148
+ vault push Upload local credentials to vault (requires write access)
149
+ vault fetch Download credentials from vault (requires read access)
150
+
151
+ Use "axauth vault <command> --help" for detailed options.`);
122
152
  vault
123
153
  .command("fetch")
124
154
  .description("Fetch credentials from vault server")
@@ -167,6 +197,7 @@ vault
167
197
  .description("Push local credentials to vault server")
168
198
  .requiredOption("-a, --agent <agent>", `Agent to push credentials from (${AGENT_CLIS.join(", ")})`)
169
199
  .requiredOption("-n, --name <name>", "Credential name in vault (e.g., ci, prod)")
200
+ .option("-p, --provider <provider>", "Provider to push (opencode only; pushes single provider instead of all)")
170
201
  .addHelpText("after", String.raw `
171
202
  Environment variables (option 1 - single JSON):
172
203
  AXVAULT JSON config: {"url":"...","apiKey":"..."}
@@ -179,6 +210,9 @@ Examples:
179
210
  # Push local Claude credentials to vault as "ci"
180
211
  axauth vault push --agent claude --name ci
181
212
 
213
+ # Push a single OpenCode provider to vault
214
+ axauth vault push --agent opencode --provider anthropic --name ci-anthropic
215
+
182
216
  # Push to a different vault instance
183
217
  AXVAULT_URL=https://vault.example.com AXVAULT_API_KEY=axv_sk_xxx \
184
218
  axauth vault push --agent gemini --name prod`)
@@ -22,6 +22,7 @@ declare function handleVaultFetch(options: VaultFetchOptions): Promise<void>;
22
22
  interface VaultPushOptions {
23
23
  agent: string;
24
24
  name: string;
25
+ provider?: string;
25
26
  }
26
27
  /**
27
28
  * Handle vault push command.
@@ -2,6 +2,7 @@
2
2
  * Vault commands - fetch and push credentials to axvault server.
3
3
  */
4
4
  import { credentialsToEnvironment, extractRawCredentials, installCredentials, } from "../auth/registry.js";
5
+ import { extractProviderCredentials } from "../auth/agents/opencode.js";
5
6
  import { fetchVaultCredentials, pushVaultCredentials, } from "../vault/vault-client.js";
6
7
  import { getVaultConfig } from "../vault/vault-config.js";
7
8
  import { validateAgent } from "./validate-agent.js";
@@ -132,6 +133,12 @@ async function handleVaultPush(options) {
132
133
  const agentId = validateAgent(options.agent);
133
134
  if (!agentId)
134
135
  return;
136
+ // Validate --provider is only used with opencode
137
+ if (options.provider && agentId !== "opencode") {
138
+ console.error("Error: --provider flag is only supported for opencode agent");
139
+ process.exitCode = 2;
140
+ return;
141
+ }
135
142
  // Check vault is configured
136
143
  const vaultConfig = getVaultConfig();
137
144
  if (!vaultConfig) {
@@ -140,13 +147,22 @@ async function handleVaultPush(options) {
140
147
  return;
141
148
  }
142
149
  // Extract local credentials
143
- const credentials = extractRawCredentials(agentId);
144
- if (!credentials) {
150
+ const rawCredentials = extractRawCredentials(agentId);
151
+ if (!rawCredentials) {
145
152
  console.error(`Error: No credentials found for ${agentId}`);
146
153
  console.error("Hint: Authenticate with the agent first, or use 'axauth list' to check status");
147
154
  process.exitCode = 1;
148
155
  return;
149
156
  }
157
+ // Handle per-provider extraction for OpenCode
158
+ const credentials = options.provider
159
+ ? extractProviderCredentials(rawCredentials, options.provider)
160
+ : rawCredentials;
161
+ if (!credentials) {
162
+ console.error(`Error: No credentials found for provider '${options.provider}'`);
163
+ process.exitCode = 1;
164
+ return;
165
+ }
150
166
  // Push to vault
151
167
  const result = await pushVaultCredentials({
152
168
  agentId,
@@ -158,6 +174,7 @@ async function handleVaultPush(options) {
158
174
  process.exitCode = 1;
159
175
  return;
160
176
  }
161
- console.error(`Credentials pushed to vault: ${agentId}/${options.name}`);
177
+ const label = options.provider ? `${agentId}/${options.provider}` : agentId;
178
+ console.error(`Credentials pushed to vault: ${label} → ${options.name}`);
162
179
  }
163
180
  export { handleVaultFetch, handleVaultPush };
@@ -14,6 +14,7 @@ const VaultCredentialResponse = z.object({
14
14
  name: z.string(),
15
15
  type: CredentialType,
16
16
  data: z.record(z.string(), z.unknown()),
17
+ provider: z.string().trim().min(1).optional(), // OpenCode provider support
17
18
  expiresAt: z.string().nullish(), // optional for oauth-token type
18
19
  updatedAt: z.string(),
19
20
  });
@@ -88,6 +89,7 @@ async function fetchVaultCredentials(options) {
88
89
  agent: options.agentId,
89
90
  type: body.type,
90
91
  data: body.data,
92
+ provider: body.provider,
91
93
  };
92
94
  return {
93
95
  ok: true,
@@ -142,6 +144,7 @@ async function pushVaultCredentials(options) {
142
144
  body: JSON.stringify({
143
145
  type: options.credentials.type,
144
146
  data: options.credentials.data,
147
+ provider: options.credentials.provider,
145
148
  }),
146
149
  });
147
150
  // Handle error responses
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "axauth",
3
3
  "author": "Łukasz Jerciński",
4
4
  "license": "MIT",
5
- "version": "1.11.1",
5
+ "version": "1.12.0",
6
6
  "description": "Authentication management library and CLI for AI coding agents",
7
7
  "repository": {
8
8
  "type": "git",