flowviant 0.80.0 → 0.81.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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * WHICH CREDENTIAL THIS MACHINE'S CLI WILL RESOLVE — presence and expiry only,
3
+ * never a token value, and never a judgement about which one is correct.
4
+ *
5
+ * ── THE CONFUSION THIS EXISTS TO END ──
6
+ *
7
+ * A turn failed with the CLI's own words relayed verbatim:
8
+ *
9
+ * Failed to authenticate: OAuth session expired and could not be refreshed
10
+ *
11
+ * and the operator did the obvious thing — opened a terminal on that same
12
+ * machine, typed `claude`, and watched it work. Both observations were true at
13
+ * once, and nothing in the product could explain how, because the one fact that
14
+ * reconciles them is invisible from a browser: THE DAEMON'S CLI AND YOUR SHELL'S
15
+ * CLI DO NOT ALWAYS RESOLVE THE SAME CREDENTIAL.
16
+ *
17
+ * Two ways they diverge, and this module reports both:
18
+ *
19
+ * · THE ENVIRONMENT. `claude.mjs` spawns the CLI with `{...process.env}` on
20
+ * purpose — its header says so: "the CLI's own credentials live in this
21
+ * environment, and handing it a curated one signs it out". It also stopped
22
+ * deleting ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN deliberately, because on
23
+ * a machine the project leaves running an inherited org key is the POINT.
24
+ * The consequence is that an auth variable in the daemon's environment is
25
+ * handed to every turn — and `process.env` is a SNAPSHOT taken when the
26
+ * daemon started, so a variable exported weeks ago is still in there long
27
+ * after it left your shell. A stale one fails in a way a fresh `claude`
28
+ * never reproduces.
29
+ * · THE HOME. The CLI reads its store from $HOME. A daemon under systemd, a
30
+ * different user, or a container has a different one than the shell you
31
+ * tested in, so "I am logged in" was measured on the wrong file.
32
+ *
33
+ * ── WHAT THIS IS NOT ──
34
+ *
35
+ * NOT A POLICY. Flowviant does not pick a credential, does not prefer the
36
+ * subscription over a key, and does not call either one wrong — `claude.mjs`
37
+ * settled that: "Which credential is correct, and whether an account may be
38
+ * shared, is between the operator and the vendor." This reports what is
39
+ * PRESENT so a person can see the divergence; it never resolves it.
40
+ *
41
+ * NOT A PRECEDENCE CLAIM. Which source the CLI actually prefers is the CLI's
42
+ * own business and it is free to change it. We report that both exist, which is
43
+ * the fact that explains the symptom, and we do not assert which one won.
44
+ *
45
+ * NOT A TOKEN READER. Values never leave — not to the server, not to a log, not
46
+ * into an error. Presence, the variable's NAME, and the refresh expiry, which
47
+ * is a date.
48
+ *
49
+ * ── THE THREE-STATE RULE, WHICH THIS FILE LIVES OR DIES BY ──
50
+ *
51
+ * `source: 'unknown'` is NOT "signed out". Claude Code can keep credentials in
52
+ * an OS keychain (macOS especially), where a file simply does not exist and
53
+ * everything is fine. Reporting a confident "no login" for a machine that is
54
+ * working perfectly is precisely the invented state this product forbids, and
55
+ * it would be worse than saying nothing. Absent means absent; the surface
56
+ * renders nothing for it.
57
+ */
58
+
59
+ import { readFileSync } from 'node:fs';
60
+ import { join } from 'node:path';
61
+
62
+ /**
63
+ * The variables that can carry auth into the CLI from the environment.
64
+ *
65
+ * The first two are the pair `claude.mjs` documents itself as deliberately NOT
66
+ * deleting, and both appear in childEnv.mjs's allowlist. The third is Claude
67
+ * Code's own headless/CI token. Names only ever leave this module — a NAME is
68
+ * what a person greps for, and it is not a secret.
69
+ */
70
+ export const AUTH_ENV_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
71
+
72
+ /** Claude Code's OAuth store, relative to the HOME the CLI will be handed. */
73
+ const CRED_REL = ['.claude', '.credentials.json'];
74
+
75
+ /** A stamp that may be seconds or milliseconds, to ISO — or null if it is
76
+ * neither. Same tolerance the rest of the daemon applies to numbers it did not
77
+ * write: an unreadable stamp is ABSENT, never zero and never "now". */
78
+ function isoFromStamp(v) {
79
+ const n = Number(v);
80
+ if (!Number.isFinite(n) || n <= 0) return null;
81
+ const ms = n > 1e11 ? n : n * 1000;
82
+ const d = new Date(ms);
83
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
84
+ }
85
+
86
+ /**
87
+ * What the CLI on this machine will resolve, as far as it can be seen without
88
+ * spending anything: no process spawned, no request made, no quota touched.
89
+ * Just this process's own environment and one file's metadata.
90
+ *
91
+ * `env` and `home` are parameters rather than reads of `process` so the tests
92
+ * can drive every branch — and so the caller passes the SAME environment the
93
+ * child will actually receive, which is the whole point of the report.
94
+ */
95
+ export function claudeAuthContext({ env = process.env, home = null } = {}) {
96
+ const HOME = home ?? env.HOME ?? env.USERPROFILE ?? null;
97
+
98
+ // Presence, by NAME. An empty or whitespace-only value is not set: an
99
+ // exported-but-blank variable is a common way to try to UNSET one, and
100
+ // reporting it as present would send somebody hunting a variable that is
101
+ // doing nothing.
102
+ const envVars = AUTH_ENV_VARS.filter((n) => typeof env[n] === 'string' && env[n].trim() !== '');
103
+
104
+ let hasFile = false;
105
+ let refreshExpiresAt = null;
106
+ let accessExpiresAt = null;
107
+ let subscriptionType = null;
108
+ if (HOME) {
109
+ try {
110
+ const raw = readFileSync(join(HOME, ...CRED_REL), 'utf8');
111
+ const oauth = JSON.parse(raw)?.claudeAiOauth;
112
+ if (oauth && typeof oauth === 'object') {
113
+ hasFile = true;
114
+ refreshExpiresAt = isoFromStamp(oauth.refreshTokenExpiresAt);
115
+ accessExpiresAt = isoFromStamp(oauth.expiresAt);
116
+ // A plan name is not a secret and it is the one field that tells an
117
+ // operator WHICH kind of account the machine is spending — the thing
118
+ // "the machine is shared and so is its CLI login" makes everyone's
119
+ // business. Anything unexpected is dropped rather than relayed.
120
+ if (typeof oauth.subscriptionType === 'string' && oauth.subscriptionType.length <= 32) {
121
+ subscriptionType = oauth.subscriptionType;
122
+ }
123
+ }
124
+ } catch {
125
+ // Absent, unreadable, or not JSON. All three mean the same thing here:
126
+ // we cannot see a file, which is NOT the same as there not being a
127
+ // login. See the three-state rule in this file's header.
128
+ }
129
+ }
130
+
131
+ /**
132
+ * THE SOURCE, and the honest thing to say about it.
133
+ *
134
+ * 'env' — at least one auth variable is set. Reported first because it
135
+ * is the state that is invisible from a shell and therefore the
136
+ * one that misleads; NOT because we know it wins.
137
+ * 'file' — no variable, and an OAuth store we could read.
138
+ * 'unknown' — neither. A keychain machine lives here, and so does a machine
139
+ * with no login at all. We cannot tell them apart, so we say
140
+ * nothing about which it is.
141
+ */
142
+ const source = envVars.length ? 'env' : hasFile ? 'file' : 'unknown';
143
+
144
+ return {
145
+ /** The OS user the daemon runs as — the other half of "which store". */
146
+ user: env.USER ?? env.LOGNAME ?? env.USERNAME ?? null,
147
+ home: HOME,
148
+ source,
149
+ /** NAMES ONLY. Never a value, never a prefix, never a length. */
150
+ envVars,
151
+ /** Is there a login sitting behind the variable that may be shadowing it?
152
+ * This — not `source` on its own — is the confusing state, and it is the
153
+ * only one the surface is allowed to raise unprompted. */
154
+ envOverridesLogin: envVars.length > 0 && hasFile,
155
+ /** The 22-day clock. Null when we cannot see the file, which includes every
156
+ * keychain machine, so null must never render as "expired". */
157
+ refreshExpiresAt,
158
+ /** The ~12-hour clock. Reported for completeness and deliberately NOT what
159
+ * any warning is built from: it lapses constantly and refreshes itself, so
160
+ * a note built on it would cry wolf twice a day. */
161
+ accessExpiresAt,
162
+ subscriptionType,
163
+ };
164
+ }
package/bin/lib/fleet.mjs CHANGED
@@ -85,6 +85,7 @@ import {
85
85
  import { createWorkManager } from './work.mjs';
86
86
  import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
87
87
  import { repoState } from './repoState.mjs';
88
+ import { claudeAuthContext } from './claudeAuth.mjs';
88
89
 
89
90
  async function fetchRoster(
90
91
  haveIds,
@@ -327,7 +328,29 @@ async function maybeReportRepoState({ repoRoot, baseRef }) {
327
328
  // time, so the value here is always current.
328
329
  const state = repoState(repoRoot, baseRef);
329
330
  if (!state) return; // not readable — say nothing rather than say "none"
330
- payload = JSON.stringify(state);
331
+ /**
332
+ * WHICH CREDENTIAL THIS MACHINE'S CLI RESOLVES, on the same beat.
333
+ *
334
+ * It rides this report rather than getting an endpoint of its own because
335
+ * it is the same KIND of fact — something only the machine can see, pushed
336
+ * because a pull client can never be asked — and it inherits this
337
+ * function's three economies for free: scanned once a minute, deduped
338
+ * against the last ACCEPTED payload, and silent forever once an older
339
+ * server 404s.
340
+ *
341
+ * The dedup keeps it cheap: `claudeAuthContext` is presence plus two dates,
342
+ * so the string is stable across scans and only moves when something about
343
+ * the credential actually moves. It DOES move when the CLI refreshes its
344
+ * access token — that stamp is carried for diagnostics — which costs a
345
+ * write roughly twice a day and is the whole of the extra traffic. The
346
+ * WARNING is built on the 22-day refresh clock instead, so a note never
347
+ * fires on the ~12h cycle.
348
+ *
349
+ * NO VERSION FLOOR, and none is possible to need: this is a daemon→server
350
+ * report, and an older SERVER strips the unknown key in its zod parse and
351
+ * stores the rest exactly as before.
352
+ */
353
+ payload = JSON.stringify({ ...state, auth: claudeAuthContext() });
331
354
  } catch {
332
355
  return; // a readout must never throw into the poll loop
333
356
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.80.0",
4
- "description": "Run your own coding CLIs as build agents for Flowviant Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
3
+ "version": "0.81.0",
4
+ "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flowviant": "bin/cli.mjs"