impel-cli 0.20.45 → 0.20.46-beta.2
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/README.md +148 -8
- package/RELEASE_NOTES.md +59 -0
- package/docs/native-agent-host-capability-matrix.md +5 -5
- package/package.json +1 -1
- package/src/agents.js +4 -4
- package/src/apps.js +84 -36
- package/src/autoReport.js +289 -0
- package/src/bugReport.js +499 -0
- package/src/cli.js +61 -5
- package/src/codexSecurity.js +0 -18
- package/src/commands/apps.js +25 -11
- package/src/commands/auth.js +5 -1
- package/src/commands/converge.js +2 -1
- package/src/commands/cursorExperimental.js +74 -2
- package/src/commands/nuke.js +2 -2
- package/src/commands/report.js +309 -0
- package/src/commands/setup.js +134 -1
- package/src/commands/status.js +28 -3
- package/src/commands/update.js +82 -1
- package/src/cursorLocal.js +8 -0
- package/src/desktopTasks.js +420 -787
- package/src/doctor.js +17 -1
- package/src/exitCodes.js +61 -0
- package/src/featureFlags.js +393 -0
- package/src/managedProfileVersion.js +3 -1
- package/src/posthog.js +833 -0
- package/src/runtimeBrand.js +2 -2
- package/src/telemetryConsent.js +334 -0
- package/src/telemetryNotice.js +147 -0
- package/src/tenants.js +48 -0
- package/src/updates.js +26 -1
package/src/doctor.js
CHANGED
|
@@ -10,6 +10,7 @@ const MAX_ERROR_BODY_BYTES = 64 * 1024;
|
|
|
10
10
|
const MAX_OUTPUT_CHARS = 16 * 1024;
|
|
11
11
|
const MAX_PENDING_SSE_CHARS = 1024 * 1024;
|
|
12
12
|
const MAX_STREAM_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
const CLAUDE_PROBE_MAX_TOKENS = 1_024;
|
|
13
14
|
const GATEWAY_REQUEST_ID_RE = /^impel-[a-f0-9]{32}$/u;
|
|
14
15
|
const POOL_STATES = new Set(["healthy", "exhausted", "rate_limited", "expired", "no_seat"]);
|
|
15
16
|
|
|
@@ -86,6 +87,12 @@ function consumeSseBlock(provider, block, state, startedAt, now) {
|
|
|
86
87
|
if (!state.responseId && typeof event?.message?.id === "string") {
|
|
87
88
|
state.responseId = event.message.id.slice(0, 512) || null;
|
|
88
89
|
}
|
|
90
|
+
if (event?.type === "message_delta" && typeof event?.delta?.stop_reason === "string") {
|
|
91
|
+
state.stopReason = event.delta.stop_reason.slice(0, 100);
|
|
92
|
+
if (state.stopReason !== "end_turn") {
|
|
93
|
+
state.error ||= `provider stopped the response with reason ${redactSecretText(state.stopReason)}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
89
96
|
if (event?.type === "message_stop") state.terminalEvent = event.type;
|
|
90
97
|
if (event?.type === "content_block_delta" && event?.delta?.type === "text_delta") {
|
|
91
98
|
delta = typeof event.delta.text === "string" ? event.delta.text : "";
|
|
@@ -131,7 +138,11 @@ async function streamProbe({ provider, url, bearer, model, tenantId, attempt, ti
|
|
|
131
138
|
const body = provider === "claude"
|
|
132
139
|
? {
|
|
133
140
|
model,
|
|
134
|
-
|
|
141
|
+
// Current reasoning-capable Claude models may spend part of this budget
|
|
142
|
+
// before emitting the tiny acknowledgement. Ninety-six tokens made a
|
|
143
|
+
// healthy route look broken when reasoning exhausted the response
|
|
144
|
+
// budget before the first text delta.
|
|
145
|
+
max_tokens: CLAUDE_PROBE_MAX_TOKENS,
|
|
135
146
|
stream: true,
|
|
136
147
|
messages: [{ role: "user", content: prompt }],
|
|
137
148
|
}
|
|
@@ -192,6 +203,7 @@ async function streamProbe({ provider, url, bearer, model, tenantId, attempt, ti
|
|
|
192
203
|
exactAck: false,
|
|
193
204
|
outputChars: 0,
|
|
194
205
|
outputTruncated: false,
|
|
206
|
+
stopReason: null,
|
|
195
207
|
terminalEvent: null,
|
|
196
208
|
doneSentinel: false,
|
|
197
209
|
receivedBytes: 0,
|
|
@@ -204,6 +216,7 @@ async function streamProbe({ provider, url, bearer, model, tenantId, attempt, ti
|
|
|
204
216
|
responseId: null,
|
|
205
217
|
ttftMs: null,
|
|
206
218
|
outputTruncated: false,
|
|
219
|
+
stopReason: null,
|
|
207
220
|
terminalEvent: null,
|
|
208
221
|
doneSentinel: false,
|
|
209
222
|
error: null,
|
|
@@ -262,6 +275,7 @@ async function streamProbe({ provider, url, bearer, model, tenantId, attempt, ti
|
|
|
262
275
|
exactAck,
|
|
263
276
|
outputChars: state.answer.length,
|
|
264
277
|
outputTruncated: state.outputTruncated,
|
|
278
|
+
stopReason: state.stopReason,
|
|
265
279
|
terminalEvent: state.terminalEvent,
|
|
266
280
|
doneSentinel: state.doneSentinel,
|
|
267
281
|
receivedBytes,
|
|
@@ -283,6 +297,7 @@ async function streamProbe({ provider, url, bearer, model, tenantId, attempt, ti
|
|
|
283
297
|
exactAck: false,
|
|
284
298
|
outputChars: 0,
|
|
285
299
|
outputTruncated: false,
|
|
300
|
+
stopReason: null,
|
|
286
301
|
terminalEvent: null,
|
|
287
302
|
doneSentinel: false,
|
|
288
303
|
receivedBytes: 0,
|
|
@@ -488,6 +503,7 @@ export async function probeTenant({
|
|
|
488
503
|
exactAck: false,
|
|
489
504
|
outputChars: 0,
|
|
490
505
|
outputTruncated: false,
|
|
506
|
+
stopReason: null,
|
|
491
507
|
terminalEvent: null,
|
|
492
508
|
doneSentinel: false,
|
|
493
509
|
receivedBytes: 0,
|
package/src/exitCodes.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Process exit codes that mean something specific.
|
|
2
|
+
//
|
|
3
|
+
// Three values are already spoken for and must never be reused:
|
|
4
|
+
//
|
|
5
|
+
// 1 — `bin/impel.js`'s catch-all for any escaped throw. A dedicated code
|
|
6
|
+
// that collides with this is indistinguishable from a crash.
|
|
7
|
+
// 78 — `MANAGED_RUNTIME_PREFLIGHT_EXIT_CODE` (EX_CONFIG) in `src/apps.js`.
|
|
8
|
+
// 127 — the shell's "command not found"; a CLI that returns it is claiming
|
|
9
|
+
// it was never invoked.
|
|
10
|
+
//
|
|
11
|
+
// Everything else follows sysexits.h, so an operator reading a bare number
|
|
12
|
+
// gets the right intuition without a lookup.
|
|
13
|
+
|
|
14
|
+
/** Codes an Impel-specific exit code must not reuse. Asserted by tests. */
|
|
15
|
+
export const RESERVED_EXIT_CODES = Object.freeze([0, 1, 78, 127]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `impel _telemetry flush` could not deliver its batch (EX_TEMPFAIL): the
|
|
19
|
+
* spool is intact and a later flush retries.
|
|
20
|
+
*
|
|
21
|
+
* Nothing reads this in production — the sender is detached with stdio
|
|
22
|
+
* ignored, and the durable drop notice is the real observability surface. It
|
|
23
|
+
* exists because the send is an external call, and Law 6 wants every failure
|
|
24
|
+
* branch to have a distinct non-zero outcome that a test and a human running
|
|
25
|
+
* the command by hand can both see.
|
|
26
|
+
*/
|
|
27
|
+
export const TELEMETRY_FLUSH_FAILED_EXIT_CODE = 75;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A gated command refused because the feature-flag state could not be
|
|
31
|
+
* established (EX_UNAVAILABLE): no fresh evaluation, no cached one for this
|
|
32
|
+
* account, and no way to reach the control plane.
|
|
33
|
+
*
|
|
34
|
+
* Distinct from the catch-all 1 on purpose. "I could not find out whether you
|
|
35
|
+
* are allowed to run this" and "I crashed" are different states, and only the
|
|
36
|
+
* first one is worth retrying once the machine is back online.
|
|
37
|
+
*/
|
|
38
|
+
export const FLAG_STATE_UNKNOWN_EXIT_CODE = 69;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A gated command refused because the flag is off for this account
|
|
42
|
+
* (EX_NOPERM): the state *was* established, and the answer was no.
|
|
43
|
+
*
|
|
44
|
+
* Separate from `FLAG_STATE_UNKNOWN_EXIT_CODE` because the two say opposite
|
|
45
|
+
* things about retrying. "Not enabled for you" is a settled answer — running it
|
|
46
|
+
* again changes nothing until someone grants access. "Could not find out" is
|
|
47
|
+
* worth retrying the moment the machine is back online. A script that treats
|
|
48
|
+
* both as one number has to guess which it got.
|
|
49
|
+
*/
|
|
50
|
+
export const FEATURE_NOT_ENABLED_EXIT_CODE = 77;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `impel report` could not deliver the bug report and wrote the envelope to
|
|
54
|
+
* disk instead (EX_IOERR): the route was unreachable, absent, or rate-limited.
|
|
55
|
+
*
|
|
56
|
+
* The report is user-initiated, so unlike telemetry someone is watching this
|
|
57
|
+
* exit code. It says "your report was not lost, and it was not sent either" —
|
|
58
|
+
* a state that deserves its own number, because the printed spool path is the
|
|
59
|
+
* next action and 1 would read as a crash that produced nothing.
|
|
60
|
+
*/
|
|
61
|
+
export const REPORT_SPOOLED_EXIT_CODE = 74;
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Remote feature flags for the CLI: a disk cache in front of
|
|
2
|
+
// `POST /api/cli/flags`, evaluated fail-closed.
|
|
3
|
+
//
|
|
4
|
+
// The only consumer today is the Cursor experiment gate, and that shapes every
|
|
5
|
+
// decision here. A gate exists so an experiment can be turned off remotely for
|
|
6
|
+
// one account; if a machine that cannot reach the control plane guessed a
|
|
7
|
+
// value, the guess would be wrong exactly when the switch is being thrown. So
|
|
8
|
+
// there is no default: `evaluateFlag` either returns an answer it can trace to
|
|
9
|
+
// a real server evaluation — fresh, cached, or stale — or it throws
|
|
10
|
+
// `FlagStateUnknownError` for the command to turn into a distinct exit code.
|
|
11
|
+
//
|
|
12
|
+
// Two timers, mirroring `src/updates.js`: `fetchedAt` decides whether the
|
|
13
|
+
// cached answer is still current (6h), and `lastFailedAt` suppresses re-probing
|
|
14
|
+
// after a failure (1h). Without the second one an offline machine would attempt
|
|
15
|
+
// a fetch on every single invocation of the gated command.
|
|
16
|
+
//
|
|
17
|
+
// The cache records the principal it was fetched for. A read under a different
|
|
18
|
+
// `{orgId, userId}` is a miss rather than a hit: serving the previous org's
|
|
19
|
+
// answers for up to six hours after a tenant or PAT switch would defeat a kill
|
|
20
|
+
// switch precisely when one org is being killed and another is not.
|
|
21
|
+
//
|
|
22
|
+
// Everything written here lives under `CONFIG_DIR`, so `impel nuke` erases it.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
CONFIG_DIR,
|
|
30
|
+
loadConfig,
|
|
31
|
+
normalizeGatewayUrl,
|
|
32
|
+
resolveDefaultAppUrl,
|
|
33
|
+
} from "./config.js";
|
|
34
|
+
import { FLAG_STATE_UNKNOWN_EXIT_CODE } from "./exitCodes.js";
|
|
35
|
+
import { fetchHttp1 } from "./http1.js";
|
|
36
|
+
import { TELEMETRY_CONTRACT } from "./posthog.js";
|
|
37
|
+
import { RUNTIME_BRAND } from "./runtimeBrand.js";
|
|
38
|
+
import { managedMarkerPresent } from "./telemetryConsent.js";
|
|
39
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
40
|
+
|
|
41
|
+
/** The route this module posts to. */
|
|
42
|
+
export const FLAGS_ENDPOINT_PATH = "/api/cli/flags";
|
|
43
|
+
|
|
44
|
+
/** Beside `update-check.json`, and erased by the same `impel nuke` sweep. */
|
|
45
|
+
export const FLAG_CACHE_PATH = path.join(CONFIG_DIR, "flags.json");
|
|
46
|
+
|
|
47
|
+
/** How long an evaluation stays authoritative. The repo's cache default. */
|
|
48
|
+
export const FLAG_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How long a failed check suppresses the next attempt. Shorter than the TTL:
|
|
52
|
+
* a failure should heal within the session, but must not turn an offline
|
|
53
|
+
* machine into one request per gated command.
|
|
54
|
+
*/
|
|
55
|
+
export const FLAG_FETCH_BACKOFF_MS = 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The flag the managed-Cursor experiment is gated on.
|
|
59
|
+
*
|
|
60
|
+
* Declared as a literal rather than read out of `TELEMETRY_CONTRACT` by index;
|
|
61
|
+
* `test/feature-flags.test.js` pins it against `featureFlagKeys` so the two
|
|
62
|
+
* cannot drift while keeping the reference here readable.
|
|
63
|
+
*/
|
|
64
|
+
export const CURSOR_EXPERIMENT_FLAG = "cli-cursor-experiment";
|
|
65
|
+
|
|
66
|
+
/** Long enough for a cold control-plane response, short enough to be a gate. */
|
|
67
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
68
|
+
|
|
69
|
+
/** Flag keys are bounded by the server schema; anything longer is not ours. */
|
|
70
|
+
const MAX_FLAG_KEY_LENGTH = 128;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The flag state could not be established, and no cached answer applies.
|
|
74
|
+
*
|
|
75
|
+
* Thrown rather than returned so a caller cannot accidentally treat it as a
|
|
76
|
+
* value. Commands catch it and set `exitCode` on `process.exitCode`: the
|
|
77
|
+
* `experimental` throw path always exits 1, so an escaping throw would be
|
|
78
|
+
* indistinguishable from a crash (see `src/exitCodes.js`).
|
|
79
|
+
*/
|
|
80
|
+
export class FlagStateUnknownError extends Error {
|
|
81
|
+
constructor(message, reason) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "FlagStateUnknownError";
|
|
84
|
+
/** Machine-readable cause; each maps to its own message. */
|
|
85
|
+
this.reason = reason;
|
|
86
|
+
this.exitCode = FLAG_STATE_UNKNOWN_EXIT_CODE;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Internal: a fetch that did not produce a usable evaluation. */
|
|
91
|
+
class FlagFetchError extends Error {
|
|
92
|
+
constructor(message, reason) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "FlagFetchError";
|
|
95
|
+
this.reason = reason;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* -------------------------------------------------------------------------- */
|
|
100
|
+
/* Cache */
|
|
101
|
+
/* -------------------------------------------------------------------------- */
|
|
102
|
+
|
|
103
|
+
export function readFlagCache() {
|
|
104
|
+
try {
|
|
105
|
+
const cache = JSON.parse(fs.readFileSync(FLAG_CACHE_PATH, "utf8"));
|
|
106
|
+
return cache && typeof cache === "object" && !Array.isArray(cache) ? cache : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Merge-and-replace, matching `writeUpdateCache`'s tmp+rename discipline. */
|
|
113
|
+
export function writeFlagCache(patch) {
|
|
114
|
+
const next = { ...(readFlagCache() || {}), ...patch };
|
|
115
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
116
|
+
const temporaryPath = `${FLAG_CACHE_PATH}.tmp-${process.pid}`;
|
|
117
|
+
try {
|
|
118
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
119
|
+
renameWithWindowsRetry(temporaryPath, FLAG_CACHE_PATH);
|
|
120
|
+
} finally {
|
|
121
|
+
try {
|
|
122
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
123
|
+
} catch {
|
|
124
|
+
// Cleanup must never mask the write/rename outcome.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Who the flags were evaluated for.
|
|
132
|
+
*
|
|
133
|
+
* `userId` comes from `config.user`, which U10 persists from the tenants
|
|
134
|
+
* response. Before that field exists it is `null` on both the write and the
|
|
135
|
+
* read, so the comparison stays consistent — an install that has never seen a
|
|
136
|
+
* `user` is one principal, not a permanent mismatch.
|
|
137
|
+
*/
|
|
138
|
+
function flagPrincipal(config) {
|
|
139
|
+
return {
|
|
140
|
+
orgId: typeof config?.tenantId === "string" ? config.tenantId : null,
|
|
141
|
+
userId: typeof config?.user?.id === "string" ? config.user.id : null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function samePrincipal(cache, principal) {
|
|
146
|
+
return (cache?.orgId ?? null) === principal.orgId
|
|
147
|
+
&& (cache?.userId ?? null) === principal.userId;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The cached evaluation, or null when there is none *for this principal*. */
|
|
151
|
+
function cachedFlags(cache, principal) {
|
|
152
|
+
if (!cache || !samePrincipal(cache, principal)) return null;
|
|
153
|
+
const flags = cache.flags;
|
|
154
|
+
return flags && typeof flags === "object" && !Array.isArray(flags) ? flags : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function withinTtl(cache, now) {
|
|
158
|
+
return Number.isFinite(cache?.fetchedAt) && now - cache.fetchedAt < FLAG_CACHE_TTL_MS;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function backoffRemainingMs(cache, now) {
|
|
162
|
+
if (!Number.isFinite(cache?.lastFailedAt)) return 0;
|
|
163
|
+
return Math.max(0, cache.lastFailedAt + FLAG_FETCH_BACKOFF_MS - now);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* -------------------------------------------------------------------------- */
|
|
167
|
+
/* Evaluation */
|
|
168
|
+
/* -------------------------------------------------------------------------- */
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read one flag out of an evaluation.
|
|
172
|
+
*
|
|
173
|
+
* A key the evaluation does not mention resolves to `enabled: false`. That is
|
|
174
|
+
* not a fabricated default: the server answered, and an answer that does not
|
|
175
|
+
* name the flag is an answer that the flag is not on for this principal. The
|
|
176
|
+
* fabricated case — no answer at all — throws instead. `known` lets a caller
|
|
177
|
+
* that cares tell the two apart.
|
|
178
|
+
*/
|
|
179
|
+
function resolveFlag(name, flags, source) {
|
|
180
|
+
const flag = flags?.[name];
|
|
181
|
+
return {
|
|
182
|
+
name,
|
|
183
|
+
enabled: flag?.enabled === true,
|
|
184
|
+
variant: typeof flag?.variant === "string" ? flag.variant : null,
|
|
185
|
+
payload: flag && Object.hasOwn(flag, "payload") ? flag.payload : null,
|
|
186
|
+
known: Boolean(flag),
|
|
187
|
+
source,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Validate a response into the shape the cache stores, or return null.
|
|
193
|
+
*
|
|
194
|
+
* One malformed flag rejects the whole response rather than being skipped: a
|
|
195
|
+
* partially-read evaluation is exactly the "some flags computed, some not"
|
|
196
|
+
* state the route already refuses to send (`flags_unavailable`), and a kill
|
|
197
|
+
* switch dropped by a lenient parser reads as "not killed".
|
|
198
|
+
*/
|
|
199
|
+
function normalizeFlagResponse(payload) {
|
|
200
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
201
|
+
const { flags } = payload;
|
|
202
|
+
if (!flags || typeof flags !== "object" || Array.isArray(flags)) return null;
|
|
203
|
+
const keys = Object.keys(flags);
|
|
204
|
+
if (keys.length > TELEMETRY_CONTRACT.limits.maxFlagsPerResponse) return null;
|
|
205
|
+
|
|
206
|
+
const normalized = {};
|
|
207
|
+
for (const key of keys) {
|
|
208
|
+
if (key.length === 0 || key.length > MAX_FLAG_KEY_LENGTH) return null;
|
|
209
|
+
const value = flags[key];
|
|
210
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
211
|
+
if (typeof value.enabled !== "boolean") return null;
|
|
212
|
+
normalized[key] = { enabled: value.enabled };
|
|
213
|
+
if (typeof value.variant === "string" && value.variant.length > 0) {
|
|
214
|
+
normalized[key].variant = value.variant;
|
|
215
|
+
}
|
|
216
|
+
if (value.payload !== undefined) normalized[key].payload = value.payload;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
flags: normalized,
|
|
220
|
+
evaluatedAt: typeof payload.evaluatedAt === "string" ? payload.evaluatedAt : null,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function jsonBody(response) {
|
|
225
|
+
try {
|
|
226
|
+
return await response.json();
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* One evaluation request.
|
|
234
|
+
*
|
|
235
|
+
* Server-supplied text is never echoed — only status codes and our own
|
|
236
|
+
* sentences — so no message can carry back something that needs redacting.
|
|
237
|
+
*/
|
|
238
|
+
async function fetchFlags({ config, fetchImpl, timeoutMs }) {
|
|
239
|
+
const appUrl = normalizeGatewayUrl(config.appUrl || resolveDefaultAppUrl());
|
|
240
|
+
const controller = new AbortController();
|
|
241
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
242
|
+
let response;
|
|
243
|
+
try {
|
|
244
|
+
response = await fetchImpl(new URL(FLAGS_ENDPOINT_PATH, appUrl), {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: {
|
|
247
|
+
accept: "application/json",
|
|
248
|
+
authorization: `Bearer ${config.pat}`,
|
|
249
|
+
"content-type": "application/json",
|
|
250
|
+
},
|
|
251
|
+
body: "{}",
|
|
252
|
+
signal: controller.signal,
|
|
253
|
+
});
|
|
254
|
+
} catch {
|
|
255
|
+
throw new FlagFetchError(
|
|
256
|
+
`could not reach ${appUrl} to check feature flags; reconnect and try again`,
|
|
257
|
+
"unreachable",
|
|
258
|
+
);
|
|
259
|
+
} finally {
|
|
260
|
+
clearTimeout(timeout);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
// R16: a `next` deployment that predates the route. Named separately from
|
|
265
|
+
// every other failure because the remedy is a deployment, not a retry.
|
|
266
|
+
if (response.status === 404) {
|
|
267
|
+
throw new FlagFetchError(
|
|
268
|
+
`${appUrl} does not serve feature flags yet; update the ${RUNTIME_BRAND.product.displayName} control plane and try again`,
|
|
269
|
+
"absent_route",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const body = await jsonBody(response);
|
|
273
|
+
if (body?.code === "flags_unavailable") {
|
|
274
|
+
throw new FlagFetchError(
|
|
275
|
+
`${appUrl} cannot evaluate feature flags right now; try again in a few minutes`,
|
|
276
|
+
"unavailable",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
throw new FlagFetchError(
|
|
280
|
+
`${appUrl} refused the feature-flag check (HTTP ${response.status}); try again later`,
|
|
281
|
+
"rejected",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const normalized = normalizeFlagResponse(await jsonBody(response));
|
|
286
|
+
if (!normalized) {
|
|
287
|
+
throw new FlagFetchError(
|
|
288
|
+
`${appUrl} returned a feature-flag response this CLI cannot read; update the CLI and try again`,
|
|
289
|
+
"malformed",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return normalized;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Why this process may not make the request, or null when it may.
|
|
297
|
+
*
|
|
298
|
+
* The managed-surface denial is the P1-8 egress boundary and is checked first:
|
|
299
|
+
* a managed vendor app profile may reach the gateway and nothing else, so a
|
|
300
|
+
* flag check from inside one presents as an unexpected call from a profile
|
|
301
|
+
* holding app credentials. Unlike analytics capture this is not a consent
|
|
302
|
+
* question — a kill switch that honors an opt-out is not a kill switch — but
|
|
303
|
+
* the context guard applies to every egress path without exception.
|
|
304
|
+
*/
|
|
305
|
+
function fetchBlocked({ config, env, homeDir, cache, now }) {
|
|
306
|
+
if (managedMarkerPresent(env, homeDir)) {
|
|
307
|
+
return {
|
|
308
|
+
reason: "managed_surface",
|
|
309
|
+
message: "feature flags cannot be checked from inside a managed Impel app profile; run this from a terminal",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (!config?.pat) {
|
|
313
|
+
return {
|
|
314
|
+
reason: "unauthenticated",
|
|
315
|
+
message: `not authenticated; run \`${RUNTIME_BRAND.cli.command} setup\` before using a gated feature`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const remaining = backoffRemainingMs(cache, now);
|
|
319
|
+
if (remaining > 0) {
|
|
320
|
+
const minutes = Math.max(1, Math.ceil(remaining / 60_000));
|
|
321
|
+
return {
|
|
322
|
+
reason: "backoff",
|
|
323
|
+
message: `a recent feature-flag check failed and no cached answer applies to this account; try again in ${minutes} minute${minutes === 1 ? "" : "s"}`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Resolve one flag for the current principal.
|
|
331
|
+
*
|
|
332
|
+
* The state machine, in the order the branches are taken:
|
|
333
|
+
*
|
|
334
|
+
* fresh cache -> use it, no request
|
|
335
|
+
* blocked (managed/no PAT/backoff) -> stale cache if there is one, else refuse
|
|
336
|
+
* fetch succeeds -> store and use
|
|
337
|
+
* fetch fails, cache exists -> stale value, failure stamped
|
|
338
|
+
* fetch fails, no cache -> refuse with that failure's own message
|
|
339
|
+
*
|
|
340
|
+
* "Cache" throughout means a cache written for *this* `{orgId, userId}`; one
|
|
341
|
+
* written for another principal is not a cache miss that degrades to stale, it
|
|
342
|
+
* is a miss that refuses.
|
|
343
|
+
*/
|
|
344
|
+
export async function evaluateFlag(name, options = {}) {
|
|
345
|
+
const {
|
|
346
|
+
config = loadConfig(),
|
|
347
|
+
fetchImpl = fetchHttp1,
|
|
348
|
+
env = process.env,
|
|
349
|
+
homeDir = os.homedir(),
|
|
350
|
+
now = Date.now(),
|
|
351
|
+
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
352
|
+
} = options;
|
|
353
|
+
|
|
354
|
+
const principal = flagPrincipal(config);
|
|
355
|
+
const cache = readFlagCache();
|
|
356
|
+
const cached = cachedFlags(cache, principal);
|
|
357
|
+
if (cached && withinTtl(cache, now)) return resolveFlag(name, cached, "cache");
|
|
358
|
+
|
|
359
|
+
const blocked = fetchBlocked({ config, env, homeDir, cache, now });
|
|
360
|
+
if (blocked) {
|
|
361
|
+
if (cached) return resolveFlag(name, cached, "stale");
|
|
362
|
+
throw new FlagStateUnknownError(blocked.message, blocked.reason);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let evaluation;
|
|
366
|
+
try {
|
|
367
|
+
evaluation = await fetchFlags({ config, fetchImpl, timeoutMs });
|
|
368
|
+
} catch (error) {
|
|
369
|
+
// Stamp the failure even when a stale answer covers this call: the point of
|
|
370
|
+
// the second timer is that the *next* invocation does not re-probe either.
|
|
371
|
+
try {
|
|
372
|
+
writeFlagCache({ lastFailedAt: now });
|
|
373
|
+
} catch {
|
|
374
|
+
// The stamp is a request-rate limiter, not required state.
|
|
375
|
+
}
|
|
376
|
+
if (cached) return resolveFlag(name, cached, "stale");
|
|
377
|
+
throw new FlagStateUnknownError(error.message, error.reason || "unreachable");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
writeFlagCache({
|
|
382
|
+
...principal,
|
|
383
|
+
flags: evaluation.flags,
|
|
384
|
+
evaluatedAt: evaluation.evaluatedAt,
|
|
385
|
+
fetchedAt: now,
|
|
386
|
+
lastFailedAt: 0,
|
|
387
|
+
});
|
|
388
|
+
} catch {
|
|
389
|
+
// An unwritable config dir costs a cache, not an answer: this evaluation is
|
|
390
|
+
// authoritative and is returned either way.
|
|
391
|
+
}
|
|
392
|
+
return resolveFlag(name, evaluation.flags, "network");
|
|
393
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// Fleet generation shared by managed-profile writers and upstream clients.
|
|
2
2
|
// Keep this isolated from apps.js so latency-sensitive transports do not load
|
|
3
3
|
// desktop bundle machinery just to identify their managed config contract.
|
|
4
|
-
|
|
4
|
+
// v38 replaces the generated desktop MCP Apps host with the authenticated web
|
|
5
|
+
// board and removes the desktop-only Codex renderer flag.
|
|
6
|
+
export const CURRENT_CONFIG_VERSION = 38;
|