discoclaw 0.1.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/.context/README.md +42 -0
- package/.context/architecture.md +58 -0
- package/.context/bot-setup.md +24 -0
- package/.context/dev.md +230 -0
- package/.context/discord.md +144 -0
- package/.context/memory.md +257 -0
- package/.context/ops.md +59 -0
- package/.context/pa-safety.md +47 -0
- package/.context/pa.md +118 -0
- package/.context/project.md +43 -0
- package/.context/runtime.md +253 -0
- package/.context/tasks.md +71 -0
- package/.context/tools.md +75 -0
- package/.env.example +88 -0
- package/.env.example.full +378 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/beads/auto-tag.js +2 -0
- package/dist/beads/auto-tag.test.js +62 -0
- package/dist/beads/bd-cli.js +9 -0
- package/dist/beads/bd-cli.test.js +495 -0
- package/dist/beads/bead-hooks-cli.js +149 -0
- package/dist/beads/bead-sync-cli.js +5 -0
- package/dist/beads/bead-sync-cli.test.js +72 -0
- package/dist/beads/bead-sync-coordinator.js +4 -0
- package/dist/beads/bead-sync-coordinator.test.js +239 -0
- package/dist/beads/bead-sync-watcher.js +2 -0
- package/dist/beads/bead-sync-watcher.test.js +96 -0
- package/dist/beads/bead-sync.js +7 -0
- package/dist/beads/bead-sync.test.js +876 -0
- package/dist/beads/bead-thread-cache.js +8 -0
- package/dist/beads/bead-thread-cache.test.js +91 -0
- package/dist/beads/discord-sync.js +18 -0
- package/dist/beads/discord-sync.test.js +782 -0
- package/dist/beads/find-bead-by-thread.test.js +36 -0
- package/dist/beads/forum-guard.js +2 -0
- package/dist/beads/forum-guard.test.js +204 -0
- package/dist/beads/initialize.js +3 -0
- package/dist/beads/initialize.test.js +304 -0
- package/dist/beads/types.js +10 -0
- package/dist/cli/daemon-installer.js +225 -0
- package/dist/cli/daemon-installer.test.js +289 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/init-wizard.js +374 -0
- package/dist/cli/init-wizard.test.js +191 -0
- package/dist/config.js +385 -0
- package/dist/config.test.js +589 -0
- package/dist/cron/auto-tag.js +100 -0
- package/dist/cron/auto-tag.test.js +91 -0
- package/dist/cron/cadence.js +74 -0
- package/dist/cron/cadence.test.js +53 -0
- package/dist/cron/cron-sync-coordinator.js +66 -0
- package/dist/cron/cron-sync-coordinator.test.js +118 -0
- package/dist/cron/cron-sync.js +165 -0
- package/dist/cron/cron-sync.test.js +228 -0
- package/dist/cron/cron-tag-map-watcher.js +128 -0
- package/dist/cron/cron-tag-map-watcher.test.js +155 -0
- package/dist/cron/default-timezone.js +23 -0
- package/dist/cron/default-timezone.test.js +30 -0
- package/dist/cron/discord-sync.js +205 -0
- package/dist/cron/discord-sync.test.js +353 -0
- package/dist/cron/executor.js +303 -0
- package/dist/cron/executor.test.js +614 -0
- package/dist/cron/forum-sync.js +347 -0
- package/dist/cron/forum-sync.test.js +539 -0
- package/dist/cron/job-lock.js +164 -0
- package/dist/cron/job-lock.test.js +178 -0
- package/dist/cron/parser.js +68 -0
- package/dist/cron/parser.test.js +115 -0
- package/dist/cron/run-control.js +24 -0
- package/dist/cron/run-control.test.js +27 -0
- package/dist/cron/run-stats.js +265 -0
- package/dist/cron/run-stats.test.js +160 -0
- package/dist/cron/scheduler.js +97 -0
- package/dist/cron/scheduler.test.js +112 -0
- package/dist/cron/tag-map.js +47 -0
- package/dist/cron/tag-map.test.js +64 -0
- package/dist/cron/types.js +1 -0
- package/dist/discoclaw-plan-format.test.js +137 -0
- package/dist/discoclaw-recipe-format.test.js +137 -0
- package/dist/discord/abort-registry.js +70 -0
- package/dist/discord/action-categories.js +36 -0
- package/dist/discord/action-types.js +1 -0
- package/dist/discord/action-utils.js +58 -0
- package/dist/discord/action-utils.test.js +58 -0
- package/dist/discord/actions-beads.js +1 -0
- package/dist/discord/actions-beads.test.js +372 -0
- package/dist/discord/actions-bot-profile.js +107 -0
- package/dist/discord/actions-bot-profile.test.js +138 -0
- package/dist/discord/actions-channels.js +427 -0
- package/dist/discord/actions-channels.test.js +697 -0
- package/dist/discord/actions-config.js +173 -0
- package/dist/discord/actions-config.test.js +322 -0
- package/dist/discord/actions-crons.js +586 -0
- package/dist/discord/actions-crons.test.js +499 -0
- package/dist/discord/actions-defer.js +60 -0
- package/dist/discord/actions-defer.test.js +134 -0
- package/dist/discord/actions-forge.js +134 -0
- package/dist/discord/actions-forge.test.js +206 -0
- package/dist/discord/actions-guild.js +301 -0
- package/dist/discord/actions-guild.test.js +386 -0
- package/dist/discord/actions-memory.js +106 -0
- package/dist/discord/actions-memory.test.js +248 -0
- package/dist/discord/actions-messaging.js +401 -0
- package/dist/discord/actions-messaging.test.js +738 -0
- package/dist/discord/actions-moderation.js +65 -0
- package/dist/discord/actions-moderation.test.js +88 -0
- package/dist/discord/actions-plan.js +445 -0
- package/dist/discord/actions-plan.test.js +610 -0
- package/dist/discord/actions-poll.js +38 -0
- package/dist/discord/actions-poll.test.js +93 -0
- package/dist/discord/actions-tasks.js +3 -0
- package/dist/discord/actions-tasks.test.js +418 -0
- package/dist/discord/actions.js +600 -0
- package/dist/discord/actions.test.js +522 -0
- package/dist/discord/allowed-mentions.js +3 -0
- package/dist/discord/allowed-mentions.test.js +17 -0
- package/dist/discord/allowlist.js +29 -0
- package/dist/discord/allowlist.test.js +24 -0
- package/dist/discord/audit-handler.js +191 -0
- package/dist/discord/audit-handler.test.js +361 -0
- package/dist/discord/bot.js +141 -0
- package/dist/discord/channel-context.js +181 -0
- package/dist/discord/defer-scheduler.js +45 -0
- package/dist/discord/destructive-confirmation.js +128 -0
- package/dist/discord/destructive-confirmation.test.js +49 -0
- package/dist/discord/discord-plan-auto-implement.test.js +18 -0
- package/dist/discord/durable-memory.js +145 -0
- package/dist/discord/durable-memory.test.js +281 -0
- package/dist/discord/durable-write-queue.js +4 -0
- package/dist/discord/file-download.js +308 -0
- package/dist/discord/file-download.test.js +303 -0
- package/dist/discord/forge-audit-verdict.js +140 -0
- package/dist/discord/forge-auto-implement.js +80 -0
- package/dist/discord/forge-auto-implement.test.js +110 -0
- package/dist/discord/forge-commands.js +698 -0
- package/dist/discord/forge-commands.test.js +1606 -0
- package/dist/discord/forge-plan-registry.js +68 -0
- package/dist/discord/forge-plan-registry.test.js +127 -0
- package/dist/discord/forum-count-sync.js +130 -0
- package/dist/discord/forum-count-sync.test.js +200 -0
- package/dist/discord/health-command.js +98 -0
- package/dist/discord/health-command.test.js +195 -0
- package/dist/discord/help-command.js +22 -0
- package/dist/discord/help-command.test.js +49 -0
- package/dist/discord/image-download.js +201 -0
- package/dist/discord/image-download.test.js +499 -0
- package/dist/discord/inflight-replies.js +228 -0
- package/dist/discord/inflight-replies.test.js +295 -0
- package/dist/discord/json-extract.js +110 -0
- package/dist/discord/keyed-queue.js +22 -0
- package/dist/discord/memory-commands.js +85 -0
- package/dist/discord/memory-commands.test.js +159 -0
- package/dist/discord/memory-timing.integration.test.js +159 -0
- package/dist/discord/message-coordinator.js +2347 -0
- package/dist/discord/message-coordinator.onboarding.test.js +183 -0
- package/dist/discord/message-coordinator.plan-run.test.js +264 -0
- package/dist/discord/message-history.js +53 -0
- package/dist/discord/message-history.test.js +95 -0
- package/dist/discord/models-command.js +59 -0
- package/dist/discord/models-command.test.js +150 -0
- package/dist/discord/nickname.test.js +76 -0
- package/dist/discord/onboarding-completion.js +55 -0
- package/dist/discord/onboarding-completion.test.js +176 -0
- package/dist/discord/output-common.js +178 -0
- package/dist/discord/output-common.test.js +198 -0
- package/dist/discord/output-utils.js +156 -0
- package/dist/discord/parse-identity-name.test.js +129 -0
- package/dist/discord/plan-commands.js +612 -0
- package/dist/discord/plan-commands.test.js +1622 -0
- package/dist/discord/plan-manager.js +1491 -0
- package/dist/discord/plan-manager.test.js +2380 -0
- package/dist/discord/plan-parser.js +110 -0
- package/dist/discord/plan-parser.test.js +63 -0
- package/dist/discord/plan-run-phase-start.js +20 -0
- package/dist/discord/plan-run-phase-start.test.js +29 -0
- package/dist/discord/platform-message.js +45 -0
- package/dist/discord/platform-message.test.js +110 -0
- package/dist/discord/prompt-common.js +240 -0
- package/dist/discord/prompt-common.test.js +423 -0
- package/dist/discord/reaction-handler.js +691 -0
- package/dist/discord/reaction-handler.test.js +1574 -0
- package/dist/discord/reaction-prompts.js +118 -0
- package/dist/discord/reaction-prompts.test.js +253 -0
- package/dist/discord/reply-reference.js +66 -0
- package/dist/discord/reply-reference.test.js +125 -0
- package/dist/discord/restart-command.js +143 -0
- package/dist/discord/restart-command.test.js +196 -0
- package/dist/discord/runtime-utils.js +43 -0
- package/dist/discord/runtime-utils.test.js +112 -0
- package/dist/discord/session-key.js +7 -0
- package/dist/discord/session-key.test.js +13 -0
- package/dist/discord/shortterm-memory.js +166 -0
- package/dist/discord/shortterm-memory.test.js +345 -0
- package/dist/discord/shutdown-context.js +122 -0
- package/dist/discord/shutdown-context.test.js +279 -0
- package/dist/discord/startup-profile.test.js +214 -0
- package/dist/discord/status-channel.js +190 -0
- package/dist/discord/status-channel.test.js +282 -0
- package/dist/discord/status-command.js +206 -0
- package/dist/discord/status-command.test.js +341 -0
- package/dist/discord/streaming-progress.js +107 -0
- package/dist/discord/streaming-progress.test.js +93 -0
- package/dist/discord/summarizer.js +89 -0
- package/dist/discord/summarizer.test.js +245 -0
- package/dist/discord/system-bootstrap.js +396 -0
- package/dist/discord/system-bootstrap.test.js +724 -0
- package/dist/discord/thread-context.js +169 -0
- package/dist/discord/thread-context.test.js +386 -0
- package/dist/discord/tool-aware-queue.js +116 -0
- package/dist/discord/tool-aware-queue.test.js +180 -0
- package/dist/discord/update-command.js +127 -0
- package/dist/discord/update-command.test.js +275 -0
- package/dist/discord/user-errors.js +40 -0
- package/dist/discord/user-errors.test.js +31 -0
- package/dist/discord/user-turn-to-durable.js +111 -0
- package/dist/discord/user-turn-to-durable.test.js +273 -0
- package/dist/discord-followup.test.js +677 -0
- package/dist/discord.channel-context.test.js +95 -0
- package/dist/discord.fail-closed.test.js +199 -0
- package/dist/discord.health-command.integration.test.js +140 -0
- package/dist/discord.js +190 -0
- package/dist/discord.prompt-context.test.js +1431 -0
- package/dist/discord.render.test.js +621 -0
- package/dist/discord.status-wiring.test.js +187 -0
- package/dist/engine/claudeCli.js +137 -0
- package/dist/engine/types.js +1 -0
- package/dist/group-queue.js +25 -0
- package/dist/health/credential-check.js +175 -0
- package/dist/health/credential-check.test.js +401 -0
- package/dist/health/startup-healing.js +139 -0
- package/dist/health/startup-healing.test.js +298 -0
- package/dist/identity.js +36 -0
- package/dist/index.js +1378 -0
- package/dist/logging/logger-like.js +1 -0
- package/dist/observability/memory-sampler.js +51 -0
- package/dist/observability/memory-sampler.test.js +93 -0
- package/dist/observability/metrics.js +88 -0
- package/dist/observability/metrics.test.js +42 -0
- package/dist/onboarding/onboarding-flow.js +246 -0
- package/dist/onboarding/onboarding-flow.test.js +238 -0
- package/dist/onboarding/onboarding-writer.js +102 -0
- package/dist/onboarding/onboarding-writer.test.js +143 -0
- package/dist/pidlock.js +187 -0
- package/dist/pidlock.test.js +128 -0
- package/dist/pipeline/engine.js +206 -0
- package/dist/pipeline/engine.test.js +771 -0
- package/dist/root-policy.js +21 -0
- package/dist/root-policy.test.js +55 -0
- package/dist/runtime/claude-code-cli.js +35 -0
- package/dist/runtime/claude-code-cli.test.js +1199 -0
- package/dist/runtime/cli-adapter.js +584 -0
- package/dist/runtime/cli-output-parsers.js +108 -0
- package/dist/runtime/cli-shared.js +96 -0
- package/dist/runtime/cli-shared.test.js +104 -0
- package/dist/runtime/cli-strategy.js +6 -0
- package/dist/runtime/codex-cli.js +16 -0
- package/dist/runtime/codex-cli.test.js +862 -0
- package/dist/runtime/concurrency-limit.js +80 -0
- package/dist/runtime/concurrency-limit.test.js +137 -0
- package/dist/runtime/gemini-cli.js +16 -0
- package/dist/runtime/gemini-cli.test.js +413 -0
- package/dist/runtime/long-running-process.js +415 -0
- package/dist/runtime/long-running-process.test.js +318 -0
- package/dist/runtime/model-smoke-helpers.js +160 -0
- package/dist/runtime/model-smoke.test.js +194 -0
- package/dist/runtime/model-tiers.js +33 -0
- package/dist/runtime/model-tiers.test.js +65 -0
- package/dist/runtime/openai-auth.js +151 -0
- package/dist/runtime/openai-auth.test.js +361 -0
- package/dist/runtime/openai-compat.js +178 -0
- package/dist/runtime/openai-compat.test.js +449 -0
- package/dist/runtime/process-pool.js +93 -0
- package/dist/runtime/process-pool.test.js +148 -0
- package/dist/runtime/registry.js +15 -0
- package/dist/runtime/registry.test.js +47 -0
- package/dist/runtime/session-scanner.js +186 -0
- package/dist/runtime/session-scanner.test.js +257 -0
- package/dist/runtime/strategies/claude-strategy.js +193 -0
- package/dist/runtime/strategies/codex-strategy.js +161 -0
- package/dist/runtime/strategies/gemini-strategy.js +64 -0
- package/dist/runtime/strategies/template-strategy.js +85 -0
- package/dist/runtime/tool-capabilities.js +27 -0
- package/dist/runtime/tool-capabilities.test.js +24 -0
- package/dist/runtime/tool-labels.js +48 -0
- package/dist/runtime/types.js +2 -0
- package/dist/sessionManager.js +47 -0
- package/dist/sessions.js +18 -0
- package/dist/tasks/architecture-contract.js +33 -0
- package/dist/tasks/architecture-contract.test.js +90 -0
- package/dist/tasks/auto-tag.js +50 -0
- package/dist/tasks/auto-tag.test.js +64 -0
- package/dist/tasks/bd-cli.js +164 -0
- package/dist/tasks/bd-cli.test.js +359 -0
- package/dist/tasks/bead-sync.js +1 -0
- package/dist/tasks/context-summary.js +27 -0
- package/dist/tasks/discord-sync.js +3 -0
- package/dist/tasks/discord-sync.test.js +685 -0
- package/dist/tasks/discord-types.js +4 -0
- package/dist/tasks/find-task-by-thread.test.js +36 -0
- package/dist/tasks/forum-guard.js +81 -0
- package/dist/tasks/forum-guard.test.js +192 -0
- package/dist/tasks/initialize.js +77 -0
- package/dist/tasks/initialize.test.js +263 -0
- package/dist/tasks/logger-types.js +1 -0
- package/dist/tasks/metrics-types.js +3 -0
- package/dist/tasks/migrate.js +33 -0
- package/dist/tasks/migrate.test.js +156 -0
- package/dist/tasks/path-defaults.js +67 -0
- package/dist/tasks/path-defaults.test.js +73 -0
- package/dist/tasks/runtime-types.js +1 -0
- package/dist/tasks/service.js +33 -0
- package/dist/tasks/service.test.js +51 -0
- package/dist/tasks/store.js +238 -0
- package/dist/tasks/store.test.js +417 -0
- package/dist/tasks/sync-context.js +1 -0
- package/dist/tasks/sync-contract.js +24 -0
- package/dist/tasks/sync-contract.test.js +25 -0
- package/dist/tasks/sync-coordinator-metrics.js +41 -0
- package/dist/tasks/sync-coordinator-retries.js +71 -0
- package/dist/tasks/sync-coordinator.js +96 -0
- package/dist/tasks/sync-coordinator.test.js +501 -0
- package/dist/tasks/sync-types.js +1 -0
- package/dist/tasks/sync-watcher.js +27 -0
- package/dist/tasks/sync-watcher.test.js +92 -0
- package/dist/tasks/tag-map.js +36 -0
- package/dist/tasks/tag-map.test.js +54 -0
- package/dist/tasks/task-action-contract.js +16 -0
- package/dist/tasks/task-action-contract.test.js +16 -0
- package/dist/tasks/task-action-executor.js +18 -0
- package/dist/tasks/task-action-executor.test.js +420 -0
- package/dist/tasks/task-action-mutation-helpers.js +17 -0
- package/dist/tasks/task-action-mutations.js +151 -0
- package/dist/tasks/task-action-prompt.js +62 -0
- package/dist/tasks/task-action-read-ops.js +73 -0
- package/dist/tasks/task-action-runner-types.js +1 -0
- package/dist/tasks/task-action-thread-sync.js +82 -0
- package/dist/tasks/task-actions.js +3 -0
- package/dist/tasks/task-cli.js +227 -0
- package/dist/tasks/task-context.js +1 -0
- package/dist/tasks/task-lifecycle.js +46 -0
- package/dist/tasks/task-lifecycle.test.js +35 -0
- package/dist/tasks/task-sync-apply-plan.js +95 -0
- package/dist/tasks/task-sync-apply-types.js +12 -0
- package/dist/tasks/task-sync-apply.js +319 -0
- package/dist/tasks/task-sync-cli.js +89 -0
- package/dist/tasks/task-sync-cli.test.js +70 -0
- package/dist/tasks/task-sync-engine.js +88 -0
- package/dist/tasks/task-sync-engine.test.js +934 -0
- package/dist/tasks/task-sync-phase-apply.js +171 -0
- package/dist/tasks/task-sync-pipeline.js +2 -0
- package/dist/tasks/task-sync-pipeline.test.js +265 -0
- package/dist/tasks/task-sync-reconcile-plan.js +182 -0
- package/dist/tasks/task-sync-reconcile.js +144 -0
- package/dist/tasks/task-sync.js +56 -0
- package/dist/tasks/task-sync.test.js +86 -0
- package/dist/tasks/thread-cache.js +42 -0
- package/dist/tasks/thread-cache.test.js +89 -0
- package/dist/tasks/thread-contracts.test.js +711 -0
- package/dist/tasks/thread-forum-ops.js +68 -0
- package/dist/tasks/thread-helpers.js +86 -0
- package/dist/tasks/thread-helpers.test.js +33 -0
- package/dist/tasks/thread-lifecycle-ops.js +144 -0
- package/dist/tasks/thread-ops-shared.js +21 -0
- package/dist/tasks/thread-ops.js +2 -0
- package/dist/tasks/types.js +20 -0
- package/dist/tasks/types.test.js +60 -0
- package/dist/test-setup.js +11 -0
- package/dist/test-setup.test.js +42 -0
- package/dist/transport/types.js +1 -0
- package/dist/validate.js +41 -0
- package/dist/validate.test.js +94 -0
- package/dist/version.js +15 -0
- package/dist/version.test.js +31 -0
- package/dist/webhook/server.js +199 -0
- package/dist/webhook/server.test.js +460 -0
- package/dist/workspace-bootstrap.js +135 -0
- package/dist/workspace-bootstrap.test.js +514 -0
- package/dist/workspace-permissions.js +134 -0
- package/dist/workspace-permissions.test.js +181 -0
- package/package.json +74 -0
- package/scripts/cron/cron-tag-map.json +9 -0
- package/scripts/tasks/tag-map.json +10 -0
- package/systemd/discoclaw.service +19 -0
- package/templates/recipes/integration.discoclaw-recipe.md +171 -0
- package/templates/workspace/AGENTS.md +217 -0
- package/templates/workspace/BOOTSTRAP.md +1 -0
- package/templates/workspace/HEARTBEAT.md +10 -0
- package/templates/workspace/IDENTITY.md +16 -0
- package/templates/workspace/MEMORY.md +24 -0
- package/templates/workspace/SOUL.md +52 -0
- package/templates/workspace/TOOLS.md +304 -0
- package/templates/workspace/USER.md +37 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const tiers = new Set(['fast', 'capable']);
|
|
2
|
+
/** Type guard for ModelTier. */
|
|
3
|
+
export function isModelTier(s) {
|
|
4
|
+
return tiers.has(s);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Maps tier × runtime to a concrete model string.
|
|
8
|
+
* Empty string = adapter-default sentinel (adapter uses its own defaultModel).
|
|
9
|
+
*/
|
|
10
|
+
const tierMap = {
|
|
11
|
+
claude_code: { fast: 'haiku', capable: 'opus' },
|
|
12
|
+
gemini: { fast: 'gemini-2.5-flash', capable: 'gemini-2.5-pro' },
|
|
13
|
+
openai: { fast: '', capable: '' },
|
|
14
|
+
codex: { fast: '', capable: '' },
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a tier name or literal model string to a concrete model string.
|
|
18
|
+
*
|
|
19
|
+
* - Known tier name → look up in the built-in map for the given runtime.
|
|
20
|
+
* - Anything else → pass through unchanged (supports `RUNTIME_MODEL=opus`
|
|
21
|
+
* or `RUNTIME_MODEL=claude-sonnet-4-5-20250929`).
|
|
22
|
+
*
|
|
23
|
+
* For runtimes not in the built-in map (`gemini`, `other`), tier inputs
|
|
24
|
+
* resolve to `''` (adapter-default sentinel).
|
|
25
|
+
*/
|
|
26
|
+
export function resolveModel(tierOrModel, runtimeId) {
|
|
27
|
+
if (!isModelTier(tierOrModel))
|
|
28
|
+
return tierOrModel;
|
|
29
|
+
const runtimeTiers = tierMap[runtimeId];
|
|
30
|
+
if (!runtimeTiers)
|
|
31
|
+
return '';
|
|
32
|
+
return runtimeTiers[tierOrModel];
|
|
33
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isModelTier, resolveModel } from './model-tiers.js';
|
|
3
|
+
describe('isModelTier', () => {
|
|
4
|
+
it('returns true for known tiers', () => {
|
|
5
|
+
expect(isModelTier('fast')).toBe(true);
|
|
6
|
+
expect(isModelTier('capable')).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
it('returns false for legacy tier names', () => {
|
|
9
|
+
expect(isModelTier('haiku')).toBe(false);
|
|
10
|
+
expect(isModelTier('opus')).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
it('returns false for arbitrary strings', () => {
|
|
13
|
+
expect(isModelTier('')).toBe(false);
|
|
14
|
+
expect(isModelTier('claude-sonnet-4-5-20250929')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
describe('resolveModel', () => {
|
|
18
|
+
describe('claude_code runtime', () => {
|
|
19
|
+
it('resolves fast → haiku', () => {
|
|
20
|
+
expect(resolveModel('fast', 'claude_code')).toBe('haiku');
|
|
21
|
+
});
|
|
22
|
+
it('resolves capable → opus', () => {
|
|
23
|
+
expect(resolveModel('capable', 'claude_code')).toBe('opus');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('openai runtime', () => {
|
|
27
|
+
it('resolves tiers to empty string (adapter-default)', () => {
|
|
28
|
+
expect(resolveModel('fast', 'openai')).toBe('');
|
|
29
|
+
expect(resolveModel('capable', 'openai')).toBe('');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('codex runtime', () => {
|
|
33
|
+
it('resolves tiers to empty string (adapter-default)', () => {
|
|
34
|
+
expect(resolveModel('fast', 'codex')).toBe('');
|
|
35
|
+
expect(resolveModel('capable', 'codex')).toBe('');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('gemini runtime', () => {
|
|
39
|
+
it('resolves fast → gemini-2.5-flash', () => {
|
|
40
|
+
expect(resolveModel('fast', 'gemini')).toBe('gemini-2.5-flash');
|
|
41
|
+
});
|
|
42
|
+
it('resolves capable → gemini-2.5-pro', () => {
|
|
43
|
+
expect(resolveModel('capable', 'gemini')).toBe('gemini-2.5-pro');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('unknown runtimes (other)', () => {
|
|
47
|
+
it('resolves tiers to empty string', () => {
|
|
48
|
+
expect(resolveModel('fast', 'other')).toBe('');
|
|
49
|
+
expect(resolveModel('capable', 'other')).toBe('');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('passthrough for non-tier strings', () => {
|
|
53
|
+
it('passes through legacy model names', () => {
|
|
54
|
+
expect(resolveModel('haiku', 'claude_code')).toBe('haiku');
|
|
55
|
+
expect(resolveModel('opus', 'claude_code')).toBe('opus');
|
|
56
|
+
});
|
|
57
|
+
it('passes through full model identifiers', () => {
|
|
58
|
+
expect(resolveModel('claude-sonnet-4-5-20250929', 'claude_code')).toBe('claude-sonnet-4-5-20250929');
|
|
59
|
+
expect(resolveModel('gpt-4o', 'openai')).toBe('gpt-4o');
|
|
60
|
+
});
|
|
61
|
+
it('passes through empty string', () => {
|
|
62
|
+
expect(resolveModel('', 'claude_code')).toBe('');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// Codex CLI's registered OAuth client_id — see https://github.com/openai/codex
|
|
4
|
+
export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
5
|
+
const OPENAI_TOKEN_ENDPOINT = 'https://auth.openai.com/oauth/token';
|
|
6
|
+
/** Read and parse the Codex auth file. Expects an absolute path. */
|
|
7
|
+
export async function loadAuthFile(filePath) {
|
|
8
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
9
|
+
const data = JSON.parse(raw);
|
|
10
|
+
if (!data.tokens?.access_token || !data.tokens?.refresh_token) {
|
|
11
|
+
throw new Error(`Auth file ${filePath} missing required tokens (access_token, refresh_token)`);
|
|
12
|
+
}
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
/** Base64url-decode the JWT payload and extract the `exp` claim. */
|
|
16
|
+
export function decodeJwtExp(token) {
|
|
17
|
+
const parts = token.split('.');
|
|
18
|
+
if (parts.length < 2) {
|
|
19
|
+
throw new Error('Invalid JWT: expected at least 2 dot-separated segments');
|
|
20
|
+
}
|
|
21
|
+
// Base64url → Base64 → Buffer → JSON
|
|
22
|
+
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
23
|
+
const json = Buffer.from(base64, 'base64').toString('utf-8');
|
|
24
|
+
const payload = JSON.parse(json);
|
|
25
|
+
if (typeof payload.exp !== 'number') {
|
|
26
|
+
throw new Error('JWT payload missing numeric "exp" claim');
|
|
27
|
+
}
|
|
28
|
+
return payload.exp;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns true if the token's `exp` minus `bufferSecs` is before now.
|
|
32
|
+
* 5-minute buffer by default to avoid edge-case expiry mid-request.
|
|
33
|
+
*/
|
|
34
|
+
export function isTokenExpired(token, bufferSecs = 300) {
|
|
35
|
+
try {
|
|
36
|
+
const exp = decodeJwtExp(token);
|
|
37
|
+
return exp - bufferSecs < Date.now() / 1000;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// If we can't decode, treat as expired so we refresh
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** POST to OpenAI's OAuth endpoint to refresh the access token. */
|
|
45
|
+
export async function refreshAccessToken(refreshToken, clientId = CODEX_CLIENT_ID) {
|
|
46
|
+
const response = await fetch(OPENAI_TOKEN_ENDPOINT, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
grant_type: 'refresh_token',
|
|
51
|
+
refresh_token: refreshToken,
|
|
52
|
+
client_id: clientId,
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const body = await response.text().catch(() => '');
|
|
57
|
+
throw new Error(`Token refresh failed (${response.status}): ${body} [client_id=${clientId}]`);
|
|
58
|
+
}
|
|
59
|
+
const result = (await response.json());
|
|
60
|
+
if (!result.access_token) {
|
|
61
|
+
throw new Error(`Token refresh returned no access_token [client_id=${clientId}]`);
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Write updated tokens back to the auth file.
|
|
67
|
+
* Atomic write via temp file + rename. On failure, logs a warning but does not throw
|
|
68
|
+
* — the in-memory token is still valid.
|
|
69
|
+
*/
|
|
70
|
+
export async function saveAuthFile(filePath, data, log) {
|
|
71
|
+
const dir = path.dirname(filePath);
|
|
72
|
+
const tmpPath = path.join(dir, `.auth.tmp.${process.pid}`);
|
|
73
|
+
try {
|
|
74
|
+
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
75
|
+
await fs.rename(tmpPath, filePath);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
log?.warn({ err, filePath }, 'openai-auth: failed to persist auth file (in-memory token still valid)');
|
|
79
|
+
// Clean up temp file on failure
|
|
80
|
+
try {
|
|
81
|
+
await fs.unlink(tmpPath);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// ignore
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Create a token provider that manages ChatGPT OAuth access tokens.
|
|
90
|
+
* Reads from the Codex CLI auth file, refreshes when expired, and persists updates.
|
|
91
|
+
* Uses a mutex to prevent concurrent refresh storms.
|
|
92
|
+
*/
|
|
93
|
+
export function createChatGptTokenProvider(opts) {
|
|
94
|
+
const { authFilePath, log } = opts;
|
|
95
|
+
let cachedToken = null;
|
|
96
|
+
let cachedAuthData = null;
|
|
97
|
+
let acquirePromise = null;
|
|
98
|
+
/** Load file, check expiry, refresh if needed — all under a single mutex. */
|
|
99
|
+
async function acquireToken(forceRefresh) {
|
|
100
|
+
// Load auth data from file if not yet cached
|
|
101
|
+
if (!cachedAuthData) {
|
|
102
|
+
try {
|
|
103
|
+
cachedAuthData = await loadAuthFile(authFilePath);
|
|
104
|
+
cachedToken = cachedAuthData.tokens.access_token;
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
log.error({ err }, 'openai-auth: failed to load auth file');
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// If token is still valid and not forcing, return it
|
|
112
|
+
if (!forceRefresh && cachedToken && !isTokenExpired(cachedToken)) {
|
|
113
|
+
return cachedToken;
|
|
114
|
+
}
|
|
115
|
+
// Refresh
|
|
116
|
+
log.debug('openai-auth: refreshing access token');
|
|
117
|
+
const result = await refreshAccessToken(cachedAuthData.tokens.refresh_token);
|
|
118
|
+
// Update cached auth data with new tokens
|
|
119
|
+
cachedAuthData = {
|
|
120
|
+
...cachedAuthData,
|
|
121
|
+
tokens: {
|
|
122
|
+
...cachedAuthData.tokens,
|
|
123
|
+
access_token: result.access_token,
|
|
124
|
+
...(result.refresh_token ? { refresh_token: result.refresh_token } : {}),
|
|
125
|
+
...(result.id_token ? { id_token: result.id_token } : {}),
|
|
126
|
+
},
|
|
127
|
+
last_refresh: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
cachedToken = result.access_token;
|
|
130
|
+
// Persist to disk (non-throwing)
|
|
131
|
+
await saveAuthFile(authFilePath, cachedAuthData, log);
|
|
132
|
+
log.debug('openai-auth: token refreshed successfully');
|
|
133
|
+
return cachedToken;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
async getAccessToken(forceRefresh = false) {
|
|
137
|
+
// Fast path: return cached non-expired token (no async work)
|
|
138
|
+
if (!forceRefresh && cachedToken && !isTokenExpired(cachedToken)) {
|
|
139
|
+
return cachedToken;
|
|
140
|
+
}
|
|
141
|
+
// Mutex: if an acquire is already in progress, wait for it
|
|
142
|
+
if (acquirePromise) {
|
|
143
|
+
return acquirePromise;
|
|
144
|
+
}
|
|
145
|
+
acquirePromise = acquireToken(forceRefresh).finally(() => {
|
|
146
|
+
acquirePromise = null;
|
|
147
|
+
});
|
|
148
|
+
return acquirePromise;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { CODEX_CLIENT_ID, createChatGptTokenProvider, decodeJwtExp, isTokenExpired, loadAuthFile, refreshAccessToken, saveAuthFile, } from './openai-auth.js';
|
|
6
|
+
/** Build a minimal JWT with the given payload claims. No signature. */
|
|
7
|
+
function makeJwt(payload) {
|
|
8
|
+
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
|
|
9
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
10
|
+
return `${header}.${body}.`;
|
|
11
|
+
}
|
|
12
|
+
function makeAuthData(overrides) {
|
|
13
|
+
return {
|
|
14
|
+
auth_mode: 'chatgpt',
|
|
15
|
+
tokens: {
|
|
16
|
+
access_token: makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }),
|
|
17
|
+
refresh_token: 'rt-test-refresh-token',
|
|
18
|
+
id_token: 'id-test',
|
|
19
|
+
account_id: 'acct-123',
|
|
20
|
+
},
|
|
21
|
+
last_refresh: new Date().toISOString(),
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function makeLogger() {
|
|
26
|
+
return {
|
|
27
|
+
debug: vi.fn(),
|
|
28
|
+
warn: vi.fn(),
|
|
29
|
+
error: vi.fn(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
describe('CODEX_CLIENT_ID', () => {
|
|
33
|
+
it('has the expected value', () => {
|
|
34
|
+
expect(CODEX_CLIENT_ID).toBe('app_EMoamEEZ73f0CkXaXp7hrann');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('decodeJwtExp', () => {
|
|
38
|
+
it('extracts exp from a valid JWT', () => {
|
|
39
|
+
const exp = 1700000000;
|
|
40
|
+
const token = makeJwt({ exp, sub: 'user-1' });
|
|
41
|
+
expect(decodeJwtExp(token)).toBe(exp);
|
|
42
|
+
});
|
|
43
|
+
it('throws for a token with fewer than 2 segments', () => {
|
|
44
|
+
expect(() => decodeJwtExp('just-one-segment')).toThrow('Invalid JWT');
|
|
45
|
+
});
|
|
46
|
+
it('throws when payload has no exp claim', () => {
|
|
47
|
+
const token = makeJwt({ sub: 'user-1' });
|
|
48
|
+
expect(() => decodeJwtExp(token)).toThrow('missing numeric "exp"');
|
|
49
|
+
});
|
|
50
|
+
it('handles base64url characters (- and _)', () => {
|
|
51
|
+
// Create payload with characters that produce -/_ in base64url
|
|
52
|
+
const exp = 1700000000;
|
|
53
|
+
const token = makeJwt({ exp, data: '>>>???<<<' });
|
|
54
|
+
expect(decodeJwtExp(token)).toBe(exp);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('isTokenExpired', () => {
|
|
58
|
+
it('returns false for a token expiring well in the future', () => {
|
|
59
|
+
const token = makeJwt({ exp: Math.floor(Date.now() / 1000) + 7200 });
|
|
60
|
+
expect(isTokenExpired(token)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
it('returns true for a token expiring within the buffer window', () => {
|
|
63
|
+
const token = makeJwt({ exp: Math.floor(Date.now() / 1000) + 100 });
|
|
64
|
+
// Default buffer is 300s, so 100s from now is "expired"
|
|
65
|
+
expect(isTokenExpired(token)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
it('returns true for an already-expired token', () => {
|
|
68
|
+
const token = makeJwt({ exp: Math.floor(Date.now() / 1000) - 100 });
|
|
69
|
+
expect(isTokenExpired(token)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('respects custom bufferSecs', () => {
|
|
72
|
+
const token = makeJwt({ exp: Math.floor(Date.now() / 1000) + 100 });
|
|
73
|
+
// With a 50s buffer, 100s from now is NOT expired
|
|
74
|
+
expect(isTokenExpired(token, 50)).toBe(false);
|
|
75
|
+
// With a 200s buffer, 100s from now IS expired
|
|
76
|
+
expect(isTokenExpired(token, 200)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it('returns true for an unparseable token (treats as expired)', () => {
|
|
79
|
+
expect(isTokenExpired('not-a-jwt')).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('loadAuthFile', () => {
|
|
83
|
+
let tmpDir;
|
|
84
|
+
beforeEach(async () => {
|
|
85
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openai-auth-test-'));
|
|
86
|
+
});
|
|
87
|
+
afterEach(async () => {
|
|
88
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
89
|
+
});
|
|
90
|
+
it('parses a valid auth file', async () => {
|
|
91
|
+
const data = makeAuthData();
|
|
92
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
93
|
+
await fs.writeFile(filePath, JSON.stringify(data));
|
|
94
|
+
const loaded = await loadAuthFile(filePath);
|
|
95
|
+
expect(loaded.tokens.access_token).toBe(data.tokens.access_token);
|
|
96
|
+
expect(loaded.tokens.refresh_token).toBe(data.tokens.refresh_token);
|
|
97
|
+
expect(loaded.auth_mode).toBe('chatgpt');
|
|
98
|
+
});
|
|
99
|
+
it('throws for a file missing access_token', async () => {
|
|
100
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
101
|
+
await fs.writeFile(filePath, JSON.stringify({
|
|
102
|
+
auth_mode: 'chatgpt',
|
|
103
|
+
tokens: { refresh_token: 'rt-123' },
|
|
104
|
+
}));
|
|
105
|
+
await expect(loadAuthFile(filePath)).rejects.toThrow('missing required tokens');
|
|
106
|
+
});
|
|
107
|
+
it('throws for a file missing refresh_token', async () => {
|
|
108
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
109
|
+
await fs.writeFile(filePath, JSON.stringify({
|
|
110
|
+
auth_mode: 'chatgpt',
|
|
111
|
+
tokens: { access_token: 'at-123' },
|
|
112
|
+
}));
|
|
113
|
+
await expect(loadAuthFile(filePath)).rejects.toThrow('missing required tokens');
|
|
114
|
+
});
|
|
115
|
+
it('throws for a nonexistent file', async () => {
|
|
116
|
+
await expect(loadAuthFile(path.join(tmpDir, 'nope.json'))).rejects.toThrow();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('saveAuthFile', () => {
|
|
120
|
+
let tmpDir;
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openai-auth-test-'));
|
|
123
|
+
});
|
|
124
|
+
afterEach(async () => {
|
|
125
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
it('writes and reads back correctly', async () => {
|
|
128
|
+
const data = makeAuthData();
|
|
129
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
130
|
+
await saveAuthFile(filePath, data);
|
|
131
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
132
|
+
const parsed = JSON.parse(raw);
|
|
133
|
+
expect(parsed.tokens.access_token).toBe(data.tokens.access_token);
|
|
134
|
+
expect(parsed.tokens.refresh_token).toBe(data.tokens.refresh_token);
|
|
135
|
+
});
|
|
136
|
+
it('logs warning on write failure (does not throw)', async () => {
|
|
137
|
+
const log = makeLogger();
|
|
138
|
+
// Writing to a nonexistent directory should fail
|
|
139
|
+
const filePath = path.join(tmpDir, 'no', 'such', 'dir', 'auth.json');
|
|
140
|
+
await saveAuthFile(filePath, makeAuthData(), log);
|
|
141
|
+
expect(log.warn).toHaveBeenCalledTimes(1);
|
|
142
|
+
expect(log.warn.mock.calls[0][1]).toContain('failed to persist');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('refreshAccessToken', () => {
|
|
146
|
+
const originalFetch = globalThis.fetch;
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
globalThis.fetch = originalFetch;
|
|
149
|
+
});
|
|
150
|
+
it('sends correct request and returns tokens', async () => {
|
|
151
|
+
const newAccessToken = 'new-access-token';
|
|
152
|
+
let capturedBody;
|
|
153
|
+
let capturedUrl;
|
|
154
|
+
globalThis.fetch = vi.fn().mockImplementation((url, init) => {
|
|
155
|
+
capturedUrl = url;
|
|
156
|
+
capturedBody = init?.body;
|
|
157
|
+
return Promise.resolve(new Response(JSON.stringify({
|
|
158
|
+
access_token: newAccessToken,
|
|
159
|
+
refresh_token: 'new-refresh',
|
|
160
|
+
id_token: 'new-id',
|
|
161
|
+
}), { status: 200 }));
|
|
162
|
+
});
|
|
163
|
+
const result = await refreshAccessToken('rt-old', 'test-client-id');
|
|
164
|
+
expect(capturedUrl).toBe('https://auth.openai.com/oauth/token');
|
|
165
|
+
const body = JSON.parse(capturedBody);
|
|
166
|
+
expect(body.grant_type).toBe('refresh_token');
|
|
167
|
+
expect(body.refresh_token).toBe('rt-old');
|
|
168
|
+
expect(body.client_id).toBe('test-client-id');
|
|
169
|
+
expect(result.access_token).toBe(newAccessToken);
|
|
170
|
+
expect(result.refresh_token).toBe('new-refresh');
|
|
171
|
+
});
|
|
172
|
+
it('defaults client_id to CODEX_CLIENT_ID', async () => {
|
|
173
|
+
let capturedBody;
|
|
174
|
+
globalThis.fetch = vi.fn().mockImplementation((_url, init) => {
|
|
175
|
+
capturedBody = init?.body;
|
|
176
|
+
return Promise.resolve(new Response(JSON.stringify({ access_token: 'at' }), { status: 200 }));
|
|
177
|
+
});
|
|
178
|
+
await refreshAccessToken('rt-test');
|
|
179
|
+
const body = JSON.parse(capturedBody);
|
|
180
|
+
expect(body.client_id).toBe(CODEX_CLIENT_ID);
|
|
181
|
+
});
|
|
182
|
+
it('throws on non-ok response with client_id in message', async () => {
|
|
183
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('bad request', { status: 400 }));
|
|
184
|
+
await expect(refreshAccessToken('rt-bad', 'my-client'))
|
|
185
|
+
.rejects.toThrow(/client_id=my-client/);
|
|
186
|
+
});
|
|
187
|
+
it('throws when response has no access_token', async () => {
|
|
188
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
189
|
+
await expect(refreshAccessToken('rt-test'))
|
|
190
|
+
.rejects.toThrow(/no access_token/);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('createChatGptTokenProvider', () => {
|
|
194
|
+
const originalFetch = globalThis.fetch;
|
|
195
|
+
let tmpDir;
|
|
196
|
+
beforeEach(async () => {
|
|
197
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openai-auth-test-'));
|
|
198
|
+
});
|
|
199
|
+
afterEach(async () => {
|
|
200
|
+
globalThis.fetch = originalFetch;
|
|
201
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
202
|
+
});
|
|
203
|
+
function mockRefreshEndpoint(newAccessToken) {
|
|
204
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ access_token: newAccessToken }), { status: 200 }));
|
|
205
|
+
}
|
|
206
|
+
it('returns cached token from file when not expired', async () => {
|
|
207
|
+
const validToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
208
|
+
const data = makeAuthData({
|
|
209
|
+
tokens: {
|
|
210
|
+
access_token: validToken,
|
|
211
|
+
refresh_token: 'rt-test',
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
215
|
+
await fs.writeFile(filePath, JSON.stringify(data));
|
|
216
|
+
const fetchSpy = vi.fn();
|
|
217
|
+
globalThis.fetch = fetchSpy;
|
|
218
|
+
const log = makeLogger();
|
|
219
|
+
const provider = createChatGptTokenProvider({ authFilePath: filePath, log });
|
|
220
|
+
const token = await provider.getAccessToken();
|
|
221
|
+
expect(token).toBe(validToken);
|
|
222
|
+
// Should NOT have called fetch (no refresh needed)
|
|
223
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
it('refreshes expired token', async () => {
|
|
226
|
+
const expiredToken = makeJwt({ exp: Math.floor(Date.now() / 1000) - 100 });
|
|
227
|
+
const newToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
228
|
+
const data = makeAuthData({
|
|
229
|
+
tokens: {
|
|
230
|
+
access_token: expiredToken,
|
|
231
|
+
refresh_token: 'rt-test',
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
235
|
+
await fs.writeFile(filePath, JSON.stringify(data));
|
|
236
|
+
mockRefreshEndpoint(newToken);
|
|
237
|
+
const log = makeLogger();
|
|
238
|
+
const provider = createChatGptTokenProvider({ authFilePath: filePath, log });
|
|
239
|
+
const token = await provider.getAccessToken();
|
|
240
|
+
expect(token).toBe(newToken);
|
|
241
|
+
});
|
|
242
|
+
it('refreshes when forceRefresh is true even if token is valid', async () => {
|
|
243
|
+
const validToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
244
|
+
const newToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 7200 });
|
|
245
|
+
const data = makeAuthData({
|
|
246
|
+
tokens: {
|
|
247
|
+
access_token: validToken,
|
|
248
|
+
refresh_token: 'rt-test',
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
252
|
+
await fs.writeFile(filePath, JSON.stringify(data));
|
|
253
|
+
mockRefreshEndpoint(newToken);
|
|
254
|
+
const log = makeLogger();
|
|
255
|
+
const provider = createChatGptTokenProvider({ authFilePath: filePath, log });
|
|
256
|
+
// First call loads cached valid token
|
|
257
|
+
const token1 = await provider.getAccessToken();
|
|
258
|
+
expect(token1).toBe(validToken);
|
|
259
|
+
// Force refresh
|
|
260
|
+
const token2 = await provider.getAccessToken(true);
|
|
261
|
+
expect(token2).toBe(newToken);
|
|
262
|
+
});
|
|
263
|
+
it('persists refreshed tokens to disk', async () => {
|
|
264
|
+
const expiredToken = makeJwt({ exp: Math.floor(Date.now() / 1000) - 100 });
|
|
265
|
+
const newToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
266
|
+
const data = makeAuthData({
|
|
267
|
+
tokens: {
|
|
268
|
+
access_token: expiredToken,
|
|
269
|
+
refresh_token: 'rt-original',
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
273
|
+
await fs.writeFile(filePath, JSON.stringify(data));
|
|
274
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
275
|
+
access_token: newToken,
|
|
276
|
+
refresh_token: 'rt-updated',
|
|
277
|
+
}), { status: 200 }));
|
|
278
|
+
const log = makeLogger();
|
|
279
|
+
const provider = createChatGptTokenProvider({ authFilePath: filePath, log });
|
|
280
|
+
await provider.getAccessToken();
|
|
281
|
+
// Read back from disk
|
|
282
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
283
|
+
const persisted = JSON.parse(raw);
|
|
284
|
+
expect(persisted.tokens.access_token).toBe(newToken);
|
|
285
|
+
expect(persisted.tokens.refresh_token).toBe('rt-updated');
|
|
286
|
+
expect(persisted.last_refresh).toBeDefined();
|
|
287
|
+
});
|
|
288
|
+
it('deduplicates concurrent refresh calls (mutex)', async () => {
|
|
289
|
+
const expiredToken = makeJwt({ exp: Math.floor(Date.now() / 1000) - 100 });
|
|
290
|
+
const newToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
291
|
+
const data = makeAuthData({
|
|
292
|
+
tokens: {
|
|
293
|
+
access_token: expiredToken,
|
|
294
|
+
refresh_token: 'rt-test',
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
298
|
+
await fs.writeFile(filePath, JSON.stringify(data));
|
|
299
|
+
let fetchCallCount = 0;
|
|
300
|
+
globalThis.fetch = vi.fn().mockImplementation(() => {
|
|
301
|
+
fetchCallCount++;
|
|
302
|
+
return Promise.resolve(new Response(JSON.stringify({ access_token: newToken }), { status: 200 }));
|
|
303
|
+
});
|
|
304
|
+
const log = makeLogger();
|
|
305
|
+
const provider = createChatGptTokenProvider({ authFilePath: filePath, log });
|
|
306
|
+
// Fire 3 concurrent getAccessToken calls
|
|
307
|
+
const [t1, t2, t3] = await Promise.all([
|
|
308
|
+
provider.getAccessToken(),
|
|
309
|
+
provider.getAccessToken(),
|
|
310
|
+
provider.getAccessToken(),
|
|
311
|
+
]);
|
|
312
|
+
expect(t1).toBe(newToken);
|
|
313
|
+
expect(t2).toBe(newToken);
|
|
314
|
+
expect(t3).toBe(newToken);
|
|
315
|
+
// Only one actual refresh should have happened
|
|
316
|
+
expect(fetchCallCount).toBe(1);
|
|
317
|
+
});
|
|
318
|
+
it('returns cached token on second call without re-reading file', async () => {
|
|
319
|
+
const validToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
320
|
+
const data = makeAuthData({
|
|
321
|
+
tokens: {
|
|
322
|
+
access_token: validToken,
|
|
323
|
+
refresh_token: 'rt-test',
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
327
|
+
await fs.writeFile(filePath, JSON.stringify(data));
|
|
328
|
+
const log = makeLogger();
|
|
329
|
+
const provider = createChatGptTokenProvider({ authFilePath: filePath, log });
|
|
330
|
+
const token1 = await provider.getAccessToken();
|
|
331
|
+
// Delete the file — second call should still work from cache
|
|
332
|
+
await fs.unlink(filePath);
|
|
333
|
+
const token2 = await provider.getAccessToken();
|
|
334
|
+
expect(token1).toBe(validToken);
|
|
335
|
+
expect(token2).toBe(validToken);
|
|
336
|
+
});
|
|
337
|
+
it('propagates error when auth file does not exist', async () => {
|
|
338
|
+
const log = makeLogger();
|
|
339
|
+
const provider = createChatGptTokenProvider({
|
|
340
|
+
authFilePath: path.join(tmpDir, 'nope.json'),
|
|
341
|
+
log,
|
|
342
|
+
});
|
|
343
|
+
await expect(provider.getAccessToken()).rejects.toThrow();
|
|
344
|
+
expect(log.error).toHaveBeenCalled();
|
|
345
|
+
});
|
|
346
|
+
it('propagates error when refresh fails', async () => {
|
|
347
|
+
const expiredToken = makeJwt({ exp: Math.floor(Date.now() / 1000) - 100 });
|
|
348
|
+
const data = makeAuthData({
|
|
349
|
+
tokens: {
|
|
350
|
+
access_token: expiredToken,
|
|
351
|
+
refresh_token: 'rt-test',
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
const filePath = path.join(tmpDir, 'auth.json');
|
|
355
|
+
await fs.writeFile(filePath, JSON.stringify(data));
|
|
356
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('unauthorized', { status: 401 }));
|
|
357
|
+
const log = makeLogger();
|
|
358
|
+
const provider = createChatGptTokenProvider({ authFilePath: filePath, log });
|
|
359
|
+
await expect(provider.getAccessToken()).rejects.toThrow(/Token refresh failed/);
|
|
360
|
+
});
|
|
361
|
+
});
|