amicus 4.9.6 → 4.9.8
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +246 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +3 -3
- package/docs/architecture-map.md +24 -4
- package/docs/configuration.md +43 -15
- package/docs/council.md +140 -3
- package/docs/electron-testing.md +133 -0
- package/docs/troubleshooting.md +14 -7
- package/docs/usage.md +8 -4
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +3 -1
- package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
- package/src/cli-council-run-tools.js +168 -0
- package/src/cli-handlers-council-run.js +6 -6
- package/src/cli.js +23 -1
- package/src/council/briefings-chair.js +1 -1
- package/src/council/briefings-task.js +11 -5
- package/src/council/briefings.js +25 -7
- package/src/council/report-lost-rows.js +89 -0
- package/src/council/report-md.js +3 -1
- package/src/council/report.js +3 -2
- package/src/council/run-degrade.js +22 -1
- package/src/council/run-finish.js +23 -1
- package/src/council/run-launch.js +33 -4
- package/src/council/run-retry-launch.js +9 -4
- package/src/council/run-retry.js +3 -0
- package/src/council/run-seat-tools-verify.js +296 -0
- package/src/council/run-seat-tools.js +274 -0
- package/src/council/run-server.js +41 -6
- package/src/council/run-stage1-launch.js +8 -3
- package/src/council/run.js +21 -21
- package/src/council/seat-tools.js +299 -0
- package/src/council/verdict-seats-reviewed.js +76 -6
- package/src/headless.js +136 -6
- package/src/mcp-council-pack-map.js +24 -0
- package/src/mcp-council-run.js +17 -15
- package/src/mcp-server.js +2 -2
- package/src/mcp-tools.js +15 -4
- package/src/opencode-client.js +26 -0
- package/src/pack/pack-validate.js +3 -1
- package/src/prompt-builder.js +2 -2
- package/src/sidecar/electron-exe-rel.js +131 -0
- package/src/sidecar/electron-install.js +7 -12
- package/src/sidecar/electron-layout.js +31 -31
- package/src/sidecar/electron-native-plan.js +23 -5
- package/src/sidecar/electron-native-rescue.js +55 -16
- package/src/sidecar/electron-rescue-notice.js +18 -1
- package/src/sidecar/fanout.js +7 -1
- package/src/sidecar/heartbeat.js +46 -0
- package/src/sidecar/session-utils.js +7 -34
- package/src/sidecar/zip-from-buffer.js +16 -5
- package/src/sidecar/zip-local-name-scan.js +238 -0
- package/src/sidecar/zip-name-scan.js +5 -0
- package/src/utils/agent-mapping.js +1 -1
- package/src/utils/degrade.js +8 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE OTHER TABLE AN ARCHIVE DECLARES ITS NAMES IN — the local file headers.
|
|
3
|
+
*
|
|
4
|
+
* SPLIT OUT of zip-name-scan.js (v4.9.7, council #239 round 2): that file reached
|
|
5
|
+
* the repo's 300-line gate, and this walk answers a different question from the
|
|
6
|
+
* central-directory scan beside it. `nameRefusal` is imported rather than
|
|
7
|
+
* reimplemented, so there is exactly ONE rule about what yauzl would refuse and
|
|
8
|
+
* the two tables cannot drift apart.
|
|
9
|
+
*
|
|
10
|
+
* WHY A SECOND WALK EXISTS. `scanEntryNames` reads the CENTRAL directory, and an
|
|
11
|
+
* archive can make that unreadable while leaving every local header whole — a
|
|
12
|
+
* truncation does it by accident, four one-field edits to a COMPLETE
|
|
13
|
+
* end-of-central-directory record do it on purpose. MEASURED before this shipped:
|
|
14
|
+
* seven such archives carrying `../../../PWNED-BY-NATIVE.txt` reached a real
|
|
15
|
+
* spawn, two of them running to completion. And the tables can DISAGREE, which is
|
|
16
|
+
* why both are read: on an archive declaring one name locally and another
|
|
17
|
+
* centrally, `tar.exe` wrote the LOCAL name and `Expand-Archive` the CENTRAL one.
|
|
18
|
+
*
|
|
19
|
+
* ONLY EVER A NARROWING. Nothing here can make a rescue HAPPEN: the sole output
|
|
20
|
+
* that changes control flow is a REFUSAL, and `complete`/`names` exist for the
|
|
21
|
+
* notice printed before a spawn and decide nothing.
|
|
22
|
+
*
|
|
23
|
+
* @module sidecar/zip-local-name-scan
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const { nameRefusal, MAX_ENTRIES } = require('./zip-name-scan');
|
|
29
|
+
|
|
30
|
+
/** The three signatures the local-header chain walks between. */
|
|
31
|
+
const LOCAL_SIG = 0x04034b50;
|
|
32
|
+
const CENTRAL_SIG = 0x02014b50;
|
|
33
|
+
const EOCD_SIG = 0x06054b50;
|
|
34
|
+
/** Bit 3: the sizes are BEHIND the payload, in a data descriptor. */
|
|
35
|
+
const FLAG_SIZES_DEFERRED = 0x08;
|
|
36
|
+
/** The same local signature as bytes, for scanning a span the walk would jump. */
|
|
37
|
+
const LOCAL_SIG_BYTES = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where this archive's END-OF-CENTRAL-DIRECTORY record says its directory begins,
|
|
41
|
+
* or -1 when no such record can be found.
|
|
42
|
+
*
|
|
43
|
+
* THE TERMINUS HAS TO BE ANCHORED TO SOMETHING THE ARCHIVE DECLARES. A walk that
|
|
44
|
+
* stops the moment it lands on `PK\x01\x02` believes four bytes it has not
|
|
45
|
+
* earned: MEASURED, planting that signature where an honest advance lands ends
|
|
46
|
+
* the chain with every local header in the archive untouched and no field lying,
|
|
47
|
+
* so neither the deferred-size demotion nor the span check fires -- and the walk
|
|
48
|
+
* reports it saw everything while `tar.exe` went on to reach a `../../../` entry
|
|
49
|
+
* sitting behind it.
|
|
50
|
+
*/
|
|
51
|
+
function declaredCentralOffset(bytes) {
|
|
52
|
+
if (bytes.length < 22) { return -1; }
|
|
53
|
+
const floor = Math.max(0, bytes.length - 22 - 0xFFFF);
|
|
54
|
+
for (let i = bytes.length - 22; i >= floor; i -= 1) {
|
|
55
|
+
if (bytes.readUInt32LE(i) === EOCD_SIG) { return bytes.readUInt32LE(i + 16); }
|
|
56
|
+
}
|
|
57
|
+
return -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* THE BYTES THE CHAIN COULD NOT REACH, ASKED THE SAME QUESTION ANYWAY.
|
|
62
|
+
*
|
|
63
|
+
* Every way the walk can stop early -- a size deferred to a data descriptor, the
|
|
64
|
+
* zip64 sentinel, a chain that runs off the end or lands on a non-signature --
|
|
65
|
+
* leaves the region AHEAD of it unexamined, and an entry the walk never reached
|
|
66
|
+
* is an entry it cannot refuse. That is the council #239 round-2 BLOCKER, and it
|
|
67
|
+
* needed no exotic archive: bit 3 with a zero size is a STANDARD encoding, so an
|
|
68
|
+
* attacker puts one entry in front of `../../../PWNED.txt`, blinds the central
|
|
69
|
+
* directory, and the walk stops before it ever sees the hostile name.
|
|
70
|
+
*
|
|
71
|
+
* WHY NOT SIMPLY REFUSE WHEN THE WALK CANNOT FINISH. Because that is the blanket
|
|
72
|
+
* fail-closed this cluster already rejected on MEASURED availability grounds: a
|
|
73
|
+
* truncated zip is the case the whole rescue exists for, and its walk cannot
|
|
74
|
+
* finish either. Refusing there kills the feature to close the hole.
|
|
75
|
+
*
|
|
76
|
+
* So the region is SWEPT rather than trusted or refused: every local-header
|
|
77
|
+
* signature in it is located and its declared name put through the SAME
|
|
78
|
+
* `nameRefusal`. It cannot follow the chain (that is what broke), so it does not
|
|
79
|
+
* pretend to -- `complete` stays false and the notice still says the names could
|
|
80
|
+
* not be confirmed. It can only ADD refusals, so the narrowing invariant holds
|
|
81
|
+
* and no archive that is rescued today stops being rescued unless it declares a
|
|
82
|
+
* hostile name.
|
|
83
|
+
*
|
|
84
|
+
* THE RESIDUAL: a `PK\x03\x04` occurring by chance inside a payload is read as a
|
|
85
|
+
* header, so a refusal can name something that is not an entry. MEASURED: zero
|
|
86
|
+
* spurious signatures across 777 MB of six real Electron artifacts, and a false
|
|
87
|
+
* hit must ALSO be followed by bytes that parse as a traversal or absolute name.
|
|
88
|
+
* It fails toward REFUSING, which costs a rescue and never grants one.
|
|
89
|
+
*
|
|
90
|
+
* Bounded: one pass, and at most MAX_ENTRIES candidates.
|
|
91
|
+
*/
|
|
92
|
+
function sweepUnreached(bytes, from) {
|
|
93
|
+
let seen = 0;
|
|
94
|
+
let at = from;
|
|
95
|
+
while (at >= 0 && at + 30 <= bytes.length && seen < MAX_ENTRIES) {
|
|
96
|
+
const hit = bytes.indexOf(LOCAL_SIG_BYTES, at);
|
|
97
|
+
if (hit === -1 || hit + 30 > bytes.length) { return null; }
|
|
98
|
+
seen += 1;
|
|
99
|
+
const nameLen = bytes.readUInt16LE(hit + 26);
|
|
100
|
+
const stop = Math.min(hit + 30 + nameLen, bytes.length);
|
|
101
|
+
const refusal = nameRefusal(bytes.subarray(hit + 30, stop).toString('latin1'));
|
|
102
|
+
if (refusal) { return refusal; }
|
|
103
|
+
at = hit + 4;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* THE SAME QUESTION, ASKED OF THE LOCAL FILE HEADERS.
|
|
110
|
+
*
|
|
111
|
+
* WHY A SECOND WALK EXISTS. `scanEntryNames` reads the CENTRAL directory, and an
|
|
112
|
+
* archive can make that unreadable while leaving every local header whole.
|
|
113
|
+
* MEASURED: four ONE-FIELD edits to a COMPLETE end-of-central-directory record —
|
|
114
|
+
* entry count `0xFFFF`, cd offset `0xFFFFFFFF`, a lying comment length, a
|
|
115
|
+
* multi-disk marker — each blind yauzl on an archive whose `../../../` entry
|
|
116
|
+
* `tar` and `Expand-Archive` then read perfectly. On the comment-length one the
|
|
117
|
+
* rescue RAN TO COMPLETION and promoted the result. Cutting the tail off the file
|
|
118
|
+
* does the same thing by accident.
|
|
119
|
+
*
|
|
120
|
+
* AND IT IS THE TABLE ONE OF THE STRATEGIES ACTUALLY USES. MEASURED on an archive
|
|
121
|
+
* declaring one name locally and another centrally: `tar.exe` (bsdtar 3.8.4) wrote
|
|
122
|
+
* the LOCAL name; `Expand-Archive` wrote the CENTRAL one. Neither table is the
|
|
123
|
+
* right one to read. Both are. That is the whole ruling — the earlier candidates
|
|
124
|
+
* argued about which BLINDNESS to tolerate while looking in one table.
|
|
125
|
+
*
|
|
126
|
+
* ONE NAME RULE, NOT TWO. It calls `nameRefusal` above, deliberately: a second
|
|
127
|
+
* lexical rule free to drift from yauzl's would start costing the rescue archives
|
|
128
|
+
* yauzl accepts, which is the failure this module was written to avoid.
|
|
129
|
+
*
|
|
130
|
+
* ONLY `refusal` MAY CHANGE CONTROL FLOW. `complete` and `names` exist for the
|
|
131
|
+
* notice printed before a spawn and decide nothing, so the module's NARROWING
|
|
132
|
+
* invariant above holds verbatim: nothing here can make a rescue HAPPEN.
|
|
133
|
+
*
|
|
134
|
+
* NEVER THROWS, and synchronous: it reads headers and SKIPS payloads, so it never
|
|
135
|
+
* decompresses. MEASURED on six real electron artifacts (v28.0.0-v43.6.0,
|
|
136
|
+
* 107-151 MB): 73-75 names in 0-1 ms, no data descriptors, and the local names
|
|
137
|
+
* equal the central names entry for entry. Truncated, the central walk goes blind
|
|
138
|
+
* and this one still enumerates all 73-75.
|
|
139
|
+
*
|
|
140
|
+
* WHAT `complete` MEANS, AND WHY IT IS A VARIABLE. It is the claim "I saw every
|
|
141
|
+
* local header", it starts true, and it may only ever be turned OFF. Three things
|
|
142
|
+
* turn it off: an entry declaring bit 3 (its stated size is not authoritative),
|
|
143
|
+
* a declared size whose span HIDES another local signature, and a chain that ends
|
|
144
|
+
* anywhere but the offset this archive's own EOCD names. All three DEMOTE and
|
|
145
|
+
* keep walking. That distinction is the whole design: a name the walk can still
|
|
146
|
+
* read is a name it can still REFUSE, and MEASURED, turning any of these into an
|
|
147
|
+
* early `return` forfeits the refusal for every entry behind it — an archive
|
|
148
|
+
* with bit 3, an honest size and `../../../PWNED.txt` at entry 2 went from a
|
|
149
|
+
* terminal refusal to a completed rescue on 5.7% of forged shapes.
|
|
150
|
+
*
|
|
151
|
+
* THE RESIDUAL: the walk still trusts a local size to FIND the next header, so a
|
|
152
|
+
* desynchronised walk can read a "name" out of payload bytes and refuse a name
|
|
153
|
+
* that is not an entry. MEASURED constructible; MEASURED to need deliberate
|
|
154
|
+
* construction — zero spurious `PK\x03\x04` signatures across 777 MB of six real
|
|
155
|
+
* Electron artifacts. It fails toward REFUSING, which costs a rescue and never
|
|
156
|
+
* grants one.
|
|
157
|
+
*
|
|
158
|
+
* @param {Buffer} bytes the archive, in this process's heap
|
|
159
|
+
* @returns {{refusal:string|null, complete:boolean, names:number, why:string}}
|
|
160
|
+
* `complete` = the chain reached the directory, so EVERY local header was seen.
|
|
161
|
+
* A refusal stops the walk early, so it reports `complete:false` too: it did not
|
|
162
|
+
* see them all, and a field must not claim otherwise on any of its return sites.
|
|
163
|
+
*/
|
|
164
|
+
function scanLocalNames(bytes) {
|
|
165
|
+
let at = 0;
|
|
166
|
+
let names = 0;
|
|
167
|
+
// `complete` MAY ONLY EVER BE TURNED OFF, and the walk is never SHORTENED by
|
|
168
|
+
// anything but a refusal or a genuinely unknowable next offset. Turning a
|
|
169
|
+
// doubt into an early `return` was MEASURED to destroy the refusal for every
|
|
170
|
+
// entry behind it -- an archive with bit 3, an HONEST size and
|
|
171
|
+
// `../../../PWNED.txt` at entry 2 went from a terminal UNZIP_UNSAFE_ARCHIVE to
|
|
172
|
+
// a completed rescue. Deleting the one control-flow-changing power this module
|
|
173
|
+
// has IS the control-flow change; demoting a claim is not.
|
|
174
|
+
let complete = true;
|
|
175
|
+
let why = '';
|
|
176
|
+
const demote = (m) => { if (complete) { complete = false; why = m; } };
|
|
177
|
+
const cdOffset = declaredCentralOffset(bytes);
|
|
178
|
+
// EVERY EARLY EXIT SWEEPS WHAT IT NEVER REACHED. A stop is not a clean bill,
|
|
179
|
+
// and it is not a refusal either -- but the bytes ahead of it may declare a
|
|
180
|
+
// name that IS one. See `sweepUnreached`.
|
|
181
|
+
const stopped = (from, m) => ({ refusal: sweepUnreached(bytes, from), complete: false, names, why: why || m });
|
|
182
|
+
try {
|
|
183
|
+
for (;;) {
|
|
184
|
+
if (at + 4 > bytes.length) { return stopped(at, `the local-header chain ran off the end at ${at}`); }
|
|
185
|
+
const sig = bytes.readUInt32LE(at);
|
|
186
|
+
if (sig === CENTRAL_SIG || sig === EOCD_SIG) {
|
|
187
|
+
// ANCHORED: the chain may claim it reached the directory only where this
|
|
188
|
+
// archive says its directory begins. See `declaredCentralOffset`.
|
|
189
|
+
if (at !== cdOffset) { demote(`the chain ended at ${at}, not where this archive declares its directory begins (${cdOffset})`); }
|
|
190
|
+
return { refusal: null, complete, names, why };
|
|
191
|
+
}
|
|
192
|
+
if (sig !== LOCAL_SIG || at + 30 > bytes.length) { return stopped(at, `no local file header at ${at}`); }
|
|
193
|
+
const flags = bytes.readUInt16LE(at + 6);
|
|
194
|
+
const compressed = bytes.readUInt32LE(at + 18);
|
|
195
|
+
const nameLen = bytes.readUInt16LE(at + 26);
|
|
196
|
+
const extraLen = bytes.readUInt16LE(at + 28);
|
|
197
|
+
if (at + 30 + nameLen > bytes.length) { return stopped(at, `a local file name was cut off at ${at}`); }
|
|
198
|
+
names += 1;
|
|
199
|
+
// Latin-1 for the reason the central walk gives: bytes to characters 1:1.
|
|
200
|
+
const refusal = nameRefusal(bytes.subarray(at + 30, at + 30 + nameLen).toString('latin1'));
|
|
201
|
+
if (refusal) { return { refusal, complete: false, names, why: '' }; }
|
|
202
|
+
// NO NEXT OFFSET AT ALL. These two are the only genuine stops: a zero size
|
|
203
|
+
// under bit 3 would advance by nothing and resynchronise on payload, and
|
|
204
|
+
// the zip64 sentinel names a size that is not here.
|
|
205
|
+
if (((flags & FLAG_SIZES_DEFERRED) && compressed === 0) || compressed === 0xFFFFFFFF) {
|
|
206
|
+
return stopped(at, `entry ${names} does not declare its size here`);
|
|
207
|
+
}
|
|
208
|
+
// BIT 3 SAYS THIS SIZE IS NOT AUTHORITATIVE. APPNOTE 4.4.4 has the writer
|
|
209
|
+
// set it to ZERO, so a nonzero value beside the flag is malformed by
|
|
210
|
+
// construction and `complete` may not rest on it -- but it is still the
|
|
211
|
+
// only lead to the next header, and a name the walk can still read is a
|
|
212
|
+
// name it can still REFUSE. Follow it; just stop claiming to have proved
|
|
213
|
+
// anything (the filed blocker: a lying nonzero size jumped a hostile entry
|
|
214
|
+
// and the walk reported it had seen them all).
|
|
215
|
+
if (flags & FLAG_SIZES_DEFERRED) { demote(`entry ${names} declares bit 3, so the size it states here is not authoritative`); }
|
|
216
|
+
const next = at + 30 + nameLen + extraLen + compressed;
|
|
217
|
+
// THE SPAN THE WALK NEVER LOOKS AT -- name, extra field and payload, every
|
|
218
|
+
// length the archive's to choose. A span holding a local signature may be
|
|
219
|
+
// hiding an entry, so the claim is demoted; the walk still follows the
|
|
220
|
+
// offset, because stopping here would forfeit the refusals behind it.
|
|
221
|
+
if (next <= bytes.length && next > at + 30) {
|
|
222
|
+
// BOUNDED TO THE SPAN. Searching to the end of the archive and only then
|
|
223
|
+
// comparing against `next` is the same answer for O(archive) work PER
|
|
224
|
+
// ENTRY -- quadratic on attacker-chosen input, synchronous, and on the
|
|
225
|
+
// rescue path before the hatch policy is even read (council #239 r2).
|
|
226
|
+
// `subarray` is a view, not a copy, so the total is one pass.
|
|
227
|
+
const hidden = bytes.subarray(at + 30, next).indexOf(LOCAL_SIG_BYTES);
|
|
228
|
+
if (hidden !== -1) { demote(`entry ${names}'s declared size jumps over a local file header at ${at + 30 + hidden}`); }
|
|
229
|
+
}
|
|
230
|
+
if (names >= MAX_ENTRIES) { return stopped(at, `stopped after ${MAX_ENTRIES} entries`); }
|
|
231
|
+
at = next;
|
|
232
|
+
}
|
|
233
|
+
} catch (e) {
|
|
234
|
+
return { refusal: null, complete: false, names, why: `the local-header chain could not be walked: ${(e && e.message) || e}` };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { scanLocalNames, declaredCentralOffset };
|
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
* It also cannot see a SYMLINK whose TARGET escapes the root: that is bytes, not
|
|
39
39
|
* a name, and `zip-from-buffer.js` refuses it only because it reads the payload.
|
|
40
40
|
*
|
|
41
|
+
* THE BLINDNESS IN THE FIRST PARAGRAPH IS WHY `scanLocalNames` EXISTS (v4.9.7,
|
|
42
|
+
* B3). An archive carries its names TWICE, and an unreadable central directory
|
|
43
|
+
* says nothing about the local file headers — which is the table `tar` was
|
|
44
|
+
* MEASURED to act on. The caller asks both.
|
|
45
|
+
*
|
|
41
46
|
* ── AND IT IS ONLY EVER A NARROWING ───────────────────────────────────────
|
|
42
47
|
* Nothing here can make a rescue happen. A refusal it forms turns a
|
|
43
48
|
* `UNZIP_BUFFER_FAILED` into the TERMINAL `UNZIP_UNSAFE_ARCHIVE` its caller
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* OpenCode Native Agents (https://opencode.ai/docs/agents/):
|
|
7
7
|
* - Chat: Reads auto, writes/bash ask permission (interactive default)
|
|
8
8
|
* - Build: Default primary agent with full tool access
|
|
9
|
-
* - Plan:
|
|
9
|
+
* - Plan: Analysis and planning without edits (reads, searches and shell allowed)
|
|
10
10
|
* - General: Full-access agent for research
|
|
11
11
|
* - Explore: Read-only agent for codebase exploration
|
|
12
12
|
*
|
package/src/utils/degrade.js
CHANGED
|
@@ -39,6 +39,14 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
39
39
|
// lost reviewer by consumers that only ever meant seats (verdict-seat-loss.js
|
|
40
40
|
// already gates the Stage-2 notes out of `seat-unbound` for the same reason).
|
|
41
41
|
'stage2-judge',
|
|
42
|
+
// #242 / spec §5 (v4.9.8): render-time rows the report derives from runStats
|
|
43
|
+
// (council/report-lost-rows.js) — never emitted by the sink, so neither can
|
|
44
|
+
// flip `degraded` or the exit code. `unverified-repair`: a seat's findings
|
|
45
|
+
// came from a repair of a response with no findings block
|
|
46
|
+
// (runStats[].findingsUnverified); `repair-refused`: the repair broke its
|
|
47
|
+
// count contract (runStats[].repairRefused). Registered here because the
|
|
48
|
+
// degrade-contract drift pin reads every `channel:` literal in src/.
|
|
49
|
+
'unverified-repair', 'repair-refused',
|
|
42
50
|
'internal',
|
|
43
51
|
// doctor channels
|
|
44
52
|
'doctor-check-failed', 'doctor-fix',
|