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/bugReport.js
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
// The bug-report core: everything that decides *what* gets sent and *where*,
|
|
2
|
+
// with no opinion about who asked.
|
|
3
|
+
//
|
|
4
|
+
// Two front-ends import this module and they differ in exactly one way — who
|
|
5
|
+
// pressed send. `src/commands/report.js` is a person typing `impel report`;
|
|
6
|
+
// `src/autoReport.js` is a failed `impel setup` or `impel update` reporting
|
|
7
|
+
// itself. Both build the same envelope, both are bound by the same destination
|
|
8
|
+
// check, and both spool the same way when delivery fails. Keeping that here
|
|
9
|
+
// rather than in either caller is what makes "the automatic path cannot send
|
|
10
|
+
// something the manual path would not" a property of the code instead of a
|
|
11
|
+
// convention.
|
|
12
|
+
//
|
|
13
|
+
// The envelope's field list is the server's `bugReportEnvelopeSchema`, which is
|
|
14
|
+
// `.strict()`: an extra key is a 400, so anything contextual — the tenant, the
|
|
15
|
+
// report id, attached `impel doctor --json` output — travels inside
|
|
16
|
+
// `diagnostics` rather than as a new top-level field.
|
|
17
|
+
//
|
|
18
|
+
// Nothing here prints, prompts, or sets an exit code. Callers own their own
|
|
19
|
+
// voice; this module throws `BugReportError` and returns data.
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
import { CONFIG_DIR, normalizeGatewayUrl } from "./config.js";
|
|
25
|
+
import {
|
|
26
|
+
installRecoveryFingerprint,
|
|
27
|
+
redactInstallRecoveryText,
|
|
28
|
+
} from "./installRecovery/redact.js";
|
|
29
|
+
import { platformLabel, TELEMETRY_CONTRACT } from "./posthog.js";
|
|
30
|
+
import { RUNTIME_BRAND } from "./runtimeBrand.js";
|
|
31
|
+
import { installedVersion, updateTagForVersion } from "./updates.js";
|
|
32
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
33
|
+
|
|
34
|
+
/** The route both front-ends post to. */
|
|
35
|
+
export const REPORT_ENDPOINT_PATH = "/api/cli/report";
|
|
36
|
+
|
|
37
|
+
/** Beside `flags.json` and `telemetry/`, and erased by the same `impel nuke`. */
|
|
38
|
+
export const REPORT_SPOOL_DIR = path.join(CONFIG_DIR, "reports");
|
|
39
|
+
|
|
40
|
+
// Mirrors `bugReportEnvelopeSchema` in the server repo. The CLI ships without
|
|
41
|
+
// runtime dependencies and cannot import that schema; sending a field the
|
|
42
|
+
// server would truncate or reject is a 400 the user discovers instead of us.
|
|
43
|
+
export const MAX_MESSAGE_LENGTH = 4_000;
|
|
44
|
+
export const MAX_VERSION_LENGTH = 32;
|
|
45
|
+
export const MAX_COMMAND_LENGTH = 64;
|
|
46
|
+
export const MAX_ERROR_CODE_LENGTH = 64;
|
|
47
|
+
export const MAX_LOG_LENGTH = 12_000;
|
|
48
|
+
export const MAX_DIAGNOSTIC_KEYS = 24;
|
|
49
|
+
export const MAX_DIAGNOSTIC_VALUE_LENGTH = 2_000;
|
|
50
|
+
|
|
51
|
+
/** Ids are echoed to a terminal, so the shape is checked rather than trusted. */
|
|
52
|
+
const TASK_ID_RE = /^[A-Za-z0-9_.-]{1,128}$/u;
|
|
53
|
+
const RETRY_AFTER_RE = /^\d{1,6}$/u;
|
|
54
|
+
|
|
55
|
+
const BUG_REPORT_REASONS = new Set(TELEMETRY_CONTRACT.bugReportReasons);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Our own sentence for each allowlisted reason; server text is never echoed.
|
|
59
|
+
* Exported so a test can hold it against `TELEMETRY_CONTRACT.bugReportReasons`
|
|
60
|
+
* — the two lists have to move together and nothing else forces that.
|
|
61
|
+
*/
|
|
62
|
+
export const REASON_NOTES = Object.freeze({
|
|
63
|
+
scope_required: "this token does not carry the tasks scope",
|
|
64
|
+
task_creation_failed: "the task board could not be reached",
|
|
65
|
+
workspace_required: "this account is not a workspace member",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A refusal from the core, carrying a `code` so a caller can choose its own
|
|
70
|
+
* wording. The manual command maps these back to the sentences it has always
|
|
71
|
+
* printed; the automatic path swallows them.
|
|
72
|
+
*/
|
|
73
|
+
export class BugReportError extends Error {
|
|
74
|
+
constructor(message, code) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.code = code;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* -------------------------------------------------------------------------- */
|
|
81
|
+
/* Destination */
|
|
82
|
+
/* -------------------------------------------------------------------------- */
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The only thing standing between a tampered destination and a PAT-bearing
|
|
86
|
+
* POST.
|
|
87
|
+
*
|
|
88
|
+
* `loadConfig` normalizes `gatewayUrl` and nothing else, so a hand-edited
|
|
89
|
+
* `appUrl` in the config file reaches a sender unchecked. That was survivable
|
|
90
|
+
* while the only sender was a person who had just been shown the payload and
|
|
91
|
+
* the destination; it is not survivable for a sender that runs on its own. So
|
|
92
|
+
* the predicate lives here, where both front-ends inherit it, rather than in
|
|
93
|
+
* the command that happened to need it first.
|
|
94
|
+
*
|
|
95
|
+
* Rejects non-HTTP(S) schemes, embedded credentials, and any path, query, or
|
|
96
|
+
* fragment — a bare origin is the only shape a report is sent to.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveReportOrigin(candidate) {
|
|
99
|
+
const appUrl = normalizeGatewayUrl(candidate);
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = new URL(appUrl);
|
|
103
|
+
} catch {
|
|
104
|
+
throw new BugReportError(`${appUrl} is not an HTTP or HTTPS URL.`, "invalid_url");
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
(parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
108
|
+
|| parsed.username
|
|
109
|
+
|| parsed.password
|
|
110
|
+
|| parsed.pathname !== "/"
|
|
111
|
+
|| parsed.search
|
|
112
|
+
|| parsed.hash
|
|
113
|
+
) {
|
|
114
|
+
throw new BugReportError(
|
|
115
|
+
`${appUrl} is not a bare HTTP or HTTPS origin without credentials, a path, query, or fragment.`,
|
|
116
|
+
"unsafe_origin",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return appUrl;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* -------------------------------------------------------------------------- */
|
|
123
|
+
/* Envelope */
|
|
124
|
+
/* -------------------------------------------------------------------------- */
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Redact, trim, and bound one free-text field.
|
|
128
|
+
*
|
|
129
|
+
* `redactInstallRecoveryText` is the envelope sanitizer: it removes home paths,
|
|
130
|
+
* emails, bearer tokens, and vendor credentials. `redactCredentialText` /
|
|
131
|
+
* `redactSecretText` are narrower (Impel credentials and control bytes) and
|
|
132
|
+
* serve as the test's tripwire, not as this path's sanitizer.
|
|
133
|
+
*/
|
|
134
|
+
export function boundedText(value, max, homeDir) {
|
|
135
|
+
if (value === undefined || value === null) return "";
|
|
136
|
+
return redactInstallRecoveryText(String(value), { homeDir }).trim().slice(0, max);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build the wire envelope, then stamp it with a fingerprint of itself.
|
|
141
|
+
*
|
|
142
|
+
* The id is derived rather than random so the same failure reported twice
|
|
143
|
+
* carries the same identifier, and so a spooled copy and the sent copy are
|
|
144
|
+
* provably the same report. The automatic path leans on that further: the
|
|
145
|
+
* fingerprint is what a repeat of the same failure is suppressed against.
|
|
146
|
+
*
|
|
147
|
+
* `fields` is already normalized — parsed exit code, file contents read. Flag
|
|
148
|
+
* parsing and file reads belong to whichever front-end has flags and files.
|
|
149
|
+
*/
|
|
150
|
+
export function buildEnvelope({ fields = {}, config, io }) {
|
|
151
|
+
const { homeDir } = io;
|
|
152
|
+
const version = installedVersion();
|
|
153
|
+
// `installedVersion` returns null when package.json cannot be read — exactly
|
|
154
|
+
// the broken install someone would be reporting. The server requires a
|
|
155
|
+
// non-empty string, so the honest placeholder ships instead of a 400.
|
|
156
|
+
const cliVersion = typeof version === "string" && version
|
|
157
|
+
? version.slice(0, MAX_VERSION_LENGTH)
|
|
158
|
+
: "unknown";
|
|
159
|
+
|
|
160
|
+
const message = boundedText(fields.message, MAX_MESSAGE_LENGTH, homeDir);
|
|
161
|
+
const command = boundedText(fields.command, MAX_COMMAND_LENGTH, homeDir);
|
|
162
|
+
const errorCode = boundedText(fields.errorCode, MAX_ERROR_CODE_LENGTH, homeDir);
|
|
163
|
+
const log = boundedText(fields.log, MAX_LOG_LENGTH, homeDir);
|
|
164
|
+
const doctor = boundedText(fields.doctor, MAX_DIAGNOSTIC_VALUE_LENGTH, homeDir);
|
|
165
|
+
const { exitCode } = fields;
|
|
166
|
+
|
|
167
|
+
const diagnostics = {};
|
|
168
|
+
const tenant = boundedText(config?.tenantId, MAX_DIAGNOSTIC_VALUE_LENGTH, homeDir);
|
|
169
|
+
if (tenant) diagnostics.tenant = tenant;
|
|
170
|
+
if (doctor) diagnostics.doctor = doctor;
|
|
171
|
+
for (const [key, value] of Object.entries(fields.diagnostics || {})) {
|
|
172
|
+
const bounded = boundedText(value, MAX_DIAGNOSTIC_VALUE_LENGTH, homeDir);
|
|
173
|
+
if (bounded) diagnostics[key] = bounded;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const envelope = {
|
|
177
|
+
...(message ? { message } : {}),
|
|
178
|
+
cliVersion,
|
|
179
|
+
nodeVersion: String(process.version).slice(0, MAX_VERSION_LENGTH),
|
|
180
|
+
platform: platformLabel(io.platform),
|
|
181
|
+
architecture: String(process.arch).slice(0, MAX_VERSION_LENGTH),
|
|
182
|
+
channel: updateTagForVersion(cliVersion),
|
|
183
|
+
...(command ? { command } : {}),
|
|
184
|
+
...(exitCode === undefined || exitCode === null ? {} : { exitCode }),
|
|
185
|
+
...(errorCode ? { errorCode } : {}),
|
|
186
|
+
...(log ? { stderr: log } : {}),
|
|
187
|
+
...(Object.keys(diagnostics).length ? { diagnostics } : {}),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const reportId = installRecoveryFingerprint(envelope).slice(0, 16);
|
|
191
|
+
envelope.diagnostics = { ...diagnostics, reportId };
|
|
192
|
+
if (Object.keys(envelope.diagnostics).length > MAX_DIAGNOSTIC_KEYS) {
|
|
193
|
+
throw new BugReportError("too many diagnostic fields to send.", "too_many_diagnostics");
|
|
194
|
+
}
|
|
195
|
+
return { envelope, reportId };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* -------------------------------------------------------------------------- */
|
|
199
|
+
/* Spool */
|
|
200
|
+
/* -------------------------------------------------------------------------- */
|
|
201
|
+
|
|
202
|
+
export function spoolFileName(reportId, now) {
|
|
203
|
+
const stamp = new Date(now).toISOString().replace(/[:.]/gu, "-");
|
|
204
|
+
return `${stamp}-${reportId}.json`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** How many spooled reports to keep, and for how long. */
|
|
208
|
+
export const SPOOL_MAX_FILES = 50;
|
|
209
|
+
export const SPOOL_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Recover the write time from a spool filename.
|
|
213
|
+
*
|
|
214
|
+
* `spoolFileName` flattens an ISO timestamp by replacing `:` and `.` with `-`,
|
|
215
|
+
* so the inverse is positional rather than a parse: four fields of the date,
|
|
216
|
+
* three of the time, then the milliseconds. Anything that does not match that
|
|
217
|
+
* shape returns null and the caller falls back to `mtime` — a file this module
|
|
218
|
+
* did not name is still a file taking up space.
|
|
219
|
+
*/
|
|
220
|
+
function spoolFileTimestamp(name) {
|
|
221
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z-/u.exec(name);
|
|
222
|
+
if (!match) return null;
|
|
223
|
+
const [, year, month, day, hour, minute, second, ms] = match;
|
|
224
|
+
const parsed = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}.${ms}Z`);
|
|
225
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Keep the spool from growing without bound.
|
|
230
|
+
*
|
|
231
|
+
* The spool exists so a report survives an outage, not so a machine accumulates
|
|
232
|
+
* every failure it has ever had. Two limits, applied in that order: anything
|
|
233
|
+
* older than `maxAgeMs` goes because nobody is going to act on a month-old
|
|
234
|
+
* failure, and then the oldest go until at most `maxFiles` remain.
|
|
235
|
+
*
|
|
236
|
+
* Two names are never touched. `.last-auto-report.json` is the suppression
|
|
237
|
+
* marker, not a report — deleting it would make a machine re-report a failure it
|
|
238
|
+
* had already reported — and it does not count toward the cap. A `.tmp-<pid>`
|
|
239
|
+
* file belongs to a write that may still be in flight in another process.
|
|
240
|
+
*
|
|
241
|
+
* Every failure is swallowed. A spool that cannot be pruned is a disk-hygiene
|
|
242
|
+
* problem; a report lost to a housekeeping error is a bug that never gets fixed.
|
|
243
|
+
*/
|
|
244
|
+
export function pruneSpool(
|
|
245
|
+
spoolDir,
|
|
246
|
+
{ now = Date.now(), maxFiles = SPOOL_MAX_FILES, maxAgeMs = SPOOL_MAX_AGE_MS } = {},
|
|
247
|
+
) {
|
|
248
|
+
const removed = [];
|
|
249
|
+
try {
|
|
250
|
+
const entries = [];
|
|
251
|
+
for (const name of fs.readdirSync(spoolDir)) {
|
|
252
|
+
// The marker is state, not a report, and a temp file is someone else's
|
|
253
|
+
// half-finished write. Neither is prunable and neither counts.
|
|
254
|
+
if (name.startsWith(".")) continue;
|
|
255
|
+
if (name.includes(".tmp-")) continue;
|
|
256
|
+
let stamp = spoolFileTimestamp(name);
|
|
257
|
+
if (stamp === null) {
|
|
258
|
+
try {
|
|
259
|
+
stamp = fs.statSync(path.join(spoolDir, name)).mtimeMs;
|
|
260
|
+
} catch {
|
|
261
|
+
// Vanished under us — already gone is the outcome pruning wanted.
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
entries.push({ name, stamp });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
entries.sort((left, right) => left.stamp - right.stamp);
|
|
269
|
+
|
|
270
|
+
const doomed = new Set();
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (now - entry.stamp > maxAgeMs) doomed.add(entry.name);
|
|
273
|
+
}
|
|
274
|
+
// Oldest first, and only as many as the cap actually requires.
|
|
275
|
+
const surviving = entries.filter((entry) => !doomed.has(entry.name));
|
|
276
|
+
for (const entry of surviving.slice(0, Math.max(0, surviving.length - maxFiles))) {
|
|
277
|
+
doomed.add(entry.name);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const name of doomed) {
|
|
281
|
+
try {
|
|
282
|
+
fs.rmSync(path.join(spoolDir, name), { force: true });
|
|
283
|
+
removed.push(name);
|
|
284
|
+
} catch {
|
|
285
|
+
// One unremovable file must not stop the rest of the sweep.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
// An unreadable spool directory is nothing to report and nothing to fix.
|
|
290
|
+
}
|
|
291
|
+
return removed;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Write JSON to a predictable path without following a symlink planted there.
|
|
296
|
+
*
|
|
297
|
+
* The install-recovery checkpoint's discipline: private directory, `wx` temp
|
|
298
|
+
* file — so a pre-planted symlink is a create-time failure rather than a write
|
|
299
|
+
* through to wherever it pointed — then rename. Both writers under this roof
|
|
300
|
+
* put attacker-adjacent data at a path an attacker can guess, so both need it.
|
|
301
|
+
*
|
|
302
|
+
* Throws what the write threw. Each caller decides what a failed write costs.
|
|
303
|
+
*/
|
|
304
|
+
export function writeJsonAtomically(filePath, value) {
|
|
305
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}`;
|
|
306
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
307
|
+
try {
|
|
308
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
309
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
310
|
+
mode: 0o600,
|
|
311
|
+
flag: "wx",
|
|
312
|
+
});
|
|
313
|
+
renameWithWindowsRetry(temporaryPath, filePath);
|
|
314
|
+
} finally {
|
|
315
|
+
try {
|
|
316
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
317
|
+
} catch {
|
|
318
|
+
// A successful rename already removed it; cleanup must not mask the write.
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Write the undelivered report to disk.
|
|
325
|
+
*/
|
|
326
|
+
export function spoolReport({ envelope, reportId, appUrl, io }) {
|
|
327
|
+
const filePath = path.join(io.spoolDir, spoolFileName(reportId, io.now));
|
|
328
|
+
writeJsonAtomically(filePath, {
|
|
329
|
+
schema: "impel.cli-bug-report.v1",
|
|
330
|
+
reportId,
|
|
331
|
+
createdAt: new Date(io.now).toISOString(),
|
|
332
|
+
appUrl,
|
|
333
|
+
report: envelope,
|
|
334
|
+
});
|
|
335
|
+
try {
|
|
336
|
+
fs.chmodSync(filePath, 0o600);
|
|
337
|
+
} catch {
|
|
338
|
+
// Windows inherits ACLs and has no POSIX mode to set.
|
|
339
|
+
}
|
|
340
|
+
// After the rename, never before: the report just written is the one thing
|
|
341
|
+
// this function exists to keep, and a prune that ran first could fail in a way
|
|
342
|
+
// that cost it. Pruning last means the worst case is a spool one file over.
|
|
343
|
+
pruneSpool(io.spoolDir, { now: io.now });
|
|
344
|
+
return filePath;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* -------------------------------------------------------------------------- */
|
|
348
|
+
/* Delivery */
|
|
349
|
+
/* -------------------------------------------------------------------------- */
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Validate a response into the two shapes a caller can print, or null.
|
|
353
|
+
*
|
|
354
|
+
* `recorded: true` with an unusable body is a failure rather than a shrug: the
|
|
355
|
+
* user is owed a task id or a named reason, and "probably fine" is the one
|
|
356
|
+
* answer a bug report must never give.
|
|
357
|
+
*/
|
|
358
|
+
export function normalizeReportResponse(payload) {
|
|
359
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
360
|
+
if (payload.recorded !== true || typeof payload.taskCreated !== "boolean") return null;
|
|
361
|
+
if (payload.taskCreated) {
|
|
362
|
+
return typeof payload.taskId === "string" && TASK_ID_RE.test(payload.taskId)
|
|
363
|
+
? { taskCreated: true, taskId: payload.taskId }
|
|
364
|
+
: null;
|
|
365
|
+
}
|
|
366
|
+
const reason = typeof payload.reason === "string" && BUG_REPORT_REASONS.has(payload.reason)
|
|
367
|
+
? payload.reason
|
|
368
|
+
: null;
|
|
369
|
+
return { taskCreated: false, reason };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function retryAfterNote(response) {
|
|
373
|
+
const header = response?.headers?.["retry-after"];
|
|
374
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
375
|
+
if (typeof value !== "string" || !RETRY_AFTER_RE.test(value.trim())) {
|
|
376
|
+
return "try again in a few minutes";
|
|
377
|
+
}
|
|
378
|
+
const seconds = Number(value.trim());
|
|
379
|
+
const minutes = Math.ceil(seconds / 60);
|
|
380
|
+
return seconds < 60
|
|
381
|
+
? `try again in ${seconds} second${seconds === 1 ? "" : "s"}`
|
|
382
|
+
: `try again in ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function jsonBody(response) {
|
|
386
|
+
try {
|
|
387
|
+
const text = await response.text();
|
|
388
|
+
return text ? JSON.parse(text) : null;
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* One delivery attempt.
|
|
396
|
+
*
|
|
397
|
+
* Returns the accepted outcome, or a `failure` sentence written here. Like the
|
|
398
|
+
* feature-flag fetch, server-supplied text is never echoed — only status codes
|
|
399
|
+
* and our own sentences — so nothing carried back needs redacting.
|
|
400
|
+
*
|
|
401
|
+
* `retryable` says whether trying again could plausibly change the answer, and
|
|
402
|
+
* it is deliberately true on one branch only. A timeout or a refused connection
|
|
403
|
+
* is a network having a bad moment. Everything else is the server having
|
|
404
|
+
* answered: a 404 means this deployment has no report route and the remedy is a
|
|
405
|
+
* deployment, a 429 already carries the server's own idea of when to come back,
|
|
406
|
+
* and a body this CLI cannot read will be equally unreadable in 500 ms.
|
|
407
|
+
* Retrying those spends the user's time to arrive at the same place.
|
|
408
|
+
*/
|
|
409
|
+
export async function deliver({ envelope, appUrl, currentPat, io }) {
|
|
410
|
+
const controller = new AbortController();
|
|
411
|
+
const timeout = setTimeout(() => controller.abort(), io.timeoutMs);
|
|
412
|
+
let response;
|
|
413
|
+
try {
|
|
414
|
+
response = await io.fetchImpl(new URL(REPORT_ENDPOINT_PATH, appUrl), {
|
|
415
|
+
method: "POST",
|
|
416
|
+
headers: {
|
|
417
|
+
accept: "application/json",
|
|
418
|
+
authorization: `Bearer ${currentPat}`,
|
|
419
|
+
"content-type": "application/json",
|
|
420
|
+
},
|
|
421
|
+
body: JSON.stringify(envelope),
|
|
422
|
+
signal: controller.signal,
|
|
423
|
+
});
|
|
424
|
+
} catch (error) {
|
|
425
|
+
const detail = error?.name === "AbortError" ? "the request timed out" : "the connection failed";
|
|
426
|
+
return {
|
|
427
|
+
failure: `could not reach ${appUrl} to send the report; ${detail}.`,
|
|
428
|
+
retryable: true,
|
|
429
|
+
};
|
|
430
|
+
} finally {
|
|
431
|
+
clearTimeout(timeout);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (!response.ok) {
|
|
435
|
+
if (response.status === 404) {
|
|
436
|
+
// R16: a `next` deployment that predates the route. Named separately from
|
|
437
|
+
// every other failure because the remedy is a deployment, not a retry.
|
|
438
|
+
return {
|
|
439
|
+
failure: `${appUrl} does not accept bug reports yet; update the ${RUNTIME_BRAND.product.displayName} control plane and try again.`,
|
|
440
|
+
retryable: false,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (response.status === 429) {
|
|
444
|
+
return {
|
|
445
|
+
failure: `${appUrl} is rate limiting bug reports; ${retryAfterNote(response)}.`,
|
|
446
|
+
retryable: false,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return { failure: `${appUrl} refused the report (HTTP ${response.status}).`, retryable: false };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const outcome = normalizeReportResponse(await jsonBody(response));
|
|
453
|
+
if (!outcome) {
|
|
454
|
+
return {
|
|
455
|
+
failure: `${appUrl} returned a response this CLI cannot read; update the CLI and try again.`,
|
|
456
|
+
retryable: false,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
return { outcome };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Attempt delays, in order. Three attempts worst case; see `deliverWithRetry`. */
|
|
463
|
+
export const DEFAULT_BACKOFF_MS = Object.freeze([500, 1_500]);
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The default `io.sleep`. Both senders inject their own in tests, so the real
|
|
467
|
+
* one lives beside the retry loop that is its only caller rather than being
|
|
468
|
+
* spelled out again in each `io` bag.
|
|
469
|
+
*/
|
|
470
|
+
export function sleep(milliseconds) {
|
|
471
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Deliver, retrying only what retrying can fix.
|
|
476
|
+
*
|
|
477
|
+
* Stops on an accepted outcome, on a non-retryable failure, or when attempts
|
|
478
|
+
* run out — and returns the *last* failure, because that is the one describing
|
|
479
|
+
* the state the caller is now in. `io.sleep` is injectable so tests do not wait
|
|
480
|
+
* out the backoff.
|
|
481
|
+
*/
|
|
482
|
+
export async function deliverWithRetry({
|
|
483
|
+
envelope,
|
|
484
|
+
appUrl,
|
|
485
|
+
currentPat,
|
|
486
|
+
io,
|
|
487
|
+
attempts = 3,
|
|
488
|
+
backoffMs = DEFAULT_BACKOFF_MS,
|
|
489
|
+
}) {
|
|
490
|
+
let last = { failure: "the report was never attempted.", retryable: false };
|
|
491
|
+
for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
|
|
492
|
+
last = await deliver({ envelope, appUrl, currentPat, io });
|
|
493
|
+
if (last.outcome || !last.retryable) return last;
|
|
494
|
+
if (attempt === Math.max(1, attempts) - 1) return last;
|
|
495
|
+
const delay = backoffMs[Math.min(attempt, backoffMs.length - 1)];
|
|
496
|
+
if (delay > 0) await io.sleep(delay);
|
|
497
|
+
}
|
|
498
|
+
return last;
|
|
499
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { cmdSkills } from "./commands/skills.js";
|
|
|
14
14
|
import { cmdAgents } from "./commands/agents.js";
|
|
15
15
|
import { cmdTenant } from "./commands/tenant.js";
|
|
16
16
|
import { cmdDoctor } from "./commands/doctor.js";
|
|
17
|
+
import { cmdReport } from "./commands/report.js";
|
|
17
18
|
import { cmdSetup } from "./commands/setup.js";
|
|
18
19
|
import { cmdSessions } from "./commands/sessions.js";
|
|
19
20
|
import { cmdUpdate } from "./commands/update.js";
|
|
@@ -24,6 +25,13 @@ import { cmdRemote } from "./commands/remote.js";
|
|
|
24
25
|
import { cmdNative } from "./commands/native.js";
|
|
25
26
|
import { ensureMacDeveloperTools } from "./macDeveloperTools.js";
|
|
26
27
|
import { refuseElevatedMacExecution } from "./privileges.js";
|
|
28
|
+
import {
|
|
29
|
+
captureCommandRun,
|
|
30
|
+
cmdTelemetry,
|
|
31
|
+
maybeSpawnTelemetryFlush,
|
|
32
|
+
TELEMETRY_FLUSH_COMMAND,
|
|
33
|
+
} from "./posthog.js";
|
|
34
|
+
import { maybePrintTelemetryNotice } from "./telemetryNotice.js";
|
|
27
35
|
|
|
28
36
|
const HELP = `impel — isolated Impel workspaces for every tenant
|
|
29
37
|
|
|
@@ -58,6 +66,7 @@ Account:
|
|
|
58
66
|
Diagnostics:
|
|
59
67
|
impel status Authentication, current tenant, and local readiness
|
|
60
68
|
impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
|
|
69
|
+
impel report --message "<text>" Send a bug report; you approve the exact payload
|
|
61
70
|
|
|
62
71
|
Reset:
|
|
63
72
|
impel nuke [--yes] Erase ALL Impel-managed local state (apps, profiles,
|
|
@@ -69,6 +78,8 @@ Reset:
|
|
|
69
78
|
Env:
|
|
70
79
|
IMPEL_SKIP_UPDATE_CHECK=1 Silence launch-time update notices.
|
|
71
80
|
IMPEL_DISABLE_INSTALL_RECOVERY=1 Disable local and hosted install recovery.
|
|
81
|
+
IMPEL_DISABLE_TELEMETRY=1 Never send usage analytics (DO_NOT_TRACK=1 and any CI value also apply).
|
|
82
|
+
Analytics are off until you opt in; see the README privacy section.
|
|
72
83
|
|
|
73
84
|
Config file:
|
|
74
85
|
~/.config/impel/config.json (mode 0600)
|
|
@@ -90,11 +101,15 @@ function normalizeTarget(token) {
|
|
|
90
101
|
return null;
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Run one command.
|
|
106
|
+
*
|
|
107
|
+
* Split out of `main` so there is a single place a command's completion can be
|
|
108
|
+
* observed. Every `case` below returns directly, so code placed after the
|
|
109
|
+
* switch runs only on the unknown-command path — a `finally` around the call
|
|
110
|
+
* is the only construct that sees every exit, including the throwing ones.
|
|
111
|
+
*/
|
|
112
|
+
async function dispatch(cmd, rest) {
|
|
98
113
|
switch (cmd) {
|
|
99
114
|
case undefined:
|
|
100
115
|
case "help":
|
|
@@ -141,6 +156,9 @@ export async function main(argv) {
|
|
|
141
156
|
case "doctor":
|
|
142
157
|
return cmdDoctor(rest);
|
|
143
158
|
|
|
159
|
+
case "report":
|
|
160
|
+
return cmdReport(rest);
|
|
161
|
+
|
|
144
162
|
case "tasks":
|
|
145
163
|
case "task":
|
|
146
164
|
case "tickets":
|
|
@@ -205,6 +223,10 @@ export async function main(argv) {
|
|
|
205
223
|
return cmdApps(["open", target, ...args]);
|
|
206
224
|
}
|
|
207
225
|
|
|
226
|
+
// Hidden detached telemetry sender, spawned by `main`'s finally below.
|
|
227
|
+
case TELEMETRY_FLUSH_COMMAND:
|
|
228
|
+
return cmdTelemetry(rest);
|
|
229
|
+
|
|
208
230
|
// Intentionally omitted from the public help while the contract is gated
|
|
209
231
|
// server-side and limited to Impel-managed desktop apps.
|
|
210
232
|
case "experimental":
|
|
@@ -217,6 +239,40 @@ export async function main(argv) {
|
|
|
217
239
|
}
|
|
218
240
|
}
|
|
219
241
|
|
|
242
|
+
export async function main(argv) {
|
|
243
|
+
if (refuseElevatedMacExecution(argv)) return;
|
|
244
|
+
if (!ensureMacDeveloperTools(argv)) return;
|
|
245
|
+
const [cmd, ...rest] = argv;
|
|
246
|
+
|
|
247
|
+
// Before the command, not after: an install that predates the consent prompt
|
|
248
|
+
// should hear what this CLI can now do before it does anything, and a notice
|
|
249
|
+
// printed after `impel claude` hands the terminal to Claude Code would scroll
|
|
250
|
+
// past unread. Self-suppressing and never throwing.
|
|
251
|
+
maybePrintTelemetryNotice({ argv });
|
|
252
|
+
|
|
253
|
+
const startedAt = Date.now();
|
|
254
|
+
let threw = false;
|
|
255
|
+
try {
|
|
256
|
+
return await dispatch(cmd, rest);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
threw = true;
|
|
259
|
+
throw error;
|
|
260
|
+
} finally {
|
|
261
|
+
// Observational only. `captureEvent` and `maybeSpawnTelemetryFlush` both
|
|
262
|
+
// swallow their own failures, and the extra guard here states the contract
|
|
263
|
+
// at the seam: whatever happens on this line, the command's outcome and
|
|
264
|
+
// exit code are already decided and must survive unchanged. A `finally`
|
|
265
|
+
// that threw would replace a real error with a telemetry one.
|
|
266
|
+
try {
|
|
267
|
+
captureCommandRun({ command: cmd, durationMs: Date.now() - startedAt, threw });
|
|
268
|
+
maybeSpawnTelemetryFlush(cmd);
|
|
269
|
+
} catch {
|
|
270
|
+
// Unreachable by design; kept so a future edit inside either call cannot
|
|
271
|
+
// turn a metric into a failed command.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
220
276
|
function runUse(mode, targetToken) {
|
|
221
277
|
const target = normalizeTarget(targetToken);
|
|
222
278
|
if (target === null) {
|
package/src/codexSecurity.js
CHANGED
|
@@ -148,24 +148,6 @@ export function hardenManagedCodexToml(toml, configPath = "managed Codex config"
|
|
|
148
148
|
return applyManagedCodexSandboxPolicy(withoutCredentialSnapshots, configPath);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
/**
|
|
152
|
-
* Enable the standard MCP Apps renderer only in the exact pinned desktop
|
|
153
|
-
* profile. CLI profiles deliberately stay headless, and vendor-pin contract
|
|
154
|
-
* tests must prove that the embedded Codex binary still recognizes this flag.
|
|
155
|
-
*/
|
|
156
|
-
export function enableManagedCodexDesktopMcpApps(
|
|
157
|
-
toml,
|
|
158
|
-
configPath = "managed Codex desktop config",
|
|
159
|
-
) {
|
|
160
|
-
return upsertManagedScalar(
|
|
161
|
-
toml,
|
|
162
|
-
"features",
|
|
163
|
-
"enable_mcp_apps",
|
|
164
|
-
"true",
|
|
165
|
-
configPath,
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
151
|
/**
|
|
170
152
|
* Enable the Code Mode execution host in the exact pinned desktop profile.
|
|
171
153
|
* Pinned Codex fails closed when a code_mode_only model is selected while
|