impel-cli 0.20.44 → 0.20.46-beta.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.
- package/README.md +134 -0
- package/docs/native-agent-host-capability-matrix.md +2 -2
- package/package.json +1 -1
- package/src/agents.js +6 -1
- package/src/apps.js +12 -8
- package/src/autoReport.js +289 -0
- package/src/bugReport.js +499 -0
- package/src/cli.js +61 -5
- package/src/commands/apps.js +11 -5
- package/src/commands/auth.js +5 -1
- package/src/commands/converge.js +9 -27
- 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 +147 -21
- package/src/commands/status.js +28 -3
- package/src/commands/tasks.js +10 -2
- package/src/commands/tenantRecoveryReport.js +101 -0
- package/src/commands/update.js +82 -1
- package/src/cursorLocal.js +8 -0
- package/src/exitCodes.js +61 -0
- package/src/featureFlags.js +393 -0
- package/src/managedProfileVersion.js +1 -1
- package/src/posthog.js +833 -0
- package/src/providerReadiness.js +4 -2
- package/src/provisioning.js +29 -1
- 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/verbatimRelay.js +10 -2
package/README.md
CHANGED
|
@@ -525,15 +525,148 @@ Main paths:
|
|
|
525
525
|
~/.config/impel/config.json
|
|
526
526
|
~/.config/impel/cli/tenants/<tenant>/
|
|
527
527
|
~/.config/impel/apps/tenants/<tenant>/
|
|
528
|
+
~/.config/impel/update-check.json
|
|
529
|
+
~/.config/impel/flags.json
|
|
530
|
+
~/.config/impel/telemetry-notice.json
|
|
531
|
+
~/.config/impel/telemetry/
|
|
532
|
+
~/.config/impel/reports/
|
|
528
533
|
```
|
|
529
534
|
|
|
530
535
|
The config directory is owner-only and the config file is written with mode
|
|
531
536
|
`0600` where supported. Generated client profiles use tenant-bound helper
|
|
532
537
|
invocations; PAT material is not embedded into shell entries.
|
|
533
538
|
|
|
539
|
+
`flags.json` caches server-evaluated feature flags for the current principal.
|
|
540
|
+
`telemetry/` holds queued analytics events waiting to be sent, plus their
|
|
541
|
+
send state; it is empty unless analytics are on. `reports/` holds bug reports
|
|
542
|
+
that could not be delivered, kept so they are never lost, bounded to the last
|
|
543
|
+
50 and nothing older than 30 days; its hidden `.last-auto-report.json` records
|
|
544
|
+
what this machine last reported automatically, so the same failure is not
|
|
545
|
+
reported twice in a day. Everything above lives under one directory, so
|
|
546
|
+
`impel nuke` erases all of it.
|
|
547
|
+
|
|
534
548
|
Losing membership does not delete local tenant data. A future cleanup feature
|
|
535
549
|
must remain explicit and destructive-state aware.
|
|
536
550
|
|
|
551
|
+
## Privacy
|
|
552
|
+
|
|
553
|
+
Analytics are off unless you turn them on: nothing in the first table below is
|
|
554
|
+
sent until `telemetry.enabled` is `true` in `config.json`, which only
|
|
555
|
+
`impel setup --analytics on` writes.
|
|
556
|
+
|
|
557
|
+
Failure reports are the one exception and are described in their own section
|
|
558
|
+
further down. When `impel setup` or `impel update` runs out of ways to fix a
|
|
559
|
+
failure, the CLI files a bug report about that failure whether or not analytics
|
|
560
|
+
are on. That is deliberate: a broken install cannot be fixed by people who never
|
|
561
|
+
hear about it, and the machines least likely to opt in are the ones failing.
|
|
562
|
+
|
|
563
|
+
**Usage analytics** (`cli_command_run`, one per command, sent when enabled):
|
|
564
|
+
|
|
565
|
+
| Field | Example | Notes |
|
|
566
|
+
|---|---|---|
|
|
567
|
+
| `command` | `claude` | The command name only — never its arguments |
|
|
568
|
+
| `outcome` | `success` | `success`, `failure`, or `refused` |
|
|
569
|
+
| `durationBucket` | `lt_5s` | A coarse bucket, not a duration |
|
|
570
|
+
| `cliVersion` | `0.20.41` | |
|
|
571
|
+
| `channel` | `latest` | npm release channel |
|
|
572
|
+
| `platform` | `darwin` | `darwin`, `linux`, `win32`, or `unknown` |
|
|
573
|
+
|
|
574
|
+
**Bug reports** (`cli_bug_report`, one per delivered report): the same
|
|
575
|
+
`cliVersion`, `channel`, and `platform`, plus whether a message was attached,
|
|
576
|
+
whether a task was filed, and if not, why.
|
|
577
|
+
|
|
578
|
+
Events are identified by the account your PAT resolves to, stamped by the
|
|
579
|
+
server. Nothing else is collected: no command arguments, file contents,
|
|
580
|
+
prompts, session content, environment variables, paths, or credentials. Events
|
|
581
|
+
are validated against a closed allowlist before they reach disk, and anything
|
|
582
|
+
that still looks like a secret after redaction is dropped locally and reported
|
|
583
|
+
by `impel status`.
|
|
584
|
+
|
|
585
|
+
Events are queued on disk and sent by a detached background process, so no
|
|
586
|
+
command ever waits on the network. Analytics never initialize inside a managed
|
|
587
|
+
desktop app profile or a non-interactive surface.
|
|
588
|
+
|
|
589
|
+
**Turning analytics off.** Any one of these is enough, and each overrides a
|
|
590
|
+
stored `yes`:
|
|
591
|
+
|
|
592
|
+
```sh
|
|
593
|
+
IMPEL_DISABLE_TELEMETRY=1 impel claude # also accepts true/yes
|
|
594
|
+
DO_NOT_TRACK=1 impel claude
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
Any non-empty `CI` value suppresses analytics as well. To change the stored
|
|
598
|
+
answer, run `impel setup --analytics off`. To erase everything already queued,
|
|
599
|
+
run `impel nuke`.
|
|
600
|
+
|
|
601
|
+
## Automatic failure reports
|
|
602
|
+
|
|
603
|
+
When `impel setup` or `impel update` fails in a way it cannot recover from, the
|
|
604
|
+
CLI files a bug report about that failure and prints one line saying so, with
|
|
605
|
+
the report's id and how to turn the behavior off. It does not ask first.
|
|
606
|
+
|
|
607
|
+
**What triggers one.** Only a terminal failure of `impel setup` or `impel
|
|
608
|
+
update` — a failure that recovery already tried and could not repair. A failure
|
|
609
|
+
that recovery fixes is not a failure, and reports nothing. No other command
|
|
610
|
+
reports itself, and a successful run never reports.
|
|
611
|
+
|
|
612
|
+
**What is in it.** The same fields `impel report` sends, assembled from what the
|
|
613
|
+
command already had in hand:
|
|
614
|
+
|
|
615
|
+
| Field | Example | Notes |
|
|
616
|
+
|---|---|---|
|
|
617
|
+
| `message` | `The npm-verified global impel-cli update failed.` | Written by the CLI, not by you |
|
|
618
|
+
| `cliVersion` | `0.20.41` | |
|
|
619
|
+
| `nodeVersion` | `v22.11.0` | |
|
|
620
|
+
| `platform` | `darwin` | |
|
|
621
|
+
| `architecture` | `arm64` | |
|
|
622
|
+
| `channel` | `latest` | npm release channel |
|
|
623
|
+
| `command` | `npm install --global impel-cli` | The command that failed, when there was one |
|
|
624
|
+
| `exitCode` | `1` | |
|
|
625
|
+
| `errorCode` | `install_failed` | |
|
|
626
|
+
| `stderr` | | That command's output, redacted |
|
|
627
|
+
| `diagnostics` | `{ "step": "install.impel_cli", "reportId": "…" }` | The failing step, the report id, and a handful of scalars naming what the run was doing |
|
|
628
|
+
|
|
629
|
+
Credentials, home directory paths, and email addresses are removed before
|
|
630
|
+
anything leaves the machine, by the same redaction the manual command uses.
|
|
631
|
+
Nothing else is collected: no arguments beyond the failing command line, no file
|
|
632
|
+
contents, no prompts, no session content, no environment variables.
|
|
633
|
+
|
|
634
|
+
**Turning it off.** Either of these, and only these:
|
|
635
|
+
|
|
636
|
+
```sh
|
|
637
|
+
IMPEL_DO_NOT_TRACK=1 impel setup
|
|
638
|
+
IMPEL_CI=1 impel setup
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
Bare `DO_NOT_TRACK` and bare `CI` **do not** suppress failure reports, though
|
|
642
|
+
they still suppress analytics. The two surfaces have different contracts on
|
|
643
|
+
purpose: `CI` is set by every CI runner on earth and says nothing about whether
|
|
644
|
+
its operators want to hear that installs are broken there, and deciding that for
|
|
645
|
+
them is how a broken install stays broken. Suppressing a failure report takes
|
|
646
|
+
the branded name, typed on purpose.
|
|
647
|
+
|
|
648
|
+
The consequence is worth stating plainly: a GitHub Actions job sets `CI=true`,
|
|
649
|
+
not `IMPEL_CI`, so a repeatedly failing `impel setup` in an unmodified CI
|
|
650
|
+
pipeline files a report every run until someone sets `IMPEL_CI=1`.
|
|
651
|
+
|
|
652
|
+
Reports are also suppressed when the CLI is a rebranded distribution, when it
|
|
653
|
+
runs inside a managed desktop app profile, and when there is no PAT to attribute
|
|
654
|
+
the report to.
|
|
655
|
+
|
|
656
|
+
**The same failure is not reported twice in a day.** The report id is a
|
|
657
|
+
fingerprint of the report's own contents, so an identical failure produces an
|
|
658
|
+
identical id; `~/.config/impel/reports/.last-auto-report.json` records what was
|
|
659
|
+
reported and when, and a repeat inside 24 hours is dropped silently.
|
|
660
|
+
|
|
661
|
+
**Delivery is best effort and never blocks.** Two retries against an unreachable
|
|
662
|
+
control plane, then the report is written to `~/.config/impel/reports/` like any
|
|
663
|
+
other undelivered report. A reporting failure never changes the exit code of the
|
|
664
|
+
command that failed, and never adds a second error message to the one you
|
|
665
|
+
already got.
|
|
666
|
+
|
|
667
|
+
`impel report` remains a message you choose to send: neither switch above
|
|
668
|
+
disables it, and it works whether or not analytics are on.
|
|
669
|
+
|
|
537
670
|
## Public command surface
|
|
538
671
|
|
|
539
672
|
```text
|
|
@@ -548,6 +681,7 @@ impel tasks ...
|
|
|
548
681
|
impel pat ...
|
|
549
682
|
impel status
|
|
550
683
|
impel doctor
|
|
684
|
+
impel report
|
|
551
685
|
```
|
|
552
686
|
|
|
553
687
|
Profile helpers, content synchronization, desktop maintenance, and transport
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Native-agent host capability matrix
|
|
2
2
|
|
|
3
|
-
Validated on 2026-08-
|
|
3
|
+
Validated on 2026-08-11 with isolated temporary homes and tenant profiles. The
|
|
4
4
|
matrix is intentionally pinned: a client upgrade must be re-qualified before it
|
|
5
5
|
is treated as an eager host.
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@ is treated as an eager host.
|
|
|
9
9
|
| Claude Code | 2.1.220 | Yes, agent frontmatter `tools` | Eager; optional single-thread selection | The bound run/answer, resume, and recovery tools are direct child tools. `impel claude --agent` verifies the exact managed adapter before enabling 2.1.220's bypass-permission opt-in and mode. |
|
|
10
10
|
| Claude Desktop | 1.26832.0 (embedded Code 2.1.222) | Yes, agent frontmatter `tools` | Eager | Same direct-tool contract as Claude Code. |
|
|
11
11
|
| Codex CLI through `impel codex` | 0.147.0 | Yes, with `features.code_mode.direct_only_tool_namespaces = ["mcp__impel_agent"]` | Eager; optional single-thread profile | Existing `@Agent` children call the fixed MCP namespace directly. `impel codex --agent` also removes the parent spawn/wait/relay thread. |
|
|
12
|
-
| ChatGPT desktop Codex | 26.803.
|
|
12
|
+
| ChatGPT desktop Codex | 26.803.81509 (embedded Codex 0.147.0-alpha.6.6) | Yes, with the same exact native namespace | Eager child, compatible parent | Keeps the app's supported `@Agent` parent/child route, but the child no longer needs code cells or code-mode waits. |
|
|
13
13
|
|
|
14
14
|
## Qualified contract
|
|
15
15
|
|
package/package.json
CHANGED
package/src/agents.js
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
claudeFaithfulCompletionGuidance,
|
|
47
47
|
claudeVerbatimCompletionGuidance,
|
|
48
48
|
customAgentVerbatimDescriptionLead,
|
|
49
|
+
VERBATIM_NO_EXTERNAL_SOURCE_CONSTRAINT,
|
|
49
50
|
usesVerbatimRelay,
|
|
50
51
|
} from "./verbatimRelay.js";
|
|
51
52
|
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
@@ -64,7 +65,7 @@ export const NATIVE_AGENT_RESUME_TOOL = "resume_native_agent_run";
|
|
|
64
65
|
export const NATIVE_AGENT_RECOVER_TOOL = "recover_native_agent_runs";
|
|
65
66
|
export const NATIVE_AGENT_CONTINUATION_SCHEMA = "impel.native-agent-continuation.v1";
|
|
66
67
|
export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
|
|
67
|
-
export const MANAGED_AGENT_MANIFEST_VERSION =
|
|
68
|
+
export const MANAGED_AGENT_MANIFEST_VERSION = 24;
|
|
68
69
|
export const IMPEL_NATIVE_PARENT_DIRECT_ENV = "IMPEL_NATIVE_PARENT_DIRECT";
|
|
69
70
|
export const IMPEL_NATIVE_SLASH_COMMANDS_ENV = "IMPEL_NATIVE_SLASH_COMMANDS";
|
|
70
71
|
|
|
@@ -926,6 +927,7 @@ function claudeParentDirectInstructions(tenantId, agent) {
|
|
|
926
927
|
`Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the user's complete request. Do not spawn a relay subagent and do not perform the request yourself.`,
|
|
927
928
|
`If the answer returns an object whose schema is exactly ${JSON.stringify(NATIVE_AGENT_CONTINUATION_SCHEMA)}, pass that complete object unchanged as the handle to ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} until terminal; after a continuation exists, never call the answer tool again.`,
|
|
928
929
|
`On success, present the attribution line ${JSON.stringify(attribution)}, followed by the returned finalText verbatim with no rewriting, Markdown changes, or independent synthesis. On failure, attribute the failure to the same named managed agent and do not invent a replacement answer.`,
|
|
930
|
+
...(usesVerbatimRelay(agent) ? [VERBATIM_NO_EXTERNAL_SOURCE_CONSTRAINT] : []),
|
|
929
931
|
].join(" ");
|
|
930
932
|
}
|
|
931
933
|
|
|
@@ -1131,6 +1133,7 @@ export function renderManagedAgents(client, tenantId, agents, invocation = null,
|
|
|
1131
1133
|
policyFingerprint: nativeAgentPolicyFingerprint(agent),
|
|
1132
1134
|
retired: false,
|
|
1133
1135
|
name,
|
|
1136
|
+
...(usesVerbatimRelay(agent) ? { verbatimRelay: true } : {}),
|
|
1134
1137
|
fileName: parentDirect ? null : `${fileStem}${extension}`,
|
|
1135
1138
|
contents,
|
|
1136
1139
|
...(claudeRendered ? { launchDefinition: claudeRendered.launchDefinition } : {}),
|
|
@@ -1174,6 +1177,7 @@ function renderClaudeSlashCommand(tenantId, agent, commandName, invocation) {
|
|
|
1174
1177
|
"",
|
|
1175
1178
|
`The JSON below came from managed agent ${JSON.stringify(agent.title)} (${agent.agentId}), not from the host model.`,
|
|
1176
1179
|
`Run the fixed command exactly once. If ok is true, output the attribution line ${JSON.stringify(attribution)} followed by finalText verbatim. If ok is false, attribute the reported error to the same managed agent. Do not perform, rewrite, or independently answer the task.`,
|
|
1180
|
+
...(usesVerbatimRelay(agent) ? [VERBATIM_NO_EXTERNAL_SOURCE_CONSTRAINT] : []),
|
|
1177
1181
|
"",
|
|
1178
1182
|
`!\`${bashCommand}\``,
|
|
1179
1183
|
"",
|
|
@@ -1976,6 +1980,7 @@ export function syncAgentProfile({
|
|
|
1976
1980
|
policyFingerprint: agent.policyFingerprint,
|
|
1977
1981
|
retired: agent.retired,
|
|
1978
1982
|
name: agent.name,
|
|
1983
|
+
...(agent.verbatimRelay ? { verbatimRelay: true } : {}),
|
|
1979
1984
|
...(client === "claude" ? {
|
|
1980
1985
|
...(agent.parentLaunchDefinition
|
|
1981
1986
|
? { launchMode: "parent-direct", parentLaunchDefinition: agent.parentLaunchDefinition }
|
package/src/apps.js
CHANGED
|
@@ -107,8 +107,8 @@ export const PINNED_VENDOR_APPS = Object.freeze({
|
|
|
107
107
|
}),
|
|
108
108
|
}),
|
|
109
109
|
chatgpt: Object.freeze({
|
|
110
|
-
version: "26.803.
|
|
111
|
-
codexVersion: "0.147.0-alpha.6.
|
|
110
|
+
version: "26.803.81509",
|
|
111
|
+
codexVersion: "0.147.0-alpha.6.6",
|
|
112
112
|
bundleName: "ChatGPT.app",
|
|
113
113
|
windows: Object.freeze({
|
|
114
114
|
storeProductId: "9PLM9XGG6VKS",
|
|
@@ -116,20 +116,20 @@ export const PINNED_VENDOR_APPS = Object.freeze({
|
|
|
116
116
|
// The Microsoft Store manifest identifies this exact reviewed build.
|
|
117
117
|
// Keep one Windows package version so the manifest contract and local
|
|
118
118
|
// AppX validation cannot silently drift apart again.
|
|
119
|
-
packageVersion: "26.803.
|
|
120
|
-
codexVersion: "0.147.0-alpha.6.
|
|
119
|
+
packageVersion: "26.803.10989.0",
|
|
120
|
+
codexVersion: "0.147.0-alpha.6.6",
|
|
121
121
|
publisherId: "2p2nqsd0c76g0",
|
|
122
122
|
executable: "app\\ChatGPT.exe",
|
|
123
123
|
updateManifestUrl: "https://persistent.oaistatic.com/codex-app-prod/windows-store-update.json",
|
|
124
124
|
}),
|
|
125
125
|
downloads: Object.freeze({
|
|
126
126
|
arm64: Object.freeze({
|
|
127
|
-
url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-arm64-26.803.
|
|
128
|
-
sha256: "
|
|
127
|
+
url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-arm64-26.803.81509.zip",
|
|
128
|
+
sha256: "34c7e629678ad5d63a639ec694a5b83b85f4e1bd7c21ee2abcfefbb041095b9c",
|
|
129
129
|
}),
|
|
130
130
|
x64: Object.freeze({
|
|
131
|
-
url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-x64-26.803.
|
|
132
|
-
sha256: "
|
|
131
|
+
url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-x64-26.803.81509.zip",
|
|
132
|
+
sha256: "c9d5a14803c3138a77be5e891a4944c04ff421645150043dd3eddd0bf34eda97",
|
|
133
133
|
}),
|
|
134
134
|
}),
|
|
135
135
|
}),
|
|
@@ -302,6 +302,10 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
|
302
302
|
// 35: enable the Code Mode host in managed desktop profiles (pinned Codex
|
|
303
303
|
// fails closed on code_mode_only models without it) and serve CLI model
|
|
304
304
|
// catalogs from the shared registry so efforts and tiers cannot drift.
|
|
305
|
+
// 36: install attributed /ask-<agent> slash commands for read-only direct
|
|
306
|
+
// answers in managed Claude profiles by default.
|
|
307
|
+
// 37: forbid external retrieval and citation edits on every opt-in
|
|
308
|
+
// verbatim-relay surface.
|
|
305
309
|
export { CURRENT_CONFIG_VERSION };
|
|
306
310
|
|
|
307
311
|
// Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// The automatic bug-report path: a failed `impel setup` or `impel update`
|
|
2
|
+
// reporting itself.
|
|
3
|
+
//
|
|
4
|
+
// The manual command in `src/commands/report.js` has a person attached — they
|
|
5
|
+
// typed it, they are watching, and an error they can read is a useful outcome.
|
|
6
|
+
// This module has none of that. It runs inside a command that has already
|
|
7
|
+
// failed, on a machine whose owner may have walked away, and the only two
|
|
8
|
+
// things it owes anyone are the disclosure line and a promise never to make the
|
|
9
|
+
// failure worse. Everything below follows from that asymmetry:
|
|
10
|
+
//
|
|
11
|
+
// - Every gate returns silently. There is no "reporting was skipped because…"
|
|
12
|
+
// sentence, because nobody asked for a report and a refusal is not news.
|
|
13
|
+
// - The whole body is wrapped in a `try`/`catch` that swallows (KTD10). A
|
|
14
|
+
// throw from here would surface as a crash inside a command that had
|
|
15
|
+
// already decided how it wanted to fail.
|
|
16
|
+
// - `process.exitCode` is never written. The failing command owns its exit
|
|
17
|
+
// code; the report is a side effect, not an outcome.
|
|
18
|
+
//
|
|
19
|
+
// What is *sent* is identical to the manual path — same `buildEnvelope`, same
|
|
20
|
+
// origin check, same spool — because "the automatic sender cannot send
|
|
21
|
+
// something the manual sender would not" has to be a property of the code
|
|
22
|
+
// rather than a habit. This file only decides *whether* to send.
|
|
23
|
+
//
|
|
24
|
+
// The suppression marker lives under `CONFIG_DIR`, so `impel nuke` erases it.
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
buildEnvelope,
|
|
32
|
+
deliverWithRetry,
|
|
33
|
+
MAX_DIAGNOSTIC_KEYS,
|
|
34
|
+
REPORT_SPOOL_DIR,
|
|
35
|
+
resolveReportOrigin,
|
|
36
|
+
sleep,
|
|
37
|
+
spoolReport,
|
|
38
|
+
writeJsonAtomically,
|
|
39
|
+
} from "./bugReport.js";
|
|
40
|
+
import { resolveDefaultAppUrl } from "./config.js";
|
|
41
|
+
import { fetchHttp1 } from "./http1.js";
|
|
42
|
+
import { sanitizeInstallFailureEnvelope } from "./installRecovery/redact.js";
|
|
43
|
+
import {
|
|
44
|
+
DO_NOT_TRACK_ENV,
|
|
45
|
+
firstPartyCli,
|
|
46
|
+
managedMarkerPresent,
|
|
47
|
+
reportEnvOptOut,
|
|
48
|
+
} from "./telemetryConsent.js";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Shorter than `impel report`'s 10 s: this timeout is spent by a command that
|
|
52
|
+
* has already failed and wants to exit. Three attempts at 5 s plus 500/1,500 ms
|
|
53
|
+
* of backoff is ~17 s worst case against a black hole (KTD5).
|
|
54
|
+
*/
|
|
55
|
+
export const AUTO_REPORT_TIMEOUT_MS = 5_000;
|
|
56
|
+
|
|
57
|
+
/** R4's record of what this machine has already reported. */
|
|
58
|
+
export const AUTO_REPORT_MARKER_NAME = ".last-auto-report.json";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The marker sits beside the spooled reports rather than in its own directory,
|
|
62
|
+
* so one `impel nuke` erases both and a test that redirects `spoolDir`
|
|
63
|
+
* redirects the marker with it.
|
|
64
|
+
*/
|
|
65
|
+
export function autoReportMarkerPath(spoolDir = REPORT_SPOOL_DIR) {
|
|
66
|
+
return path.join(spoolDir, AUTO_REPORT_MARKER_NAME);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** R4's window. A failure that recurs the next day is worth hearing about again. */
|
|
70
|
+
export const AUTO_REPORT_SUPPRESSION_MS = 24 * 60 * 60 * 1_000;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A list, not a single slot (KTD4). One machine reaches more than one failure
|
|
74
|
+
* mode, and two alternating failures sharing a slot would evict each other's
|
|
75
|
+
* record so neither is ever suppressed — the exact case automatic dispatch
|
|
76
|
+
* makes common.
|
|
77
|
+
*/
|
|
78
|
+
export const AUTO_REPORT_MARKER_ENTRIES = 32;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `buildEnvelope` adds `tenant` and `reportId` of its own, and this mapping adds
|
|
82
|
+
* four more, so the failure's own diagnostics are trimmed to leave room.
|
|
83
|
+
* Exceeding `MAX_DIAGNOSTIC_KEYS` throws, and a report lost to a crowded
|
|
84
|
+
* diagnostics bag is a worse outcome than a report missing its 19th diagnostic.
|
|
85
|
+
*/
|
|
86
|
+
const RESERVED_DIAGNOSTIC_KEYS = 6;
|
|
87
|
+
|
|
88
|
+
/* -------------------------------------------------------------------------- */
|
|
89
|
+
/* Envelope */
|
|
90
|
+
/* -------------------------------------------------------------------------- */
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fold the sanitized install-failure envelope into the bug-report field bag.
|
|
94
|
+
*
|
|
95
|
+
* The two shapes are close enough to look interchangeable and are not.
|
|
96
|
+
* `bugReportEnvelopeSchema` is `.strict()`, so `scope`, `tenantId`, `step`, and
|
|
97
|
+
* `signal` have no top-level home and must travel inside `diagnostics`. Each
|
|
98
|
+
* mismatch left uncorrected is a 400, not a dropped field.
|
|
99
|
+
*
|
|
100
|
+
* The wire's own bounds are not re-applied here: `buildEnvelope` already
|
|
101
|
+
* truncates each field to its schema length and drops a falsy one rather than
|
|
102
|
+
* sending it, which is the same answer the sanitizer's `null`s want. A second
|
|
103
|
+
* copy of `64` in this file would be a bound that can drift from the one the
|
|
104
|
+
* server actually enforces.
|
|
105
|
+
*
|
|
106
|
+
* `step` doubles as `command` when the failure has none, so the server's task
|
|
107
|
+
* title has something to name.
|
|
108
|
+
*/
|
|
109
|
+
function reportFields(sanitized) {
|
|
110
|
+
const own = Object.entries(sanitized.diagnostics || {})
|
|
111
|
+
.slice(0, MAX_DIAGNOSTIC_KEYS - RESERVED_DIAGNOSTIC_KEYS);
|
|
112
|
+
return {
|
|
113
|
+
message: sanitized.message,
|
|
114
|
+
command: sanitized.command || sanitized.step,
|
|
115
|
+
errorCode: sanitized.errorCode,
|
|
116
|
+
exitCode: sanitized.exitCode,
|
|
117
|
+
log: sanitized.stderr || sanitized.stdout || "",
|
|
118
|
+
diagnostics: {
|
|
119
|
+
...Object.fromEntries(own),
|
|
120
|
+
scope: sanitized.scope,
|
|
121
|
+
step: sanitized.step,
|
|
122
|
+
...(sanitized.tenantId ? { tenantId: sanitized.tenantId } : {}),
|
|
123
|
+
...(sanitized.signal ? { signal: sanitized.signal } : {}),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* -------------------------------------------------------------------------- */
|
|
129
|
+
/* Suppression marker */
|
|
130
|
+
/* -------------------------------------------------------------------------- */
|
|
131
|
+
|
|
132
|
+
/** An unreadable or malformed marker reads as absent: it must not block a report. */
|
|
133
|
+
function readMarker(markerPath) {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(fs.readFileSync(markerPath, "utf8"));
|
|
136
|
+
if (!Array.isArray(parsed?.entries)) return [];
|
|
137
|
+
return parsed.entries.filter((entry) => (
|
|
138
|
+
entry && typeof entry.reportId === "string" && typeof entry.sentAt === "string"
|
|
139
|
+
));
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function alreadyReported(entries, reportId, now) {
|
|
146
|
+
return entries.some((entry) => {
|
|
147
|
+
if (entry.reportId !== reportId) return false;
|
|
148
|
+
const sentAt = Date.parse(entry.sentAt);
|
|
149
|
+
return Number.isFinite(sentAt) && now - sentAt < AUTO_REPORT_SUPPRESSION_MS;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Record this send.
|
|
155
|
+
*
|
|
156
|
+
* Losing the marker costs one duplicate report; refusing to report because the
|
|
157
|
+
* marker could not be written costs the report. So every failure here is
|
|
158
|
+
* swallowed by the caller.
|
|
159
|
+
*/
|
|
160
|
+
function writeMarker(markerPath, entries, reportId, now) {
|
|
161
|
+
const kept = entries
|
|
162
|
+
.filter((entry) => entry.reportId !== reportId)
|
|
163
|
+
.filter((entry) => {
|
|
164
|
+
const sentAt = Date.parse(entry.sentAt);
|
|
165
|
+
return Number.isFinite(sentAt) && now - sentAt < AUTO_REPORT_SUPPRESSION_MS;
|
|
166
|
+
});
|
|
167
|
+
// Newest first, so the cap drops the oldest.
|
|
168
|
+
const next = [{ reportId, sentAt: new Date(now).toISOString() }, ...kept]
|
|
169
|
+
.slice(0, AUTO_REPORT_MARKER_ENTRIES);
|
|
170
|
+
writeJsonAtomically(markerPath, { entries: next });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* -------------------------------------------------------------------------- */
|
|
174
|
+
/* Dispatch */
|
|
175
|
+
/* -------------------------------------------------------------------------- */
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* File a bug report for a failed install, or do nothing.
|
|
179
|
+
*
|
|
180
|
+
* `failure` is the same object the command already hands `io.recoverInstall`,
|
|
181
|
+
* so a call site adds no new data collection — it hands over what it had
|
|
182
|
+
* assembled anyway.
|
|
183
|
+
*
|
|
184
|
+
* `io` is the *calling command's* bag, which carries none of the delivery
|
|
185
|
+
* seams: `impel setup` and `impel update` have no `fetchImpl`, no `spoolDir`,
|
|
186
|
+
* no `sleep`. Those defaults are supplied here and caller keys win, so a test
|
|
187
|
+
* can inject a fake fetch and a command does not have to grow twelve fields it
|
|
188
|
+
* has no other use for.
|
|
189
|
+
*/
|
|
190
|
+
export async function reportInstallFailure({ failure, config, io = {} }) {
|
|
191
|
+
try {
|
|
192
|
+
const environment = io.environment || process.env;
|
|
193
|
+
const homeDir = io.homeDir || os.homedir();
|
|
194
|
+
|
|
195
|
+
// KTD2's order. `firstPartyCli` leads: a rebranded fork must not inherit
|
|
196
|
+
// Impel's phone-home, and that answer does not depend on this machine's
|
|
197
|
+
// config, environment, or PAT — so it is also the only gate that can be
|
|
198
|
+
// decided without reading any of them.
|
|
199
|
+
if (!firstPartyCli()) return;
|
|
200
|
+
if (reportEnvOptOut(environment)) return;
|
|
201
|
+
// Before the marker check only because it is a property read and that one
|
|
202
|
+
// walks `realpath` up the ancestor chain. Nothing to authenticate with is
|
|
203
|
+
// just as final an answer, and this is a command that has already failed
|
|
204
|
+
// and wants to exit.
|
|
205
|
+
if (!config?.pat) return;
|
|
206
|
+
// Not a consent question (R7): a managed vendor app profile may reach the
|
|
207
|
+
// gateway and nothing else, so a report from inside one presents as an
|
|
208
|
+
// unexpected egress call from a profile holding app credentials.
|
|
209
|
+
if (managedMarkerPresent(environment, homeDir)) return;
|
|
210
|
+
|
|
211
|
+
const delivery = {
|
|
212
|
+
environment,
|
|
213
|
+
homeDir,
|
|
214
|
+
fetchImpl: fetchHttp1,
|
|
215
|
+
spoolDir: REPORT_SPOOL_DIR,
|
|
216
|
+
timeoutMs: AUTO_REPORT_TIMEOUT_MS,
|
|
217
|
+
now: Date.now(),
|
|
218
|
+
platform: process.platform,
|
|
219
|
+
sleep,
|
|
220
|
+
log: console.log,
|
|
221
|
+
warn: console.warn,
|
|
222
|
+
...io,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// The same destination the manual command resolves, through the same
|
|
226
|
+
// check, for the same reason and one more: this sender has no user to
|
|
227
|
+
// notice that `appUrl` was hand-edited. A rejection is a gate like any
|
|
228
|
+
// other and returns silently — there is no `--app` flag to correct.
|
|
229
|
+
let appUrl;
|
|
230
|
+
try {
|
|
231
|
+
appUrl = resolveReportOrigin(config.appUrl || resolveDefaultAppUrl());
|
|
232
|
+
} catch {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Sanitize first, then build — not instead of building. The sanitizer
|
|
237
|
+
// removes credentials, home paths, and emails from the recovery envelope;
|
|
238
|
+
// `buildEnvelope` is what makes it a wire-legal bug report.
|
|
239
|
+
const sanitized = sanitizeInstallFailureEnvelope(failure, { homeDir });
|
|
240
|
+
const { envelope, reportId } = buildEnvelope({
|
|
241
|
+
fields: reportFields(sanitized),
|
|
242
|
+
config,
|
|
243
|
+
io: delivery,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// R4. Read before printing: a suppressed report prints nothing at all,
|
|
247
|
+
// because the disclosure line exists to explain a send that is happening.
|
|
248
|
+
const markerPath = autoReportMarkerPath(delivery.spoolDir);
|
|
249
|
+
const entries = readMarker(markerPath);
|
|
250
|
+
if (alreadyReported(entries, reportId, delivery.now)) return;
|
|
251
|
+
|
|
252
|
+
// R9/KTD6: before the request, and worded as an attempt. The id is a
|
|
253
|
+
// fingerprint of the envelope so it is already known, and up to ~17 s of
|
|
254
|
+
// retries against an unreachable control plane should not be a silent
|
|
255
|
+
// pause. A past-tense claim would be false in exactly the case that
|
|
256
|
+
// matters — a spooled report announcing a send that never happened.
|
|
257
|
+
delivery.log(`Reporting this failure automatically (id ${reportId}). Set ${DO_NOT_TRACK_ENV}=1 to disable.`);
|
|
258
|
+
|
|
259
|
+
const { outcome } = await deliverWithRetry({
|
|
260
|
+
envelope,
|
|
261
|
+
appUrl,
|
|
262
|
+
currentPat: config.pat,
|
|
263
|
+
io: delivery,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (!outcome) {
|
|
267
|
+
// The outcome line that corrects the attempt line. Nothing drains this
|
|
268
|
+
// directory — the spool is a local paper trail, not a queue — so the next
|
|
269
|
+
// occurrence of this failure dispatches again rather than being
|
|
270
|
+
// suppressed, which is also why the marker below is not written here.
|
|
271
|
+
const filePath = spoolReport({ envelope, reportId, appUrl, io: delivery });
|
|
272
|
+
delivery.log(`The report could not be sent. It is saved at ${filePath}`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A successful send prints nothing further, and is the only thing that
|
|
277
|
+
// suppresses a repeat. Marker failures are swallowed on purpose.
|
|
278
|
+
try {
|
|
279
|
+
writeMarker(markerPath, entries, reportId, delivery.now);
|
|
280
|
+
} catch {
|
|
281
|
+
// Costs one duplicate report. Refusing to report would cost the report.
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
// KTD10. The command that called this is already failing; the reporting
|
|
285
|
+
// path must not add a second failure mode, and there is nobody here to
|
|
286
|
+
// tell. Deliberately empty: a warning printed from this catch would be the
|
|
287
|
+
// second error message R10 promises the user will not see.
|
|
288
|
+
}
|
|
289
|
+
}
|