@coworker-jp/aidr 0.1.302 → 0.1.305

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coworker-jp/aidr",
3
- "version": "0.1.302",
3
+ "version": "0.1.305",
4
4
  "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,18 +49,54 @@ export function detectOpengrepPlatform() {
49
49
  * being handled as a release problem.
50
50
  */
51
51
  export class HttpStatusError extends Error {
52
- constructor(url, status) {
52
+ constructor(url, status, payload = null) {
53
53
  super(`GET ${url} -> HTTP ${status}`);
54
54
  this.name = "HttpStatusError";
55
55
  this.status = status;
56
56
  this.url = url;
57
+ /**
58
+ * The endpoint's own JSON error body, or null when there wasn't one.
59
+ *
60
+ * Carried because a status code alone cannot say WHY. `/download/red-team`
61
+ * answers 403 for two unrelated reasons — a disabled access key and an
62
+ * account type that does not include the scanner — and until #2003 the CLI
63
+ * printed `GET <url> -> HTTP 403` for both, which names neither. The
64
+ * server writes one sentence for each (`aidr_common.red_team.
65
+ * red_team_refusal_message`); reading it here is what lets the caller show
66
+ * it instead of a bare number.
67
+ *
68
+ * Never parsed for a DECISION. It is display material, and it is
69
+ * sanitised at the point of display, not here — a body we could not parse
70
+ * must stay distinguishable from one that had no message.
71
+ */
72
+ this.payload = payload;
73
+ }
74
+ }
75
+
76
+ /** Cap on the error body we will read. Big enough for any refusal we emit. */
77
+ const ERROR_BODY_LIMIT = 8192;
78
+
79
+ /**
80
+ * Best-effort read of a non-2xx JSON body. Returns the parsed object or null.
81
+ *
82
+ * Never throws and never rejects: a failure to read the explanation must not
83
+ * replace the real failure (the HTTP status) with a parse error, which would
84
+ * turn "your key is not entitled" into "unexpected token < in JSON".
85
+ */
86
+ async function readErrorPayload(res) {
87
+ try {
88
+ const text = (await res.text()).slice(0, ERROR_BODY_LIMIT);
89
+ const parsed = JSON.parse(text);
90
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
91
+ } catch {
92
+ return null;
57
93
  }
58
94
  }
59
95
 
60
96
  async function fetchBuffer(url, accessKey) {
61
97
  const headers = accessKey ? { "x-access-key": accessKey } : {};
62
98
  const res = await fetch(url, { redirect: "follow", headers });
63
- if (!res.ok) throw new HttpStatusError(url, res.status);
99
+ if (!res.ok) throw new HttpStatusError(url, res.status, await readErrorPayload(res));
64
100
  const ab = await res.arrayBuffer();
65
101
  return Buffer.from(ab);
66
102
  }
package/src/cli.mjs CHANGED
@@ -784,10 +784,13 @@ async function cmdRedTeamInstall(opts) {
784
784
  );
785
785
  }
786
786
  } catch (e) {
787
- // 403 red_team_requires_10_seats は「Pro/Trial かつ契約シート 10 以上」の
788
- // エンタイトルメント不足。サーバー側が唯一の判断者なので、ここでは
789
- // メッセージをそのまま見せる。
790
- console.error(`red-team install failed: ${e.message}`);
787
+ // サーバー側が唯一の判断者であり、**理由を知っているのもサーバーだけ**なので、
788
+ // 403 の本文をそのまま見せる (issue #2003)。ここが `e.message` を出していた
789
+ // 間、利用者が受け取っていたのは `GET <url> -> HTTP 403` だけで、応答の本文は
790
+ // 1 バイトも読まれていなかった —— このコメント自身が「メッセージをそのまま
791
+ // 見せる」「シート 10 以上が要る」と、どちらも事実でないことを書いていた。
792
+ const { redTeamInstallFailureText } = await import("./red-team.mjs");
793
+ console.error(redTeamInstallFailureText(e));
791
794
  process.exit(1);
792
795
  }
793
796
  }
package/src/red-team.mjs CHANGED
@@ -82,9 +82,10 @@ function binFileName(meta) {
82
82
  * Red Team の生バイナリを取得する。
83
83
  *
84
84
  * verify Lambda の `GET /download/red-team/[<product>/]<platform>` はアクセス
85
- * キー認証に加えて Red Team エンタイトルメント (pro/trial かつ契約シート >= 10)
86
- * を要求し、不適格なら 403 を返す。fetchAsset は非 2xx で throw するため、
87
- * ここでは特別扱いせず呼び出し側にメッセージを見せる。
85
+ * キー認証に加えて Red Team エンタイトルメント (`account_type == "red_team"`、
86
+ * issue #1954) を要求し、不適格なら 403 を返す。fetchAsset は非 2xx で throw
87
+ * するため、ここでは特別扱いせず呼び出し側にメッセージを見せる ——
88
+ * **見せるものは `redTeamInstallFailureText` が組み立てる** (issue #2003)。
88
89
  *
89
90
  * network-scanner は従来どおり 1 セグメント (`/red-team/<platform>`) を使う
90
91
  * (配信済みルートとバイト等価)。他 product は `/red-team/<product>/<platform>`。
@@ -107,6 +108,93 @@ export async function fetchRedTeamBinary(
107
108
  return { ...result, platform, product };
108
109
  }
109
110
 
111
+ /**
112
+ * サーバーが Red Team のエンタイトルメント拒否に使う識別子。
113
+ *
114
+ * 単一情報源は `aidr_common.red_team.RED_TEAM_ACCOUNT_REQUIRED` で、これはその
115
+ * 写しである。**判定の写しではない** —— ここに在るのは「サーバーが返した拒否は
116
+ * どの拒否か」を読むための名前だけで、可否を計算する行は 1 つも無い。
117
+ */
118
+ export const RED_TEAM_ACCOUNT_REQUIRED = "red_team_account_required";
119
+
120
+ /**
121
+ * サーバー由来の文字列を端末に出す前の掃除。
122
+ *
123
+ * 制御文字 (改行とタブを除く) を落とし、長さを切る。**内容は書き換えない** ——
124
+ * 拒否の理由を我々が要約し直すと、サーバーが言ったことと違うものを見せる経路が
125
+ * できる。掃除するのは端末に対する形だけである。
126
+ */
127
+ function sanitizeServerText(raw, limit = 1000) {
128
+ if (typeof raw !== "string") return "";
129
+ // eslint-disable-next-line no-control-regex
130
+ return raw.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").trim().slice(0, limit);
131
+ }
132
+
133
+ /**
134
+ * `red-team install` が失敗したときに端末へ出す文面を組み立てる (issue #2003)。
135
+ *
136
+ * ## 何が壊れていたか
137
+ *
138
+ * 失敗経路は `console.error(\`red-team install failed: \${e.message}\`)` の 1 行
139
+ * だけで、`e.message` は `fetchBuffer` が投げる `GET <url> -> HTTP 403` だった。
140
+ * **応答の本文は 1 バイトも読んでいなかった。** つまり利用者が受け取っていたのは
141
+ * **理由の書かれていない番号**であり、その横のコメントは
142
+ * 「403 red_team_requires_10_seats は『Pro/Trial かつ契約シート 10 以上』の
143
+ * エンタイトルメント不足。…メッセージをそのまま見せる」と書いていた ——
144
+ * **条件は #1954 で消えており、「そのまま見せる」も事実ではなかった。**
145
+ *
146
+ * ## なぜ `status` ではなく `error` で分岐するか
147
+ *
148
+ * このルートの 403 は 2 つある: **鍵が無効 / 停止中** と
149
+ * **Red Team アカウントではない**。番号だけで分岐すると、停止された鍵を持つ人に
150
+ * 「Red Team アカウントを発行してもらってください」と言うことになる ——
151
+ * 発行されても、その鍵は動かない。
152
+ *
153
+ * ## なぜサーバーの文面を優先するか
154
+ *
155
+ * 可否を決めるのはサーバーだけで、**理由を知っているのもサーバーだけ**である
156
+ * (解決済みの `account_type` を見て、通常アカウントと「解釈できない種別」を
157
+ * 書き分けている)。ここで書き直すと 2 つ目の説明ができて必ず食い違う。
158
+ * 手元の文面は**本文が無かったとき**にだけ使う保険で、そちらも
159
+ * プランやシート数を解決策として名指ししない (docs/product/UI_COPY.md)。
160
+ */
161
+ export function redTeamInstallFailureText(err) {
162
+ const status = err?.status;
163
+ const payload = err?.payload && typeof err.payload === "object" ? err.payload : null;
164
+ const code = typeof payload?.error === "string" ? payload.error : "";
165
+ const serverMessage = sanitizeServerText(payload?.message);
166
+
167
+ if (code === RED_TEAM_ACCOUNT_REQUIRED) {
168
+ return [
169
+ "red-team install failed: nothing was installed — the download endpoint " +
170
+ "refused this access key.",
171
+ serverMessage ||
172
+ // 本文の無い古い配備向け。**シートもプランも解決策として出さない。**
173
+ "The scanner is only available on a Red Team account — its own account " +
174
+ "type, issued from the CoWorker admin console. Ask your CoWorker " +
175
+ "administrator to issue one, then re-run this command with that " +
176
+ "account's access key.",
177
+ ].join("\n");
178
+ }
179
+
180
+ if (status === 401 || status === 403) {
181
+ // 同じ番号の別の拒否 (無効・停止中の鍵など)。**Red Team の説明を貼らない。**
182
+ return [
183
+ `red-team install failed: the download endpoint refused this access key ` +
184
+ `(HTTP ${status}), so nothing was installed.`,
185
+ serverMessage ||
186
+ (typeof payload?.error === "string" && payload.error) ||
187
+ "Check that the key is the one issued for this environment and is still " +
188
+ "active; ask your CoWorker administrator if it is not.",
189
+ ].join("\n");
190
+ }
191
+
192
+ // それ以外は従来どおり。**握りつぶさない** —— 分類できなかったことと、
193
+ // 分類した結果として何も言うことが無いことは別である。
194
+ const detail = sanitizeServerText(err?.message) || String(err);
195
+ return `red-team install failed: ${detail}`;
196
+ }
197
+
110
198
  /**
111
199
  * aidr の scope を `claude mcp add` の scope にマップする。
112
200
  *
@@ -184,6 +272,68 @@ export function buildNpxMcpAddArgs(meta, scope) {
184
272
  ];
185
273
  }
186
274
 
275
+ /**
276
+ * `fs.mkdir(dir, { recursive: true })` と同じことをして、**このプロセスが
277
+ * 実際に作ったディレクトリだけ**を深い順に返す (issue #2012)。
278
+ *
279
+ * ## なぜ「作ったかどうか」を覚える必要があるか
280
+ *
281
+ * `--scope project` は「いま居るディレクトリに入れる」意味なので、`home` は
282
+ * 利用者のリポジトリそのものである。**403 で拒否された回** ——
283
+ * 何もダウンロードせず、MCP にも何も登録しない回 —— でも
284
+ * `<cwd>/.claude/bin/` が先に作られていたので、**点検を試して断られただけの
285
+ * 人のリポジトリにディレクトリが 2 つ生えて残った。**
286
+ *
287
+ * 失敗経路で畳むなら、**自分が作ったものと、既に在ったものを必ず区別する。**
288
+ * 利用者の既存の `.claude/` を失敗時に消す実装は、元の欠陥よりはるかに重い。
289
+ */
290
+ export async function mkdirTracked(dir) {
291
+ const created = [];
292
+ let cur = path.resolve(dir);
293
+ for (;;) {
294
+ try {
295
+ await fs.stat(cur);
296
+ break; // ここから上は既に在る = 我々のものではない
297
+ } catch (e) {
298
+ // **ENOENT 以外は「無い」と読まない。** 権限で見えないだけのものを
299
+ // 「自分が作った」と記録すると、後で他人のディレクトリを畳みにいく。
300
+ if (e?.code !== "ENOENT") break;
301
+ }
302
+ created.push(cur); // 深い順に積まれる = そのまま削除の順序
303
+ const parent = path.dirname(cur);
304
+ if (parent === cur) break;
305
+ cur = parent;
306
+ }
307
+ await fs.mkdir(dir, { recursive: true });
308
+ return created;
309
+ }
310
+
311
+ /**
312
+ * `mkdirTracked` が返したディレクトリを、**空である間だけ**深い順に畳む。
313
+ *
314
+ * **`export` しているのは、この 2 つが単体で検査できないと安全保証が固定できない
315
+ * から**である。`fetchAsset` は fail-closed で、**検証を通ってから**一時ファイルを
316
+ * 書いて rename する (`binary-fetcher.mjs`) ので、失敗経路で `bin/` の中に物が
317
+ * 残ることは通常起きない —— つまり install を丸ごと駆動するテストでは、
318
+ * **非再帰であることが効く場面に到達できない**。守っているのは並行して書いた
319
+ * 別プロセス (もう 1 つの `aidr install`、利用者自身) との競合であり、そこは
320
+ * ここを直接呼ばないと測れない。
321
+ *
322
+ * 削除は `fs.rmdir` (非再帰) で行う。これが唯一の安全保証である ——
323
+ * **中身のあるディレクトリに対しては ENOTEMPTY で必ず失敗する**ので、
324
+ * この関数は利用者のファイルを 1 バイトも消せない。1 つでも残ったら親も
325
+ * 空ではないので、そこで止める。
326
+ */
327
+ export async function pruneEmptyCreatedDirs(dirs) {
328
+ for (const dir of dirs) {
329
+ try {
330
+ await fs.rmdir(dir);
331
+ } catch {
332
+ break;
333
+ }
334
+ }
335
+ }
336
+
187
337
  /**
188
338
  * Red Team バイナリを取得し、Claude Code に MCP サーバーとして登録する。
189
339
  *
@@ -284,8 +434,18 @@ export async function installRedTeam({
284
434
  return { installed: false, dryRun: true, path: binPath };
285
435
  }
286
436
 
287
- await fs.mkdir(binDir, { recursive: true });
288
- const res = await fetchRedTeamBinary(binPath, { env, accessKey, product });
437
+ // 失敗した回に痕跡を残さないため、作ったディレクトリを覚えておく (#2012)
438
+ const createdDirs = await mkdirTracked(binDir);
439
+ let res;
440
+ try {
441
+ res = await fetchRedTeamBinary(binPath, { env, accessKey, product });
442
+ } catch (e) {
443
+ // ダウンロードが失敗した = 何も置いていない。**空のままなら畳む。**
444
+ // 部分書き込みが残っていれば `rmdir` が ENOTEMPTY で拒むので、
445
+ // 診断できるものを勝手に消すこともない。
446
+ await pruneEmptyCreatedDirs(createdDirs);
447
+ throw e;
448
+ }
289
449
  stdout(
290
450
  `binary: ${res.path} (${res.platform}, sha256 verified)`,
291
451
  );