@indigoai-us/hq-cli 5.345.156 → 5.345.160
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/CHANGELOG.md +255 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +110 -2
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +101 -5
- package/assets/scaffold/core/scripts/worktree.sh +4 -0
- package/dist/command-catalog.generated.d.ts +535 -10
- package/dist/command-catalog.generated.js +678 -31
- package/dist/command-registration-plan.d.ts +18 -0
- package/dist/command-registration-plan.js +4 -0
- package/dist/commands/_patterns.d.ts +17 -0
- package/dist/commands/_patterns.js +22 -0
- package/dist/commands/access.d.ts +6 -0
- package/dist/commands/access.js +18 -1
- package/dist/commands/acl-lock.d.ts +41 -0
- package/dist/commands/acl-lock.js +28 -0
- package/dist/commands/agent-enroll.d.ts +2 -0
- package/dist/commands/agent-enroll.js +9 -4
- package/dist/commands/agent-kit.js +8 -6
- package/dist/commands/agents.js +5 -11
- package/dist/commands/billing-sponsor.d.ts +4 -4
- package/dist/commands/billing-sponsor.js +11 -11
- package/dist/commands/billing-usage.d.ts +22 -8
- package/dist/commands/billing-usage.js +57 -22
- package/dist/commands/billing.d.ts +7 -7
- package/dist/commands/billing.js +13 -13
- package/dist/commands/bot.js +7 -4
- package/dist/commands/channel-suggest.d.ts +56 -0
- package/dist/commands/channel-suggest.js +170 -0
- package/dist/commands/channels.js +7 -1
- package/dist/commands/cloud-provision.js +2 -2
- package/dist/commands/cloud.d.ts +45 -0
- package/dist/commands/cloud.js +157 -17
- package/dist/commands/company.js +4 -3
- package/dist/commands/core-checkpoint-native.d.ts +18 -0
- package/dist/commands/core-checkpoint-native.js +67 -2
- package/dist/commands/core-pipeline-driver-native.d.ts +12 -1
- package/dist/commands/core-pipeline-driver-native.js +131 -5
- package/dist/commands/core.js +12 -0
- package/dist/commands/crm.js +83 -6
- package/dist/commands/db-remote-deps.js +12 -1
- package/dist/commands/deploy-access.d.ts +33 -0
- package/dist/commands/deploy-access.js +322 -0
- package/dist/commands/dm-attach.d.ts +106 -0
- package/dist/commands/dm-attach.js +184 -0
- package/dist/commands/dm.d.ts +37 -0
- package/dist/commands/dm.js +172 -10
- package/dist/commands/feedback-show.d.ts +40 -0
- package/dist/commands/feedback-show.js +261 -0
- package/dist/commands/feedback.d.ts +21 -0
- package/dist/commands/feedback.js +152 -2
- package/dist/commands/files.d.ts +24 -0
- package/dist/commands/files.js +71 -8
- package/dist/commands/integrations-connect.js +7 -3
- package/dist/commands/lanes-accounts.js +16 -1
- package/dist/commands/lanes-loop.js +14 -4
- package/dist/commands/lanes-message.js +6 -4
- package/dist/commands/lanes-resume.js +36 -13
- package/dist/commands/lanes-shared.d.ts +5 -0
- package/dist/commands/lanes-shared.js +13 -3
- package/dist/commands/lanes-watch.js +1 -0
- package/dist/commands/lanes.d.ts +20 -1
- package/dist/commands/lanes.js +201 -51
- package/dist/commands/meetings.d.ts +4 -4
- package/dist/commands/meetings.js +17 -8
- package/dist/commands/mesh.js +149 -1
- package/dist/commands/monitor.d.ts +1 -0
- package/dist/commands/monitor.js +4 -1
- package/dist/commands/repo.d.ts +41 -0
- package/dist/commands/repo.js +331 -0
- package/dist/commands/scan.d.ts +3 -1
- package/dist/commands/scan.js +7 -3
- package/dist/commands/secrets.d.ts +21 -0
- package/dist/commands/secrets.js +114 -14
- package/dist/commands/signals.js +17 -5
- package/dist/commands/skill.js +6 -6
- package/dist/commands/staff/listings.d.ts +41 -0
- package/dist/commands/staff/listings.js +160 -0
- package/dist/commands/staff/reset-test-account.d.ts +3 -2
- package/dist/commands/staff/reset-test-account.js +4 -3
- package/dist/commands/whoami.js +1 -1
- package/dist/hook-engine-path.js +13 -2
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +3392 -1243
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/agent-kit/run/inbox.js +2 -1
- package/dist/lib/agent-kit/wake-secrets.d.ts +7 -0
- package/dist/lib/agent-kit/wake-secrets.js +32 -0
- package/dist/lib/agent-kit/wake.d.ts +4 -0
- package/dist/lib/agent-kit/wake.js +49 -3
- package/dist/lib/billing/plan-limit-refusals.d.ts +13 -3
- package/dist/lib/billing/plan-limit-refusals.js +26 -3
- package/dist/lib/billing/plan-limit-upgrade-url.d.ts +8 -0
- package/dist/lib/billing/plan-limit-upgrade-url.js +18 -0
- package/dist/lib/billing/plan-lock.d.ts +22 -6
- package/dist/lib/billing/plan-lock.js +58 -30
- package/dist/lib/billing/usage-cost.d.ts +24 -2
- package/dist/lib/billing/usage-cost.js +103 -3
- package/dist/lib/billing/usage-only.d.ts +37 -1
- package/dist/lib/billing/usage-only.js +89 -4
- package/dist/lib/billing/usage-pause.d.ts +27 -0
- package/dist/lib/billing/usage-pause.js +50 -0
- package/dist/lib/bot/inflight.d.ts +7 -1
- package/dist/lib/bot/inflight.js +10 -2
- package/dist/lib/bot/prompt.js +3 -0
- package/dist/lib/bot/run.js +72 -20
- package/dist/lib/bot/session.d.ts +14 -0
- package/dist/lib/bot/session.js +70 -0
- package/dist/lib/capacity/collectors/claude-oauth.d.ts +6 -0
- package/dist/lib/capacity/collectors/claude-oauth.js +12 -0
- package/dist/lib/capacity/lane-login.d.ts +6 -0
- package/dist/lib/capacity/lane-login.js +5 -0
- package/dist/lib/core-utils/worktree.js +2 -0
- package/dist/lib/daemon/exit-cause.d.ts +43 -0
- package/dist/lib/daemon/exit-cause.js +88 -0
- package/dist/lib/daemon/handoff.d.ts +2 -0
- package/dist/lib/daemon/handoff.js +6 -1
- package/dist/lib/daemon/hq-child-bootstrap.mjs +20 -3
- package/dist/lib/daemon/paths.d.ts +2 -0
- package/dist/lib/daemon/paths.js +1 -0
- package/dist/lib/daemon/run.js +13 -0
- package/dist/lib/daemon/supervisor.d.ts +54 -1
- package/dist/lib/daemon/supervisor.js +297 -21
- package/dist/lib/daemon/unit-holds.d.ts +22 -0
- package/dist/lib/daemon/unit-holds.js +86 -0
- package/dist/lib/db/control-plane.d.ts +5 -0
- package/dist/lib/db/control-plane.js +10 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/hook-engine/resolve.d.ts +14 -0
- package/dist/lib/hook-engine/resolve.js +55 -1
- package/dist/lib/index-render/company-knowledge.js +1 -3
- package/dist/lib/index-render/projects.js +1 -3
- package/dist/lib/lanes/account-company.d.ts +28 -0
- package/dist/lib/lanes/account-company.js +53 -0
- package/dist/lib/lanes/accounts.d.ts +22 -3
- package/dist/lib/lanes/accounts.js +38 -2
- package/dist/lib/lanes/claude-balance.d.ts +6 -1
- package/dist/lib/lanes/claude-balance.js +6 -1
- package/dist/lib/lanes/claude-credential.d.ts +78 -0
- package/dist/lib/lanes/claude-credential.js +163 -0
- package/dist/lib/lanes/compaction.d.ts +2 -0
- package/dist/lib/lanes/compaction.js +9 -0
- package/dist/lib/lanes/config.js +6 -0
- package/dist/lib/lanes/envelope-repair-dispatch.js +2 -1
- package/dist/lib/lanes/hooks-install.js +2 -1
- package/dist/lib/lanes/host-capacity.d.ts +2 -0
- package/dist/lib/lanes/host-capacity.js +1 -0
- package/dist/lib/lanes/keep-alive.d.ts +1 -1
- package/dist/lib/lanes/keep-alive.js +3 -1
- package/dist/lib/lanes/links.js +10 -4
- package/dist/lib/lanes/loop.js +16 -1
- package/dist/lib/lanes/node-binary.d.ts +17 -0
- package/dist/lib/lanes/node-binary.js +30 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +2 -1
- package/dist/lib/lanes/outpost-dispatch.js +1 -0
- package/dist/lib/lanes/reconcile.js +4 -0
- package/dist/lib/lanes/remote-control-lane.js +2 -0
- package/dist/lib/lanes/spawn.d.ts +3 -0
- package/dist/lib/lanes/spawn.js +119 -9
- package/dist/lib/lanes/store.d.ts +6 -0
- package/dist/lib/lanes/store.js +107 -18
- package/dist/lib/lanes/types.d.ts +2 -0
- package/dist/lib/lanes/watch.js +28 -5
- package/dist/lib/lanes/watcher-main.js +4 -3
- package/dist/lib/lanes/watcher.d.ts +4 -0
- package/dist/lib/lanes/watcher.js +110 -21
- package/dist/lib/mesh/api.d.ts +32 -0
- package/dist/lib/mesh/api.js +75 -0
- package/dist/lib/plan-limit-nag.d.ts +0 -16
- package/dist/lib/plan-limit-nag.js +17 -18
- package/dist/lib/secret-scan/detect.js +9 -0
- package/dist/lib/secret-scan/repo-config.d.ts +41 -0
- package/dist/lib/secret-scan/repo-config.js +237 -0
- package/dist/lib/secret-scan/rules.d.ts +7 -1
- package/dist/lib/secret-scan/rules.js +3 -1
- package/dist/lib/secret-scan/staged.d.ts +5 -0
- package/dist/lib/secret-scan/staged.js +19 -2
- package/dist/lib/secret-scan/types.d.ts +2 -0
- package/dist/lib/sync/server-owned-local-only.d.ts +29 -0
- package/dist/lib/sync/server-owned-local-only.js +75 -0
- package/dist/main.d.ts +0 -2
- package/dist/main.js +19 -57
- package/dist/utils/package-use-lease.js +27 -9
- package/dist/utils/plan-gate-error.d.ts +30 -14
- package/dist/utils/plan-gate-error.js +60 -46
- package/dist/utils/team-upgrade.d.ts +19 -6
- package/dist/utils/team-upgrade.js +37 -39
- package/dist/utils/version-gate.d.ts +7 -0
- package/dist/utils/version-gate.js +66 -0
- package/package.json +8 -8
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SpawnSyncReturns } from 'node:child_process';
|
|
1
2
|
export declare const HOOK_ENGINE_REPAIR = "npm install -g @indigoai-us/hq-cli --include=optional";
|
|
2
3
|
export declare const HOOK_ENGINE_REPAIR_PIN = "hq core hook-engine-path --repair";
|
|
3
4
|
export interface EngineOptions {
|
|
@@ -23,6 +24,19 @@ export declare function pinnedHookEngineMatches(engine: EnginePackage): boolean;
|
|
|
23
24
|
export declare function findHookEngine(options?: EngineOptions): EnginePackage | null;
|
|
24
25
|
/** Never truncate or replace a running Windows executable. */
|
|
25
26
|
export declare function installHookEngine(options?: EngineOptions): string | null;
|
|
27
|
+
/** master-hook.sh reads this one-line file with shell builtins to find the
|
|
28
|
+
* pinned engine without starting Node (US-021 bridge). It always names the
|
|
29
|
+
* engine of the CLI that wrote it, so a CLI update retargets it. */
|
|
30
|
+
export declare function hookEnginePointerPath(options?: EngineOptions): string;
|
|
31
|
+
/** Best effort and atomic: a missing pointer only keeps the bridge off. */
|
|
32
|
+
export declare function writeHookEnginePointer(pinned: string, options?: EngineOptions): void;
|
|
33
|
+
export declare function removeHookEnginePointer(options?: EngineOptions): void;
|
|
34
|
+
export interface VerifyOptions {
|
|
35
|
+
spawn?: (command: string, args: readonly string[]) => Pick<SpawnSyncReturns<string>, 'status' | 'stdout' | 'error'>;
|
|
36
|
+
}
|
|
37
|
+
/** Run the engine's own `--self-check` and read its report. Returns the
|
|
38
|
+
* problem as text, or null when the binary answers as an hq-hook engine. */
|
|
39
|
+
export declare function verifyHookEngine(binary: string, options?: VerifyOptions): string | null;
|
|
26
40
|
/** Call only after settings no longer reference these old versions. Windows
|
|
27
41
|
* refuses unlink of a mapped executable; a busy version stays for a later pass.
|
|
28
42
|
* POSIX permits unlink while running, so retain those versions conservatively.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import {
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
5
6
|
const require = createRequire(import.meta.url);
|
|
@@ -104,8 +105,61 @@ export function installHookEngine(options = {}) {
|
|
|
104
105
|
if (!pinnedHookEngineMatches(engine)) {
|
|
105
106
|
throw new Error(`Hook engine ${engine.version} differs from its pinned copy; close HQ sessions and repair with: ${HOOK_ENGINE_REPAIR_PIN}`);
|
|
106
107
|
}
|
|
108
|
+
writeHookEnginePointer(engine.pinned, options);
|
|
107
109
|
return engine.pinned;
|
|
108
110
|
}
|
|
111
|
+
/** master-hook.sh reads this one-line file with shell builtins to find the
|
|
112
|
+
* pinned engine without starting Node (US-021 bridge). It always names the
|
|
113
|
+
* engine of the CLI that wrote it, so a CLI update retargets it. */
|
|
114
|
+
export function hookEnginePointerPath(options = {}) {
|
|
115
|
+
return join(options.home ?? homedir(), '.hq', 'bin', 'hq-hook', 'current');
|
|
116
|
+
}
|
|
117
|
+
/** Best effort and atomic: a missing pointer only keeps the bridge off. */
|
|
118
|
+
export function writeHookEnginePointer(pinned, options = {}) {
|
|
119
|
+
const pointer = hookEnginePointerPath(options);
|
|
120
|
+
try {
|
|
121
|
+
mkdirSync(dirname(pointer), { recursive: true });
|
|
122
|
+
const stage = `${pointer}.${process.pid}.tmp`;
|
|
123
|
+
writeFileSync(stage, `${pinned}\n`);
|
|
124
|
+
renameSync(stage, pointer);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// The hook path falls back to bash when the pointer is absent.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function removeHookEnginePointer(options = {}) {
|
|
131
|
+
try {
|
|
132
|
+
unlinkSync(hookEnginePointerPath(options));
|
|
133
|
+
}
|
|
134
|
+
catch { /* already absent */ }
|
|
135
|
+
}
|
|
136
|
+
/** Run the engine's own `--self-check` and read its report. Returns the
|
|
137
|
+
* problem as text, or null when the binary answers as an hq-hook engine. */
|
|
138
|
+
export function verifyHookEngine(binary, options = {}) {
|
|
139
|
+
const spawn = options.spawn ?? ((command, args) => spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }));
|
|
140
|
+
let result;
|
|
141
|
+
try {
|
|
142
|
+
result = spawn(binary, ['--self-check']);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return `hook engine self-check could not start: ${error instanceof Error ? error.message : String(error)}`;
|
|
146
|
+
}
|
|
147
|
+
if (result.error)
|
|
148
|
+
return `hook engine self-check could not start: ${result.error.message}`;
|
|
149
|
+
let report;
|
|
150
|
+
try {
|
|
151
|
+
report = JSON.parse(String(result.stdout ?? '').trim().split('\n').pop() ?? '');
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return `hook engine self-check printed no report (exit ${result.status ?? 'signal'})`;
|
|
155
|
+
}
|
|
156
|
+
if (report.engine !== 'hq-hook' || typeof report.version !== 'string' || typeof report.bridge_schema !== 'number') {
|
|
157
|
+
return 'hook engine self-check report is not from hq-hook';
|
|
158
|
+
}
|
|
159
|
+
if (result.status !== 0 || report.ok !== true)
|
|
160
|
+
return `hook engine self-check failed: ${typeof report.error === 'string' ? report.error : `exit ${result.status ?? 'signal'}`}`;
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
109
163
|
/** Call only after settings no longer reference these old versions. Windows
|
|
110
164
|
* refuses unlink of a mapped executable; a busy version stays for a later pass.
|
|
111
165
|
* POSIX permits unlink while running, so retain those versions conservatively.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
|
-
import { at, basename, date, extractKeepFences, generatedFingerprint, generatedStamp, hasUnclosedKeepMarker, heading, immediateEntries, isHidden, log,
|
|
2
|
+
import { at, basename, date, extractKeepFences, generatedFingerprint, generatedStamp, hasUnclosedKeepMarker, heading, immediateEntries, isHidden, log, readJson, readText, sanitize, stampGenerated, titleize, truncate, write, } from "./shared.js";
|
|
3
3
|
function describe(item) {
|
|
4
4
|
const name = basename(item);
|
|
5
5
|
try {
|
|
@@ -65,8 +65,6 @@ function shouldOverwrite(context, existing, next) {
|
|
|
65
65
|
return "write";
|
|
66
66
|
if (stored)
|
|
67
67
|
return "refuse";
|
|
68
|
-
if (looksAutoGenerated(existing))
|
|
69
|
-
return "write";
|
|
70
68
|
if (generatedFingerprint(existing) === generatedFingerprint(next))
|
|
71
69
|
return "write";
|
|
72
70
|
return "refuse";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import { at, basename, date, exists, extractKeepFences, generatedFingerprint, generatedStamp, hasUnclosedKeepMarker, immediateEntries, isHidden, log,
|
|
3
|
+
import { at, basename, date, exists, extractKeepFences, generatedFingerprint, generatedStamp, hasUnclosedKeepMarker, immediateEntries, isHidden, log, projectStatus, readJson, readText, sanitize, stampGenerated, titleize, truncate, write, } from "./shared.js";
|
|
4
4
|
function composeCompanyProjectsIndex(raw, existing) {
|
|
5
5
|
const stamped = stampGenerated(raw);
|
|
6
6
|
const fences = existing === undefined ? "" : extractKeepFences(existing);
|
|
@@ -20,8 +20,6 @@ function shouldOverwriteCompanyProjectsIndex(context, existing, next) {
|
|
|
20
20
|
return "write";
|
|
21
21
|
if (stored)
|
|
22
22
|
return "refuse";
|
|
23
|
-
if (looksAutoGenerated(existing))
|
|
24
|
-
return "write";
|
|
25
23
|
if (generatedFingerprint(existing) === generatedFingerprint(next))
|
|
26
24
|
return "write";
|
|
27
25
|
return "refuse";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { LanesError } from "./errors.js";
|
|
2
|
+
import type { AccountConfig, LaneCompanyRef } from "./types.js";
|
|
3
|
+
type ProviderAccounts = Record<string, AccountConfig | undefined>;
|
|
4
|
+
/**
|
|
5
|
+
* Company enforcement is opt-in per provider: it begins once any account for
|
|
6
|
+
* that provider carries a `company` tag. Until then every account stays open
|
|
7
|
+
* to every lane, so existing installs keep creating company lanes after upgrade.
|
|
8
|
+
*/
|
|
9
|
+
export declare function companyEnforcementActive(providerAccounts: ProviderAccounts | undefined): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Account ownership is explicit; names and homes never imply a tenant.
|
|
12
|
+
* Pass the provider's account map to apply the opt-in rule above.
|
|
13
|
+
*/
|
|
14
|
+
export declare function accountMatchesCompany(account: AccountConfig | undefined, company: LaneCompanyRef | null, providerAccounts?: ProviderAccounts): boolean;
|
|
15
|
+
/** Legacy untagged accounts may retain an existing lane, never acquire one. */
|
|
16
|
+
export declare function accountMayRetainLane(account: AccountConfig | undefined, company: LaneCompanyRef | null, providerAccounts?: ProviderAccounts): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* When enforcement is active and every enabled candidate was excluded only by
|
|
19
|
+
* company ownership, name the problem instead of reporting no enabled account.
|
|
20
|
+
*/
|
|
21
|
+
export declare function companyMismatchError(input: {
|
|
22
|
+
provider: string;
|
|
23
|
+
providerAccounts: ProviderAccounts;
|
|
24
|
+
candidates: readonly string[];
|
|
25
|
+
company: LaneCompanyRef | null;
|
|
26
|
+
}): LanesError | undefined;
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=account-company.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { LanesError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Company enforcement is opt-in per provider: it begins once any account for
|
|
4
|
+
* that provider carries a `company` tag. Until then every account stays open
|
|
5
|
+
* to every lane, so existing installs keep creating company lanes after upgrade.
|
|
6
|
+
*/
|
|
7
|
+
export function companyEnforcementActive(providerAccounts) {
|
|
8
|
+
if (providerAccounts === undefined)
|
|
9
|
+
return true;
|
|
10
|
+
return Object.values(providerAccounts).some((account) => account?.company !== undefined);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Account ownership is explicit; names and homes never imply a tenant.
|
|
14
|
+
* Pass the provider's account map to apply the opt-in rule above.
|
|
15
|
+
*/
|
|
16
|
+
export function accountMatchesCompany(account, company, providerAccounts) {
|
|
17
|
+
if (account === undefined)
|
|
18
|
+
return false;
|
|
19
|
+
if (providerAccounts !== undefined && !companyEnforcementActive(providerAccounts))
|
|
20
|
+
return true;
|
|
21
|
+
if (company === null)
|
|
22
|
+
return account.company === undefined;
|
|
23
|
+
return account.company === company.uid || account.company === company.slug;
|
|
24
|
+
}
|
|
25
|
+
/** Legacy untagged accounts may retain an existing lane, never acquire one. */
|
|
26
|
+
export function accountMayRetainLane(account, company, providerAccounts) {
|
|
27
|
+
return account !== undefined && (account.company === undefined || accountMatchesCompany(account, company, providerAccounts));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* When enforcement is active and every enabled candidate was excluded only by
|
|
31
|
+
* company ownership, name the problem instead of reporting no enabled account.
|
|
32
|
+
*/
|
|
33
|
+
export function companyMismatchError(input) {
|
|
34
|
+
if (!companyEnforcementActive(input.providerAccounts))
|
|
35
|
+
return undefined;
|
|
36
|
+
const enabled = input.candidates.filter((id) => input.providerAccounts[id]?.enabled === true);
|
|
37
|
+
if (enabled.length === 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (enabled.some((id) => accountMatchesCompany(input.providerAccounts[id], input.company, input.providerAccounts)))
|
|
40
|
+
return undefined;
|
|
41
|
+
const companyLabel = input.company === null ? "(no company)" : input.company.slug;
|
|
42
|
+
const excluded = enabled.map((id) => ({ account: id, company: input.providerAccounts[id]?.company ?? null }));
|
|
43
|
+
return new LanesError("account_company_mismatch", `No enabled ${input.provider} account belongs to lane company ${companyLabel}. ` +
|
|
44
|
+
`Excluded for company: ${excluded.map((row) => `${row.account} (${row.company ?? "untagged"})`).join(", ")}. ` +
|
|
45
|
+
`Set "company" on an account under providers.${input.provider}.accounts in personal/lanes/config.json.`, {
|
|
46
|
+
provider: input.provider,
|
|
47
|
+
company: input.company?.slug ?? null,
|
|
48
|
+
excluded_accounts: excluded,
|
|
49
|
+
config_key: `providers.${input.provider}.accounts.<id>.company`,
|
|
50
|
+
config_file: "personal/lanes/config.json",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=account-company.js.map
|
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
* Naming an account and omitting one mean different things: an explicit
|
|
5
5
|
* disabled id is refused; omitting picks the least-loaded eligible account.
|
|
6
6
|
* Status reports `configured` and `logged_in` separately. logged_in is
|
|
7
|
-
* derived from credential-file existence or a provider status command
|
|
8
|
-
*
|
|
7
|
+
* derived from credential-file existence or a provider status command. For
|
|
8
|
+
* Claude, an injected `readClaudeCredential` also checks the access-token
|
|
9
|
+
* expiry of the credential the worker will use (see claude-credential.ts), so
|
|
10
|
+
* an expired sign-in reports `logged_in: false` with `sign_in_state: "expired"`.
|
|
11
|
+
* This module never prints, copies, or hashes credentials.
|
|
9
12
|
*/
|
|
10
13
|
import { type LaneRecord, type LaneProvider } from "./types.js";
|
|
11
14
|
import type { LanesConfig } from "./types.js";
|
|
15
|
+
import type { ClaudeCredentialReader, ClaudeTokenState } from "./claude-credential.js";
|
|
12
16
|
export declare const PROVIDER_HOME_ENV: Record<LaneProvider, string>;
|
|
13
17
|
/**
|
|
14
18
|
* Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
|
|
@@ -71,6 +75,12 @@ export interface AccountDeps {
|
|
|
71
75
|
/** Provider status output is parsed for a boolean only; it is never printed. */
|
|
72
76
|
probe?: (command: string, args: string[], env: NodeJS.ProcessEnv) => AccountStatusProbeResult;
|
|
73
77
|
env?: NodeJS.ProcessEnv;
|
|
78
|
+
/**
|
|
79
|
+
* Reads the access-token expiry of the Claude credential the worker uses.
|
|
80
|
+
* Production callers pass readLaneClaudeCredential; omitted, expiry is not
|
|
81
|
+
* checked and logged_in keeps the presence-only result.
|
|
82
|
+
*/
|
|
83
|
+
readClaudeCredential?: ClaudeCredentialReader;
|
|
74
84
|
}
|
|
75
85
|
export interface AccountResolveContext {
|
|
76
86
|
hqRoot?: string;
|
|
@@ -110,9 +120,16 @@ export interface AccountStatus {
|
|
|
110
120
|
disabled_reason?: string;
|
|
111
121
|
/** Present in personal/lanes/config.json. */
|
|
112
122
|
configured: boolean;
|
|
113
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* True/false comes from file existence or the provider status command.
|
|
125
|
+
* For Claude, an expired access token forces false.
|
|
126
|
+
*/
|
|
114
127
|
logged_in: boolean | null;
|
|
115
128
|
auth_state: AuthState;
|
|
129
|
+
/** Claude only, when the credential was read: whether its access token is still valid. */
|
|
130
|
+
sign_in_state?: ClaudeTokenState;
|
|
131
|
+
/** Claude only: when the access token expires, if the credential records it. */
|
|
132
|
+
sign_in_expires_at?: string | null;
|
|
116
133
|
envVar: string;
|
|
117
134
|
}
|
|
118
135
|
export declare function credentialPaths(provider: LaneProvider, home: string): string[];
|
|
@@ -129,6 +146,8 @@ export declare function authStateFor(provider: LaneProvider, present: boolean):
|
|
|
129
146
|
export declare function loggedInFromAuthState(state: AuthState): boolean | null;
|
|
130
147
|
/** Admission and listing share this: refuse only a conclusive logged-out. */
|
|
131
148
|
export declare function isLoggedOut(status: Pick<AccountStatus, "logged_in">): boolean;
|
|
149
|
+
/** True when the Claude credential exists but its access token has expired. */
|
|
150
|
+
export declare function isSignInExpired(status: Pick<AccountStatus, "sign_in_state">): boolean;
|
|
132
151
|
/** Auto-select requires a positive file or provider-status login result. */
|
|
133
152
|
export declare function isCredentialProven(status: Pick<AccountStatus, "logged_in">): boolean;
|
|
134
153
|
/**
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* Naming an account and omitting one mean different things: an explicit
|
|
5
5
|
* disabled id is refused; omitting picks the least-loaded eligible account.
|
|
6
6
|
* Status reports `configured` and `logged_in` separately. logged_in is
|
|
7
|
-
* derived from credential-file existence or a provider status command
|
|
8
|
-
*
|
|
7
|
+
* derived from credential-file existence or a provider status command. For
|
|
8
|
+
* Claude, an injected `readClaudeCredential` also checks the access-token
|
|
9
|
+
* expiry of the credential the worker will use (see claude-credential.ts), so
|
|
10
|
+
* an expired sign-in reports `logged_in: false` with `sign_in_state: "expired"`.
|
|
11
|
+
* This module never prints, copies, or hashes credentials.
|
|
9
12
|
*/
|
|
10
13
|
import * as fs from "node:fs";
|
|
11
14
|
import * as os from "node:os";
|
|
@@ -184,6 +187,10 @@ export function loggedInFromAuthState(state) {
|
|
|
184
187
|
export function isLoggedOut(status) {
|
|
185
188
|
return status.logged_in === false;
|
|
186
189
|
}
|
|
190
|
+
/** True when the Claude credential exists but its access token has expired. */
|
|
191
|
+
export function isSignInExpired(status) {
|
|
192
|
+
return status.sign_in_state === "expired";
|
|
193
|
+
}
|
|
187
194
|
/** Auto-select requires a positive file or provider-status login result. */
|
|
188
195
|
export function isCredentialProven(status) {
|
|
189
196
|
return status.logged_in === true;
|
|
@@ -330,6 +337,9 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
|
|
|
330
337
|
...(ctx.accountDeps?.probe ? { probe: ctx.accountDeps.probe } : {}),
|
|
331
338
|
...(ctx.accountDeps?.env ? { env: ctx.accountDeps.env } : {}),
|
|
332
339
|
...(ctx.accountDeps?.homedir ? { homedir: ctx.accountDeps.homedir } : {}),
|
|
340
|
+
...(ctx.accountDeps?.readClaudeCredential
|
|
341
|
+
? { readClaudeCredential: ctx.accountDeps.readClaudeCredential }
|
|
342
|
+
: {}),
|
|
333
343
|
...(ctx.hostname !== undefined ? { hostname: ctx.hostname } : {}),
|
|
334
344
|
});
|
|
335
345
|
if (status.logged_in === true)
|
|
@@ -434,6 +444,9 @@ export function resolveAccount(provider, requestedId, config, ctx = {}) {
|
|
|
434
444
|
probe: ctx.accountDeps.probe,
|
|
435
445
|
...(ctx.accountDeps.env ? { env: ctx.accountDeps.env } : {}),
|
|
436
446
|
...(ctx.accountDeps.homedir ? { homedir: ctx.accountDeps.homedir } : {}),
|
|
447
|
+
...(ctx.accountDeps.readClaudeCredential
|
|
448
|
+
? { readClaudeCredential: ctx.accountDeps.readClaudeCredential }
|
|
449
|
+
: {}),
|
|
437
450
|
...(ctx.hostname !== undefined ? { hostname: ctx.hostname } : {}),
|
|
438
451
|
});
|
|
439
452
|
throwIfQuotaBlocked(block, provider, requestedId, status.identity);
|
|
@@ -469,6 +482,18 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
|
|
|
469
482
|
let auth_state = homeExists ? authStateFor(provider, present) : "home_missing";
|
|
470
483
|
let logged_in = homeExists ? loggedInFromAuthState(auth_state) : false;
|
|
471
484
|
let identity;
|
|
485
|
+
let token;
|
|
486
|
+
if (homeExists && provider === "claude" && deps.readClaudeCredential !== undefined) {
|
|
487
|
+
try {
|
|
488
|
+
token = deps.readClaudeCredential({
|
|
489
|
+
home: effective.home,
|
|
490
|
+
defaultHome: isDefaultProviderHome("claude", effective.home, deps.homedir),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
process.stderr.write(`hq lanes: Claude credential expiry check failed for account ${accountId}\n`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
472
497
|
if (homeExists && !present && provider === "claude" && deps.probe !== undefined) {
|
|
473
498
|
try {
|
|
474
499
|
const result = deps.probe("claude", ["auth", "status"], withProviderHomeEnv(deps.env ?? process.env, "claude", effective.home, deps.homedir));
|
|
@@ -483,6 +508,13 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
|
|
|
483
508
|
process.stderr.write(`hq lanes: Claude auth status probe failed for account ${accountId}\n`);
|
|
484
509
|
}
|
|
485
510
|
}
|
|
511
|
+
if (token?.state === "expired") {
|
|
512
|
+
// The credential is there but the worker would start with a dead token.
|
|
513
|
+
logged_in = false;
|
|
514
|
+
}
|
|
515
|
+
else if (token?.state === "valid" && logged_in === null) {
|
|
516
|
+
logged_in = true;
|
|
517
|
+
}
|
|
486
518
|
const status = {
|
|
487
519
|
provider,
|
|
488
520
|
id: accountId,
|
|
@@ -494,6 +526,10 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
|
|
|
494
526
|
auth_state,
|
|
495
527
|
envVar,
|
|
496
528
|
};
|
|
529
|
+
if (token !== undefined) {
|
|
530
|
+
status.sign_in_state = token.state;
|
|
531
|
+
status.sign_in_expires_at = token.expires_at;
|
|
532
|
+
}
|
|
497
533
|
if (identity)
|
|
498
534
|
status.identity = identity;
|
|
499
535
|
if (account.disabled_reason !== undefined) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AuthState } from "./accounts.js";
|
|
2
2
|
import type { HostCapacityAccount, HostCapacitySnapshot } from "./host-capacity.js";
|
|
3
|
-
import type { LaneHost, LanePlacementPolicy, LaneRecord, LanesConfig } from "./types.js";
|
|
3
|
+
import type { LaneCompanyRef, LaneHost, LanePlacementPolicy, LaneRecord, LanesConfig } from "./types.js";
|
|
4
4
|
import { type QuotaState } from "./quota.js";
|
|
5
5
|
export declare const BALANCE_LIVE_STATES: readonly ["running", "queued", "awaiting_input"];
|
|
6
6
|
export declare const BALANCE_MOVABLE_STATES: readonly ["interrupted", "done", "awaiting_input"];
|
|
@@ -71,6 +71,9 @@ export interface BalanceMoveSuggestion {
|
|
|
71
71
|
skip_reason?: "running_turn" | "state_not_movable" | "no_provider_session" | "pending_resume";
|
|
72
72
|
}
|
|
73
73
|
export declare function selectClaudeAccount(input: {
|
|
74
|
+
company?: LaneRecord["company"];
|
|
75
|
+
/** Only an already-recorded account may use the legacy untagged exception. */
|
|
76
|
+
retainedAccount?: string;
|
|
74
77
|
hosts: readonly BalanceHostSnapshot[];
|
|
75
78
|
targetHost: LaneHost;
|
|
76
79
|
config: LanesConfig;
|
|
@@ -95,5 +98,7 @@ export declare function accountLoadsForReport(input: {
|
|
|
95
98
|
export declare function planClaudeBalanceMoves(input: {
|
|
96
99
|
hosts: readonly BalanceHostSnapshot[];
|
|
97
100
|
config: LanesConfig;
|
|
101
|
+
/** Resolves a lane's effective company (lane, mesh company, then project owner). */
|
|
102
|
+
laneCompany?: (lane: LaneRecord) => LaneCompanyRef | null;
|
|
98
103
|
}): BalanceMoveSuggestion[];
|
|
99
104
|
//# sourceMappingURL=claude-balance.d.ts.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LanesError } from "./errors.js";
|
|
2
|
+
import { accountMatchesCompany, accountMayRetainLane } from "./account-company.js";
|
|
2
3
|
import { quotaBlockFor } from "./quota.js";
|
|
3
4
|
export const BALANCE_LIVE_STATES = ["running", "queued", "awaiting_input"];
|
|
4
5
|
export const BALANCE_MOVABLE_STATES = ["interrupted", "done", "awaiting_input"];
|
|
@@ -134,7 +135,10 @@ function buildLoads(input) {
|
|
|
134
135
|
const quotaBlock = input.quotaState === undefined
|
|
135
136
|
? undefined
|
|
136
137
|
: quotaBlockFor(input.quotaState, "claude", account, quotaModel, input.now);
|
|
137
|
-
const
|
|
138
|
+
const companyMismatch = input.company !== undefined && !(account === input.retainedAccount
|
|
139
|
+
? accountMayRetainLane(configAccounts[account], input.company, configAccounts)
|
|
140
|
+
: accountMatchesCompany(configAccounts[account], input.company, configAccounts));
|
|
141
|
+
const ineligible = companyMismatch ? "company_mismatch" : targetIneligibilityReason(account, configAccounts[account]?.enabled === true, input.allowedAccounts, targetLoad) ?? (quotaBlock?.blocked === true ? "quota_held" : undefined);
|
|
138
142
|
if (ineligible !== undefined)
|
|
139
143
|
skipped.push({ account, host: targetHost, reason: ineligible });
|
|
140
144
|
const knownCounts = hosts.flatMap((host) => host.live_lanes === null ? [] : [host.live_lanes]);
|
|
@@ -285,6 +289,7 @@ export function planClaudeBalanceMoves(input) {
|
|
|
285
289
|
if (sourceStatus === undefined)
|
|
286
290
|
continue;
|
|
287
291
|
const candidates = Object.keys(input.config.providers.claude.accounts)
|
|
292
|
+
.filter((account) => accountMatchesCompany(input.config.providers.claude.accounts[account], input.laneCompany ? input.laneCompany(lane) : lane.company, input.config.providers.claude.accounts))
|
|
288
293
|
.filter((account) => account !== source && input.config.providers.claude.accounts[account]?.enabled === true)
|
|
289
294
|
.map((account) => ({
|
|
290
295
|
account,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expiry check for the Claude OAuth credential a lane worker will use.
|
|
3
|
+
*
|
|
4
|
+
* `hq lanes accounts list` used to report `logged_in: true` when a
|
|
5
|
+
* credential merely existed (a `.credentials.json` file, or `claude auth
|
|
6
|
+
* status` saying a keychain item is present). An expired access token still
|
|
7
|
+
* passed, and the lane exited seconds after start with an OAuth expiry
|
|
8
|
+
* error. This module reads the same credential the worker reads and reports
|
|
9
|
+
* whether its access token is still valid.
|
|
10
|
+
*
|
|
11
|
+
* The worker's credential is keyed by CLAUDE_CONFIG_DIR:
|
|
12
|
+
* - macOS: the login user's Keychain item `Claude Code-credentials` for the
|
|
13
|
+
* default home (the worker runs with the variable unset), or the
|
|
14
|
+
* hash-suffixed item for any other home; `<home>/.credentials.json` when no
|
|
15
|
+
* Keychain item exists.
|
|
16
|
+
* - elsewhere: `<home>/.credentials.json`.
|
|
17
|
+
*
|
|
18
|
+
* An access token past its expiry is only reported expired when there is no
|
|
19
|
+
* refresh token, or the capacity collector has recorded the refresh token as
|
|
20
|
+
* revoked. Otherwise Claude Code renews the access token at startup, so the
|
|
21
|
+
* account is still usable.
|
|
22
|
+
*
|
|
23
|
+
* Only `claudeAiOauth.expiresAt` and the presence of the tokens are read.
|
|
24
|
+
* Token values are never returned, logged, or copied; the refresh token is
|
|
25
|
+
* only passed to the revoked check, which compares a fingerprint.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* valid: an access token whose expiry is in the future (or not recorded).
|
|
29
|
+
* expired: an access token whose expiry has passed and that cannot be renewed
|
|
30
|
+
* (no refresh token, or the refresh token is recorded as revoked). A lane
|
|
31
|
+
* started now fails.
|
|
32
|
+
* missing: no OAuth credential for this home (API-key setups land here).
|
|
33
|
+
* unknown: the credential could not be read (Keychain timeout, bad JSON).
|
|
34
|
+
*/
|
|
35
|
+
export type ClaudeTokenState = "valid" | "expired" | "missing" | "unknown";
|
|
36
|
+
export interface ClaudeCredentialCheck {
|
|
37
|
+
state: ClaudeTokenState;
|
|
38
|
+
/** ISO time the access token expires, when the credential records it. */
|
|
39
|
+
expires_at: string | null;
|
|
40
|
+
source: "keychain" | "file" | null;
|
|
41
|
+
}
|
|
42
|
+
export interface ClaudeCredentialTarget {
|
|
43
|
+
/** The account home the lane runs with. */
|
|
44
|
+
home: string;
|
|
45
|
+
/** True when the worker runs with CLAUDE_CONFIG_DIR unset for this home. */
|
|
46
|
+
defaultHome: boolean;
|
|
47
|
+
}
|
|
48
|
+
export type ClaudeCredentialReader = (target: ClaudeCredentialTarget) => ClaudeCredentialCheck;
|
|
49
|
+
export interface SecurityResult {
|
|
50
|
+
status: number | null;
|
|
51
|
+
/** Holds the credential JSON on success. Parsed in memory, never printed. */
|
|
52
|
+
stdout: string;
|
|
53
|
+
timedOut: boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface ClaudeCredentialReaderDeps {
|
|
56
|
+
platform?: NodeJS.Platform;
|
|
57
|
+
now?: () => number;
|
|
58
|
+
security?: (args: readonly string[]) => SecurityResult;
|
|
59
|
+
readFile?: (file: string) => string;
|
|
60
|
+
/** Keychain account name; defaults to the login user. */
|
|
61
|
+
username?: string;
|
|
62
|
+
/** True when this refresh token is recorded as revoked. */
|
|
63
|
+
isRefreshRevoked?: (refreshToken: string) => boolean;
|
|
64
|
+
}
|
|
65
|
+
/** Revoked check against the capacity collector's marker file. Never throws. */
|
|
66
|
+
export declare function defaultIsRefreshRevoked(refreshToken: string): boolean;
|
|
67
|
+
export declare const CLAUDE_CREDENTIAL_SECURITY_TIMEOUT_MS = 5000;
|
|
68
|
+
/** Keychain service the worker reads for this home. */
|
|
69
|
+
export declare function laneClaudeKeychainService(target: ClaudeCredentialTarget): string;
|
|
70
|
+
/** Classify a credential document. Returns only state and expiry. */
|
|
71
|
+
export declare function claudeTokenStateFromDocument(text: string, nowMs: number, isRefreshRevoked?: (refreshToken: string) => boolean): {
|
|
72
|
+
state: ClaudeTokenState;
|
|
73
|
+
expires_at: string | null;
|
|
74
|
+
};
|
|
75
|
+
export declare function createClaudeCredentialReader(deps?: ClaudeCredentialReaderDeps): ClaudeCredentialReader;
|
|
76
|
+
/** Production reader: real Keychain on macOS, the credentials file elsewhere. */
|
|
77
|
+
export declare const readLaneClaudeCredential: ClaudeCredentialReader;
|
|
78
|
+
//# sourceMappingURL=claude-credential.d.ts.map
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expiry check for the Claude OAuth credential a lane worker will use.
|
|
3
|
+
*
|
|
4
|
+
* `hq lanes accounts list` used to report `logged_in: true` when a
|
|
5
|
+
* credential merely existed (a `.credentials.json` file, or `claude auth
|
|
6
|
+
* status` saying a keychain item is present). An expired access token still
|
|
7
|
+
* passed, and the lane exited seconds after start with an OAuth expiry
|
|
8
|
+
* error. This module reads the same credential the worker reads and reports
|
|
9
|
+
* whether its access token is still valid.
|
|
10
|
+
*
|
|
11
|
+
* The worker's credential is keyed by CLAUDE_CONFIG_DIR:
|
|
12
|
+
* - macOS: the login user's Keychain item `Claude Code-credentials` for the
|
|
13
|
+
* default home (the worker runs with the variable unset), or the
|
|
14
|
+
* hash-suffixed item for any other home; `<home>/.credentials.json` when no
|
|
15
|
+
* Keychain item exists.
|
|
16
|
+
* - elsewhere: `<home>/.credentials.json`.
|
|
17
|
+
*
|
|
18
|
+
* An access token past its expiry is only reported expired when there is no
|
|
19
|
+
* refresh token, or the capacity collector has recorded the refresh token as
|
|
20
|
+
* revoked. Otherwise Claude Code renews the access token at startup, so the
|
|
21
|
+
* account is still usable.
|
|
22
|
+
*
|
|
23
|
+
* Only `claudeAiOauth.expiresAt` and the presence of the tokens are read.
|
|
24
|
+
* Token values are never returned, logged, or copied; the refresh token is
|
|
25
|
+
* only passed to the revoked check, which compares a fingerprint.
|
|
26
|
+
*/
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import * as fs from "node:fs";
|
|
29
|
+
import * as os from "node:os";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import { CLAUDE_KEYCHAIN_SERVICE, claudeKeychainServiceName, isRefreshTokenRevoked, } from "../capacity/collectors/claude-oauth.js";
|
|
32
|
+
import { resolveLanesHqRoot } from "./hq-root.js";
|
|
33
|
+
/** Revoked check against the capacity collector's marker file. Never throws. */
|
|
34
|
+
export function defaultIsRefreshRevoked(refreshToken) {
|
|
35
|
+
try {
|
|
36
|
+
return isRefreshTokenRevoked(resolveLanesHqRoot(), refreshToken);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function safeUsername() {
|
|
43
|
+
try {
|
|
44
|
+
return os.userInfo().username || null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export const CLAUDE_CREDENTIAL_SECURITY_TIMEOUT_MS = 5_000;
|
|
51
|
+
/** `security find-generic-password` exit status when the item does not exist. */
|
|
52
|
+
const SECURITY_ITEM_NOT_FOUND = 44;
|
|
53
|
+
/** Keychain service the worker reads for this home. */
|
|
54
|
+
export function laneClaudeKeychainService(target) {
|
|
55
|
+
return target.defaultHome ? CLAUDE_KEYCHAIN_SERVICE : claudeKeychainServiceName(target.home);
|
|
56
|
+
}
|
|
57
|
+
/** Classify a credential document. Returns only state and expiry. */
|
|
58
|
+
export function claudeTokenStateFromDocument(text, nowMs, isRefreshRevoked = () => false) {
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { state: "unknown", expires_at: null };
|
|
65
|
+
}
|
|
66
|
+
if (!isRecord(parsed))
|
|
67
|
+
return { state: "unknown", expires_at: null };
|
|
68
|
+
const oauth = parsed.claudeAiOauth;
|
|
69
|
+
if (!isRecord(oauth))
|
|
70
|
+
return { state: "missing", expires_at: null };
|
|
71
|
+
const token = typeof oauth.accessToken === "string" ? oauth.accessToken : oauth.access_token;
|
|
72
|
+
if (typeof token !== "string" || token.trim() === "")
|
|
73
|
+
return { state: "missing", expires_at: null };
|
|
74
|
+
const raw = oauth.expiresAt;
|
|
75
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
76
|
+
return { state: "valid", expires_at: null };
|
|
77
|
+
const ms = raw < 1e12 ? raw * 1000 : raw;
|
|
78
|
+
const date = new Date(ms);
|
|
79
|
+
const expires_at = Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
80
|
+
if (ms > nowMs)
|
|
81
|
+
return { state: "valid", expires_at };
|
|
82
|
+
// Past expiry. Claude Code renews the access token from the refresh token
|
|
83
|
+
// at startup, so this is only a dead sign-in when there is nothing to renew
|
|
84
|
+
// with or the refresh token is known to be revoked.
|
|
85
|
+
const refresh = typeof oauth.refreshToken === "string" ? oauth.refreshToken : oauth.refresh_token;
|
|
86
|
+
if (typeof refresh !== "string" || refresh.trim() === "")
|
|
87
|
+
return { state: "expired", expires_at };
|
|
88
|
+
let revoked = false;
|
|
89
|
+
try {
|
|
90
|
+
revoked = isRefreshRevoked(refresh);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
revoked = false;
|
|
94
|
+
}
|
|
95
|
+
return { state: revoked ? "expired" : "valid", expires_at };
|
|
96
|
+
}
|
|
97
|
+
function isRecord(value) {
|
|
98
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
99
|
+
}
|
|
100
|
+
function defaultSecurity(args) {
|
|
101
|
+
const result = spawnSync("security", [...args], {
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
timeout: CLAUDE_CREDENTIAL_SECURITY_TIMEOUT_MS,
|
|
104
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
105
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
106
|
+
});
|
|
107
|
+
const timedOut = result.error?.code === "ETIMEDOUT";
|
|
108
|
+
return {
|
|
109
|
+
status: result.error ? null : result.status,
|
|
110
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
111
|
+
timedOut,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function createClaudeCredentialReader(deps = {}) {
|
|
115
|
+
const platform = deps.platform ?? process.platform;
|
|
116
|
+
const now = deps.now ?? (() => Date.now());
|
|
117
|
+
const security = deps.security ?? defaultSecurity;
|
|
118
|
+
const readFile = deps.readFile ?? ((file) => fs.readFileSync(file, "utf8"));
|
|
119
|
+
const isRefreshRevoked = deps.isRefreshRevoked ?? defaultIsRefreshRevoked;
|
|
120
|
+
const fromFile = (home) => {
|
|
121
|
+
let text;
|
|
122
|
+
try {
|
|
123
|
+
text = readFile(path.join(home, ".credentials.json"));
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const code = err?.code;
|
|
127
|
+
return { state: code === "ENOENT" ? "missing" : "unknown", expires_at: null, source: null };
|
|
128
|
+
}
|
|
129
|
+
return { ...claudeTokenStateFromDocument(text, now(), isRefreshRevoked), source: "file" };
|
|
130
|
+
};
|
|
131
|
+
return (target) => {
|
|
132
|
+
if (platform !== "darwin")
|
|
133
|
+
return fromFile(target.home);
|
|
134
|
+
const service = laneClaudeKeychainService(target);
|
|
135
|
+
let read;
|
|
136
|
+
try {
|
|
137
|
+
// Claude Code writes its item under the login user's account name. A
|
|
138
|
+
// second item with the same service and another account (MCP OAuth
|
|
139
|
+
// only) can exist, so ask for the user's item first, as the capacity
|
|
140
|
+
// collector does, and fall back to any item for the service.
|
|
141
|
+
const username = deps.username ?? safeUsername();
|
|
142
|
+
read = username
|
|
143
|
+
? security(["find-generic-password", "-s", service, "-a", username, "-w"])
|
|
144
|
+
: { status: SECURITY_ITEM_NOT_FOUND, stdout: "", timedOut: false };
|
|
145
|
+
if (!read.timedOut && read.status === SECURITY_ITEM_NOT_FOUND) {
|
|
146
|
+
read = security(["find-generic-password", "-s", service, "-w"]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return { state: "unknown", expires_at: null, source: null };
|
|
151
|
+
}
|
|
152
|
+
if (read.timedOut)
|
|
153
|
+
return { state: "unknown", expires_at: null, source: null };
|
|
154
|
+
if (read.status === SECURITY_ITEM_NOT_FOUND)
|
|
155
|
+
return fromFile(target.home);
|
|
156
|
+
if (read.status !== 0)
|
|
157
|
+
return { state: "unknown", expires_at: null, source: null };
|
|
158
|
+
return { ...claudeTokenStateFromDocument(read.stdout.replace(/\n$/, ""), now(), isRefreshRevoked), source: "keychain" };
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Production reader: real Keychain on macOS, the credentials file elsewhere. */
|
|
162
|
+
export const readLaneClaudeCredential = createClaudeCredentialReader();
|
|
163
|
+
//# sourceMappingURL=claude-credential.js.map
|