@claude-flow/cli 3.32.2 → 3.32.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.claude/helpers/helpers.manifest.json +3 -3
  2. package/.claude/helpers/statusline.cjs +38 -38
  3. package/catalog-manifest.json +2 -2
  4. package/dist/src/auth/client.d.ts +89 -0
  5. package/dist/src/auth/client.js +242 -0
  6. package/dist/src/auth/constants.d.ts +7 -0
  7. package/dist/src/auth/constants.js +7 -0
  8. package/dist/src/auth/scopes.d.ts +14 -0
  9. package/dist/src/auth/scopes.js +21 -0
  10. package/dist/src/auth/security-bridge.d.ts +36 -0
  11. package/dist/src/auth/security-bridge.js +42 -0
  12. package/dist/src/auth/session.d.ts +20 -0
  13. package/dist/src/auth/session.js +32 -0
  14. package/dist/src/auth/state.d.ts +19 -0
  15. package/dist/src/auth/state.js +53 -0
  16. package/dist/src/auth/types.d.ts +27 -0
  17. package/dist/src/auth/types.js +11 -0
  18. package/dist/src/commands/auth.d.ts +15 -0
  19. package/dist/src/commands/auth.js +244 -0
  20. package/dist/src/commands/doctor.js +211 -4
  21. package/dist/src/commands/index.js +2 -0
  22. package/dist/src/commands/proxy-lifecycle.d.ts +12 -0
  23. package/dist/src/commands/proxy-lifecycle.js +232 -0
  24. package/dist/src/commands/proxy.js +92 -4
  25. package/dist/src/proxy/install.d.ts +29 -0
  26. package/dist/src/proxy/install.js +135 -0
  27. package/dist/src/proxy/lifecycle.d.ts +61 -0
  28. package/dist/src/proxy/lifecycle.js +249 -0
  29. package/dist/src/proxy/paths.d.ts +34 -0
  30. package/dist/src/proxy/paths.js +70 -0
  31. package/dist/src/proxy/release.d.ts +47 -0
  32. package/dist/src/proxy/release.js +138 -0
  33. package/dist/src/proxy/token-bridge.d.ts +5 -0
  34. package/dist/src/proxy/token-bridge.js +61 -0
  35. package/dist/src/proxy/verify.d.ts +44 -0
  36. package/dist/src/proxy/verify.js +68 -0
  37. package/package.json +2 -2
  38. package/plugins/ruflo-metaharness/scripts/smoke.sh +11 -11
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Process-lifetime in-memory access-token cache (ADR-306).
3
+ *
4
+ * The access token is never written to `auth.json` or any other disk file —
5
+ * only the refresh token (via the OS keychain, or nowhere in session-only
6
+ * mode) survives a process exit. Every fresh `ruflo` invocation starts with
7
+ * an empty cache and re-derives an access token from the refresh token (or
8
+ * asks the user to log in again, in session-only mode). This is a deliberate
9
+ * ADR-306 usability cost, not something to "fix" by persisting the access
10
+ * token — see auth/types.ts's doc comment.
11
+ */
12
+ const sessions = new Map();
13
+ export function setSessionToken(profile, accessToken, expiresAtMs) {
14
+ sessions.set(profile, { accessToken, expiresAt: expiresAtMs });
15
+ }
16
+ /**
17
+ * Returns the cached access token when it remains valid for at least
18
+ * `minValidityMs`. Authenticated callers use a small refresh window so a
19
+ * token cannot expire while an outbound request is in flight.
20
+ */
21
+ export function getSessionToken(profile, minValidityMs = 0) {
22
+ const entry = sessions.get(profile);
23
+ if (!entry)
24
+ return null;
25
+ if (Date.now() + Math.max(0, minValidityMs) >= entry.expiresAt)
26
+ return null;
27
+ return entry.accessToken;
28
+ }
29
+ export function clearSessionToken(profile) {
30
+ sessions.delete(profile);
31
+ }
32
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `auth.json` persistence — atomic tmp+rename, 0600, under ~/.ruflo (ADR-306).
3
+ * Reuses the exact same primitives `proxy-config.toml`'s consent mirror and
4
+ * every other funnel state file already use (src/funnel/state.ts).
5
+ */
6
+ import type { AuthFile, ProfileAuthState } from './types.js';
7
+ export declare const DEFAULT_PROFILE = "default";
8
+ export declare function readAuthFile(): AuthFile;
9
+ export declare function getProfile(name?: string): ProfileAuthState | null;
10
+ export declare function listProfiles(): {
11
+ defaultProfile: string;
12
+ profiles: ProfileAuthState[];
13
+ };
14
+ /** Writes/overwrites a profile. The first profile ever written becomes the default. */
15
+ export declare function setProfile(name: string, state: ProfileAuthState, makeDefault?: boolean): void;
16
+ export declare function removeProfile(name: string): boolean;
17
+ /** `auth logout --all` — forgets every profile. */
18
+ export declare function clearAllProfiles(): void;
19
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `auth.json` persistence — atomic tmp+rename, 0600, under ~/.ruflo (ADR-306).
3
+ * Reuses the exact same primitives `proxy-config.toml`'s consent mirror and
4
+ * every other funnel state file already use (src/funnel/state.ts).
5
+ */
6
+ import { readStateJson, writeStateJson, deleteStateFile } from '../funnel/state.js';
7
+ const AUTH_FILE = 'auth.json';
8
+ export const DEFAULT_PROFILE = 'default';
9
+ function emptyAuthFile() {
10
+ return { schemaVersion: 1, defaultProfile: DEFAULT_PROFILE, profiles: {} };
11
+ }
12
+ export function readAuthFile() {
13
+ const file = readStateJson(AUTH_FILE);
14
+ return file ?? emptyAuthFile();
15
+ }
16
+ function writeAuthFile(file) {
17
+ writeStateJson(AUTH_FILE, file);
18
+ }
19
+ export function getProfile(name) {
20
+ const file = readAuthFile();
21
+ const key = name ?? file.defaultProfile;
22
+ return file.profiles[key] ?? null;
23
+ }
24
+ export function listProfiles() {
25
+ const file = readAuthFile();
26
+ return { defaultProfile: file.defaultProfile, profiles: Object.values(file.profiles) };
27
+ }
28
+ /** Writes/overwrites a profile. The first profile ever written becomes the default. */
29
+ export function setProfile(name, state, makeDefault = false) {
30
+ const file = readAuthFile();
31
+ const isFirst = Object.keys(file.profiles).length === 0;
32
+ file.profiles[name] = state;
33
+ if (makeDefault || isFirst)
34
+ file.defaultProfile = name;
35
+ writeAuthFile(file);
36
+ }
37
+ export function removeProfile(name) {
38
+ const file = readAuthFile();
39
+ if (!(name in file.profiles))
40
+ return false;
41
+ delete file.profiles[name];
42
+ if (file.defaultProfile === name) {
43
+ const remaining = Object.keys(file.profiles);
44
+ file.defaultProfile = remaining[0] ?? DEFAULT_PROFILE;
45
+ }
46
+ writeAuthFile(file);
47
+ return true;
48
+ }
49
+ /** `auth logout --all` — forgets every profile. */
50
+ export function clearAllProfiles() {
51
+ deleteStateFile(AUTH_FILE);
52
+ }
53
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `ruflo auth` state shapes (ADR-306).
3
+ *
4
+ * `auth.json` holds identity metadata ONLY — never token material. The
5
+ * access token lives exclusively in a process-lifetime in-memory singleton
6
+ * (session.ts); the refresh token goes to the OS keychain (keychainRef set)
7
+ * or nowhere at all when no keychain backend is reachable (keychainRef:
8
+ * null — session-only mode, ADR-306's accepted usability cost, not a bug).
9
+ */
10
+ export interface ProfileAuthState {
11
+ accountId: string;
12
+ scopes: string[];
13
+ /** ISO timestamp — the access token's own expiry, not tracked beyond this. */
14
+ accessTokenExpiresAt: string;
15
+ /** OS-keychain account identifier for this profile's refresh token, or null if session-only. */
16
+ keychainRef: string | null;
17
+ profile: string;
18
+ loginMethod: 'pkce' | 'device' | 'token-stdin';
19
+ /** ISO timestamp of the login that created/last-refreshed this profile. */
20
+ linkedAt: string;
21
+ }
22
+ export interface AuthFile {
23
+ schemaVersion: 1;
24
+ defaultProfile: string;
25
+ profiles: Record<string, ProfileAuthState>;
26
+ }
27
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `ruflo auth` state shapes (ADR-306).
3
+ *
4
+ * `auth.json` holds identity metadata ONLY — never token material. The
5
+ * access token lives exclusively in a process-lifetime in-memory singleton
6
+ * (session.ts); the refresh token goes to the OS keychain (keychainRef set)
7
+ * or nowhere at all when no keychain backend is reachable (keychainRef:
8
+ * null — session-only mode, ADR-306's accepted usability cost, not a bug).
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `ruflo auth` — Cognitum identity (ADR-306).
3
+ *
4
+ * `login` obtains OAuth tokens via the loopback PKCE flow (default,
5
+ * interactive desktop), the OOB manual-paste flow (`--no-browser`, or
6
+ * auto-detected headless environments), or `--token-stdin` (CI/enterprise
7
+ * automation — refuses interactively otherwise, per ADR-306). The refresh
8
+ * token goes to the OS keychain when reachable, or nowhere (session-only —
9
+ * a deliberate usability cost, not a bug) when it isn't. The access token
10
+ * itself is NEVER written to disk; see src/auth/session.ts.
11
+ */
12
+ import type { Command } from '../types.js';
13
+ export declare const authCommand: Command;
14
+ export default authCommand;
15
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1,244 @@
1
+ /**
2
+ * `ruflo auth` — Cognitum identity (ADR-306).
3
+ *
4
+ * `login` obtains OAuth tokens via the loopback PKCE flow (default,
5
+ * interactive desktop), the OOB manual-paste flow (`--no-browser`, or
6
+ * auto-detected headless environments), or `--token-stdin` (CI/enterprise
7
+ * automation — refuses interactively otherwise, per ADR-306). The refresh
8
+ * token goes to the OS keychain when reachable, or nowhere (session-only —
9
+ * a deliberate usability cost, not a bug) when it isn't. The access token
10
+ * itself is NEVER written to disk; see src/auth/session.ts.
11
+ */
12
+ import { output } from '../output.js';
13
+ import { isCI, isInteractive, hasConsent, recordConsent, revokeConsent } from '../funnel/index.js';
14
+ import { browserLogin, manualLogin, tokenStdinLogin, isProbablyHeadless, LoginCancelledError, LoginDeniedError, StateMismatchError, } from '../auth/client.js';
15
+ import { loadSecurityOAuth, SecurityPackageMissingError } from '../auth/security-bridge.js';
16
+ import { setProfile, removeProfile, listProfiles, clearAllProfiles, DEFAULT_PROFILE } from '../auth/state.js';
17
+ import { setSessionToken, clearSessionToken } from '../auth/session.js';
18
+ import { INITIAL_SCOPE, domainForScope } from '../auth/scopes.js';
19
+ import { KEYCHAIN_SERVICE } from '../auth/constants.js';
20
+ import { getValidAccessToken, NotLoggedInError, SessionOnlyExpiredError } from '../auth/client.js';
21
+ import { removeInjectedToken } from '../proxy/token-bridge.js';
22
+ function nowIso() {
23
+ return new Date().toISOString();
24
+ }
25
+ async function persistLogin(profileName, tokens, method) {
26
+ const expiresInSec = tokens.expires_in ?? 0;
27
+ const expiresAtMs = Date.now() + expiresInSec * 1000;
28
+ setSessionToken(profileName, tokens.access_token, expiresAtMs);
29
+ let keychainRef = null;
30
+ if (tokens.refresh_token) {
31
+ const sec = await loadSecurityOAuth();
32
+ const keychain = await sec.createKeychainAdapter();
33
+ if (await keychain.isAvailable()) {
34
+ await keychain.setSecret(KEYCHAIN_SERVICE, profileName, tokens.refresh_token);
35
+ keychainRef = profileName;
36
+ }
37
+ // else: session-only — the refresh token is simply not persisted anywhere.
38
+ }
39
+ const state = {
40
+ accountId: tokens.account_email ?? 'unknown',
41
+ scopes: [INITIAL_SCOPE],
42
+ accessTokenExpiresAt: new Date(expiresAtMs).toISOString(),
43
+ keychainRef,
44
+ profile: profileName,
45
+ loginMethod: method,
46
+ linkedAt: nowIso(),
47
+ };
48
+ setProfile(profileName, state);
49
+ recordConsent('account', true, 'auth-login');
50
+ return state;
51
+ }
52
+ function refuseNonInteractive(hasTokenStdin) {
53
+ if (hasTokenStdin)
54
+ return null;
55
+ if (isInteractive() && !isCI())
56
+ return null;
57
+ // The CLI harness only acts on exitCode — it never auto-prints
58
+ // CommandResult.message, so this path must print for itself.
59
+ const message = 'ruflo auth login refuses to run interactively in a non-TTY/CI environment. ' +
60
+ 'Use --token-stdin for automation (reads {"access_token",...} JSON from stdin).';
61
+ output.printError(message);
62
+ return { success: false, message, exitCode: 1 };
63
+ }
64
+ const loginCommand = {
65
+ name: 'login',
66
+ description: 'Sign in to Cognitum (PKCE browser flow, OOB manual flow, or --token-stdin)',
67
+ options: [
68
+ { name: 'profile', description: 'Named profile to store this login under', type: 'string', default: DEFAULT_PROFILE },
69
+ { name: 'no-browser', description: 'Force the headless OOB copy-paste flow', type: 'boolean', default: false },
70
+ { name: 'token-stdin', description: 'Read a pre-obtained token as JSON from stdin (CI/automation)', type: 'boolean', default: false },
71
+ ],
72
+ action: async (ctx) => {
73
+ const profileName = typeof ctx.flags.profile === 'string' ? ctx.flags.profile : DEFAULT_PROFILE;
74
+ // Parser camelCases kebab-case flag names — read via tokenStdin/noBrowser,
75
+ // not ['token-stdin']/['no-browser'] (see doctor.ts's fixHandles comment).
76
+ const tokenStdin = Boolean(ctx.flags.tokenStdin ?? ctx.flags['token-stdin']);
77
+ const noBrowser = Boolean(ctx.flags.noBrowser ?? ctx.flags['no-browser']);
78
+ const refusal = refuseNonInteractive(tokenStdin);
79
+ if (refusal)
80
+ return refusal;
81
+ try {
82
+ const result = tokenStdin
83
+ ? await tokenStdinLogin()
84
+ : noBrowser || isProbablyHeadless()
85
+ ? await manualLogin((line) => output.writeln(line))
86
+ : await browserLogin((line) => output.writeln(line));
87
+ const state = await persistLogin(profileName, result.tokens, result.method);
88
+ output.printSuccess(`Logged in as ${state.accountId} (profile: ${profileName})`);
89
+ output.writeln(state.keychainRef
90
+ ? ' refresh token: stored in the OS keychain'
91
+ : ' refresh token: NOT stored (no keychain backend reachable) — session-only, you will need to log in again next time');
92
+ return { success: true, data: { profile: profileName, accountId: state.accountId } };
93
+ }
94
+ catch (e) {
95
+ if (e instanceof SecurityPackageMissingError || e instanceof LoginCancelledError || e instanceof LoginDeniedError || e instanceof StateMismatchError) {
96
+ output.printError(e.message);
97
+ return { success: false, message: e.message, exitCode: 1 };
98
+ }
99
+ const message = e instanceof Error ? e.message : String(e);
100
+ output.printError('Sign-in failed', message);
101
+ return { success: false, message, exitCode: 1 };
102
+ }
103
+ },
104
+ };
105
+ const logoutCommand = {
106
+ name: 'logout',
107
+ description: 'Sign out — clears the local session, keychain entry, and account consent',
108
+ options: [
109
+ { name: 'profile', description: 'Profile to log out of', type: 'string', default: DEFAULT_PROFILE },
110
+ { name: 'all', description: 'Log out of every profile', type: 'boolean', default: false },
111
+ ],
112
+ action: async (ctx) => {
113
+ removeInjectedToken();
114
+ const all = ctx.flags.all === true;
115
+ const profileName = typeof ctx.flags.profile === 'string' ? ctx.flags.profile : DEFAULT_PROFILE;
116
+ const { profiles } = listProfiles();
117
+ if (profiles.length === 0) {
118
+ output.writeln('Nothing to log out of — no profile is signed in.');
119
+ return { success: true, data: { hadSession: false } };
120
+ }
121
+ const toClear = all ? profiles : profiles.filter((p) => p.profile === profileName);
122
+ if (toClear.length === 0) {
123
+ output.writeln(`No such profile: ${profileName}`);
124
+ return { success: false, exitCode: 1 };
125
+ }
126
+ let sec;
127
+ try {
128
+ sec = await loadSecurityOAuth();
129
+ }
130
+ catch {
131
+ sec = null; // best-effort keychain cleanup — logout must still succeed locally
132
+ }
133
+ for (const p of toClear) {
134
+ clearSessionToken(p.profile);
135
+ if (p.keychainRef && sec) {
136
+ const keychain = await sec.createKeychainAdapter();
137
+ await keychain.deleteSecret(KEYCHAIN_SERVICE, p.keychainRef).catch(() => { });
138
+ }
139
+ removeProfile(p.profile);
140
+ }
141
+ if (all) {
142
+ clearAllProfiles();
143
+ revokeConsent('account', 'auth-logout');
144
+ }
145
+ else if (listProfiles().profiles.length === 0) {
146
+ revokeConsent('account', 'auth-logout');
147
+ }
148
+ output.printSuccess(all ? 'Logged out of all profiles.' : `Logged out of profile "${profileName}".`);
149
+ output.writeln(' Note: this only forgets the local copy. Cognitum does not currently expose a token ' +
150
+ 'revocation endpoint for this flow, matching the same known limitation meta-proxy documents ' +
151
+ 'for its own logout — revoke server-side access from the Cognitum dashboard if needed.');
152
+ return { success: true, data: { hadSession: true } };
153
+ },
154
+ };
155
+ const statusCommand = {
156
+ name: 'status',
157
+ description: 'Show signed-in profile(s), scopes, and expiry',
158
+ options: [
159
+ { name: 'profile', description: 'Show only this profile', type: 'string' },
160
+ { name: 'json', description: 'Machine-readable output', type: 'boolean', default: false },
161
+ {
162
+ name: 'check',
163
+ description: 'Validate credentials now; silently refresh from the OS keychain when needed',
164
+ type: 'boolean',
165
+ default: false,
166
+ },
167
+ ],
168
+ action: async (ctx) => {
169
+ const { defaultProfile, profiles } = listProfiles();
170
+ const filterName = typeof ctx.flags.profile === 'string' ? ctx.flags.profile : undefined;
171
+ const shown = filterName ? profiles.filter((p) => p.profile === filterName) : profiles;
172
+ if (shown.length === 0) {
173
+ const message = filterName ? `No such profile: ${filterName}` : 'Not logged in. Run: ruflo auth login';
174
+ if (ctx.flags.json) {
175
+ output.printJson({ profiles: [] });
176
+ }
177
+ else {
178
+ output.writeln(message);
179
+ }
180
+ return { success: true, data: { profiles: [] } };
181
+ }
182
+ const withConsistency = shown.map((p) => {
183
+ const missingConsent = p.scopes.filter((scope) => {
184
+ const domain = domainForScope(scope);
185
+ return domain !== undefined && !hasConsent(domain);
186
+ });
187
+ return { ...p, isDefault: p.profile === defaultProfile, missingConsent };
188
+ });
189
+ const checked = await Promise.all(withConsistency.map(async (p) => {
190
+ if (!ctx.flags.check)
191
+ return { ...p, credentialStatus: 'not-checked' };
192
+ try {
193
+ await getValidAccessToken(p.profile);
194
+ return { ...p, credentialStatus: 'valid' };
195
+ }
196
+ catch (e) {
197
+ const message = e instanceof Error ? e.message : String(e);
198
+ return {
199
+ ...p,
200
+ credentialStatus: e instanceof NotLoggedInError || e instanceof SessionOnlyExpiredError
201
+ ? 'login-required'
202
+ : 'unavailable',
203
+ credentialError: message,
204
+ };
205
+ }
206
+ }));
207
+ if (ctx.flags.json) {
208
+ output.printJson({ profiles: checked });
209
+ return { success: true, data: { profiles: checked } };
210
+ }
211
+ for (const p of checked) {
212
+ output.writeln(`Profile: ${p.profile}${p.isDefault ? ' (default)' : ''}`);
213
+ output.writeln(` account: ${p.accountId}`);
214
+ output.writeln(` scopes: ${p.scopes.join(', ')}`);
215
+ output.writeln(` access token expires: ${p.accessTokenExpiresAt}`);
216
+ output.writeln(` refresh token: ${p.keychainRef ? 'in OS keychain' : 'session-only (not persisted)'}`);
217
+ if (ctx.flags.check) {
218
+ output.writeln(` credential check: ${p.credentialStatus}`);
219
+ if ('credentialError' in p && p.credentialError)
220
+ output.writeln(` ${p.credentialError}`);
221
+ }
222
+ if (p.missingConsent.length > 0) {
223
+ output.printError(` scope-vs-consent mismatch: ${p.missingConsent.join(', ')} granted without a matching consent receipt`);
224
+ }
225
+ output.writeln('');
226
+ }
227
+ return { success: true, data: { profiles: checked } };
228
+ },
229
+ };
230
+ export const authCommand = {
231
+ name: 'auth',
232
+ description: 'Cognitum identity — login, logout, status (ADR-306)',
233
+ subcommands: [loginCommand, logoutCommand, statusCommand],
234
+ examples: [
235
+ { command: 'ruflo auth login', description: 'Sign in via the browser PKCE flow' },
236
+ { command: 'ruflo auth login --no-browser', description: 'Sign in via the headless OOB copy-paste flow' },
237
+ { command: 'ruflo auth status', description: 'Show signed-in profile(s)' },
238
+ { command: 'ruflo auth status --check', description: 'Validate or silently refresh credentials now' },
239
+ { command: 'ruflo auth logout', description: 'Sign out of the default profile' },
240
+ ],
241
+ action: statusCommand.action,
242
+ };
243
+ export default authCommand;
244
+ //# sourceMappingURL=auth.js.map
@@ -978,7 +978,7 @@ async function checkFunnel() {
978
978
  }
979
979
  }
980
980
  /** Meta LLM Proxy — sponsored-downtime health (ADR-313). */
981
- async function checkProxy() {
981
+ async function checkProxySponsoredConsent() {
982
982
  try {
983
983
  const { funnelStateDir, hasConsent, readRateLimitStatus, lastRecordedEvent } = await import('../funnel/index.js');
984
984
  const dir = funnelStateDir();
@@ -1019,6 +1019,208 @@ async function checkProxy() {
1019
1019
  };
1020
1020
  }
1021
1021
  }
1022
+ /**
1023
+ * Binary presence + tamper check (ADR-307). Deliberately NEVER spawns the
1024
+ * binary to probe a version — confirmed empirically (2026-07-16) that
1025
+ * `meta-proxy` has no `--version`/`--help` flag and starts the live server
1026
+ * as a side effect of ANY invocation, which a doctor health check must never
1027
+ * do. Version info instead comes from install-manifest.json (written at
1028
+ * install time) and, once running, the proxy's own `/status` endpoint via
1029
+ * checkProxyProcess below.
1030
+ */
1031
+ async function checkProxyBinary() {
1032
+ const NAME = 'Meta LLM Proxy binary (ADR-307)';
1033
+ try {
1034
+ const { proxyBinaryPath, proxyInstallManifestPath } = await import('../proxy/paths.js');
1035
+ const binPath = proxyBinaryPath();
1036
+ if (!existsSync(binPath)) {
1037
+ return { name: NAME, status: 'warn', message: 'not installed', fix: 'ruflo proxy install' };
1038
+ }
1039
+ const manifestPath = proxyInstallManifestPath();
1040
+ if (!existsSync(manifestPath)) {
1041
+ return {
1042
+ name: NAME,
1043
+ status: 'warn',
1044
+ message: 'binary present but no install-manifest.json — provenance unknown (installed outside `ruflo proxy install`?)',
1045
+ fix: 'ruflo proxy update --release <x.y.z>',
1046
+ };
1047
+ }
1048
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
1049
+ const liveSha = createHash('sha256').update(readFileSync(binPath)).digest('hex');
1050
+ if (liveSha !== manifest.sha256) {
1051
+ return {
1052
+ name: NAME,
1053
+ status: 'fail',
1054
+ message: `binary sha256 does not match the recorded install manifest — possible tampering or a manual overwrite (expected ${manifest.sha256.slice(0, 12)}…, got ${liveSha.slice(0, 12)}…)`,
1055
+ fix: 'ruflo proxy update --release <x.y.z>',
1056
+ };
1057
+ }
1058
+ return { name: NAME, status: 'pass', message: `v${manifest.version}, signature-verified at install (${manifest.verifiedAt})` };
1059
+ }
1060
+ catch (err) {
1061
+ return { name: NAME, status: 'warn', message: `check failed: ${err instanceof Error ? err.message : String(err)}` };
1062
+ }
1063
+ }
1064
+ /** PID-file liveness (ADR-307) — mirrors daemon.ts's signal-0 probe pattern. */
1065
+ async function checkProxyProcess() {
1066
+ const NAME = 'Meta LLM Proxy process (ADR-307)';
1067
+ try {
1068
+ const { proxyPidFilePath } = await import('../proxy/paths.js');
1069
+ const pidPath = proxyPidFilePath();
1070
+ if (!existsSync(pidPath)) {
1071
+ return { name: NAME, status: 'warn', message: 'not running (no PID file)', fix: 'ruflo proxy start' };
1072
+ }
1073
+ const pidRaw = readFileSync(pidPath, 'utf-8').trim();
1074
+ const pid = parseInt(pidRaw, 10);
1075
+ if (!Number.isFinite(pid)) {
1076
+ return { name: NAME, status: 'warn', message: `PID file is malformed: ${JSON.stringify(pidRaw)}`, fix: 'ruflo proxy stop && ruflo proxy start' };
1077
+ }
1078
+ try {
1079
+ process.kill(pid, 0); // signal-0 liveness probe — throws if the process is dead
1080
+ }
1081
+ catch {
1082
+ return { name: NAME, status: 'warn', message: `PID file points at ${pid}, which is not running — stale PID file`, fix: 'ruflo proxy start' };
1083
+ }
1084
+ // Live version-compat + data-plane info, ONLY once PID liveness already
1085
+ // confirmed a process is running (never spawns anything — see
1086
+ // checkProxyBinary's comment on why probing via process launch is unsafe).
1087
+ // GET /status shape confirmed against the real v0.1.0 binary:
1088
+ // {"version","data_plane","bind","sponsored_available","proxy_token_valid"}.
1089
+ const { proxyConfigPath, proxyTokenPath, proxyInstallManifestPath } = await import('../proxy/paths.js');
1090
+ const bindMatch = existsSync(proxyConfigPath())
1091
+ ? readFileSync(proxyConfigPath(), 'utf-8').match(/^bind\s*=\s*"([^"]*)"/m)
1092
+ : null;
1093
+ const bind = bindMatch ? bindMatch[1] : '127.0.0.1:11435';
1094
+ let token;
1095
+ try {
1096
+ token = readFileSync(proxyTokenPath(), 'utf-8').trim();
1097
+ }
1098
+ catch {
1099
+ return { name: NAME, status: 'pass', message: `running (pid ${pid}); no proxy-token to query /status` };
1100
+ }
1101
+ try {
1102
+ const controller = new AbortController();
1103
+ const timer = setTimeout(() => controller.abort(), 2000);
1104
+ const resp = await fetch(`http://${bind}/status`, {
1105
+ headers: { authorization: `Bearer ${token}` },
1106
+ signal: controller.signal,
1107
+ });
1108
+ clearTimeout(timer);
1109
+ if (!resp.ok) {
1110
+ return { name: NAME, status: 'warn', message: `running (pid ${pid}); /status returned HTTP ${resp.status}` };
1111
+ }
1112
+ const body = (await resp.json());
1113
+ let versionNote = '';
1114
+ if (existsSync(proxyInstallManifestPath())) {
1115
+ const manifest = JSON.parse(readFileSync(proxyInstallManifestPath(), 'utf-8'));
1116
+ if (manifest.version && body.version && manifest.version !== body.version) {
1117
+ return {
1118
+ name: NAME,
1119
+ status: 'warn',
1120
+ message: `running (pid ${pid}) reports v${body.version}, but the installed binary is v${manifest.version} — a stale process from a previous version?`,
1121
+ fix: 'ruflo proxy stop && ruflo proxy start',
1122
+ };
1123
+ }
1124
+ versionNote = body.version ? ` v${body.version}` : '';
1125
+ }
1126
+ return {
1127
+ name: NAME,
1128
+ status: 'pass',
1129
+ message: `running (pid ${pid})${versionNote}; data plane: ${body.data_plane ?? 'unknown'}`,
1130
+ };
1131
+ }
1132
+ catch (err) {
1133
+ // Live process but /status unreachable (still starting up, or a
1134
+ // network hiccup) — not a failure, PID liveness already passed.
1135
+ return {
1136
+ name: NAME,
1137
+ status: 'pass',
1138
+ message: `running (pid ${pid}); /status unreachable (${err instanceof Error ? err.message : String(err)})`,
1139
+ };
1140
+ }
1141
+ }
1142
+ catch (err) {
1143
+ return { name: NAME, status: 'warn', message: `check failed: ${err instanceof Error ? err.message : String(err)}` };
1144
+ }
1145
+ }
1146
+ /** Non-loopback bind exposure warning (ADR-307's mandated startup warning, surfaced in doctor too). */
1147
+ async function checkProxyBindAddress() {
1148
+ const NAME = 'Meta LLM Proxy bind address (ADR-307)';
1149
+ try {
1150
+ const { proxyConfigPath, isLoopbackBind } = await import('../proxy/paths.js');
1151
+ const cfgPath = proxyConfigPath();
1152
+ if (!existsSync(cfgPath)) {
1153
+ return { name: NAME, status: 'pass', message: 'no config file yet — defaults to loopback-only (127.0.0.1:11435)' };
1154
+ }
1155
+ const raw = readFileSync(cfgPath, 'utf-8');
1156
+ const match = raw.match(/^bind\s*=\s*"([^"]*)"/m);
1157
+ const bind = match ? match[1] : '127.0.0.1:11435';
1158
+ if (!isLoopbackBind(bind)) {
1159
+ return {
1160
+ name: NAME,
1161
+ status: 'warn',
1162
+ message: `bound to non-loopback address ${bind} — this exposes the proxy to your network`,
1163
+ fix: 'Set bind back to 127.0.0.1:<port> in proxy-config.toml unless external exposure is intended',
1164
+ };
1165
+ }
1166
+ return { name: NAME, status: 'pass', message: `loopback-only (${bind})` };
1167
+ }
1168
+ catch (err) {
1169
+ return { name: NAME, status: 'warn', message: `check failed: ${err instanceof Error ? err.message : String(err)}` };
1170
+ }
1171
+ }
1172
+ /** `ruflo auth` health (ADR-306). Warn (never fail) on absence — auth is never required for core functionality. */
1173
+ async function checkAuth() {
1174
+ const NAME = 'Cognitum identity (ADR-306)';
1175
+ try {
1176
+ const { listProfiles } = await import('../auth/state.js');
1177
+ const { domainForScope } = await import('../auth/scopes.js');
1178
+ const { hasConsent } = await import('../funnel/index.js');
1179
+ const { profiles } = listProfiles();
1180
+ if (profiles.length === 0) {
1181
+ return { name: NAME, status: 'warn', message: 'not logged in', fix: 'ruflo auth login' };
1182
+ }
1183
+ let keychainAvailable = 'unknown';
1184
+ try {
1185
+ const sec = await import('@claude-flow/security');
1186
+ keychainAvailable = await (await sec.createKeychainAdapter()).isAvailable();
1187
+ }
1188
+ catch {
1189
+ keychainAvailable = 'unknown'; // security package unavailable — surfaced by checkProxyBinary's sibling concerns, not duplicated here
1190
+ }
1191
+ // Scope-vs-receipt consistency check (ADR-306: "fail-closed... reports").
1192
+ // Unlike every other check in this file, a violation here is a FAIL, not
1193
+ // a warn — a scope present without a matching consent receipt is exactly
1194
+ // the condition ADR-306 says must never silently pass.
1195
+ const violations = [];
1196
+ for (const p of profiles) {
1197
+ for (const scope of p.scopes) {
1198
+ const domain = domainForScope(scope);
1199
+ if (domain && !hasConsent(domain))
1200
+ violations.push(`${p.profile}: ${scope}`);
1201
+ }
1202
+ }
1203
+ if (violations.length > 0) {
1204
+ return {
1205
+ name: NAME,
1206
+ status: 'fail',
1207
+ message: `scope granted without a matching consent receipt: ${violations.join(', ')}`,
1208
+ fix: 'ruflo auth logout && ruflo auth login',
1209
+ };
1210
+ }
1211
+ const names = profiles.map((p) => p.profile).join(', ');
1212
+ const sessionOnly = profiles.filter((p) => !p.keychainRef).map((p) => p.profile);
1213
+ const parts = [`profiles: ${names}`];
1214
+ if (keychainAvailable === false)
1215
+ parts.push('keychain backend unreachable — falling back to session-only tokens');
1216
+ if (sessionOnly.length > 0)
1217
+ parts.push(`session-only (no persisted refresh token): ${sessionOnly.join(', ')}`);
1218
+ return { name: NAME, status: 'pass', message: parts.join('; ') };
1219
+ }
1220
+ catch (err) {
1221
+ return { name: NAME, status: 'warn', message: `check failed: ${err instanceof Error ? err.message : String(err)}` };
1222
+ }
1223
+ }
1022
1224
  async function checkMetaharnessIntegration() {
1023
1225
  // Locate plugins dir.
1024
1226
  //
@@ -1395,7 +1597,7 @@ export const doctorCommand = {
1395
1597
  {
1396
1598
  name: 'component',
1397
1599
  short: 'c',
1398
- description: 'Check specific component (version, node, npm, config, daemon, memory, api, git, mcp, claude, disk, typescript, agentic-flow, encryption, federation, funnel, proxy, metaharness)',
1600
+ description: 'Check specific component (version, node, npm, config, daemon, memory, api, git, mcp, claude, disk, typescript, agentic-flow, encryption, federation, funnel, proxy, auth, metaharness)',
1399
1601
  type: 'string'
1400
1602
  },
1401
1603
  {
@@ -1525,7 +1727,8 @@ export const doctorCommand = {
1525
1727
  checkMetaharness, // ADR-150 — MetaHarness upstream package
1526
1728
  checkMetaharnessIntegration, // iter 45 — ruflo-side integration layer
1527
1729
  checkFunnel, // ADR-305 — effective funnel state + deciding precedence source
1528
- checkProxy, // ADR-313 — Meta LLM Proxy sponsored-downtime health
1730
+ checkProxySponsoredConsent, // ADR-313 — Meta LLM Proxy sponsored-downtime health
1731
+ checkAuth, // ADR-306 — Cognitum identity (warn-only; never fails bare `ruflo doctor`)
1529
1732
  ];
1530
1733
  // #2677: `--component memory` now runs the whole memory-health suite,
1531
1734
  // not just the existence check. Values can be a single check or an
@@ -1563,7 +1766,11 @@ export const doctorCommand = {
1563
1766
  'metaharness': checkMetaharness, // ADR-150 — upstream package
1564
1767
  'metaharness-integration': checkMetaharnessIntegration, // iter 45 — ruflo-side
1565
1768
  'funnel': checkFunnel, // ADR-305
1566
- 'proxy': checkProxy, // ADR-313
1769
+ // ADR-307 — deep-dive array, same pattern as 'memory' above: the cheap
1770
+ // sponsored-consent check first, then binary/process/bind in the order
1771
+ // a user would actually debug them (is it installed? running? exposed?).
1772
+ 'proxy': [checkProxySponsoredConsent, checkProxyBinary, checkProxyProcess, checkProxyBindAddress],
1773
+ 'auth': checkAuth, // ADR-306
1567
1774
  };
1568
1775
  let checksToRun = allChecks;
1569
1776
  if (component && componentMap[component]) {
@@ -79,6 +79,8 @@ const commandLoaders = {
79
79
  // User-facing preferences wrapper (ADR-311 copy discipline — no "funnel" in
80
80
  // the user surface). Forwards to the funnel primitives internally.
81
81
  settings: () => import('./settings.js'),
82
+ // Cognitum identity — login/logout/status (ADR-306)
83
+ auth: () => import('./auth.js'),
82
84
  // Meta LLM Proxy — sponsored downtime capacity (ADR-304/307/313)
83
85
  proxy: () => import('./proxy.js'),
84
86
  // Fable co-pilot advisor tip in the statusline insight ticker (ADR-316)